跳到正文

R控制流:if条件判断、for与while循环用法详解

R 的分支和循环语法跟其他语言差不多,真正需要适应的只有两件事:条件必须是长度 1 的逻辑值,以及显式循环在 R 里通常不是最优解。先把语法写对,再理解为什么要少用它。

if / else:条件只能是长度 1 的逻辑值

Section titled “if / else:条件只能是长度 1 的逻辑值”
x <- 7
if (x > 10) {
"big"
} else if (x > 5) {
"medium"
} else {
"small"
}
[1] "medium"

这里的 "medium" 是 if 块的返回值,所以整段 if/else 可以当表达式用,比如 y <- if (x > 5) "high" else "low"。这跟 C 系语言的写法不太一样,但省掉不少中间变量。

条件部分不能是向量:

v <- c(3, 8, 12)
if (v > 5) "yes" else "no"
Error in if (v > 5) "yes" else "no" : the condition has length > 1

R 4.2 之前这里只给一条警告,然后拿第一个元素当条件用;4.2 之后直接报错。这个改动是好事——v > 5 得到的是 c(FALSE, TRUE, TRUE),拿它当 if 的条件在逻辑上根本没有意义。想要逐个元素判断,用下面这个函数。

条件里的 NA 是另一类失败,同样会中断执行:

x <- c(3, NA, 12)
mean(x)
if (mean(x) > 5) "yes" else "no"
[1] NA
Error in if (mean(x) > 5) "yes" else "no" :
missing value where TRUE/FALSE needed

带缺失值的向量算均值返回 NANA > 5 仍然是 NA,而 if 不接受 NA——它既不是真也不是假。这类错误往往在数据更新之后才冒出来:流程前面跑了几个月都没事,某天原始数据里多了一个空值,脚本就停在这个 if 上。

要挡住它,先用 is.na() 判一次,或者用 isTRUE()

isTRUE(TRUE)
isTRUE(NA)
isTRUE(1)
[1] TRUE
[1] FALSE
[1] FALSE

isTRUE(x) 的定义是 is.logical(x) && length(x) == 1L && !is.na(x) && x,只有严格等于 TRUE 才返回 TRUEisTRUE(1)FALSE,因为 1 不是逻辑值。写 if (isTRUE(cond)) 能一次挡掉 NA、长度不为 1 的向量和数值型条件,代价是条件真写错时不再报错,而是静默走 else 分支——出错时静默和出错时崩溃,两者各有代价,按场景选。

ifelse(test, yes, no) 对向量的每个元素做判断,返回等长的结果:

v <- c(3, 8, 12)
ifelse(v > 5, "yes", "no")
[1] "no" "yes" "yes"

这个用法是对的,也很常用。但 ifelse() 有两个坑必须知道。

第一个坑:它会丢掉属性。ifelse() 组装结果时用的是向量子集赋值,Date、因子这类带 class 的对象经手之后会退化:

d <- as.Date("2026-01-01") + 0:2
r <- ifelse(c(TRUE, FALSE, TRUE), d, as.Date("2026-12-31"))
r
class(r)
[1] 20454 20818 20456
[1] "numeric"

日期变成了整数。因子也一样,会退化成字符。处理带 class 的向量时不要用 ifelse(),改用索引赋值:r <- d; r[!cond] <- ...,属性就能保留。

第二个坑:yesno 长度不足时会被静默循环补齐,跟 ifelse 的循环规则一样:

ifelse(c(TRUE, FALSE, TRUE, FALSE), "yes", c("no1", "no2"))
[1] "yes" "no2" "yes" "no2"

结果不报错,但很可能不是你想要的。两个分支老老实实写等长的向量。

最后:ifelse() 只适合处理简单的逐元素判断。嵌套三层以上就该换写法了,那种代码没人能读懂。

for 循环:用 seq_along(),别用 1:length()

Section titled “for 循环:用 seq_along(),别用 1:length()”

遍历下标的标准写法是 for (i in seq_along(x))

nm <- c("mpg", "wt", "hp")
for (i in seq_along(nm)) {
cat(i, nm[i], mean(mtcars[[nm[i]]]), "\n")
}
1 mpg 20.09062
2 wt 3.21725
3 hp 146.6875

为什么不用 for (i in 1:length(nm))?因为当 x 是空向量时,1:length(x) 会得到 1 0 而不是空:

