什么是函数式编程?
这是个宏大的问题,值得长篇作答(维基百科给出了一份答案,Advanced R给出了另一份)。
就我们的目的而言,以下是几个关键特性:
不过,R 并不是一门纯函数式语言(与之相对的例子是 Haskell)。
把 R 看作一门务实的语言,当函数式能力能干净利落地完成任务时,它(越来越多地)提供这些能力。 通常,数据分析才是目标,编程只是达到这个目标的手段。
用管道 |> 连接函数,是一种在多种语言中越来越常见的技术。
简要来说:
有些程序员在阅读代码时,习惯把管道读作“then”:先做第 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"
这种管道在 v4.1.0 中被加入 Base R,你可能看到它被称为“原生管道”。
以前,管道是通过magrittr包里的%>%来实现的(这个名字是献给艺术爱好者的一个双关)。
“magrittr 管道”仍然可用,但原生管道不需要外部包,而且某些实现细节也更简单。
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 函数:
vapply()和sapply()类似,但它还有一个必填的形参,用来指定输出格式。apply()用于矩阵和更高维的数组。mapply()用于并行处理多个列表或向量实参。tapply()比较难解释,但它适用于分组操作和不规则数组。vectorize()是个相当神秘的函数,看起来是mapply()的包装。够了,别再说了!
为这类混乱提供更简单、更一致的替代方案,是 Tidyverse 的一个关键目标。
现在有了purrr包,它提供了一些额外的选项,比如map(),同时在设计上更一致,错误信息也更清晰。
与 Base R 相比,这个包受向下兼容的约束更少,它让 R 大大靠近了其他语言中那种现代函数式编程。
purrr中的函数又多又杂,很难简单概括。
有助于了解这个库的资源包括:
purrr库几乎只处理一维数据:向量和列表。
我们会在后面的概念中看到,dplyr库为二维数据框提供了等价的功能。
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。
映射函数通常返回与输入等长的值。
另一些函数会_降低_数据的维度,例如sum()。
sum(1:100)
#> [1] 5050
在上面的例子里,我们把一个长度为 100 的向量转换成了一个长度为 1 的整数。 我们会在之后关于矩阵与数组的概念中看到,降维这个概念其实更普遍。
sum()函数是内置的(许多其他统计函数也是如此)。
不过,我们需要一种方式,用某个相当于map()的高阶函数,把任意的降维函数应用到整个数据结构上。
在其他几种语言里,会用到fold、foldl和foldr这样的函数名。在 R 里,相应的函数是reduce(),它对应着(对算法爱好者来说)大名鼎鼎的MapReduce框架。
第一个实参照例是输入数据。第二个实参是一个接受两个实参的函数:通常是匿名函数,但也可能简单到只是一个像+这样的算术运算符。
# 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
一些常见的累加类型有各自的函数。
向量函数概念讨论了 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 里已经有完全不同的含义)。
相应的purrr函数包括keep()和discard(),还有另外几个。
另外,我们还有every()、some()和none(),用来替代all()和any(),返回单个逻辑值。
大多数函数式语言都用递归作为循环的首选替代方案。
R 有别的、往往更好的选择:向量化函数和高阶函数。
不过,R 支持递归,而且在遍历树结构之类的场景中可能很有用。
最大递归深度默认是 5000,这相对比较宽裕(Python 默认是 1000)。 也可以覆盖这个默认值。
尾调用优化在 R 里不是标准特性,不过已经有人开发出了一些变通办法。
按照传统,此处应当展示一个阶乘的例子,作为递归定义的经典示例。
fact <- function(n) ifelse(n == 0, 1, n * fact(n - 1))
fact(5)
#> [1] 120
# some more idiomatic R:
factorial(5)
#> [1] 120
prod(1:5)
#> [1] 120
你可以在 R 里使用递归,有时它很有价值,但通常还有更简单的做法。