purrr 函数式编程:用 map 家族替代 for 循环
分析脚本里出现 for 循环,通常意味着要对一组对象重复做同一件事:对 11 个变量各拟合一个模型、对 30 个受试者各画一张图、对 60 个文件各跑一遍清洗函数。循环本身没问题,问题在于循环的样板代码——先建一个长度正确的容器、再按下标逐个填、还得保证类型不出错。填错一个下标,结果就是错的,而且不一定报错。
purrr 的 map 家族把这套样板拿掉:你只需要说明「对每个元素做什么」,循环和容器交给函数处理。
map:返回列表
Section titled “map:返回列表”library(tidyverse)
map(1:3, \(x) x^2)[[1]][1] 1
[[2]][1] 4
[[3]][1] 9\(x) x^2 是 R 4.1 起的匿名函数简写,等价于 function(x) x^2。map() 的返回值永远是列表,长度和输入一致。列表的好处是能装下任何类型的结果——一次回归的 lm 对象、一张图、一个长度不定的向量。lm() 结果这种复杂对象只能用列表装,这也是 map() 而不是 map_dbl() 的用武之地。
数据框在 R 里就是「每个元素是一列」的列表,所以 map() 可以直接按列迭代:
map_dbl(mtcars, mean) mpg cyl disp hp drat wt qsec 20.090625 6.187500 230.721875 146.687500 3.596563 3.217250 17.848750 vs am gear carb 0.437500 0.406250 3.687500 2.812500这行代码算出了 mtcars 全部 11 个变量的均值。map_dbl() 和 map() 的唯一区别是返回值:它要求每个结果都是长度为 1 的数值,并把这些结果拼成一个数值向量——能直接参与后续计算,打印时也不会刷屏。
类型稳定的 map_dbl、map_chr、map_lgl、map_int
Section titled “类型稳定的 map_dbl、map_chr、map_lgl、map_int”这四个函数把类型写进了名字,也写进了检查:结果类型不匹配时立刻报错,而不是悄悄降级。base R 的 sapply() 正相反——输入长度不同或类型不一,它会返回一个列表,下游代码就得额外判断返回值到底是什么。map_dbl() 报错虽然让人烦躁,但它把「结果类型失控」这类 bug 提前到了出错的那一行。
按组迭代是最常见的用法,配合 split() 使用:
map_dbl(split(mtcars, mtcars$cyl), nrow) 4 6 811 7 14split() 按 cyl 把数据拆成三个数据框组成的列表,map_dbl() 对每个元素数行数,返回带名字的向量:四缸 11 辆、六缸 7 辆、八缸 14 辆。这个模式(split() + map_*() + 汇总)在 R 里就是「分组统计」的通用写法,dplyr 的 group_by() + summarise() 能覆盖的场合优先用后者,但涉及模型对象、绘图对象这些 summarise() 装不下的东西时,还是得回到 split() + map()。
匿名函数的三种写法
Section titled “匿名函数的三种写法”map_dbl(1:3, function(x) x + 1) # 完整写法,任何版本都支持map_dbl(1:3, \(x) x + 1) # R 4.1 起,最简洁map_dbl(1:3, ~ .x + 1) # purrr 的公式写法,.x 代表当前元素三种写法完全等价,选一种在项目里用到底就行。~ 写法里 .x 是第一个参数、.y 是第二个,超过两个参数就用 ..1、..2、..3 按位置引用——这是它相对 \(x) 的缺点:参数多了可读性会明显下降。公式写法在 pmap() 里很常见,但要留意 .x 在多层嵌套时容易指错对象。
map_dfr:把结果拼成数据框
Section titled “map_dfr:把结果拼成数据框”map() 返回列表,要进一步分析通常得拼成数据框。map_dfr() 就是「map + 按行合并」,.id 参数还能把列表元素的名字变成一列:
split(ToothGrowth, ToothGrowth$supp) |> map_dfr(\(df) data.frame(n = nrow(df), mean_len = mean(df$len)), .id = "supp")# A tibble: 2 × 3 supp n mean_len <chr> <int> <dbl>1 OJ 30 20.72 VC 30 17.0每个元素算出一个单行数据框,map_dfr() 把它们摞起来,.id = "supp" 把列表名(OJ、VC)写成第一列。注意回调函数必须返回数据框(data.frame() 或 tibble() 都行),返回向量会报错。
purrr 1.0 起,map_dfr() 被标记为「已替代」(superseded),新的等价写法是 map(...) |> list_rbind(),功能相同,只是把「映射」和「合并」两步分开了。老代码里的 map_dfr() 不会失效,两种写法学一种即可。
walk:只要副作用
Section titled “walk:只要副作用”有些操作的目的是「做点什么」而不是「返回什么」:把每张图存成 PNG、把每个数据框写成 CSV。这时候用 walk()——它和 map() 一样逐个执行,但返回的是输入本身(不可见),不会在控制台刷出一堆结果:
split(mtcars, mtcars$cyl) |> walk(\(df) cat(nrow(df), "行已处理\n"))11 行已处理7 行已处理14 行已处理画图时这个区别很实际:map() 会把每个 ggplot 对象都打印一遍,walk() 只画到设备上。想同时拿到下标,用 iwalk()(传 .x 和 .y,分别是元素和它的名字)或者 walk2()。
map2 与 pmap:多个输入
Section titled “map2 与 pmap:多个输入”map2() 同时遍历两个等长向量,\(x, y) 里分别接两个序列的元素:
map2_dbl(mtcars$mpg[1:4], mtcars$wt[1:4], \(m, w) round(m / w, 2))[1] 8.02 7.30 9.83 6.66这是「每加仑英里数 ÷ 车重」的前四辆车。map2() 要求两个输入长度相同,长度不等会报错。超过两个输入就用 pmap():它接收一个列表(或数据框),数据框按行迭代,正好可以当作参数表来用:
params <- data.frame(x = 1:3, y = 4:6, z = 7:9)
pmap_dbl(params, \(x, y, z) x + y * z)[1] 29 42 57数据框的列名自动对应到函数的参数名,所以 pmap() 的回调可以直接写 \(x, y, z) 而不用 ..1、..2。用 ~ 写法时参数名取不到,只能写 ~ ..1 + ..2 * ..3——这也是 pmap() 里优先用 \(...) 的原因。
possibly 与 safely:让一处失败不拖垮整轮
Section titled “possibly 与 safely:让一处失败不拖垮整轮”批量拟合 60 个模型,第 37 个因为数据太稀疏而报错,整个 map() 就会中断,前 36 个结果一起丢掉。possibly() 把函数包装成「出错时返回兜底值」:
safe_log <- possibly(log, otherwise = NA_real_)
map_dbl(list(1, 10, "a"), safe_log)[1] 0.000000 2.302585 NAlog("a") 会报错,包装之后这一处变成 NA,其余结果照常返回。safely() 更细一些:它返回一个列表,里面 result 是正常结果、error 是错误信息,适合事后统计「到底哪几个失败了、为什么」。quietly() 处理的是警告和消息而不是错误,批量建模时用来收警告。
兜底值的选择要看语义:NA 表示「这里没有结果」,NULL 表示「这里什么都没有」,数字 0 则会让下游的 mean() 静默失真。用 0 之前先想清楚,它对统计量的影响是实打实的。
什么时候不该用 map
Section titled “什么时候不该用 map”- 向量化运算能直接做的事。
x^2、sqrt(x)对向量本来就是逐元素计算的,写成map_dbl(x, \(i) i^2)只是绕远路,还更慢。 - 结果依赖上一次迭代。累积求和、递推这类逻辑,用
accumulate()、reduce(),或者老实写 for 循环。map()的设计前提是每一步互相独立。 - 需要中途跳出。
map()不能break。要「找到第一个满足条件的就停」,用循环加break,或者先detect()、keep()等purrr的筛选函数。 - 性能敏感且结果简单。
map()不比 for 循环快,两者本质上都是 R 层面的循环。真正的加速来自向量化、data.table或者把内层函数写成 Rcpp——map换来的是可读性和类型稳定,不是速度。
purrr 的循环抽象和 Python 的列表推导、numpy 的向量化解决的是同一类问题:把「对每个元素做什么」和「怎么遍历」分开。如果你也用 Python,可以对照 /python/basics/numpy 看向量化在另一边是怎么做的;函数本身怎么写、默认参数和返回值怎么设计,则在 /r/basics/functions 里有更基础的一遍。