x <- numeric(0)
length(x)
1:length(x)
seq_along(x)
[1] 0
[1] 1 0
integer(0)

1:0 在 R 里是合法的递减序列,于是循环跑两轮,x[1] 拿到 NA,代码不报错却算出了垃圾结果。这类 bug 在写通用函数时特别隐蔽——你自己的数据永远不为空,别人的数据可能是空的。seq_along(x) 永远返回与 x 等长的下标序列,空向量返回空,没有例外。

注意 for 遍历的是值而不是下标。for (v in c(2, 4, 6)) 里的 v 依次取到 2、4、6,需要下标时才用 seq_along()

两个小细节。循环变量在循环结束后依然存在:

for (i in 1:5) {}
i
[1] 5

如果函数里已经有一个叫 i 的变量,循环会把它覆盖掉。其次,在循环体内修改循环变量不会影响被遍历的向量,因为 for 每次都从原向量取下一个值:

z <- c(1, 2, 3)
for (v in z) {
v <- v * 2
}
z
[1] 1 2 3

想让 z 变,得按 for (i in seq_along(z)) z[i] <- z[i] * 2 这样写下标。

while 在条件为真时反复执行,适合「不知道要跑几轮」的场景:

n <- 1
while (n * 2 < 100) n <- n * 2
n
[1] 64

repeat 是无条件循环,必须靠 break 退出。两个关键字一起看:

for (i in 1:10) {
if (i %% 2 == 0) next
if (i > 7) break
cat(i, "")
}
1 3 5 7

next 跳过本轮剩下的语句直接进入下一轮,break 跳出整个循环。它们都只作用于最内层的循环——R 没有 break 2 这种跳出多层循环的语法。要一次跳出两层,只能设一个标志变量,或者把内层循环包成函数用 return() 返回。后者更干净。

while 时有个风险要自己兜住:如果条件永远为真,循环不会停。写的时候顺手加一个最大轮数限制,比如 while (cond && iter < 10000),比半夜发现脚本卡死要划算。

找到就停:break 与查找的向量化写法

Section titled “找到就停:break 与查找的向量化写法”

「在向量里找第一个满足条件的元素」是个常见需求,循环配合 break 是最直观的写法:

x <- c(3, 8, 12, 5, 20)
found <- NA
for (v in x) {
if (v > 10) {
found <- v
break
}
}
found
[1] 12

break 让循环在拿到答案后立刻停下,不必跑完全部元素。R 没有 Python 那种 for...else 语法,要区分「找到了」和「一个都没找到」,得靠初始值——上例把 found 初始化成 NA,循环结束后 is.na(found) 就说明没找到。若初始化成 0NULL 之外的任意值,这个判断就失效了。

同一件事在 R 里几乎总有不用循环的写法:

which(x > 10)
x[which(x > 10)[1]]
match(TRUE, x > 10)
[1] 3 5
[1] 12
[1] 3

which() 返回所有满足条件的下标,取第一个再索引就得到第一个匹配的元素。match(TRUE, x > 10) 返回第一次出现 TRUE 的位置,语义和 which(x > 10)[1] 相同,区别在中间产物:which() 会先把所有匹配位置物化成一个整数向量再取第一个,匹配很多时这块内存白花了;match() 找到第一个就停。两者都要先算完整的 x > 10,省下的只是下标向量。

只想问「有没有」而不关心是哪一个,用 any():它一碰到 TRUE 就返回,等价于带 break 的循环,但写在一条表达式里。

要特别注意:上面这三种写法在没有匹配项时行为不同which() 返回 integer(0),用 [1] 取元素得到 NAmatch() 返回 NAany() 返回 FALSE。写代码时想清楚「找不到」的分支该怎么走,比事后补参数检查便宜。

R 是解释型语言,for 循环的每一次迭代都要走一遍解释器;向量化函数则把整条运算交给编译好的 C 代码。差距有多大,让四种写法做同一件事(算出 1 到 20000 的平方),交错轮流跑 5 轮取中位数:

