函数与函数式编程
函数是代码模板。
前面我们使用符号(Symbol)来对数据抽象形成我们所谓的变量,变量名解释了所指向数据的内含但遮掩了底层的结构。类似地,我们也利用符号来对代码块所运行的操作集合进行抽象,并将其称为函数。
- 变量 <- 数据。
- 函数 <- 操作。
这样,函数就使得一组操作可以像使用变量那样重复使用了。
创建和使用函数
我们通过自定义一个计算均值的函数来查看函数是如何创建的:
customMean <- function(x) { # x 是输入参数
# 以下是操作集合,即代码块
s <- i <- 0
for (j in x) {
s <- s + j
i <- i + 1
}
return(s / i) # s / i 是返回值
}
一个函数包含输入参数、代码块和返回值 3 部分。当函数中没有使用 return()
时,函数默认会返回最后一个表达式的结果,因此上述代码中将 return(s / i)
改为 s / i
是完全一样的,但后者代码逻辑没有前者清楚。
接下来我们看如何使用这个函数。在创建函数时其实我们已经默认假设输入的是一个数值向量,先试试看:
customMean(x = c(1, 2, 3))
结果是对的。
假设我们不仅仅想返回结果,还想要打印计算信息,实现如下新的函数版本:
customMean_v2 <- function(x) {
s <- i <- 0
for (j in x) {
s <- s + j
i <- i + 1
}
mu <- s / i
message(
"Mean of sequence ",
paste(x, collapse = ","),
" is ",
mu
)
return(mu)
}
再来看下结果:
customMean_v2(x = c(1:3))
这样结果看起来更加人性化了。但仔细思考一下,更新后的函数引入了新的问题:如果有 10000 个数字相加,这样打印信息还是一件好事吗?
我们不妨再引入一个新的函数版本,这个版本处理打印以及如何打印的问题:
customMean_v3 <- function(x, verbose = TRUE) {
s <- i <- 0
for (j in x) {
s <- s + j
i <- i + 1
}
mu <- s / i
if (verbose) {
l <- length(x)
if (l > 10) {
message(
"Mean of sequence ",
paste(c(x[1:5], "...", x[(l-4):l]), collapse = ","),
" is ",
mu
)
} else {
message(
"Mean of sequence ",
paste(x, collapse = ","),
" is ",
mu
)
}
}
return(mu)
}
我们用这个函数试一下输入少或多的情况。
customMean_v3(x = 1:10)
customMean_v3(x = 1:100)
除此之外,我们在新的版本中引入了一个默认参数 verbose
,我们可以选择不打印信息:
customMean_v3(x = 1:100, verbose = FALSE)
当按顺序输入函数参数时,参数的名称是可以不输入的,下面的结果一致:
customMean_v3(1:100, FALSE)
以上的输入都是基于函数使用者很清楚的知道输入是一个数值型向量,有时候这一点很难做到。例如,你将代码发送给一位不懂编程的人员使用。此时,添加参数检查和注释是有必要的,我们由此创建一个新的函数版本:
# @title 计算均值
# @param x 输入数据,一个数值向量
# @param verbose 逻辑值,控制是否打印
customMean_v4 <- function(x, verbose = TRUE) {
if (!is.numeric(x)) {
stop("输入数据必须是一个数值型向量!")
}
s <- i <- 0
for (j in x) {
s <- s + j
i <- i + 1
}
mu <- s / i
if (verbose) {
l <- length(x)
if (l > 10) {
message(
"Mean of sequence ",
paste(c(x[1:5], "...", x[(l-4):l]), collapse = ","),
" is ",
mu
)
} else {
message(
"Mean of sequence ",
paste(x, collapse = ","),
" is ",
mu
)
}
}
return(mu)
}
以#
开始的文本被 R 认为是一个代码注释,后续 @title
和 @param
是注释标签,这些是非必需的,它只是用来更好地描述注释的内容。
代码标签符合 roxygen2 包的定义,有兴趣的读者可以看一看这个包文档。
customMean_v4(c("1", "2", "3"))
最后,我们来了解一下函数的计算效率。这里我们将创建的 customMean()
函数与 R 内置的 mean()
函数进行对比。system.time()
函数用来判断函数执行消耗的时间。
system.time(customMean(1:1e7))
system.time(mean(1:1e7))
elapsed
项给出了计算机执行函数消耗的总时间(以秒为单位),可以看出,内置的函数还是要快很多的。当然,这并不是一个严格的性能测评,但它已经能清楚地表明两者的差距。
作用域
每个函数都有它的领地,更专业地说,当一个函数被创建后,R 中存在一个让这个函数发挥作用的环境。举一个比较形象的例子,冬天我们在购物商场外常常感到寒冷,而进去之后会感到暖和,这是因为商场空调的作用范围只是整个商场。
R 中所有的对象都处于各自的环境之中,我们可以把环境想象成城市里各种不同房子,而对象是处于其中的物品。当然这只是一些形象的比喻,实际上 R 的工作原理可能远不仅如此,但它已经能够很好地帮助理解我们这个概念了。
在启动 R 之后,我们就进去了一个全局环境之中(Global Environment),我们创建的各自变量、函数都会处于其中。这一点我们可以轻易地从 RStudio 右上角的环境窗口中观察到,如图
所示:
一个函数(如 customMean()
)与全局环境的关系可以简单用下面两个嵌套的矩形表示:
我使用了蓝色箭头来表示两者的从属关系,先有全局环境,再有函数环境。我使用绿色箭头表示函数查询变量的方向,先从自己内部查找,如果找不到,再从外部查找。
前面我们创建的函数内部都是自给自足的,下面我们创建一个不一样的。
a <- 3
Sum <- function(b) {
a + b
}
我在函数 Sum()
之外创建了一个变量 a
,而函数内部并没有创建相同名字的变量,这样的函数能够成功调用吗?
我们试试。
Sum(10)
结果显示是当然可行的。也就是说,当函数在自身内部无法查询到变量 a
的值时,它会向外面一层寻找。事实上,如果在外层还找不到,而且外层环境如果也处于另一个环境之中,它会再次往外面一层查找。如果按照这个规则真的都找不到,R 就会抛出错误。
zzzz
如果函数内部存在一个同名变量会怎么样呢?如果读者理解了上面我介绍的规则,那么应该不难猜到下面变量 result
保存的值。
a <- 3
Sum <- function(b) {
a <- 1
a + b
}
result <- Sum(10)
下面揭晓答案:
result
在某些情景下,我们可能需要在函数内部修改函数外部变量的值。此时我们可以引入新的操作符 <<-
,我们简单修改上面的代码看看 全局变量 a
变成了什么。
a <- 3
# 运行函数之前
a
Sum <- function(b) {
a <<- 1
a + b
}
result <- Sum(10)
# 运行函数之后
a
任意参数
我们在 R 中可能会经常看到函数的参数中有 ...
这样的符号,它代表可以传入任意长度的参数。
例如,我们利用它构造一个可以求取任意个参数之和的函数:
addAll <- function(x, ...) {
args <- list(...)
for (a in args) {
x <- x + a
}
return(x)
}
试一试效果:
addAll(1, 2, 3)
addAll(3, 4, 5, 6, 7, 8)
函数中我们使用了 list()
将传入的 ...
转换为列表,然后再进行处理。除此之外,我们还可以直接使用 ..1
、..2
等直接引用 ...
对象中的第 1 个元素、第 2 个元素。
函数式编程
函数不仅仅可以被调用,它还可以被当作函数的参数和返回值,这是函数式编程的特点。
传入和返回函数
例如,我们创建一个略显奇怪的函数:
f <- function(x, fun) {
fun(x)
}
它可以将常见的数值计算函数作为参数计算相应的结果,在讲解之前我们先看看效果:
f(1:10, sum)
f(1:10, mean)
f(1:10, quantile)
不难理解,上述代码中发挥计算功效的是函数的第 2 个参数。在我们创建的函数 f()
中,我们可以理解为对传入函数的 mean()
、sum()
等函数重命名成 fun()
并进行调用。
我们还可以构建一个函数作为返回值的例子:
f2 <- function(type) {
switch(type,
mean = mean,
sum = sum,
quantile = quantile)
}
f()
函数使用了 switch 语句,如果使用 if-else 语句实现该函数也是可以的(读者不妨一试),但此处 switch 让代码更加简明。
下面看看效果:
f2("mean")
f2("sum")
f2("quantile")
返回的全部都是函数,那么我们是不是可以直接调用它呢?
f2("mean")(1:10)
事实证明是可以的。
虽然上面只是通过 2 段简单的代码展示函数式编程的特性,但不难想象到它给 R 语言编程赋予了更多的灵活性。
apply 家族
apply 函数家族包括 apply()
、lapply()
、sapply()
和 vapply()
等成员,其中前三者比较常用。apply 函数家族正是以函数作为输入来进行批量计算,因此它可以取代我们之前学习的循环控制。
apply
apply()
最常用,针对的也是最常见的表格型数据,在 R 中为矩阵或数据框。
为了展示它的用法和效率,这里我先构造一个 100 列 100,000 行的服从正态分布的数据矩阵:
# 设置随机种子数
set.seed(1234)
mat <- matrix(rnorm(1e7), ncol = 100, byrow = TRUE)
# 展示数据维度
dim(mat)
# 查看少量数据
mat[1:5, 1:5]
现在如果我们想要计算每一行的均值,该怎么实现呢(不使用 rowMeans()
函数)?
先试试使用之前学习的 for 循环构建一个计算函数:
calcRowMeans <- function(mat) {
# 先初始化一个结果向量
# 这样更有效率
res <- vector("numeric", nrow(mat))
for (i in 1:nrow(mat)) {
res[i] <- mean(mat[i, ])
}
return(res)
}
看一下该函数的用时:
system.time(
rm <- calcRowMeans(mat)
)
10 万行的过程花了不到 1 秒的时间,计算效率着实不低。如果是 apply()
该怎么写呢?效率又如何?
system.time(
rm <- apply(mat, 1, mean)
)
在 apply 写法中,我们没有新建函数,而是直接利用 R 内置的 mean()
函数直接进行计算,相比 for 循环此处的计算效率虽然未见明显提升,但代码却被极度精简了。
让我们来看一下 apply()
是如何完成计算的,其结构如下:
apply(X, MARGIN, FUN, ...)
第 1 个参数是数组(可以是矩阵和数据框),第 2 个参数是设置按行(设为 1
)或列(设为 2
)逐行取子集,第 3 个参数是对子集调用的函数,接下来是传入函数 FUN
中的可选参数列表。前 3 个参数最关键。
因此,在 rm <- apply(mat, 1, mean)
中 apply()
所做的是逐行提取矩阵 mat
的值并传入函数 mean()
进行 计算,然后返回结果。
利用 apply()
我们可以抛弃 for 循环对矩阵按行按列各种运算:
# 行和
r <- apply(mat, 1, sum)
# 行最大值
r <- apply(mat, 1, max)
# 行最小值
r <- apply(mat, 1, min)
# 列和
r <- apply(mat, 2, sum)
# 列最大值
r <- apply(mat, 2, max)
# 列最小值
r <- apply(mat, 2, min)
上面第 3 个参数传入的函数都是 R 内置的,我们完全可以传入自定义函数,这没有区别。
整体上看,apply()
非常得精简灵活。
lapply、sapply 和 vapply
lapply()
、sapply()
和 vapply()
针对的都是列表结构的数据,sapply()
是简化版本的 lapply()
,而 vapply()
则在 sapply()
的基础上加了结果验证,以保证可靠性。
我们假设有 4 组温度数据:
set.seed(1234)
temp <- list(
35 + rnorm(10, mean = 1, sd = 10),
20 + rnorm(5, mean = 1, sd = 3),
25 + rnorm(22, mean = 2, sd = 6),
33 + rnorm(14, mean = 4, sd = 20)
)
现在要求取每一组的温度最小、最大、平均值与中位数。我们针对列表的子集创建处理函数:
basic <- function(x) {
c(min = min(x), mean = mean(x), median = median(x), max = max(x))
}
直接将列表数据、处理函数依次传入 lapply()
函数:
lapply(temp, basic)
虽然传入的列表子集不是等长的,但处理的结果却是等长的,因此上述输出看起来略显冗余。
因此我们使用 sapply()
进行简化,它的用法与 lapply()
相同,函数名中的 s
是简化(simplified)的首字母缩写。
sapply(temp, basic)
是不是更加紧凑?
我们再看一下 vapply()
:
vapply(temp, basic, numeric(4))
结果与 sapply()
完全一致。vapply()
第 3 个参数传入对每一个子集调用函数后结果的预期,上述设定为包含 4 个元素的数值型向量。
如果与预期不一致,R 会抛出错误信息:
vapply(temp, basic, numeric(3))
apply 函数家族还有其他成员,如 tapply()
,由于使用频率较低,这里就不再过多介绍。如果有需要,读者也能够基于上述知识轻松地通过 R 提供的代码示例进行快速学习和掌握。