R语言函数:定义、默认参数与词法作用域完全指南
分析脚本写长了必然会遇到重复代码:同一段数据清洗在三个地方出现,改一处忘两处。函数是 R 里唯一的复用单位,把「一次做对的事」固定下来,后面就只剩调用。写函数的门槛不高,但参数、作用域这两块不清楚的话,脚本会从「能跑」变成「不知道为什么会跑出这个结果」。
定义与返回值
Section titled “定义与返回值”add <- function(a, b) a + badd(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] NANA_real_ 明确指定了类型。只写 NA 得到的是 logical 型缺失值,返回值类型会随输入变化,调用方不好处理。
参数:默认值可以引用其他参数
Section titled “参数:默认值可以引用其他参数”默认参数在函数内部求值,所以可以写成依赖其他参数的表达式:
nrm <- function(x, center = mean(x), scale = sd(x)) (x - center) / scaleround(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 + cr(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 * 2lazy(3, stop("这个参数永远不会被求值"))[1] 6b 从头到尾没被用到,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。
函数里的修改不影响外面
Section titled “函数里的修改不影响外面”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 这种赋值创建的是局部变量,函数结束后就没了,不会污染全局环境。
作用域:词法作用域
Section titled “作用域:词法作用域”函数内部找不到的变量,R 会到函数被定义时的环境里找,而不是调用它的地方。这个规则叫词法作用域(lexical scoping):
x <- 100f <- function() xg <- function() { x <- 1; f() }g()[1] 100g() 里明明有 x <- 1,f() 却返回 100。因为 f 是在全局环境定义的,它去那里找 x。很多人第一次看到这个结果会以为 R 出错了。理解它的价值在写嵌套函数、写需要共享配置的函数时:函数的查找路径在你写代码时就定下来了,不取决于谁调用它。
查找顺序是:函数内部 → 定义环境 → 全局环境 → 已加载的包。所以全局环境里只要有个叫 mean 的变量,函数里的 mean(x) 就会失效。给变量起名避开内置函数名(c、df、mean、T、F)能省掉不少离奇报错。
<<- 与全局变量
Section titled “<<- 与全局变量”<<- 会跳过当前层,去外层环境里找同名变量并修改它:
total <- 0acc <- 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 <- 0acc2 <- function(x) { total2 <- total2 + sum(x); total2 }acc2(1:3)total2[1] 6[1] 0函数返回 6 看着没问题,但外层的 total2 还是 0。因为函数体里的赋值创建了一个新的局部变量,右边的 total2 读的是全局那份。调用方以为累加生效了,实际什么都没留下。
结论是:能用返回值就不要用 <<-。真需要跨调用保持状态,把状态放在一个环境(environment)里显式管理,比到处撒 <<- 清晰得多。
函数不要依赖全局变量
Section titled “函数不要依赖全局变量”这是新手写分析脚本最容易犯的错:
df <- airqualitybad_mean <- function() mean(df$Temp, na.rm = TRUE)bad_mean()df <- ToothGrowthbad_mean()[1] 77.88235[1] NAWarning 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():该静默返回的时候
Section titled “invisible():该静默返回的时候”有些函数的价值在于副作用(打印、画图、写文件),返回值只是顺便。invisible() 让返回值可以赋给变量,但不在控制台打印:
quiet <- function(x) { cat("计算了", length(x), "个数\n") invisible(sum(x))}quiet(1:3)s <- quiet(1:3)s计算了 3 个数计算了 3 个数[1] 6第一次调用只看到那行提示,第二次照样能看到提示,但多出来的返回值被赋给了 s 并打印成 6。plot()、library()、print() 都用这个机制——library(tidyverse) 不会在屏幕糊一大堆包信息之外的东西,就是这个道理。
完整示例:一个描述统计函数
Section titled “完整示例:一个描述统计函数”把上面几件事合起来:参数带默认值、提前退出、返回命名向量。这个函数从数据里算出样本量、均值、标准差、标准误和 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 upper116.00 42.13 32.99 3.06 36.06 48.20 n mean sd se lower upper30.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 upper30.00 5.07 0.70 0.13 4.81 5.33 n mean sd se lower upper150.000 5.843 0.828 0.068 5.710 5.977n 是整数,跟其他数字一起 round() 之后显示成 30.00,报告里这样够看。
如果这两个函数你想用 Python 对照着写一遍,NumPy 里的 mean、std 和 scipy.stats.t 组合起来是同一套逻辑,可以看 /python/basics/numpy。函数写完,基础部分就齐了;接下来是 tidyverse 的数据操作,从 /r/tidyverse/dplyr-basics 开始。