n <- 20000
f_append <- function(n) { out <- c(); for (i in 1:n) out <- c(out, i^2); out }
f_prealloc <- function(n) { out <- numeric(n); for (i in 1:n) out[i] <- i^2; out }
f_sapply <- function(n) sapply(1:n, function(i) i^2)
f_vector <- function(n) (1:n)^2
variants <- list(
"for + c() 拼接" = f_append,
"for + 预分配" = f_prealloc,
"sapply" = f_sapply,
"向量化" = f_vector
)
times <- lapply(variants, function(...) numeric(0))
for (round in 1:5) {
for (nm in names(variants)) {
times[[nm]] <- c(times[[nm]], system.time(variants[[nm]](n))[["elapsed"]])
}
}
base <- median(times[["for + 预分配"]])
for (nm in names(variants)) {
m <- median(times[[nm]])
cat(sprintf("%-16s %8.3f s %8.1fx\n", nm, m, m / base))
}

一次代表性输出:

for + c() 拼接 0.617 s 617.0x
for + 预分配 0.001 s 1.0x
sapply 0.016 s 16.0x
向量化 0.000 s 0.0x

表里以「for + 预分配」为基准。for + c() 拼接 慢 600 倍以上:R 的向量不可变,c() 每次都要复制整个向量,n 轮下来总代价是 O(n²)。f_prealloc 一开始就分配好长度 n 的容器,只做赋值。两者做的是同一件事,差别只在有没有预分配空间。真要写循环,先分配容器再填值是底线。

sapply 比手写的预分配循环慢一个数量级以上(实测 16 到 17 倍),这一条和常见的推荐相反。原因是 sapply 每一轮都要调用一次匿名函数,函数调用本身的开销远大于 out[i] <- i^2 这一步;它内部还要判断结果能否简化成向量或者矩阵。sapply 的价值是写得短、不用自己管下标和容器,不是跑得快。把 apply 家族当性能手段会失望。

向量化 的耗时落在计时精度之下(system.time 的分辨率约 1 毫秒),换更大的规模再测:

n <- 2e6
median(replicate(5, system.time(f_prealloc(n))[["elapsed"]]))
median(replicate(5, system.time(f_vector(n))[["elapsed"]]))
[1] 0.116
[1] 0.005

同样 200 万次运算,预分配循环 0.116 秒,向量化 0.005 秒,相差约 23 倍。绝对数字随机器和当时负载浮动,同一台机器上重复测也会有几个百分点的抖动,稳定的是量级关系。三条结论按收益从大到小排:能向量化就别写循环;必须写循环就先分配容器;sapply 用不用看可读性,别指望它提速。

更彻底的办法是不写循环。同一件事用 sapply() 一行就够:

sapply(1:3, function(i) i^2)
[1] 1 4 9

apply 家族的分工:lapply() 返回列表,sapply() 尽量简化成向量或矩阵,apply() 对矩阵的行或列批量计算(apply(iris[1:4], 2, mean) 一次算出四个列的均值),tapply() 按因子分组计算。它们内部仍然是循环,但省掉了你手写下标、预分配、拼接结果的全部环节,出错机会少得多。

判断标准很简单:结果能用一条向量化表达式算出来,就别写循环;确实要逐轮迭代(比如上一轮的结果决定下一轮的输入,或者循环里有读写文件、调外部程序这类副作用),才用 for

变异系数(coefficient of variation, CV)是标准差除以均值,用来比较量纲不同的几组数据的离散程度。对 iris 的四个数值列各算一个:

num_cols <- names(iris)[1:4]
cv <- numeric(length(num_cols))
for (i in seq_along(num_cols)) {
v <- iris[[num_cols[i]]]
cv[i] <- sd(v) / mean(v)
}
names(cv) <- num_cols
round(cv, 3)
Sepal.Length Sepal.Width Petal.Length Petal.Width
0.142 0.143 0.470 0.636

