1. 条件被求值的那个瞬间,决定了 while 的全部脾气
这个编号"6.2"看着就像某份教程大纲里的第二节,前面多半刚讲完 if。事实上 while 循环跟 if 的差别小到很多人第一眼会轻视它:判断条件、执行代码块、结束。但如果只记住"条件为真就重复执行",后面写出来的代码大概率会在某个深夜把 CPU 单核跑满。while 循环真正需要吃透的,是条件在什么时刻被求值、被求值多少次、以什么身份参与循环。这三点决定了它到底是帮你干活的工具,还是拖住整个进程的钉子。
我见过很多初学阶段踩的坑,根因都不是语法写错,而是脑子里对"条件求值时机"的模型是模糊的。比如有人以为循环条件只在进入循环时判断一次,那其实描述的是 if;有人以为循环体执行完才判断条件,那描述的是 do-while。模型差一点,调试起来就要多花两小时。下面把这几层拆开讲,先把地基夯实。
1.1 先判断后执行:while 与 if 只差一个"回头"
while 的执行序是固定的:先求值条件表达式,为真才进入循环体,循环体跑完后无条件回到条件处再求值一次。这个"回头"就是它和 if 的全部区别。if (x < 10) { ... }里的条件只被看一次;while (x < 10) { ... }里的条件会被看 N+1 次,其中最后一次是导致循环退出的那次求值。
这一点推导出一个常被忽略的结论:条件表达式的副作用会随着循环次数放大。如果条件里带了自增、函数调用、读取状态,那它在每一轮都会被触发一次。比如 C 风格里常见的while (i++ < n),它的退出行为和循环体里再改一次i的行为交织在一起,读代码的人要在脑子里跑一遍才敢确认边界。经验上,条件表达式里尽量只放纯比较,把副作用挪进循环体,这样读的人不用做心算。
# 条件里只有比较,副作用在体内,读起来没有歧义 i = 0 while i < n: handle(i) i += 1另外值得一提的是一类少见但确实存在的写法:while配合逗号表达式或赋值表达式,把"求值结果"和"判断"合并。C 里while ((c = getchar()) != EOF)是经典范例,Python 3.8 之后的海象运算符while (line := f.readline()) != "":是同样思路。这类写法的价值在于避免"先读一次再进循环"的重复代码,但它牺牲的是可读性,团队里如果有人不熟悉这个语法糖,会增加沟通成本。用不用,看团队整体水位,不要为了省两行让人看不懂。
1.2 零次执行不是意外,是契约的一部分
while 的循环体可能一次都不执行。i = 10时while i < 5直接跳过。这不是边界情况,这是语言规范明确定义的行为。问题是这个特性在业务里经常被当成意外:某个初始化逻辑写在循环体里,条件一开始就不成立,初始化就没跑,后面的变量全是未定义状态,报错位置离真正的错误十万八千里。
我的做法是:凡是循环体内承担了"初始化"职责的代码,一律拎到循环外。循环体只做"每轮都该做的事",不做"只该做一次的事"。这条规则听起来废话,但能挡掉一大类空集合、空列表、空输入引发的问题。举个具体场景,遍历一个可能为空的待处理队列:
# 不推荐:初始化藏在循环里 while queue: first = queue.pop(0) # 队列空的时候 first 根本不存在 process(first) # 推荐:先处理空集合,再进循环 if not queue: return Result.empty() while queue: process(queue.pop(0))第二种写法把"空输入"这个分支显式表达出来,读代码的人不必去推演循环是否执行。代码里显式的东西越多,隐性 bug 越少。
1.3 do-while 存在的唯一理由:至少跑一遍
有些语言有 do-while(C、C++、Java、JavaScript、C#),有些没有(Python、Go 早期版本)。do-while 的语义是先执行一次循环体,再判断条件。它存在的理由非常具体:当"第一次执行"是获取判断所需数据的前提时,用它最自然。
典型场景是交互式输入校验:你必须先读一次用户输入,才知道输入是否合法,判断所需的数据来自循环体本身。用 while 写就得先读一次再进循环,代码里出现两段一样的读取逻辑;用 do-while 一次就够。
let input; do { input = prompt("请输入 1-100 的整数"); } while (!isValid(input));Python 没有 do-while,标准做法是while True加break:
while True: raw = input("请输入 1-100 的整数:") if is_valid(raw): break这两种写法在语义上等价,区别在于while True版本把退出条件写在体内,位置灵活,可以在任意分支退出;do-while 的退出条件固定在末尾。所以即使语言支持 do-while,遇到多出口逻辑时我还是倾向while True+break,它的表达力更强,代价是需要读者扫一遍循环体找出口,因此出口必须写得显眼。
提示:
while True不是坏味道,坏味道是"出口藏在深层的条件分支里、且没有被注释标注"。第 2 节专门讲这件事。
2. while(true) 不是坏味道,缺退出路径才是
很多代码规范里写着"禁止使用 while(true)",我理解这条规则的初衷,但把它当铁律会误伤。长驻循环、事件分发、读取流直到结束,这些场景天然没有"先验的循环条件",你只能写成while True然后在内部判断。强行改写只会得到一个语义更模糊的条件表达式,比如while (!shouldStop),而shouldStop又是在体内被赋值的,可读性并没有变好,只是把true藏起来了。
真正该管的是退出路径是否可枚举、是否可验证。一个while True如果只有两三个出口,每个出口都有注释说明触发条件,那它比一个绕来绕去的复杂条件安全得多。反过来,一个普通的while (cond)如果条件里塞了四个布尔量做与或组合,实际风险可能比while True更大。
2.1 事件循环与主循环:天然的长驻结构
事件循环、消息分发、服务端 accept 循环、游戏主循环,本质都是"没有终点,直到外部要求停止"。这类代码的正确写法就是无限循环,重点在于停止信号如何被捕获并干净地退出。
running = True while running: event = queue.get() if event.type == "shutdown": running = False # 用标志位退出,便于在循环外做清理 continue dispatch(event) cleanup()这里用标志位而不是直接break,是因为break之后循环外的清理逻辑要和"正常结束"共用一条路径。如果有多处退出点,用标志位能保证所有退出都收敛到同一个善后流程里,不容易漏。
2.2 出口清单法:写循环体之前先列出口
这是我自己一直用的习惯,成本极低但收益很高。在写任何可能长期运行的循环之前,先在纸上或注释里列出所有可能让它停下来的条件,然后逐个确认它在代码里对应哪一行。
比如写一个"重试直到成功或达到上限"的循环,出口清单是:成功、达到最大次数、遇到不可重试的致命错误、外部取消。四条出口,一条都不能少。少一条的表现是:某类错误不会触发任何退出,循环永远转。清单列完再写代码,代码结构自然就是四个分支,不容易遗漏。
attempt = 0 while True: try: return call_once() except FatalError: raise # 出口 3:不可重试 except TransientError: attempt += 1 if attempt >= max_retries: raise # 出口 2:达到上限 if cancelled.is_set(): raise Cancelled() # 出口 4:外部取消 time.sleep(backoff(attempt)) # 出口 1 是 try 里的 return2.3 break 只跳一层这件事,坑了太多人
break只终止最内层的循环,continue只作用于最内层的这一轮。这条规则人人都背过,但在三层嵌套里写代码时依然会犯迷糊:内层break之后以为整个搜索结束了,结果外层继续跑,逻辑全乱。
多层跳出有几种干净写法,按可读性排序:把内层抽成函数并用 return 返回最优,其次是使用标志位 + 外层条件判断,最差是用 goto 或异常控制流程(后者在某些语言里可行,但会污染错误处理语义)。标志位写法要注意在每轮开始时复位,否则会出现"上一次命中之后,后续循环全部被跳过"的问题,这个 bug 极其隐蔽,因为它在小数据量测试里往往不触发。
found = False for row in matrix: for cell in row: if cell == target: found = True break # 只跳出内层 if found: break # 外层需要显式再跳一次抽函数版本:
def find(matrix, target): for row in matrix: for cell in row: if cell == target: return True return False后者的可读性明显更好,而且不用担心标志位复位。嵌套超过两层时,我会优先考虑抽函数,不用纠结。
3. 循环变量被拆成三处,是 while 最容易出事的地方
for 循环把"初始化、判断、更新"三件事挤在一行里,虽然有点挤,但它的好处是这三件事绑定在一起,局部性好。while 把这三件事分散到了三个位置:初始化在循环前、判断在条件里、更新在循环体末尾。分散意味着三者之间可以被任意多的代码隔开,也就意味着更新语句最容易被漏掉或被跳过。
这不是风格问题,是事故高发点。我排查过的死循环里,相当大的比例可以归到"更新语句没执行"这一类,而不是条件写错。
3.1 初始化、判断、更新三件套的分离代价
先看最朴素的计数循环。它有一个隐性的顺序依赖:更新必须发生在循环体的最后,或者在所有continue之前。一旦中间插入了提前跳过的分支,更新就可能被绕过。
i = 0 while i < len(items): if should_skip(items[i]): i += 1 # 这一行极容易漏,漏了就是死循环 continue process(items[i]) i += 1这段代码能跑,但它的正确性依赖两个地方都写了自增,任何一次修改都可能只改一处。我在代码评审里看到这种结构,一般会建议改成for i, item in enumerate(items),把更新交给语言机制,人只负责业务分支。
for i, item in enumerate(items): if should_skip(item): continue process(item)同样的逻辑,行数更少,而且不可能因为漏写自增而死循环。这就是"把容易犯的错从代码结构上消除"的思路,比写注释提醒自己可靠得多。
3.2 continue 跳过更新语句:一个高频事故
上节的例子已经说明了问题,这里再强调一种变体:更新语句放在循环体末尾,但循环体中部有一个continue。写代码时人在思考业务,continue是顺手加的,加完不会回头看末尾那行自增。
处理办法有三条,按优先级:
- 能改成 for 就改成 for,从根上消掉这个问题;
- 改不了 for 的话,把更新语句挪到循环体的最前面(第一轮除外),或者用一个"每个分支都必走"的结构保证更新一定执行;
- 实在要保留原结构,就在每个
continue之前复制一份更新,并且在代码评审时把"continue 与更新"作为固定检查项。
第三条本质上是靠人力保证,风险最高,只能作为兜底。实践中我更倾向第一、二条。
3.3 用循环不变式代替"盯着变量看"
调试循环时最常见的姿势是打印变量、一行行看。数据量小的时候有效,逻辑一复杂就失效。更可靠的方法是找到这个循环的循环不变式:一个在每次进入循环体时都必然成立的性质。
比如"二分查找"的循环不变式是:目标如果存在,一定在当前[left, right]区间内。写代码时每一步都维护这个性质,边界就不会错。再比如"双指针收缩"的不变式是:区间外的元素已经处理完毕。
left, right = 0, len(arr) - 1 while left <= right: mid = (left + right) // 2 if arr[mid] == target: return mid if arr[mid] < target: left = mid + 1 # 维护不变式:目标只能在 [mid+1, right] else: right = mid - 1 # 维护不变式:目标只能在 [left, mid-1] return -1把不变式写清楚之后,"为什么更新是mid+1而不是mid"就不用背了,直接由不变式推出来。这个思维方式对 while 尤其重要,因为 while 的循环条件本身就承担了"区间还在有效范围内"的职责,条件和更新必须配合着维护同一个性质。写完循环之后,我会做两件事验证:把区间缩到最短的情况(一个元素、零个元素)在脑子里过一遍,确认不会越界也不会漏判。
3.4 Python 的 while...else:知道就行,别滥用
Python 有一个很多语言没有的特性:while可以带else子句,else在循环正常结束(条件变假)时执行,break跳出时不执行。它的语义是"没被打断就跑一遍"。
i = 2 while i < n: if n % i == 0: print("不是质数") break i += 1 else: print("是质数")这个写法在质数判断里相当优雅,省掉了一个标志位。但可读性上有争议:很多工程师(包括不少写 Python 多年的人)不熟悉else挂在while上的语义,第一眼会误读成"while 不成立时执行",这与实际语义只差一点点,但理解错就会写出 bug。
我的判断标准:如果团队里超过一半的人需要停下来想一下,就不用。改用标志位或者抽函数,多两行换来零歧义,划算。
4. while 在真实工程里的四类主场
讲了这么多规范,得落到具体场景上。while 不是 for 的降级替代,它在一些场景里是唯一自然的选择。我把常见的四类主场列出来,配套给写法。
4.1 读到没有为止:流式读取与哨兵值
处理数据流时,你不知道要读多少次,只知道"读到结束为止"。这是 while 的经典场景。
# 按行读取,文件可能非常大,不能一次性读入 while True: line = f.readline() if not line: # 文件结束返回空字符串,注意不是 None break handle(line) # 海象运算符版本,少两行 while (line := f.readline()) != "": handle(line)分块读取同理,f.read(8192)返回空字节串时结束。这里有个容易踩的点:不同 API 的"结束信号"不一样,有的返回None,有的返回空字符串,有的返回空列表,有的抛异常。写循环之前必须先确认这个接口的结束语义,写错了的表现就是死循环或者提前退出。
哨兵值(sentinel)是同一类思路的变体:数据流里约定某个特殊值表示"到此为止",比如传感器读到-1结束、命令行读到quit退出。
while True: value = read_sensor() if value == SENTINEL: break buffer.append(value)用哨兵值要注意:哨兵值必须是不可能出现在正常数据里的值。如果传感器正常读数范围包含 -1,那这个约定就是定时炸弹。选哨兵值时留足余量,或者干脆改用Optional加None表示结束。
4.2 状态机与游戏主循环
状态机的核心结构就是"当前状态 + 事件 → 新状态",外层套一个循环不断取事件。这类循环没有先验的终止条件,天然写成while running或者while True。
state = "idle" while state != "done": event = next_event() state = transition(state, event) render(state)游戏主循环比这复杂一层,它还要处理时间步长。常见的固定步长循环长这样:
accumulator = 0.0 while running: delta = clock.tick() accumulator += delta while accumulator >= FIXED_STEP: update(FIXED_STEP) accumulator -= FIXED_STEP render()内层的 while 是"追帧"逻辑:一帧渲染耗时过长时,用多次固定步长的更新把时间补回来。这里的退出条件依赖浮点累减,所以内层循环必须保证accumulator单调递减,否则会卡死。这是浮点 + while 的典型组合风险,后面第 6 节会专门讲。
4.3 带退避的重试
重试逻辑用 while 比 for 自然,因为循环次数可能因为"不可重试错误"提前终止,也可能因为"成功"提前终止,两种情况都不是"跑满 N 次"。
attempt = 0 while attempt < max_retries: try: return call() except TransientError: attempt += 1 if attempt >= max_retries: break time.sleep(min(base * (2 ** attempt) + jitter(), cap)) raise RetriesExhausted()几个实践中的细节:指数退避必须加上限,否则 2 的 n 次方很快就超出可接受的等待时间;加随机抖动,避免所有客户端在同一时刻重试造成尖峰;区分可重试与不可重试错误,把不可重试的异常直接抛出,不要浪费重试次数。
4.4 双指针与滑动窗口
算法题里 while 的出场率极高,因为这类问题的循环次数事先不知道。双指针收缩区间、滑动窗口扩张与收缩,都是"条件满足就动,不满足就停"的结构。
left = 0 current_sum = 0 best = 0 for right in range(len(nums)): current_sum += nums[right] while current_sum > limit: # 收缩到满足条件为止 current_sum -= nums[left] left += 1 best = max(best, right - left + 1)内层 while 是"收缩窗口",它每轮至少让left加一,所以总的收缩次数不超过 n,整体复杂度仍是 O(n)。这种"内层 while 看起来像嵌套循环但实际是线性的"结构,是滑动窗口题目的标准写法,理解它的关键是看内层循环的计数器是否单调移动。
5. while、for、递归到底怎么选
选型不是审美问题,选错会实实在在地增加维护成本。我总结的判据是看循环次数的可知性与循环变量的归属。
5.1 一张决策表
| 场景特征 | 首选 | 理由 |
|---|---|---|
| 次数已知,或可遍历的集合 | for | 三件套集中,不可能漏更新 |
| 次数未知,靠条件退出 | while | 条件本身就是退出依据 |
| 至少执行一次(先做后判) | do-while / while True | 判断依赖第一次执行的结果 |
| 需要"没被打断"的收尾逻辑 | while + 标志位 | 比 else 子句更通用可读 |
| 结构自相似(树、分治) | 递归 | 用循环描述会引入显式栈,反而复杂 |
| 内部有多次提前退出 | 抽成函数 + return | 消除多层 break |
这张表覆盖了我日常遇到的绝大多数情况。需要注意的是最后两行:抽函数不是为了"代码整洁",而是为了把控制流从嵌套里解放出来,让每一层只有一个出口,可读性和可测试性都会提升。
5.2 什么时候该把 while 改写成 for
一个 while 循环如果满足下面所有条件,我会建议改写成 for:
- 有一个显式的计数器或迭代变量;
- 这个变量在每轮末尾固定地变化一次;
- 循环体内没有会跳过该变化的提前退出;
- 循环结束后不需要这个变量的值。
四条都满足时,while 的唯一优势(灵活)已经用不上了,剩下的只有"忘记更新"的风险。改成 for 之后,这类风险直接归零。
反过来,如果循环体内有多处continue,或者更新步长是动态的(比如每次增加的量由本轮计算决定),那 while 更合适,强行用 for 会写出for (;;)这种四不像。
# 步长动态,while 更自然 pos = 0 while pos < len(data): step = parse_step(data[pos:]) if step == 0: break pos += step5.3 什么时候递归反而更清楚
树的遍历、分治、回溯,这三类问题用循环写需要手动维护栈,代码量翻倍且容易出错。递归的写法直接对应问题的结构,读起来就是"处理当前节点 + 递归处理子结构"。
但递归有代价:调用栈深度受限,深度几千层就可能栈溢出;隐式开销比循环大,在热路径上要谨慎。所以我的一般原则是:结构天然递归(树、图、分治)就用递归,能用尾递归优化且语言支持就用尾递归,其余情况一律用循环。
还有一个折中方案:显式栈 + while。它保留递归的思路,但把栈放在堆上,深度不受限。
stack = [root] while stack: node = stack.pop() visit(node) stack.extend(node.children)这段代码就是深度优先遍历的显式栈版本,结构简单,深度只受内存限制。处理深层结构时我基本都用这个形式。
6. 死循环排查的完整链路:从卡住到定位
纸上谈兵讲完了,讲点实战。死循环是我见过最"磨人"的一类问题,因为程序不崩溃、不打日志、就静静地占着一个核,你在外面看不出任何信息。下面是我实际用的排查顺序,按这个顺序走,绝大多数情况下十分钟内能定位。
6.1 第一步永远是确认它真的没在前进
别急着看代码,先确认现象。有些"卡住"其实是慢,不是死。判断方法很简单:打一行带计数器的日志,看计数是否增长。如果计数器在涨但业务没进展,那是逻辑在空转;如果计数器不涨,那是阻塞在某个 IO 或锁上,跟循环无关。
guard = 0 while cond: guard += 1 if guard % 10000 == 0: logger.info("loop alive, guard=%d, key_var=%s", guard, key_var) ...这行日志的价值在于把两种完全不同的故障区分开。方向搞错了,后面所有努力都是浪费。
6.2 加护栏计数器与心跳打印
护栏(guard)是我在写任何"可能长时间运行"的循环时的默认动作:人为设一个上限,超了就抛异常。它的作用不是修复 bug,而是让 bug 以明确的异常形式暴露出来,而不是以"CPU 100%"的形式存在。
MAX_ITER = 10_000_000 guard = 0 while not done(): guard += 1 if guard > MAX_ITER: raise RuntimeError(f"loop exceeded {MAX_ITER}, state={snapshot()}") work()异常信息里带上关键状态快照,能省掉大量复现时间。上限值不用太精确,按业务量级估一个数量级以上的值即可,它的职责是"兜底"而不是"精确控制"。
6.3 把条件拆开:常见的五类失效条件
护栏触发之后,问题就从"程序卡住"变成了"循环没有在预期时退出",排查范围立刻缩小。按我的经验,失效原因集中在五类:
| 类别 | 典型表现 | 检查点 |
|---|---|---|
| 更新缺失 | 变量从头到尾不变 | 每个 continue / 提前 return 之前是否有更新 |
| 条件永真 | 条件里用了赋值而非比较 | =与==是否写错 |
| 浮点相等 | 累加后永远差一点点 | 是否用==比较浮点,应改用容差 |
| 状态不同步 | 判断用的变量和更新的不是同一个 | 是否有两个同名或相似的变量 |
| 集合边遍历边改 | 迭代器失效或长度不变 | 循环内是否增删了正在遍历的集合 |
浮点这一类特别值得单独说。i = 0.0; while i != 1.0: i += 0.1永远不会退出,因为 0.1 在二进制里无法精确表示,累加十次得到的是 0.9999999999999999。正确写法是给容差或者改用整数计数:
# 用整数计数,最后再换算,避免浮点累积误差 steps = 0 while steps < 10: value = steps * 0.1 steps += 1集合边遍历边改是另一类高频问题。Python 里在遍历 list 的同时删除元素,会导致跳过元素甚至死循环;在遍历 dict 时增删键会直接抛异常。正确做法是遍历副本,或者先收集要删的键再统一处理。
6.4 修复后怎么验证没有引入新的边界问题
改完就完事是最危险的心态。循环的边界错误往往不会在大数据量下暴露,反而在"空输入""单元素""刚好达到上限"这三种情况下才现形。所以修复之后,我固定补三个用例:空集合、只有一个元素、数量刚好等于上限或上限减一。
这三个用例覆盖了循环最容易出错的位置:入口条件、单次执行、边界相等。跑通这三个,剩下的风险基本可控。如果这个循环在线上跑,再加一条:确认护栏计数器的阈值和业务量级匹配,避免正常业务量撞到护栏误报。
7. 嵌套 while:复杂度、跳出与可读性
单层 while 的问题大多是局部的,嵌套之后复杂度会指数级上升,因为你要同时跟踪多个变量的状态,还要考虑内外层的交互。
7.1 隐性 O(n²) 是怎么长出来的
最常见的性能坑是在外层循环里做了一件看起来很快的事,但它本身是 O(n)。比如列表的in判断、字符串拼接、重复查询数据库。
# 外层 n 次,内层 in 判断 O(n),整体 O(n²) i = 0 while i < len(ids): if ids[i] in processed_list: # processed_list 是 list i += 1 continue processed_list.append(ids[i]) i += 1把processed_list换成 set,判断变 O(1),整体回到 O(n)。这类优化的收益在小数据量下完全看不出来,到了线上数据量级却可能差出两个数量级的耗时,所以写嵌套循环时养成习惯:先看内层每一步的数据结构选型。
数据库层面的 N+1 是同一类问题的变体:外层循环每轮查一次库,看起来是 n 次单条查询,实际开销可能是批量查询的几十倍。这类问题在开发环境数据量小的时候完全无感,上线后才炸。
7.2 多层跳出的三种干净写法
前面提过break只跳一层,这里给出具体方案的对比。
第一种,标志位:
found = False i = 0 while i < rows and not found: j = 0 while j < cols and not found: if grid[i][j] == target: found = True j += 1 i += 1把标志位写进外层条件里,让外层在下一轮判断时自然退出。这个写法不需要在内层写额外的break,语义清晰,推荐。
第二种,抽函数 + return,前面讲过,可读性最好。
第三种,异常跳出。某些语言里用自定义异常从深层跳出,写法简洁但会污染错误处理语义,团队里有人用except兜住所有异常时会误吞,风险较高,不推荐。
7.3 把内层抽成函数
嵌套超过两层,我会先问一句:内层在干什么,能不能独立命名?如果内层做的是"在一个区域里找某个东西"这种有明确语义的事,抽成函数几乎是必然更好的选择。函数名直接表达意图,调用处只剩一行,外层逻辑立刻清爽。
def find_in_grid(grid, target): for i, row in enumerate(grid): for j, cell in enumerate(row): if cell == target: return (i, j) return None while has_more_tasks(): grid = fetch_grid() pos = find_in_grid(grid, target) if pos: handle(pos)这个结构里外层 while 的职责只有"还有任务就继续",细节全部下沉,读代码时不用同时装两层循环的状态。
8. 最后几条容易被忽略的细节
写到这里核心内容差不多了,补几条我在实际代码里反复见到的细节问题,都是小点,但踩上就要花时间。
循环里创建对象。在循环体里 new 对象、拼字符串、开连接,如果循环次数上万,开销会非常可观。能提到循环外的就提出来,尤其是正则编译、格式化模板、连接对象这类初始化成本高的东西。
循环里做阻塞操作没有超时。while 循环里调用了没有超时设置的网络请求或锁等待,一旦对端不响应,整个循环就卡在那里,护栏计数器也帮不上忙(它根本走不到)。所有可能阻塞的调用都要设超时,这是硬性要求。
循环条件的求值成本。while i < len(huge_list)每次迭代都算一次长度,虽然多数语言对 length 有优化,但在热路径上把它提到循环外仍是好习惯。更重要的是,如果条件里调用了一个耗时函数,那每轮都会付出这个成本,容易忽略。
并发下的条件检查。多个线程读写同一个退出标志时,要确保这个标志的可见性有保证(用原子类型或者加锁),否则可能出现"标志已经置位但循环还在跑"的情况。这类问题复现率低,排查成本高,写的时候就要用对工具。
循环变量在循环外的值。很多语言里循环结束后变量仍然可见,它的值可能是"最后一次更新后的值"或"导致退出的那个值",不同语言不一样。依赖这个值做后续逻辑是危险的,代码评审时看到这种用法我会要求显式记录。
我个人的体会是,while 循环的难点从来不在语法,而在于它把控制流的责任完全交给了写代码的人。for 帮你管住了一部分,while 一点忙都不帮,所有的初始化、更新、退出、异常路径都要自己想清楚。所以写 while 的时间应该比写 for 多花一点在"出口清单"上,前期花的这几分钟,能省掉后面几个小时的排查。