什么是函数式编程?
对我们来说,下面是几个关键特性:
不过,R 并不是一门纯函数式语言(这一点与例如 Haskell 相反)。
把 R 看作一门务实的语言:在这些能力是干净、简单地完成任务的方式时,R(越来越多地)会提供函数式能力。一般来说,数据分析才是目标,编程只是达成这个目标的手段。
用管道 |> 把函数连接起来,在好几种语言里都是越来越常见的技术。
简单来说:
有些程序员在查看代码时,习惯把管道读作“然后”:先做第 1 步,然后做第 2 步……
下面的例子演示了这种语法。
library(stringr)
"Monday-25-February" |>
str_split("-") |>
unlist() |> # [1] "Monday" "25" "February"
str_sub(1, 3) |> # [1] "Mon" "25" "Feb"
str_to_upper() |> # [1] "MON" "25" "FEB"
str_flatten(collapse = ", ")
#> [1] "MON, 25, FEB"
这种管道在 Base R 的 v4.1.0 版本中被加入,你可能还会看到它被称为“原生管道”。
RStudio 编辑器(以及其他一些编辑器)使用 Ctrl-Shift-M 作为键盘快捷键,插入一个带合适空格的管道。
我们之前看到,R 中很多函数是“向量化的”:它们会作用于整个向量,不需要写显式的循环。
只使用向量化函数的函数,本身很可能也会自动向量化。看一个简单的例子:
triple <- function(x) return(3 * x)
triple(1:10)
[1] 3 6 9 12 15 18 21 24 27 30
但是,更复杂的函数可能不接受向量输入,例如函数里含有循环或 if-else 块。
我们需要一种更通用的方式,把函数应用到向量上(类似于其他一些语言中的 map())。
由于 R 的基础语言有丰富多样的数据结构,它也就有一整套 *apply() 函数来处理它们。更技术性地说,程序员常把这些函数叫作“高阶函数”,数学家则叫它们“泛函”,因为它们接收函数作为实参。
现在先看看 lapply() 和 sapply()。
lapply()“List-apply” 设计用来处理数组输入,但向量会在必要时被静默地强制转换成数组。
输出是一个数组,所以可能需要拆解才能得到向量:
h <- function(size) {
switch(size,
"small" = 6,
"medium" = 8,
"large" = 10
)
}
v <- c("small", "large", "medium")
h(v) # bad use of an unvectorized function
#> Error in switch(size, small = 6, medium = 8, large = 10) :
#> EXPR must be a length 1 vector
lapply(v, h) # output is a list
#> [[1]]
#> [1] 6
#> [[2]]
#> [1] 10
#> [[3]]
#> [1] 8
lapply(v, h) |> unlist() # output is a vector
#> [1] 6 10 8
熟悉 Switch 概念的学生可能会注意到,我们也可以把标量 switch 换成 dplyr 库中向量化的 recode_values()。向量化是好事,而现代 R 提供了多种实现向量化的方式。
library(dplyr)
h <- function(size) {
recode_values(size,
"small" ~ 6,
"medium" ~ 8,
"large" ~ 10
)
}
v <- c("small", "large", "medium")
h(v)
#> [1] 6 10 8
sapply()“Simplified-apply” 做的事和 lapply 类似,但还会尝试简化返回值:例如用带名字的向量代替数组。视上下文而定,可能需要用到 unname():
sapply(v, h)
small large medium
6 10 8
sapply(v, h) |> unname()
#> [1] 6 10 8
purrr库lapply 和 sapply 很简单,大多数 R 用户都熟悉,而且对于向量和简单的数组来说仍然相当流行。
Base R 还有_很多_其他的 apply 函数。为这类混乱提供更简单、更一致的替代方案,是 Tidyverse 的一个关键目标。
现在有了 purrr 包,它提供了 map() 等好几个额外的选项,同时让设计更一致、错误信息更清楚。
由于不像 base R 那样受向下兼容的束缚,这个包让 R 明显更接近其他语言中的现代函数式编程。
purrr 中的函数数量众多、种类各异,很难用一句话概括。有助于熟悉这个库的资源包括:
map() 函数把函数应用到输入中的每个元素上,并且总是返回一个数组。
到目前为止,这听起来就像 lapply() 换了个名字,也许错误信息更好一些。
不过,map() 只是一个_庞大_家族中的一员。
挑选函数时可以考虑这些问题:
map() 总是返回数组,但如果你想要某种特定类型的原子向量,可以用 map_chr() 处理字符串、map_int() 处理整数,等等。i,例如 imap()。map 换成 map2,例如用 map2_dbl() 处理两个输入(比如值和权重,用来计算加权平均)pmap 来处理一串并行输入,例如 pmap_chr()。是的,硬件级别的并行也是可能的。map_if() 应用一个谓词(布尔)过滤,用 map_at() 指定位置下标条件,用 head_while() 处理输入,直到某个元素不满足谓词。modify() 系列函数代替 map,或者用 modify_tree() 来做递归应用。这些函数尽可能地把输入数据作为第一个实参,以保证它们能很好地配合管道使用。
另外,purrr(有意地)比 Base R 更严格。如果你要求某个特定的东西,这些函数会返回_恰好_那个结果,或者直接报错停止。这很严厉,但非常有效地减少了难以发现的 bug。
map 函数返回的通常和输入长度相同。
其他一些函数则会_归约_数据的维度,例如 sum()。
sum(1:100)
#> [1] 5050
在上面的例子中,我们把一个长度为 100 的向量转换成了一个长度为 1 的整数。在之后关于矩阵和数组的概念里,我们会看到降维这个概念其实更普遍。
sum() 函数是内置的(很多其他统计函数也是)。不过,我们需要一种方式,用某个相当于 map() 的高阶函数,把任意的降维函数应用到某个数据结构上。
在好几种其他语言里,会用 fold、foldl、foldr 这样的函数名。在 R 中,相关的函数是 reduce(),对应(对算法爱好者来说)大名鼎鼎的 MapReduce 框架。
第一个实参照例是输入数据。第二个实参是一个接收 2 个实参的函数:通常是匿名函数,但也可能简单到像 + 这样的算术运算符。
# surround an infix operator with backticks, to use as a function
reduce(1:100, `+`)
#> [1] 5050 # same as sum(1:100)
# anonymous function, alternately add and subtract
reduce(1:10, \(accum, nextval) ifelse(nextval %% 2 == 0, accum + nextval, accum - nextval))
#> [1] 7
我们是从输入的开头还是从末尾开始应用函数,有影响吗?
对于像 + 或 * 这样的结合性运算符,没有影响。显然,(1 + 2) + 3 == 1 + (2 + 3)。
相反,- 和 / 不满足结合律:
(1 - 2) - 3
#> [1] -4
1 - (2 - 3)
#> [1] 2
对于方向有影响的函数,有一个可选的 .dir 实参。
# default is .dir = "forward"
reduce(1:10, `-`)
#> [1] -53
reduce(1:10, `-`, .dir = "backward")
#> [1] -5
和 map() 与 map2() 一样,也有一个 reduce2() 函数,可以并行地处理两个输入向量。
在 R 中,我们喜欢使用向量。假设你想要的不是一个最终的单一值,而是想看到所有中间步骤。
这时对应的函数是 accumulate() 和 accumulate2()。语法和 reduce() 类似,还有一些额外的可选实参用来指定输出类型。
accumulate(1:10, `+`)
#> [1] 1 3 6 10 15 21 28 36 45 55
有些常见的 accumulate 类型有自己的函数。向量函数概念讲到了 Base R 里的 cumsum() 和 cumprod(),而 dplyr 库有用来算数值平均的 cummean(),以及用于逻辑向量的 cumall() 和 cumany()。
通常,我们想留下(或排除)输入中满足某个谓词的元素。
在向量筛选概念中,我们看到了一种惯用的做法。
v <- 1:3
v[v >= 2]
#> [1] 2 3
这在传统 R 中用得很多,但它很难融入现代的函数式流水线。
因此,purrr 提供了一组谓词函数,它们遵循通常的实参约定 f(input, func, ...),以便配合管道使用。
这些名字和大多数其他语言不太一样(像 filter 和 drop 这样的名字在 R 里已经有完全不同的含义了)。
我们即将创办一个奶酪俱乐部。俱乐部会利用机器学习,根据客户的购买历史和口味,挑选出新的奶酪推荐给热爱奶酪的客户。
新会员需要填写一份初始问卷,好让我们先收集一些基础数据。通过这些问卷,我们发现有一小部分极端客户的评价不够细腻。由于这可能会给更精细的算法带来无法挽回的偏差,我们专门设置了另一套算法来满足他们的需求。需要你提供一些辅助函数来整理他们的数据。
虽然解决下面这些任务可能有多种方法,但每个任务都可以用不超过两个高阶函数解决。
评分系统以五星为基准,也就是由整数1:5组成。极端客户只会给出1或5的评分,我们想知道某位客户是否表现出这种行为。
实现all_15(),它接收一个评分向量,如果所有评分都是1或5,就返回true,否则返回 false。
all_15(c(2, 3, 4, 4, 1))
#> [1] FALSE
all_15(c(1, 5, 5, 1, 5))
#> [1] TRUE
我们需要把客户姓名(或 ID)与他们的评分关联起来。
实现name_customers(),它接收一个姓名向量和一个评分列表,把它们“配对”成一个列表的列表。
内层列表元素的名称应为name和rating。
names <- c("c1", "c2")
ratings <- list(c(2, 3, 5), c(1, 1, 5))
name_customers(names, ratings)
#> [[1]]
#> [[1]]$name
#> [1] "c1"
#> [[1]]$rating
#> [1] 2 3 5
#> [[2]]
#> [[2]]$name
#> [1] "c2"
#> [[2]]$rating
#> [1] 1 1 5
我们需要把更极端的客户与其他客户分开。
实现emphatics(),它接收客户和评分,返回一个列表,其中只保留只用1或5星评分的客户。`
# names and ratings as in the task 2 example
emphatics(names, ratings)
#> [[1]]
#> [[1]]$name
#> [1] "c2"
#> [[1]]$rating
#> [1] 1 1 5
由于极端客户只用1和5两种评分,把这些评分转换成0和1在计算上会更方便。
实现to_binary(),它接收一个极端评分向量,返回二进制评分,其中1已转换为0,5已转换为1。
to_binary(c(1, 1, 5, 5, 1))
#> [1] 0 0 1 1 0
随着你对每位客户(而不只是极端客户)的口味了解得越来越深入,你会希望不断改进为他们提供的推荐。
实现satisfactions(),它接收一个评分向量,返回截至当前为止的累计平均评分。把结果四舍五入到 2 位小数。
希望这些数字不断增长,让客户满意!
satisfactions(c(1, 2, 2, 3))
#> [1] 1.00 1.50 1.67 2.00