三段结构:先按长度分配容器 cv,再用 seq_along() 遍历下标,最后给结果加名字。列名用 [[ 取——因为 num_cols[i] 是字符串,iris[[num_cols[i]]] 才能按变量里的名字取列。写成 iris$num_cols[i] 拿到的是 NULL,因为 $ 后面跟的必须是列名本身,它不会去查同名变量;更糟的是这一步不报错,后面的计算结果全是 NA

结果读起来很清楚:花萼长度和宽度的变异系数都在 0.14 左右,花瓣长度 0.47、花瓣宽度 0.64。花瓣的离散程度是花萼的四倍多,所以用花瓣尺寸做品种判别比用花萼尺寸有效得多。

这段循环同样可以向量化:

sapply(iris[1:4], function(v) sd(v) / mean(v))
Sepal.Length Sepal.Width Petal.Length Petal.Width
0.1417113 0.1425642 0.4697441 0.6355511

一行搞定,不需要预分配、不需要命名、不用担心下标越界。写循环之前先想一下有没有这样的写法——这是 R 和大多数语言习惯上最不一样的一点。

sapply 有时不返回向量,返回列表

结果的长度或类型不一致时,sapply 的简化就失败了,它会悄悄退回成列表:

set.seed(1)
res <- sapply(1:3, function(i) seq_len(i))
str(res)
List of 3
$ : int 1
$ : int [1:2] 1 2
$ : int [1:3] 1 2 3

三次调用的返回值长度分别是 1、2、3,拼不成矩阵,于是得到一个列表。下游代码如果写了 res[2],拿到的是列表的一个子列表而不是元素本身,再往下算就会报 non-numeric argument to binary operator 这种看不出原因的错。要结果类型稳定,用 lapply() 明确拿列表,或者用 vapply(x, f, numeric(1)) ——vapply 要求你声明返回值的类型和长度,不符合就直接报错。

apply() 作用在 data.frame 上会把所有列转成同一类型

apply() 的输入最终会被转成矩阵,而矩阵只能有一种类型,于是字符列会把整个数据框拉成字符:

d <- data.frame(a = 1:3, b = c("x", "y", "z"))
apply(d, 1, mean)
[1] NA NA NA
Warning messages:
1: In mean.default(newX[, i], ...) :
argument is not numeric or logical: returning NA
2: In mean.default(newX[, i], ...) :
argument is not numeric or logical: returning NA
3: In mean.default(newX[, i], ...) :
argument is not numeric or logical: returning NA

数字被转成了字符,mean() 算不了,返回 NA。警告按行各报一条,三行数据就有三条——看到同一个警告重复出现,通常是某一列的类型拖累到了整批计算。用 apply() 之前先确认数据框里全是数值列,把要参与计算的列显式取出来,写成 apply(iris[1:4], 1, mean)

遍历时删除元素:R 报错,Python 静默出错

同一个意图在两种语言里的失败方式不同。R 里按下标删除会让向量变短,循环变量却仍然按原来的长度推进:

xs <- c(1, 2, 2, 3)
for (i in seq_along(xs)) {
if (xs[i] == 2) xs <- xs[-i]
}
Error in if (xs[i] == 2) xs <- xs[-i] :
missing value where TRUE/FALSE needed

xs 删到只剩 3 个元素时 xs[4]NAif (NA) 直接报错。R 在这里是「响亮地失败」,比 Python 那种悄悄跳过元素的写法好排查。正确做法是先算条件再一次性取子集:

xs[xs != 2]
unlist(Filter(function(v) v != 2, xs))
[1] 1 3
[1] 1 3

Filter() 对列表同样适用,处理列表元素时比下标运算清楚。

嵌套循环先想清楚要不要展开

两两组合的计算写成嵌套循环很自然,但结果通常能一次算完:

outer(1:3, 1:3, "+")
[,1] [,2] [,3]
[1,] 2 3 4
[2,] 3 4 5
[3,] 4 5 6

outer() 返回矩阵,行和列分别对应两个输入。需要的是「所有组合的清单」而不是矩阵时,用 expand.grid() 配合 mapply()

g <- expand.grid(dose = c(0.5, 1), supp = c("OJ", "VC"))
g
mapply(function(d, s) paste0(s, "-", d), g$dose, g$supp)
dose supp
1 0.5 OJ
2 1.0 OJ
3 0.5 VC
4 1.0 VC
[1] "OJ-0.5" "OJ-1" "VC-0.5" "VC-1"

expand.grid() 把所有组合铺成数据框,每个组合一行,mapply() 按行并行处理。实验设计里「每个处理组合各跑一次模拟」这类需求用这个组合比写两层 for 短得多,也不用担心下标顺序写错。

如果你也使用 Python,向量化和循环的取舍在 NumPy 里是同一回事,可以对照 /python/basics/numpy 看广播(broadcasting)是怎么替代循环的。写完控制流之后,下一步是把重复的逻辑收进函数:/r/basics/functions