## 引言
这是第三章「流程控制」的第 4 篇。前 3 篇我们完成了分支(if/elif/else)、结构化分支(match)与条件驱动循环(while)。它们的共同点是:循环是否继续、走哪个分支,都由一个「条件」说了算。但真实工程里还有一大类任务不是「条件能不能继续」,而是「把一组数据从头到尾逐项处理一遍」:统计一份成绩单、逐行扫描一份日志、给每个文件打上标记。这类「遍历」(iteration)如果用 while + 下标去写,代码会充满索引计算和边界判断,啰嗦且易错——for 循环正是为它而生的一等语法。
本篇解决四个问题:for 与 while 的分工边界是什么;for 遍历的底层机制(iter()/next()/StopIteration);range 的三种形态与它的惰性求值(lazy evaluation)原理;以及三种让遍历更省事的写法——序列解包、enumerate 与 zip(后两者本篇先给用法与直觉,系统深入的讨论在后面的迭代器章节展开)。读者应先掌握:第二章的字符串与列表基础(字面量、下标、切片)、真值测试(第 3 篇的 while 也依赖它)、f-string 的基本写法。for 循环是 Python 项目里出现频率最高的控制结构之一,把本篇吃透,第三章后半部分(break/continue、嵌套组合、推导式)就有了地基。
## 概念与原理
### for 与 while 的分工:条件驱动与序列驱动
两种循环的分工可以一句话概括:**while 问「还能不能继续」,for 问「还有没有下一个」**。while 适合迭代次数未知、由运行时条件决定的情况(比如等待用户输入直到输入 quit);for 适合「对象里的元素按顺序取尽」的情况。Python 的 for 与 C 语言的 for 有本质区别:C 的 `for (i = 0; i < n; i++)` 是一个计数器循环,语法本身就是「初始化、条件、步进」三段;Python 的 for 没有计数器概念,它是 **foreach**——遍历可迭代对象里的每一个元素。`for i in range(5)` 里的 i 依次取 0 到 4,是因为 range(5) 这个对象「产出」这些值,而不是因为循环结构在计数。把这两者分清,很多「为什么这样写」的疑问就迎刃而解:Python 设计者刻意不提供 C 式计数器循环,因为绝大多数计数场景都能被「遍历一个对象」覆盖,而后者更不容易出现下标越界。
### 可迭代对象与迭代器:for 的机械翻译
要理解 for,必须先理解两个概念。**可迭代对象**(iterable)——可以被遍历的东西:字符串(逐个字符)、列表、元组、字典、集合、range、文件对象都是;**迭代器**(iterator)——一个「记住当前位置、每次取一个」的对象。它们的关系通过两个内置函数建立:`iter(x)` 从可迭代对象 x 取得迭代器;`next(it)` 让迭代器 it 推进一步、返回当前位置的元素;当元素取尽时,next 抛出 **StopIteration 异常**。
for 循环没有任何魔法,它的完整语义可以机械地翻译成 while 加异常处理:
```python
data = [10, 20, 30]
for v in data: # 这句 for
print(v)
```
等价于:
```python
data = [10, 20, 30]
it = iter(data) # 1. 取得迭代器
while True:
try:
v = next(it) # 2. 取下一个元素;取尽时抛 StopIteration
except StopIteration:
break # 3. 捕获异常,说明遍历完毕,退出
print(v) # 4. 执行循环体
```
所以 for 循环能遍历什么,完全取决于 iter() 对它有没有定义——这就是「可迭代对象」的定义本身。字符串与列表等内置类型天生支持;文件对象遍历时按行产出;某个类型只要实现了 `__iter__` 方法(面向对象章节会讲),就能被 for 遍历。这也是 Python 中「约定优于接口检查」的体现:不要求对象是某个固定类型,只要求它具备迭代能力(duck typing 的雏形)。理解了这层翻译,你自然能回答两个疑难问题:为什么循环里修改列表会出怪事(见易错点)、为什么同一块数据第二次 for 就「空转」了(迭代器是一次性消耗品)。
### range 的三种形态与半开区间
range 是「整数序列」的工厂,三种形态对应三个参数:
- `range(stop)`:从 0 到 stop-1,即 `range(5)` 产出 0、1、2、3、4;
- `range(start, stop)`:从 start 到 stop-1,`range(2, 7)` 产出 2 到 6;
- `range(start, stop, step)`:按步长 step 跳跃,`range(0, 10, 3)` 产出 0、3、6、9;step 为负时倒着走,`range(10, 0, -2)` 产出 10、8、6、4、2。
无论哪种形态,都遵循同一个关键约定:**区间半开**(half-open),即包含 start、不包含 stop。记录方式 `range(start, stop)` 的写法让「长度 = stop - start」一眼可算,也让 `range(n)` 与「长度为 n」的列表下标天然对齐——这是 Python 大量 API 共同遵守的习惯。另一个性质是:当序列为空时(比如 step 为正但 start >= stop),range 直接产出空序列,循环体一次也不执行,这是合法的正常行为,不是错误。
### 惰性求值:range 为什么不是列表
初学者最容易产生的误解是「range 就是列表」。请记住事实:**range 对象不是列表,它根本不含任何元素**。它内部只保存 start、stop、step 三个整数,其余一切(长度、某个下标的值、某个数是否在范围内)都是「当场用算术算出来」的。这就是**惰性求值**(lazy evaluation):需要多少、算多少,而不是提前生成全部。三个直接推论:
- 内存占用 O(1):`range(10**9)` 与 `range(10)` 占的内存一样大,拿它 for 遍历时,数字是边循环边临时产生的,不会一次造出一亿个整数对象;
- 长度、下标、成员测试都是 O(1) 的算术运算,不需要扫描:`len(r)` 直接算 (stop-start)/step,`888888888 in r` 只做一次整除与取模判断;
- 想要真正的列表,必须显式转换:`list(range(5))` 才会一次性把 0 到 4 装进列表。
惰性不是炫技,而是工程上的必需:数据规模可以是任意大(乃至无法一次性放入内存),把「序列的描述」与「序列的物化」分开,遍历大范围计数时的内存占用才始终是常数级。range 也因此成了后面第 7 篇「推导式」中最常用的底座之一——届时你会看到它被直接嵌进列表推导式里。
### 序列解包:遍历时的「拆箱」
解包(unpacking)在第二章的赋值里已见过雏形:`a, b = (3, 4)` 把元组的两个元素分别绑定给 a、b。for 循环把这一机制用到了极致:当可迭代对象里的**每个元素本身是定长序列**时,可以直接在 for 的变量处写多个名字,让循环体里直接拿到「拆开后的零件」:
```python
for x, y in [(1, 2), (3, 4)]:
print(x + y)
```
这等价于先取元素 e = (1, 2),再做 x, y = e 的解包赋值。要求是:**每个元素的长度必须与名字个数严格相等**,多一个或少一个都会抛 ValueError(详见易错点)。这个写法是处理「成对数据」「坐标点」「棋盘位置」的标准姿势,比 `for p in pairs: p[0], p[1]` 的下标写法可读性高出一个档次。
### enumerate 与 zip:预告两个高频搭档
再说两个高频内置函数,本篇给出用法与直觉,深入细节留到后面的迭代器章节。**enumerate** 解决「遍历时同时要下标和元素」的问题——这是 C 式计数器循环唯一难以被替代的场景,但 enumerate 把它也替代掉了:`enumerate("abc")` 依次产出 (0, 'a')、(1, 'b')、(2, 'c'),配合解包写成 `for i, ch in enumerate(...)`。**zip** 解决「把多个序列按位置一一配对」的问题:`zip(["张", "李"], [85, 92])` 产出 ('张', 85)、('李', 92),长度不等时以短的为准(截断)。它们的共同底层逻辑是「产出元组」,所以与解包天然互补——这再次印证本篇的主题:for 的变量位就是解包位。
## 操作与实现
### 基础遍历:字符串、列表与 range
```python
word = "Python"
for ch in word:
print(ch, end=" ") # end=" " 让 print 以空格而不是换行收尾
print() # 补一个换行
scores = [85, 42, 91, 60, 77]
for s in scores:
print(s, end=" ")
print()
for n in range(5):
print(n, end=" ")
print()
```
运行输出:
```text
P y t h o n
85 42 91 60 77
0 1 2 3 4
```
字符串的遍历是按 Unicode 码点逐个产出(「你」「好」各算一个字符,第二章字符串篇讲过码点的概念),列表遍历按元素顺序产出,range 遍历按算术序列产出——三者行为一致,因为 for 只认「可迭代对象」这一个契约。
### range 三种形态与性质实测
```python
print(list(range(5))) # [0, 1, 2, 3, 4]
print(list(range(2, 7))) # [2, 3, 4, 5, 6]
print(list(range(0, 10, 3))) # [0, 3, 6, 9]
print(list(range(10, 0, -2))) # [10, 8, 6, 4, 2]
print(list(range(0, 10, -1))) # []:步长为负但 start < stop,产出空序列
print(sum(range(1, 101))) # 5050:1 到 100 求和
```
`list()` 在这里只是「把 range 物化出来看个究竟」的辅助手段,日常遍历根本不需要它。最后一行 `sum(range(1, 101))` 用 range 直接参与运算,是惰性求值的日常体现:求和逐项进行,没有任何中间大列表。
### 惰性求值的直接证据
```python
big = range(10**9) # 十亿规模的 range
print(len(big)) # 1000000000:长度当场算出
print(888888888 in big) # True:成员测试是一次算术判断
print(big[-1]) # 999999999:下标访问是算术,不扫描
```
这段代码的运行几乎瞬时完成,因为 range 内部只有三个整数。如果换成 `range(10**9)` 先转列表,光创建就要占用数 GB 内存——这就是「省着算」与「全部造出来」的量级差异。同样的理念塑造了第 7 篇的生成器表达式:能流式产出,就绝不整体物化。
### 徒手模拟 for:iter 与 next
```python
data = [10, 20, 30]
it = iter(data) # 取得迭代器
while True:
try:
v = next(it) # 推进一次
except StopIteration: # 取尽了
break
print(v)
```
再观察迭代器的一次性。
```python
it = iter([1, 2, 3])
print(next(it)) # 1
print(next(it)) # 2
print(list(it)) # [3]:剩下的元素
print(list(it)) # []:迭代器已耗尽,重复取是空
```
第二段值得细看:迭代器内部维护着「当前位置」,每 next 一次推进一格;它一旦耗尽就永远为空,不能「从头再来」。这也解释了为什么同一个列表可以反复 for——`for v in data` 每次都会重新调用 iter(data) 取得新迭代器;而如果你自己 `it = iter(data)` 后拿它 for,就只能遍历一次。
### 解包遍历:成对数据与坐标点
```python
points = [(35, 120), (40, 90), (28, 110)]
for x, y in points:
print(x, y, "乘积 =", x * y)
```
```python
# 把 product 值与折扣率配对,逐个算出最终价
products = [("机械键盘", 399, 0.9), ("显示器", 1299, 0.85), ("支架", 59, 1.0)]
for name, price, rate in products:
print(name, "折后价", price * rate)
```
第二个场景演示三元素解包:只要每个元组都恰好是三个元素,循环体里就能直接操作 name、price、rate 三个高可读性名字,完全避开下标魔法。
### enumerate 与 zip 的首次亮相
```python
fruits = ["apple", "banana", "cherry"]
for i, fruit in enumerate(fruits):
print(i, fruit) # i 是下标,fruit 是元素
names = ["张三", "李四", "王五"]
scores = [85, 92, 78]
for name, score in zip(names, scores):
print(name, score)
```
输出:
```text
0 apple
1 banana
2 cherry
张三 85
李四 92
王五 78
```
`zip` 在两边长度不一致时以短的为准——这是它默认的截断行为,需要「取长补短」时要用别的手段,不过那是迭代器章节的内容。此刻请先记住:需要下标用 enumerate,需要配对用 zip,两者都不要用「下标循环 + 手工对齐」的写法。
### 工程场景一:成绩单统计
```python
scores = [85, 42, 91, 60, 77]
total = 0
passed = 0
for s in scores:
total += s
if s >= 60: # 循环体内嵌分支:第 1 篇的分支知识在此复用
passed += 1
print("平均分", total / len(scores)) # 平均分 71.0
print("及格", passed, "人") # 及格 4 人
```
### 工程场景二:日志级别统计
```python
log_lines = [
"INFO 服务启动完成",
"ERROR 数据库连接超时",
"WARN 磁盘使用率 85%",
"INFO 收到请求",
]
counts = {"INFO": 0, "WARN": 0, "ERROR": 0}
for line in log_lines:
level = line.split(" ", 1)[0] # split 按空格切一次,取第一个字段
counts[level] = counts[level] + 1
print(counts) # {'INFO': 2, 'WARN': 1, 'ERROR': 1}
```
这是「逐行处理数据」的真实样板:外层循环遍历来源,循环体内用字符串方法把原始文本加工成结构化信息,再累积到统计结构里——for + 分支 + 字符串三者的第一次合体,它将在第 6 篇嵌套与组合中升级成二维数据处理。
## 易错点与陷阱
### 陷阱一:循环体内修改正在遍历的列表
这是 Python 圈流传最广的坑之一。遍历列表时删除或插入元素,迭代器的「当前位置」和列表的实际长度会错位:
```python
data = [1, 2, 2, 3, 4]
for v in data:
if v == 2:
data.remove(2) # 遍历中删除元素
print(data) # 想删掉所有 2,实际剩 [1, 2, 3, 4]
```
本机 Python 3.12 运行结果是 `[1, 2, 3, 4]`,第二个 2 漏网了。过程是这样的:迭代器先指向下标 1 的元素 2,`remove(2)` 删掉它后列表变成 [1, 2, 3, 4],后面的元素整体前移一位;迭代器却继续前进到下标 2,新顶替上来的那个 2 从未被检查,侥幸存活。修法是「先收集、后统一处理」——把要删的元素先记到新列表里,循环结束后再统一删除;或者遍历副本 data[:]。规则一句话:**遍历时只读,要改就另建列表**。
### 陷阱二:迭代器的一次性消耗
自己取得的迭代器只能遍历一次,第二次从头「遍历」得到的是空:
```python
it = iter([1, 2, 3])
print(list(it)) # [1, 2, 3]
print(list(it)) # []:同一个迭代器,已经耗尽
```
而直接 `for v in lst` 每次都会重新调用 iter(lst),所以列表可以反复遍历。混淆二者的典型症状是:「同一个数据第一次遍历有内容、第二次遍历空空的」。记牢:**可迭代对象可重复遍历,迭代器只能一遍**。需要多遍处理时,保存可迭代对象本身(或先 list 化),不要保存迭代器。
### 陷阱三:半开区间的「差一」与参数顺序
`range(1, 5)` 不含 5,`range(5)` 是 0 到 4——「我要循环 5 次」写成 `range(1, 5)` 会少跑一次。另一个高频失误是把参数写反:`range(10, 2)` 的步长默认是 1,start 大于 stop,直接得到空序列,循环体一次不执行且不报任何错——这是最安静的 bug 之一。需要倒序遍历必须显式给出负步长 `range(10, 2, -1)`。写 range 前默念:start 含、stop 不含、步长别忘。
### 陷阱四:解包个数不匹配
```python
pairs = [(1, 2), (3, 4, 5)]
for x, y in pairs:
print(x, y)
```
第二行元组有三个元素,与两个名字不匹配,抛 `ValueError: too many values to unpack (expected 2)`。注意一个细节:**解包发生在 for 语句本身**,所以 try/except 必须包住整个 for 循环,包在循环体内是接不住的(异常还没进循环体就抛出来了):
```python
pairs = [(1, 2), (3, 4, 5)]
try:
for x, y in pairs:
print(x, y)
except ValueError as e:
print("解包失败:", e) # 输出:解包失败: too many values to unpack (expected 2)
```
数据来自外部(文件、接口)时尤其容易踩:上游数据格式变了,解包当场炸。预防手段是解包前校验元素长度,或确保数据源格式稳定(在文件解析场景里加断言)。
## 小结
for 是序列驱动循环:它遍历可迭代对象,底层是 iter() 取迭代器、next() 逐项推进、StopIteration 收尾的机械翻译,所以「能不能 for」取决于对象是否可迭代。range 生成整数序列,三种形态对应 start/stop/step 三个参数,半开区间是它的核心约定;它惰性求值、只存三个整数,内存 O(1)、长度与成员测试 O(1),必要时才用 list() 物化。遍历成对数据用解包,需要下标用 enumerate,需要配对用 zip。三个高频陷阱——遍历中修改列表、迭代器一次性、半开区间差一——都已在本篇给出证据与修法。下一篇将给 for 配上 break/continue/else 三件套,把「提前结束」与「正常走完」表达清楚。
## 练习与思考题
1. 用 range 打印 100 以内所有 7 的倍数(从 7 到 98),再改为只打印「个位是 7 的 7 的倍数」(如 7、77)。
2. 给定 `words = ["hello", "world", "python", "ai"]`,用枚举遍历找出长度大于 4 的单词及其下标;再用 zip 把 `[1, 2, 3]` 与 `["一", "二"]` 配对打印,观察短的一方发生什么。
3. 亲手把 `for v in "你好"` 翻译成 iter/next 的 while 版本并在本机运行,确认输出与直接 for 完全一致。
4. 思考题:为什么 `for n in range(10**9)` 可以瞬间开始遍历,而 `for n in list(range(10**9))` 会卡住甚至内存耗尽?从「元素何时产生」的角度给出解释。