Python里的循环说多不多说少不少但每次带新人或者自己回看老代码总能发现一些值得唠叨的细节。这份整理我是按“极简但不简单”的方式做的目的不是把文档抄一遍而是把 for 和 while 两条主线、break/continue/else 这类控制语句、迭代器协议、列表推导式以及我在实际项目里踩过的几个坑一次性讲明白。你如果刚接触 Python可以把它当成查漏补缺的索引如果你已经写了几年 Python那重点看看后面的坑位和排错思路说不定能帮你省下一个下午。1. 循环的两条主线for 和 while1.1 for 循环的本质遍历可迭代对象很多从 C 语言转过来的人刚用 Python 的 for 循环时会不习惯因为在 C 里 for 是一个带初始条件、终止条件和步长的“计数器循环”而 Python 的 for 是彻头彻尾的“迭代循环”——它的核心不是数数字而是把一个可迭代对象里的元素挨个取出来。for i in range(10): print(i)这段代码里的range(10)并不是一个列表而是一个 range 对象。Python 看到 for 时会先对range(10)调用iter()得到一个迭代器然后不断调用next()拿到下一个值直到抛出的异常是StopIteration才停止。换句话说它和下面这种手动写法在原理上是一样的it iter([1, 2, 3, 4, 5]) while True: try: value next(it) print(value) except StopIteration: break只是 for 帮你把这些细节全包了。这也是为什么 Python 的 for 能遍历字符串、列表、元组、字典、集合、文件对象、生成器等等几乎所有内置类型。你在 for 里写的那个变量比如上面的i只是每次从迭代器里取出来的“当前值”的引用循环结束后它仍然存在并且保留最后一次取值。实际使用中有几个高频场景值得注意。遍历字典时默认遍历的是 keyperson {name: 张三, age: 18, city: 北京} for key in person: print(key)如果想同时拿到 key 和 value我会用.items()for key, value in person.items(): print(key, value)遍历字符串时拿到的每个字符包括空格和标点for ch in Python: print(ch)另外很多人好奇为什么 Python 没有 C 风格的for (i 0; i 10; i)。因为这种用法可以被range完整覆盖而且 Python 的设计哲学是“统一序列操作接口”。你不需要记数组长度不需要手动下标递增只要对象是可迭代的for 就能安全遍历完。这对代码的可读性提升不是一点点。还有一个容易忽略的点range本身是惰性求值的它不会一次性生成 1 亿个数放在内存里。所以你写for i in range(100000000)时内存占用很小。但如果你写list(range(100000000))那才是真的把列表展开内存立刻爆炸。记住这个区别在数据量大的场景里很关键。1.2 while 循环条件才是主角while 循环的结构更接近传统语言它只判断一个条件表达式是否为真为真就继续执行为假就退出。常见格式count 0 while count 5: print(count) count 1这里最容易犯的错误就是忘记在循环体里更新count一旦忘了条件永远为真程序就死循环了。我在调试别人的代码时看到 while 循环第一眼永远是找“条件变量在哪里被修改”找不到就怀疑是不是死循环。while 的最佳使用场景是“循环次数不确定但退出条件明确”。比如从用户输入里反复读取命令直到用户输入quitcommand while command ! quit: command input(请输入命令输入 quit 退出)再比如轮询某个任务状态、等待某个文件出现、重试网络请求直到成功。这种场景用 for 写会很别扭因为你不知道总共要循环多少次只有 while 能很自然地表达“只要条件满足就一直跑”。不过要注意while 循环如果条件写得太宽泛很容易跑飞。我比较喜欢在 while 里加一个最大重试次数限制避免程序真正进入无限循环。例如retry 0 max_retry 3 while retry max_retry: # 模拟尝试操作 success False if success: break retry 1 else: print(重试次数用完仍然失败)这里顺便用到了后面的 else 分支先不展开。整体来说while 适合表达“直到某件事发生”for 适合表达“遍历这个集合里的一切”。拿到需求先问自己一句我关心的是元素还是退出条件答案会决定你用哪个。2. 循环控制break、continue、else2.1 break 和 continue 的正确用法break 的作用是立即结束整个循环continue 则是跳过本次循环的剩余部分直接进入下一次迭代。这两个词看起来简单但放的位置和边界条件很讲究。看一个经典的例子找 1 到 20 之间第一个能被 7 整除的数。for i in range(1, 21): if i % 7 0: print(找到了, i) break没有 break 的话循环会把所有 1 到 20 都遍历完。加了 break一旦找到就立刻跳出程序更高效语义也更清晰。再看 continue 的场景打印 1 到 10 之间的所有偶数。for i in range(1, 11): if i % 2 ! 0: continue print(i)当i是奇数时continue 会让程序跳过下面的print(i)直接进入下一次循环所以最后只打印偶数。continue 在你需要过滤某些条件时特别好用它能让循环体保持平铺直叙不用把所有逻辑都塞进一个大 if 里。但注意别滥用。嵌套好几层循环的时候break 只能跳出它所在的那一层并不能跳出外层。很多人在这里犯迷糊。如果想跳出多层循环更干净的做法是设置一个标志变量或者把多层逻辑抽成函数后用 return 返回。found False for i in range(10): for j in range(10): if i * j 42: found True break if found: break这种写法可读性还行但如果你不想要标志变量也可以把两层循环的逻辑封装到一个函数里直接 return。我自己更倾向于函数化因为 return 的意图比标志变量更明确。2.2 容易被忽略的 else 分支Python 的 for 和 while 后面可以跟一个 else这个 else 会在循环“自然结束”时执行也就是没有被 break 打断时执行。这个设计很多从其他语言过来的人会觉得反直觉但用好了确实很舒服。最经典的应用是判断素数num 29 for i in range(2, num): if num % i 0: print(num, 不是素数) break else: print(num, 是素数)这里如果循环过程中num % i 0就会 breakelse 块不会执行如果遍历完都没有找到因子循环正常结束else 块执行输出“是素数”。这个写法的好处是省掉了一个is_prime True的中间变量代码更短语义也更贴合“如果没有被提前打断就说明是素数”。while 的 else 同理。比如轮询重试时如果重试次数用完了还没有成功就走 else 提示失败attempt 0 while attempt 3: # 模拟请求 success False if success: break attempt 1 else: print(三次都没成功)不过我得提醒一句for-else 和 while-else 的语义在团队协作中经常被误解很多人不知道这个特性。如果你在团队项目里使用最好在旁边加一行注释“循环没有被 break 才会来到这”。不然同事 review 时可能会一脸问号。3. 循环背后的迭代协议3.1 可迭代对象、迭代器和迭代器协议要说循环绕不开迭代器协议。Python 里只要对象实现了__iter__()方法返回迭代器或者实现了__getitem__()方法支持按索引取值它就是可迭代对象。迭代器本身还需要实现__next__()每次返回下一个元素没有元素时抛出StopIteration。我经常用一个生活化类比来解释可迭代对象像一本翻页日历迭代器像日历的书签书签每次往后挪一页翻到最后一页再往后翻就会提示“没有下一页了”这个提示就是StopIteration。for 循环就是帮你反复做“翻页”这个动作并在看到“没有下一页”时自动合上书。如果你想更底层地感受循环可以自己写一个简单的迭代器class CountDown: def __init__(self, start): self.count start def __iter__(self): return self def __next__(self): if self.count 0: raise StopIteration self.count - 1 return self.count 1 for num in CountDown(5): print(num)这段代码会打印 5、4、3、2、1。关键是__next__里要自己判断什么时候结束并抛出StopIteration。这个“手动感”能帮你理解 for 循环内部到底做了什么。很多内置对象是迭代器但有些是序列。字符串、列表、元组是可迭代的序列但它们本身不是迭代器。迭代器是一次性的遍历完就“耗尽”了。比如用iter()包一下列表a [1, 2, 3] it iter(a) print(next(it)) # 1 print(next(it)) # 2 print(next(it)) # 3 print(next(it)) # StopIteration一旦耗尽再 for 循环它就没有输出了。所以如果你想多次遍历同一个迭代器得再重新生成一个。这个坑在把迭代器当作列表使用时特别容易踩后面章节会再提到。3.2 让循环更好用的内置函数Python 提供了一些内置工具函数它们本身并不神秘但放在循环里能极大提升效率。第一个是enumerate。它可以在遍历时同时得到索引和元素省去手动维护一个index 0再index 1的麻烦fruits [苹果, 香蕉, 橙子] for index, fruit in enumerate(fruits, start1): print(index, fruit)start1可以让人工序号从 1 开始这在输出报表时很常用。第二个是zip。它能把多个可迭代对象按位置一一对应组合names [张三, 李四, 王五] scores [88, 92, 75] for name, score in zip(names, scores): print(name, score)输出类似“张三 88”这样的组合。zip 在同时迭代两个列表时比用下标取names[i]和scores[i]要优雅得多而且当两个列表长度不一致时zip 会以较短的那个为准避免越界报错。第三个是reversed。它可以反向遍历序列for i in reversed(range(10)): print(i)注意reversed返回的是一个反向迭代器不是列表所以如果你要真正的列表用list(reversed(range(10)))包一下。第四个是sorted。虽然它返回的是列表但你可以直接for i in sorted(set_a)遍历排序后的结果。需要注意sorted默认会新建列表并排序不会修改原对象而列表的.sort()方法会原地排序。这些工具函数的共同点是让循环的“意图”更清晰减少你在循环体内手工算下标的工作量。我一般看到别人写for i in range(len(lst)): print(i, lst[i])都会建议改成enumerate版本因为后者明确表达了“我需要索引的同时也需要元素”。3.3 生成器让循环里的数据流变得轻量生成器可以理解为一种特殊的迭代器它用yield关键字产出值每次 for 循环在取下一个值时生成器函数会在上次停止的地方继续执行直到函数结束或遇到return。最简单的生成器函数def my_range(n): i 0 while i n: yield i i 1 for i in my_range(5): print(i)如果你手动调next()能看到它是一步一步“挤牙膏”地把值吐出来的。这种惰性求值方式在处理大数据时非常有用。比如你要读取一个 10 GB 的文件如果直接data open(big.txt).read()内存会撑不住但用循环一行一行处理就完全没问题with open(big.txt, r, encodingutf-8) as f: for line in f: process(line)这里文件对象本身就是惰性迭代器每次循环只取一行。生成器和迭代器协议是 Python 处理流式数据的基石。生成器表达式也值得提一句用小括号写推导式不立即生成全部值而是生成一个生成器对象gen (x * x for x in range(10000)) for value in gen: print(value)它比列表推导式省内存适合在数据量未知或很大的场景下使用。但缺点是不能反复遍历只能迭代一次所以用之前要想清楚数据流是一次性消费还是需要复用。4. 写循环的进阶姿势推导式与更精简的表达4.1 列表推导式一行顶五行的常见写法列表推导式是 Python 里把循环和列表构建压缩成一行的语法糖基本格式是[expression for item in iterable if condition]例如把 1 到 20 里的偶数平方收集到一个列表squares [i * i for i in range(1, 21) if i % 2 0]如果不用推导式你得写squares [] for i in range(1, 21): if i % 2 0: squares.append(i * i)显然推导式更短而且在高频操作里性能通常更好因为它在底层用 C 优化的迭代循环减少了方法查找和动态扩展的开销。我建议你把它理解成一个“先写循环再压缩表达式”的过程。当你面对一个数据转换任务时先写完整版result [] for item in list_a: if item 0: result.append(item * 2)然后再压缩成result [item * 2 for item in list_a if item 0]熟练之后直接写推导式没问题。但不建议一上来就写特别复杂的链式推导式比如同时嵌套多个 for 和 if那样子可读性很差。代码是给人读的不只是给机器跑的。4.2 字典推导式与集合推导式推导式不只属于列表。字典推导式用花括号key 和 value 之间用冒号分隔squares_dict {i: i * i for i in range(5)} # 结果{0: 0, 1: 1, 2: 4, 3: 9, 4: 16}集合推导式同样用花括号但没有冒号nums [1, 2, 2, 3, 3, 3, 4] unique_nums {x for x in nums} # 结果{1, 2, 3, 4}用途很广。比如从一堆数据中提取字段构造字典或者做去重统计推导式都能派上用场。注意集合是一组无序且唯一的对象所以输出顺序不保证如果你需要有序去重配合sorted()使用。4.3 什么时候不该用列表推导式列表推导式虽好但有三类情况我倾向用普通 for 循环。第一种是逻辑分支太复杂的时候。比如要同时对多个条件做不同变换推导式会写成一长串表达式肉眼很难追踪。第二种是需要修改外部变量或者有副作用的操作。推导式本质上是构建新列表如果你在推导式里调用函数去改外部状态那就很反直觉也会让代码难调试。第三种是嵌套层次超过两层。正常的多重嵌套可以用推导式但超过两层后忽略缩进的视觉层次会让人看不明白。举一个比较“痛苦”的例子matrix [[1, 2, 3], [4, 5, 6], [7, 8, 9]] flat [num for row in matrix for num in row if num % 2 0]这是一层嵌套展开成普通 for 是这样flat [] for row in matrix: for num in row: if num % 2 0: flat.append(num)这种程度的嵌套还能接受。但如果再加一层可读性就会明显下降。我通常的建议是推导式让你觉得“有点绕”的时候就拆回普通 for 循环。代码规范是给自己和同事看的不是为了炫耀语法技巧。5. 嵌套循环、性能陷阱与优化思路5.1 嵌套循环的经典场景矩阵与笛卡尔积嵌套循环在数据处理里很常见。最典型的是遍历二维矩阵matrix [ [1, 2, 3], [4, 5, 6], [7, 8, 9], ] for row in matrix: for cell in row: print(cell, end ) print()这也对应着“每个元素都要访问一遍”的场景。再比如生成九九乘法表for i in range(1, 10): for j in range(1, i 1): print(f{j}x{i}{i*j}, end\t) print()嵌套循环本身不复杂复杂的是性能。多重循环会让计算量指数级增长所以优化思路一般集中在“减少循环层数”或者“减少内层循环的执行次数”。5.2 用 itertools.product 替代多层嵌套当你要枚举多个序列的笛卡尔积时不要手工写三层 forPython 的itertools模块里已经有现成的工具import itertools colors [红, 绿, 蓝] sizes [S, M, L] for color, size in itertools.product(colors, sizes): print(color, size)这段代码等价于两层 for 嵌套。用product之后代码更扁平也更容易在后面增加更多维度而不会导致缩进越来越深。实际工作中我遇到过一个需求要从时间、地点、业务类型三个维度组合生成报表最初实现是三层 for 嵌套后面需求要加一个渠道维度导致嵌套又深了一层看起来非常臃肿。改成itertools.product后无论多少维度循环体都不用改参数加一个列表就行修改成本极低。5.3 循环性能优化的小技巧循环性能并不是玄学很多时候只要减少无谓的重复计算就能明显提速。我总结几个自己常用的点。第一把循环体内不会变化的值提到循环外。比如# 不推荐len(lst) 每次循环都调用 for i in range(len(lst)): do_something(lst[i], len(lst)) # 推荐 n len(lst) for i in range(n): do_something(lst[i], n)虽然 Python 的len()很快但原理上没必要重复计算。第二能用内置函数解决就别自己写循环。比如求和用sum()统计数量用count()排序用sorted()去重用set()。内置函数是 C 实现的性能远高于 Python 层的逐元素循环。第三查找成员用集合而不是列表。如果你需要在循环里反复判断某个元素是否在另一个容器中用列表的in是 O(n)用集合的in是 O(1)。数据量一上来差别非常明显。# 慢列表查找 allowed [1, 2, 3, 4, 5] for x in big_list: if x in allowed: pass # 快集合查找 allowed {1, 2, 3, 4, 5} for x in big_list: if x in allowed: pass第四有限循环中尽量用局部变量缓存方法。比如在循环里反复调用list.append可以先append lst.append再把append(x)放进循环。这个优化在老代码里很常见虽然现代 Python 解释器已经优化了不少但性能敏感场景仍然有效。优化循环时别魔怔先分析瓶颈在哪。大多数时候我们要先保证逻辑正确再用timeit对这种细节做基准测试。如果循环体内是外部 IO 操作那再快的 Python 循环也救不了网络延迟这时候该考虑并发或批量请求。6. 实战中容易踩的坑与排查技巧6.1 循环遍历过程中修改列表经典翻车现场最经典的坑是边遍历边删除列表元素。看这个例子lst [1, 2, 3, 4, 5] for item in lst: if item % 2 0: lst.remove(item) print(lst)预期想删掉所有偶数但结果却是[1, 3, 5]吗不是实际输出可能让你意外[1, 3, 5]其实看着是对的但为什么说它是坑因为我们遍历时会跳过元素。试一个更明显的lst [1, 2, 3, 4, 5] for item in lst: lst.remove(item) print(lst)结果并不是空列表而是[2, 4]。原因是 for 循环通过索引迭代第一次取lst[0]是 1删除它之后列表变成[2,3,4,5]下一次循环取lst[1]此时取到的是原来的 3原来的 2 被跳过了。这种问题很隐蔽完全不会报错但结果就是错的。正确的做法有两个方向。如果只是读和改不改列表长度那没问题如果要删除符合条件的元素建议遍历副本lst [1, 2, 3, 4, 5] for item in lst[:]: if item % 2 0: lst.remove(item) print(lst)或者用推导式直接构建新列表lst [item for item in lst if item % 2 ! 0]更推荐后者filter 的意图更清晰而且没有修改原列表时的副作用。如果你确实要原地修改也可以倒序遍历lst [1, 2, 3, 4, 5] for i in range(len(lst) - 1, -1, -1): if lst[i] % 2 0: del lst[i]倒序遍历时删除元素不会影响前面元素的索引比较安全。6.2 对迭代器“一次性”的错误预期另一个让我印象深刻的坑是把迭代器当成列表重复遍历。比如lines (line.strip() for line in open(a.txt)) for line in lines: print(line) # 这里再想遍历一遍发现什么也没有 for line in lines: print(line)第二次循环不会有任何输出因为生成器已经耗尽。如果你需要多次遍历要么把数据存成列表要么重新生成迭代器。这在处理文件对象时特别常见很多人第一次用完文件对象后想再遍历一次文件指针已经在末尾了自然读不到内容。一个相关场景是range对象。range 可以被反复迭代因为它不是一次性迭代器你每次 for 它都会生成一个全新的迭代器。所以想重复遍历时优先考虑用range、list这类支持多次迭代的对象而不是生成器或单个迭代器。6.3 while 循环变量忘了更新死循环排查思路while 死循环最常见的原因是循环体里没有更新条件变量。比如i 0 while i 10: print(i) # 忘记 i 1程序会一直打印 0。处理这种问题时我会先检查循环体内有没有修改条件变量的语句如果没有基本可以断定是死循环。如果确认有修改但程序还是卡住我会在循环体开头打印条件变量观察它是否在变化i 0 while i 10: print(debug i , i) i 1实际工作中还有一种更隐蔽的情况条件变量被改为意想不到的类型比如在某个分支中把数字i变成了字符串导致i 10永远不成立。所以建议在 while 循环条件里写明确的比较表达式避免用一个大布尔变量被很多地方改来改去。有时候你会遇到“程序卡住但 CPU 不飙高”的情况那多半不是死循环而是等待外部 IO比如网络请求没有超时、文件读取被阻塞。你需要区分清楚死循环通常 CPU 占用率高IO 阻塞通常 CPU 占用率低。排查思路完全不一样。6.4 循环变量作用域和后续变量覆盖Python 的循环变量不像某些语言那样拥有局部作用域它在循环结束后仍然是可见的。比如for i in range(5): pass print(i) # 4这个特性在某些场景下很方便但也可能造成麻烦。假如你前面用循环变量result存了一批数据后面又写了另一个 for 循环循环变量也叫result前面的值就会被覆盖。尤其在 Jupyter Notebook 这种交互式环境里循环变量的残留很容易导致后续代码出现诡异问题。我的习惯是循环变量的命名要有意义尽量用item、key、value这类单数名词避免用temp、data这种含糊名字。这样可以减少覆盖变量的风险。如果循环内部有临时计算结果尽量用不同名字保存而不是重复使用一个变量。6.5 异常处理放在循环里还是循环外这个选择会直接影响程序行为。如果 try/except 放在循环内部那么一次迭代出错会被捕捉后续迭代继续执行for item in data: try: process(item) except ValueError: print(跳过, item)如果 try/except 放在循环外部那么任何一个 item 抛出异常都会直接退出循环不再处理后面的数据try: for item in data: process(item) except ValueError: print(整个循环中断)实际项目里拉取批量接口时我一般会把单条数据处理的异常放在循环内部保证一条失败不会拖垮整个批次。但如果你需要“一批任务要么全部成功要么全部标记失败”的语义那就把异常放在循环外或者收集所有异常统一处理。没有对错关键看业务需求。6.6 常见循环问题速查现象原因解决方案遍历列表删除元素后部分元素被跳过删除导致索引前移遍历副本或用倒序删除for 第二次遍历生成器没有结果生成器是一次性的改用列表或重新创建生成器while 循环不退出条件变量未更新在循环体内更新条件变量或添加最大次数两层 for 里 break 只跳出内层break 只作用于最近一层用标志变量或抽函数 return找元素是否存在时循环很慢列表in是 O(n)改用集合 set循环中调用 len() 等不变函数重复计算提到循环外赋值给局部变量循环结束后变量残留Python 循环变量无独立作用域使用不冲突的变量名单条数据异常导致整个循环停止异常处理在循环外把 try/except 移到循环体内6.7 调试循环的一些小工具当你面对一个很复杂的循环怎么高效定位问题我自己的习惯是三个招式。第一打印法。在循环体关键位置打印变量的值。Python 的print是最原始但也最直接的调试手段。不过别在生产代码里乱留打印语句可以用logging.debug()替代。第二pdb断点调试。在循环前设置断点然后单步执行逐行观察变量变化。命令很简单import pdb for i in range(10): pdb.set_trace() print(i)运行到pdb.set_trace()会进入交互式调试你可以输入n继续下一行p i打印变量c继续循环。第三给循环添加进度显示。如果循环次数很多比如几百万次直接用print会很刷屏我一般用tqdmfrom tqdm import tqdm for i in tqdm(range(1000000)): pass这个库能显示进度条、剩余时间、每秒迭代次数。在处理大批量文件、训练模型、数据清洗的时候进度条能让你快速判断循环是“正在跑”还是“卡死了”。如果不想引入第三方库也可以在每 N 次迭代的时候打印一次当前进度比如for i in range(total): if i % 10000 0: print(f进度 {i}/{total})这种方式开销很小但能给你一个程序还在呼吸的感觉。7. 最后再分享一点个人体会循环这个东西看着基础但在实际项目里几乎无处不在。我在写量化交易策略的时候经常要循环回测历史行情数据写爬虫时要用循环拉取多页内容写数据处理脚本时要用循环清洗每一行数据。可以说循环写得好不好直接决定代码能不能维护。我个人在写循环的时候会先想清楚三件事这次循环是在“遍历集合”还是“等待条件”需不需要提前退出以及循环过程中的数据结构选对没有。想清楚之后再决定用 for 还是 while要不要 break/else能不能用推导式或者内置函数替代。这些判断比单纯记住语法更重要。还有一个很小的技巧循环体里的代码尽量短。如果循环体超过 10 行我会考虑把里面的逻辑抽成函数比如process_item(item)。这样循环本身变成一层薄薄的壳逻辑清晰也方便测试。尤其在 for 循环里函数调用看起来多了一次开销但带来的可维护性提升远超那点性能损耗。最后再分享一个我自己用得很顺的小习惯写循环时如果发现代码里出现了一堆下标 i、j、k就要警惕是不是在“用 Python 写 C”。Python 的循环魅力在于数据流和迭代器尽量用 Python 的方式去表达你会发现代码简短、清爽得多。希望这份极简整理能帮你少踩几个坑。