跳到正文

R语言函数:定义、默认参数与词法作用域完全指南

分析脚本写长了必然会遇到重复代码:同一段数据清洗在三个地方出现,改一处忘两处。函数是 R 里唯一的复用单位,把「一次做对的事」固定下来,后面就只剩调用。写函数的门槛不高,但参数、作用域这两块不清楚的话,脚本会从「能跑」变成「不知道为什么会跑出这个结果」。

add <- function(a, b) a + b
add(2, 3)
add(b = 3, a = 2)
[1] 5
[1] 5

函数体只有一行时可以省略大括号。参数可以按位置传,也可以按名字传——按名字传更啰嗦,但参数一多就不用去数位置了。

参数没给值又不带默认值,会报错:

add(2)
Error in add(2) : argument "b" is missing, with no default

这个报错信息很直白,遇到时看函数签名就能定位。

函数体有多行时,最后一行表达式的值就是返回值,不需要写 return()

f <- function(x) {
y <- x^2
y + 1
}
f(3)
[1] 10

很多人从别的语言转过来,会在每行末尾都加 return()。语法上没错,但 R 里没必要,而且一旦写成 return() 后换行,后面的代码永远不执行——这类「写了但没跑到」的死代码很难发现。

真正需要 return() 的是提前退出。下面这个函数遇到非正数就不继续算了:

safe_log <- function(x) {
if (any(x <= 0)) return(NA_real_)
log(x)
}
safe_log(c(1, exp(1)))
safe_log(c(1, -2))
[1] 0 1
[1] NA

NA_real_ 明确指定了类型。只写 NA 得到的是 logical 型缺失值,返回值类型会随输入变化,调用方不好处理。

参数:默认值可以引用其他参数

Section titled “参数:默认值可以引用其他参数”

默认参数在函数内部求值,所以可以写成依赖其他参数的表达式:

nrm <- function(x, center = mean(x), scale = sd(x)) (x - center) / scale
round(nrm(1:5), 3)
round(nrm(1:5, center = 0), 3)
[1] -1.265 -0.632 0.000 0.632 1.265
[1] 0.632 1.265 1.897 2.530 3.162

第一行把数据标准化成均值 0、标准差 1;第二行只把中心移到 0,不缩放。这个默认值链是 R 很实用的一个特性,省掉了写 if (missing(center)) 这类判断。

参数是按顺序匹配的,部分匹配也允许(r(1, c = 2) 里的 c 唯一匹配到 c 参数),但除了交互式敲命令,永远按名字传关键参数

r <- function(a, b = 10, c = 100) a + b + c
r(1)
r(1, 2)
r(1, c = 2)
[1] 111
[1] 103
[1] 13

注意第二个调用 r(1, 2) 改的是 b 而不是 c。真实例子里参数常常有五六个,靠位置传参是给自己埋雷。

还有个特性容易让人意外:R 的参数是惰性求值(lazy evaluation),传进去的表达式在函数真正用到它之前不会计算。

lazy <- function(a, b) a * 2
lazy(3, stop("这个参数永远不会被求值"))
[1] 6

b 从头到尾没被用到,stop() 也就没执行。好处是能省掉无用计算;坏处是报错会出现在离问题很远的地方,栈里看不出是谁的锅。调试时如果错误位置莫名其妙,先检查参数是不是在函数深处才被求值。

… 参数:继续传递,而不是吞掉

Section titled “… 参数:继续传递,而不是吞掉”

... 用来接收任意多个参数,再原样转发给内部函数:

my_mean <- function(..., na.rm = FALSE) mean(c(...), na.rm = na.rm)
my_mean(1, 2, 3, NA)
my_mean(1, 2, 3, NA, na.rm = TRUE)
[1] NA
[1] 2

na.rm 单独写成有名参数是关键。如果只写 function(...) mean(c(...)),那么调用 my_mean(1, 2, 3, NA, na.rm = TRUE) 时,na.rm = TRUE 会被 c() 当作一个元素收进去,c() 里多出一个逻辑值 TRUE,均值照样是 NA,而且不报错。

... 之后如果还有别的参数,调用时必须写全名传值,R 不会对它们做位置匹配。lapply(x, f, na.rm = TRUE) 能跑通,靠的就是把 na.rm 按名字转发给 f

R 的函数调用是传值语义:传进去的是副本,函数内的修改不会影响调用方的变量。

modify <- function(v) { v[1] <- 999; v }
z <- c(1, 2, 3)
modify(z)
z
[1] 999 2 3
[1] 1 2 3

函数返回的 999 2 3 是一个新向量,z 一点没变。想要结果就用返回值接收:z <- modify(z)。同理,函数里 x <- 1 这种赋值创建的是局部变量,函数结束后就没了,不会污染全局环境。

函数内部找不到的变量,R 会到函数被定义时的环境里找,而不是调用它的地方。这个规则叫词法作用域(lexical scoping):

x <- 100
f <- function() x
g <- function() { x <- 1; f() }
g()
[1] 100

g() 里明明有 x <- 1f() 却返回 100。因为 f 是在全局环境定义的,它去那里找 x。很多人第一次看到这个结果会以为 R 出错了。理解它的价值在写嵌套函数、写需要共享配置的函数时:函数的查找路径在你写代码时就定下来了,不取决于谁调用它。

