学习路径
/
R
R
/
教学大纲
/
函数式编程
函数

函数式编程 属于 R

1 个练习

关于 函数式编程

什么是函数式编程?

这是个宏大的问题,值得长篇作答(维基百科给出了一份答案,Advanced R给出了另一份)。

就我们的目的而言,以下是几个关键特性:

  • 纯函数,它一致地把输入映射到输出,并且没有副作用。
  • 不可变数据。
  • 一等函数,可以像其他任何类型一样被当作值来对待。
  • 高阶函数,它接受其他函数作为实参或返回值。

不过,R 并不是一门纯函数式语言(与之相对的例子是 Haskell)。

把 R 看作一门务实的语言,当函数式能力能干净利落地完成任务时,它(越来越多地)提供这些能力。 通常,数据分析才是目标,编程只是达到这个目标的手段。

管道

用管道 |> 连接函数,是一种在多种语言中越来越常见的技术。

简要来说:

  • 管道左侧的结果会成为右侧函数的第一个实参(这里的“右侧”会忽略可选的换行)。
  • 其余实参可以放进这个函数调用里。
  • 在 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中的函数又多又杂,很难简单概括。 有助于了解这个库的资源包括:

Note

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 里使用递归,有时它很有价值,但通常还有更简单的做法。

通过 GitHub 编辑 该链接会在新窗口或标签页中打开

学习 函数式编程