查找顺序是:函数内部 → 定义环境 → 全局环境 → 已加载的包。所以全局环境里只要有个叫 mean 的变量,函数里的 mean(x) 就会失效。给变量起名避开内置函数名(cdfmeanTF)能省掉不少离奇报错。

<<- 会跳过当前层,去外层环境里找同名变量并修改它:

total <- 0
acc <- function(x) { total <<- total + sum(x); total }
acc(1:3)
acc(1:3)
total
[1] 6
[1] 12
[1] 12

每次调用都把外层的 total 改掉。看着方便,但它把函数变成了有状态的东西:同样调用两次 acc(1:3),第一次返回 6、第二次返回 12,结果取决于调用历史,测试的时候没法独立验证某一次调用。

忘了写 <<- 是另一种失败方式,而且更隐蔽:

total2 <- 0
acc2 <- function(x) { total2 <- total2 + sum(x); total2 }
acc2(1:3)
total2
[1] 6
[1] 0

函数返回 6 看着没问题,但外层的 total2 还是 0。因为函数体里的赋值创建了一个新的局部变量,右边的 total2 读的是全局那份。调用方以为累加生效了,实际什么都没留下。

结论是:能用返回值就不要用 <<-。真需要跨调用保持状态,把状态放在一个环境(environment)里显式管理,比到处撒 <<- 清晰得多。

这是新手写分析脚本最容易犯的错:

df <- airquality
bad_mean <- function() mean(df$Temp, na.rm = TRUE)
bad_mean()
df <- ToothGrowth
bad_mean()
[1] 77.88235
[1] NA
Warning message:
In mean.default(df$Temp, na.rm = TRUE) :
argument is not numeric or logical: returning NA

同一个函数,两次调用给出完全不同的结果,只因为全局的 df 被换掉了。更糟的是它不报「找不到变量」,而是返回一个 NA 加一条容易忽略的警告。这种函数没法单独测试,也没法在另一个脚本里复用。

把数据作为参数传进去就解决了:

good_mean <- function(data, var) mean(data[[var]], na.rm = TRUE)
good_mean(airquality, "Temp")
good_mean(ToothGrowth, "len")
[1] 77.88235
[1] 18.81333

判断标准:函数体里出现了一个既不是参数、也不是函数内部创建的变量,那它就是在依赖全局状态。写完顺手检查一遍,把该传的参数补上。

有些函数的价值在于副作用(打印、画图、写文件),返回值只是顺便。invisible() 让返回值可以赋给变量,但不在控制台打印:

quiet <- function(x) {
cat("计算了", length(x), "个数\n")
invisible(sum(x))
}
quiet(1:3)
s <- quiet(1:3)
s
计算了 3 个数
计算了 3 个数
[1] 6

第一次调用只看到那行提示,第二次照样能看到提示,但多出来的返回值被赋给了 s 并打印成 6plot()library()print() 都用这个机制——library(tidyverse) 不会在屏幕糊一大堆包信息之外的东西,就是这个道理。

把上面几件事合起来:参数带默认值、提前退出、返回命名向量。这个函数从数据里算出样本量、均值、标准差、标准误和 95% 置信区间。

标准误(standard error)是均值这个估计量的标准差,等于样本标准差除以样本量的平方根;95% 置信区间(confidence interval)用 t 分布的分位数算,自由度是 n-1。

describe <- function(x, na.rm = TRUE, digits = 2, conf = 0.95) {
if (na.rm) x <- x[!is.na(x)]
n <- length(x)
se <- sd(x) / sqrt(n)
q <- qt(1 - (1 - conf) / 2, df = n - 1)
est <- c(n = n, mean = mean(x), sd = sd(x), se = se,
lower = mean(x) - q * se, upper = mean(x) + q * se)
round(est, digits)
}
describe(airquality$Ozone)
describe(ToothGrowth$len[ToothGrowth$supp == "OJ"])
n mean sd se lower upper
116.00 42.13 32.99 3.06 36.06 48.20
n mean sd se lower upper
30.00 20.66 6.61 1.21 18.20 23.13

第一行是纽约 1973 年臭氧浓度的日均值:116 天有效记录,均值 42.13,标准误 3.06,95% 置信区间 36.06 到 48.20。第二行是橙汁组 30 只豚鼠的牙齿长度,均值 20.66,区间 18.20 到 23.13。两组数据的置信区间不重叠,粗略看差异是显著的——正式检验要用 t.test(),不能只看区间叠不叠。

函数里每一处都只依赖参数:数据通过 x 进来,na.rm 控制缺失值处理,digits 控制输出精度,没有任何全局变量。这样的函数可以直接搬到另一个脚本里,也能单独写测试。

单看一列不够放心的话,换几个数据集试试:

describe(PlantGrowth$weight)
describe(iris$Sepal.Length, digits = 3)
n mean sd se lower upper
30.00 5.07 0.70 0.13 4.81 5.33
n mean sd se lower upper
150.000 5.843 0.828 0.068 5.710 5.977

n 是整数,跟其他数字一起 round() 之后显示成 30.00,报告里这样够看。

如果这两个函数你想用 Python 对照着写一遍,NumPy 里的 meanstdscipy.stats.t 组合起来是同一套逻辑,可以看 /python/basics/numpy。函数写完,基础部分就齐了;接下来是 tidyverse 的数据操作,从 /r/tidyverse/dplyr-basics 开始。