
开头Python的for循环和while循环看起来就两行语法但真要在项目里写出清晰、高效、不出bug的循环代码里面门道不少。尤其是循环和异常处理搭配使用时很多人会踩到变量作用域、无限循环、异常吞掉等让人头疼的坑。这篇博文不讲那些网上到处复制粘贴的入门教程而是围绕循环语句和异常处理这两块核心控制语句把底层原理、实践细节、易错点和解决方案一次说透。你可以把它当成一份“循环与异常处理的进阶避坑指南”无论你是刚学Python语法的新手还是写了几个月脚本、想夯实基础的初级开发者这篇文章都能让你少走弯路。我写的所有示例都尽量贴近真实场景不是那种for i in range(10)就结束的演示。具体安排是这样的先拆解for循环的底层迭代机制和写法技巧再讲清楚while循环的使用边界然后重点说透异常处理的完整机制最后用几个综合场景把for、while和try/except串起来边写边聊经验和教训。开始之前多说一句如果你连Python环境都还没装好建议先随便找个最新版Python安装包装上。不建议一上来就折腾虚拟环境先跑通代码比什么都重要。下面进入正题。1. for循环深度拆解迭代的本质是什么1.1 先理解迭代器协议再谈for循环很多初学者用for循环就死记硬背“for i in range(10)是循环10次”。这样学语法可以但遇到稍复杂的数据结构就会懵。比如为什么for能直接遍历字典的键为什么遍历集合时顺序会变这背后都是迭代器协议在起作用。一个对象能被for循环遍历是因为它实现了迭代器协议——也就是有__iter__()方法返回迭代器迭代器又有__next__()方法逐次取元素取完时抛StopIteration异常通知循环结束。换句话说Python里for循环本质上就是一个不断调用next()的过程。看下面这段代码就清楚了# for循环背后的原理 my_list [10, 20, 30] it iter(my_list) # 调用 __iter__() while True: try: item next(it) # 调用 __next__() except StopIteration: break print(item)这里iter()和next()是两个关键的内置函数它们分别对应__iter__和__next__。StopIteration这个异常不是错误它是循环正常结束的信号。理解这一点很好因为你自己写生成器、迭代器时就是靠手动抛出StopIteration来让for循环停下来的。我见过不少人在自定义迭代器时忘了处理结束条件结果无限循环内存暴涨这个细节值得敲黑板。1.2 range函数的常见误区和正确用法range几乎是for循环中最常搭配的对象了。但关于range有几个坑我每次都会被问到。先看基本语法range(start, stop, step)左闭右开也就是包含起始值但不包含结束值。range(5)产生0、1、2、3、4没有5。很多人把它跟list混淆但实际上range是惰性求值的它不一次性生成全部数字而是每次迭代才算出一个数。这就是为什么range(100000000)也不会把内存撑爆。我自己在写循环时强烈建议不要用索引遍历列表除非你确实需要知道当前下标。比如# 反例为了拿元素而用下标 fruits [苹果, 香蕉, 橙子] for i in range(len(fruits)): print(fruits[i]) # 正常做法直接遍历元素 for fruit in fruits: print(fruit) # 需要下标和值时用 enumerate for idx, fruit in enumerate(fruits, start1): print(idx, fruit)enumerate是处理“既需要索引又需要元素”场景的最优解我平时写代码基本都靠它。另外range支持负数步长可以实现逆序循环比如range(10, 0, -1)就是从10数到1注意别把0也带进去因为stop0是不包含的我相信这个细节能帮到不少新手。1.3 遍历字典、集合和文件对象的正确姿势for循环不仅处理数字序列更常见的是遍历各种容器。字典默认遍历的是键但也别忽略了items()和values()这两个好搭档user_info {name: 小明, age: 18, city: 上海} for key in user_info: print(key) # 默认遍历键 for key, value in user_info.items(): print(f{key}: {value}) for value in user_info.values(): print(value)遍历字典时有个大坑不能在循环过程中直接修改字典的大小。比如你在for key in dict里面执行dict.pop(key)运行时会直接报RuntimeError: dictionary changed size during iteration。正确的做法是先把键复制一份或者用字典推导式重建一个新字典这个在实战里经常遇到。集合遍历相对简单但注意集合是无序的每次遍历顺序可能不同如果你依赖顺序做逻辑判断先把集合转成排序的列表更稳妥。文件对象也是可迭代的打开一个大文件时逐行遍历不会把整个文件读进内存这点对日常处理日志的人特别有用with open(access.log, r, encodingutf-8) as f: count 0 for line in f: if ERROR in line: count 1 print(f错误日志行数: {count})我见过不少新手处理几GB的日志用f.readlines()直接全读进去内存直接爆掉。其实用for逐行迭代内存占用几乎可以忽略这里面的差别就在于是否理解for处理文件时是“懒加载”的。1.4 for-else结构一个经常被忽略的特性Python里有个很少被人提到的语法for循环可以搭配else子句。这里的else不是“循环结束之后执行”而是“循环正常走完才会执行”。如果循环被break中断了else块不会执行。举一个最常见的例子判断一个数字是否是素数。def is_prime(num): if num 2: return False for i in range(2, int(num ** 0.5) 1): if num % i 0: print(f{num} 不是素数能被 {i} 整除) break else: print(f{num} 是素数) return True return False is_prime(17) # 输出17 是素数 is_prime(15) # 输出15 不是素数能被 3 整除这个特性在“查找一个东西找到就返回/没找到就走另一个分支”的场景里特别简洁。如果你不知道for-else通常会用一个found False的标志变量来标记代码啰嗦不说还容易漏更新标志位。类似的还有while-else逻辑一致。2. while循环的正确打开方式和结束条件设计2.1 while循环的本质条件驱动的重复执行while循环和for循环的区别用一句话说就是for擅长遍历已知序列while擅长“只要条件成立就一直执行”的场景。理解while最关键的是理解“循环条件的布尔判定”。Python里一切对象都可以转成布尔值0、空字符串、空列表、空字典、None都对应False。所以while my_list:的意思是“只要列表不为空就继续循环”。我建议大家写while循环时条件表达式要尽量直白明确不要依赖隐式转换。比如count 0 while count 5: # 明确的条件 print(count) count 1比while count:更清晰也不会让浏览代码的人在心里多换算一步。2.2 怎样避免陷入无限循环while循环最大的风险就是死循环。说句实在话谁没写过几次死循环呢关键是要建立“循环必有出口”的条件反射。常见的死循环原因有以下几种第一忘记更新循环变量或者更新逻辑有误。这个最经典。i 0 while i 10: print(i) # 忘了写 i 1直接死循环第二循环条件本身永远为真比如while True但内部的break条件永远满足不了。这种一般是你希望“先执行一遍再判断退出条件”时容易犯的错。第三浮点数精度导致的条件永远不满足。比如x 0.0 while x ! 1.0: x 0.1这段代码实际上会死循环因为浮点数0.1的三进制二进制表示是不精确的0.1累加多次不会精确等于1.0。判断浮点数是否相等永远要使用“差的绝对值小于某个极小值”的方式否则就等着机房冒烟。预防死循环的好习惯是调试时加一个“安全计数器”或者跑之前先想清楚退出条件。我现在写复杂while时会在草稿上先画一下状态变化确认每一次循环都让条件“离终点更近一步”。2.3 break、continue和else的组合策略循环控制里break是结束整个循环continue是跳过本次循环进入下一次。这两个语句看似简单实际组合使用时要注意别写出让人很难看懂的代码。一个建议嵌套循环中break只跳出一层循环不会跳出外层。如果你需要跳出多层循环不要靠一层层break硬跳可以用一个标志变量或者更优雅的方案——把循环封装成函数用return来退出。continue则要注意别和计数逻辑打架。比如下面的代码本来想跳过偶数但累加了所有数total 0 for i in range(1, 11): if i % 2 ! 0: # 如果是奇数 total i # 累加奇数 else: continue print(total) # 25这里continue其实没起到关键作用因为if-else结构已经区分了逻辑。使用continue时切记它跳到下一次迭代本次迭代后面所有代码都不执行了。如果后面有“本应每次循环都执行”的代码比如打印调试信息、累计循环次数把它放在continue之前否则会出偏差。组合策略上我个人习惯是简单的过滤逻辑优先用continue复杂的跳出逻辑优先用break加函数封装。另外while-else的应用场景和for-else一样正常走完才执行else块适合“寻找后是否成功”这类判断。2.4 用while处理交互式输入和重试场景while最拿手的场景之一是“不确定什么时候停止”的交互场景。比如用户输入直到输入quit才退出while True: user_input input(请输入命令输入 quit 退出) if user_input.strip().lower() quit: print(退出程序) break print(f执行命令: {user_input})这里用了while True加显式break比用一个is_running True标志位更直观。我在写网络请求的重试逻辑时也喜欢用while加计数器max_retries 3 attempt 0 while attempt max_retries: try: # 模拟一次可能失敗的请求 result make_request() break except ConnectionError as e: attempt 1 print(f第 {attempt} 次请求失败: {e}) if attempt max_retries: print(重试次数已达上限放弃请求)这种“带着重试上限的while循环”是最实用的模式之一我一直推荐新人在写爬虫、调用API时都采用这个框架。上限变量要单独定义不要写死在条件里方便后续调整。3. 异常处理语句全解析try/except/else/finally怎么组合不出错3.1 异常处理的基本结构和语法细节异常处理是生产级Python程序里不可缺少的一环。一个程序如果没有异常处理一个小小网络抖动就能让整个脚本崩掉这在真实项目里完全不可接受。基础语法是try-excepttry: num int(input(请输入一个数字: )) result 100 / num print(f结果是: {result}) except ValueError: print(输入的不是有效数字) except ZeroDivisionError: print(除数不能为零)这里要重点强调几个细节第一except可以捕获多个异常类型用元组包起来用一个except处理except (ValueError, TypeError):。如果不同异常要不同处理就写多个except子句。第二捕获异常时别盲目用“裸except:”因为它会捕获所有异常包括KeyboardInterrupt用户按CtrlC和SystemExit程序启动退出这样一来用户想强制退出程序都不行极其反人类。我第一次写爬虫时偷懒用了裸except结果程序卡死在死循环里按CtrlC都没反应那次教训让我记住了永远不要这么干。第三使用except Exception as e:是相对稳妥的兜底写法因为Exception是大多数常规异常的基类而KeyboardInterrupt和SystemExit继承自BaseException不会被它捕获。特殊场景再单独捕获它们。as e获取到的异常对象打印str(e)能直接看到错误描述这个在调试时很有用。我习惯在开发阶段打印完整的traceback来定位问题线上环境则只打印摘要信息避免泄露敏感内容。3.2 else和finally谁先执行用途分别是什么很多教程对else和finally的介绍一笔带过但这两个子句在实战中能帮你写出更干净的代码。先看elsetry块里没发生任何异常时会执行else。它的好处是把“可能出现异常的代码”和“正常情况下才执行的后续代码”分开。举个例子try: with open(data.txt, r, encodingutf-8) as f: content f.read() except FileNotFoundError: print(文件不存在) else: # 只有文件正常读取后才执行 print(f文件内容长度是: {len(content)})如果不使用else你可能会把print语句直接写在try块的最后。这样虽然也能工作但如果print本身抛异常就会被后面的except捕获干扰真正的错误定位。把“期望不发生异常的代码”放到else语义清晰且安全。再看finally无论是否发生异常finally块都会执行。它的典型用途是释放资源、关闭文件、断开连接。比如conn None try: conn open_connection() data conn.query(SELECT * FROM users) except ConnectionError: print(连接失败) finally: if conn: conn.close()finally的执行时机是try块结束、except块结束或者else块结束后都会走finally即使你在try里写了return函数返回前也会先执行finally。这一点在配合文件操作和网络资源释放时非常关键。顺带说一句如果你用了with上下文管理器很多资源释放场景不需要finally因为with的魔力就是在离开时自动关闭资源。但finally在处理“无论任何情况都必须执行的收尾工作”时仍然不可替代。3.3 主动抛出异常raise语句的正确使用异常不只是“被动捕获”的我们还可以主动通过raise抛出异常。这在参数校验和业务逻辑判断里非常常见。def withdraw(balance, amount): if amount 0: raise ValueError(取款金额必须大于 0) if amount balance: raise ValueError(余额不足) return balance - amount主动抛出异常的好处是把错误判断逻辑从业务逻辑中分离出来调用方可以用try-except统一处理各种失败情况而不是层层嵌套判断返回值。raise还有两种高级用法一种是raise ... from ...用于链接异常让调用者知道当前异常是从哪个底层异常转换过来的。这在重新抛出异常时特别有用。try: result 1 / 0 except ZeroDivisionError as e: raise ValueError(计算失败请检查输入) from e另一种是直接raise不带参数在except块里重新抛出当前异常。这种做法适合“先记录日志再继续抛出”的场景让上层继续处理。我在实战中绕了不少弯路才明白不要动不动就自定义一大堆异常类型。对于多数中小型项目使用Python内置的ValueError、TypeError、RuntimeError通常就够了自定义异常过多反而增加维护成本。只有当你的异常类型确实携带了特殊语义比如业务上某个特定错误需要单独捕获时才自定义。3.4 自定义异常类型的思路和实际案例虽然前面说别过度设计但某些业务场景下自定义异常确实值回票价。比如你写了一个支付类库定义了PaymentError、InsufficientFundsError、InvalidCardError调用方就能精确处理每一种失败情况而不是拿到一个模糊的Exception再去解析字符串。自定义异常的写法很简单class PaymentError(Exception): 支付错误基类 pass class InsufficientFundsError(PaymentError): 余额不足 def __init__(self, balance, amount): self.balance balance self.amount amount super().__init__(f余额不足当前余额 {balance}需要 {amount})继承自Exception即可因为只有继承Exception的异常才能被except Exception捕获。特别注意一定不要直接继承BaseException去自定义异常那会造成异常捕获的混乱。自定义异常的优点是异常类型本身就是错误描述代码读起来特别清晰可以携带额外属性方便上层丰富异常上下文在异常层级设计时用基类能统一兜底我见过有些项目里异常对象里塞了一大堆关联数据比如请求头、响应体、用户ID这对排查问题很有帮助。但也要克制一点敏感信息不要放进异常消息里否则打日志的时候就泄露出去了。4. 循环与异常的综合实战三种典型场景手把手拆解4.1 场景一凯撒密码加密器练手循环与异常处理的顺序凯撒密码是大家学习时很经典的小项目也适合用来理解“先捕获异常再执行逻辑”的顺序。它把所有字母按照固定的位数偏移比如偏移3时A变成D。def caesar_encrypt(text, shift): result [] for char in text: if char.isupper(): base ord(A) new_char chr((ord(char) - base shift) % 26 base) elif char.islower(): base ord(a) new_char chr((ord(char) - base shift) % 26 base) else: new_char char result.append(new_char) return .join(result) def caesar_decrypt(text, shift): return caesar_encrypt(text, -shift) text Hello, Python! shift 3 encrypted caesar_encrypt(text, shift) decrypted caesar_decrypt(encrypted, shift) print(f原文: {text}) print(f加密后: {encrypted}) print(f解密后: {decrypted})这个小例子完整使用了for循环来遍历每个字符并基于字符编码做偏移计算。你在设计这个函数时可能要区分大小写、处理字母循环回绕、保留非字母字符。这些都是循环内部逻辑的细节处理。如果你想在程序入口处接收用户输入并且处理用户输入的不是数字等情况就需要结合异常处理。这样用户输入abc时程序不会直接崩溃而是给出友好提示并让用户重新输入。4.2 场景二猜数字游戏while加异常锻造一个完整小项目猜数字游戏是练习while循环和异常处理的经典项目。计算机生成一个1到100之间的随机数用户不断猜测程序提示“大了”或“小了”直到猜中为止。这个场景把“不确定何时结束的循环”和“处理不规范输入”完美結合在一起。import random target random.randint(1, 100) guess_count 0 print(猜数字游戏猜一个 1-100 之间的整数) while True: user_input input(请输入你猜的数字) try: guess int(user_input) except ValueError: print(输入无效请输入一个整数) continue guess_count 1 if guess target: print(小了再猜大一点。) elif guess target: print(大了再猜小一点。) else: print(f恭喜你猜对了答案是 {target}你一共猜了 {guess_count} 次。) break这个游戏里try-except配合continue形成了一条“输入无效就重新来”的循环路径同时不会打断计数逻辑。很多人在正式开发里也是这么写的用户输入不可信必须校验校验失败不是致命错误而是回到“等待下一次输入”的状态。这种模式我在写命令行工具、配置解析器、表单校验时反复使用它已经成了一块心头好。4.3 场景三爬虫请求的重试和异常降级爬虫是一个最能体现“循环异常”组合价值的场景。网络请求随时可能因为超时、连接重置、返回非200状态等原因失败写爬虫不做好异常处理就是拿脚后跟写代码。下面这个示例模拟带重试的请求逻辑并在失败达到上限后降级返回Noneimport time import random def fetch_data(url, max_retries3, timeout5): for attempt in range(1, max_retries 1): try: # 模拟网络请求随机失败 if random.random() 0.4: raise ConnectionError(连接超时) if random.random() 0.1: raise ValueError(服务器返回异常数据) # 模拟成功 return {url: url, status: 200, data: [1, 2, 3]} except ConnectionError as e: print(f[尝试 {attempt}/{max_retries}] 连接错误: {e}) if attempt max_retries: print(达到最大重试次数请求失败) return None time.sleep(1 * attempt) # 退避递增不要一失败就猛冲 except ValueError as e: # 数据异常通常重试没有意义直接放弃 print(f数据错误无法恢复: {e}) return None return None # 执行调用 for i in range(5): print(f发送请求 {i1}) result fetch_data(https://example.com/api) if result: print(请求成功)这个代码里有几个值得说的细节使用for attempt in range(...)来控制重试次数而不是while结构更清晰超时/连接类异常属于“可重试”的所以继续循环数据格式错误属于“不可恢复”的直接返回None重试间隔time.sleep(1 * attempt)实现了简单的退避策略避免对目标服务器造成过大压力异常处理的艺术不在于“多写几个except”而在于想清楚“哪些错误可以重试哪些错误不能重试”。这个思路在你后面写爬虫、对接第三方API时会救命。4.4 综合练习用循环和异常实现简易命令行计算器最后用一个更综合的场景把所学内容串起来。简易计算器既能体现循环结构又能复用异常处理很适合当成课后练习来做。def calculator(): print(简易计算器输入两个数字和一个运算符(, -, *, /)) print(输入 q 退出) while True: expr input(\n请输入表达式如 1 2) if expr.strip().lower() q: print(再见) break try: # 期望输入的格式是 数字 运算符 数字 parts expr.split() if len(parts) ! 3: raise ValueError(输入格式不正确请用空格分隔例如1 2) num1 float(parts[0]) operator parts[1] num2 float(parts[2]) if operator : result num1 num2 elif operator -: result num1 - num2 elif operator *: result num1 * num2 elif operator /: if num2 0: raise ZeroDivisionError(除数不能为 0) result num1 / num2 else: raise ValueError(f不支持的运算符: {operator}) print(f计算结果: {result}) except ValueError as e: print(f输入错误: {e}) except ZeroDivisionError as e: print(f计算错误: {e}) if __name__ __main__: calculator()这个程序虽然简单但把while True、break、try-except、多类异常捕获、错误后的循环回流都覆盖了。而且它留下了一个有意思的扩展空间如果你希望程序运行时能自动记录所有计算日志到文件里那么可以考虑引入“打开文件后再循环log写失败时怎么处理”的新一轮循环与异常设计的思考。5. 常见问题与排查技巧实录5.1 循环里修改了正在被迭代的列表这个问题出现的频率非常高。比如你想删掉列表中所有小于0的数可能会写出下面这种代码numbers [1, -2, 3, -4, 5] for num in numbers: if num 0: numbers.remove(num) print(numbers) # 实际输出 [1, 3, -4, 5]-4 没删掉出现这个问题的原因是遍历时列表的索引在不断变化remove元素后后续元素会补位导致部分元素跳过遍历。你在思考这个例子时会发现不是所有负数都被处理了这会造成bug。推荐的解决办法是遍历列表的副本或者用列表推导式直接生成新列表。# 方案1遍历副本 numbers [1, -2, 3, -4, 5] for num in numbers[:]: if num 0: numbers.remove(num) print(numbers) # [1, 3, 5] # 方案2列表推导式最推荐 numbers [num for num in numbers if num 0] print(numbers) # [1, 3, 5]同样的原则适用于字典。如果你需要删除字典中符合条件的键值对最好先收集满足条件的键遍历键列表时再删除。5.2 浮点数作循环条件的精度问题前面提到过用浮点数相等当作循环结束条件是极其危险的。再强调一次根本原因是浮点数是二进制近似表示很多十进制小数在二进制中是无限循环的累加时误差会累积。x 0.0 steps 0 while x 1.0: x 0.1 steps 1 print(f最终 x {x}, 循环了 {steps} 次) # 最终 x 1.0000000000000007 或类似值如果循环退出条件是x 1.0那这个程序基本不会正常结束。解决办法是用整数计数来控制循环次数然后在需要浮点数时再除以倍数或者用decimal.Decimal处理高精度场景再或者用“大于等于某个区间”来判断边界。# 用整数计数避免浮点误差 for i in range(10): x i / 10 print(x)这个思路在生产代码里经常用到比如按百分比进度循环时就用range(100)而不是逐步累加浮点数。5.3 异常捕获范围过大导致的问题很多人写异常处理时喜欢把一大段代码塞进try然后用一个笼统的except Exception: pass收场。结果就是代码出错了程序却一声不吭地“成功”了。这种“异常吞没”的代码比不写异常处理还要恐怖因为问题会被掩盖到很难发现。# 反面教材错误被静默吞掉 try: data parse_config_file(config.ini) result process_data(data) except Exception: pass # 什么都不输出出错了也不知道改进的做法是缩小try的范围每个except都记录日志或至少输出信息。至少也要有一个else或finally来保证关键逻辑不丢失。import logging logging.basicConfig(levellogging.INFO) try: data parse_config_file(config.ini) except FileNotFoundError as e: logging.error(f配置文件不存在: {e}) except PermissionError as e: logging.error(f没有文件读取权限: {e}) else: result process_data(data)把“可能失败的读取”和“正常情况下的处理”分离日志里有清晰记录未来排查起来效率就高多了。我一直觉得日志是程序员最好的朋友写异常处理时不要吝啬日志输出。5.4 常见问题速查表做了一张速查表方便你平时遇到问题直接对照现象可能原因解决思路for循环中删除列表元素部分元素未遍历到迭代时修改了列表大小遍历副本或用推导式生成新列表while条件永远为真程序卡死循环变量未更新或更新不到边界为循环条件增加计数器检查更新逻辑浮点数循环次数异常浮点累加精度误差改用整数计数或使用Decimal直接用except:捕获不到CtrlC退出裸except捕获了KeyboardInterrupt之外全部异常使用except Exception:并显式记录日志程序不报错但结果不对异常被except: pass静默吞掉移除pass添加日志输出嵌套循环break无法跳出外层break只作用当前层循环封装函数用return或使用标志变量字典在遍历时修改报错遍历期间改变了字典大小先取键列表再循环这张表的每一条都是我自己或身边的开发者真实踩过的坑。如果你遇到的bug不在表里也可以按类似的思路去排查先定位是循环问题还是异常处理问题再缩小try范围同时看日志。结尾这篇关于Python循环和异常处理的内容到这里就差不多了。其实循环和异常处理这两个主题单独看都不难难的是在真实项目里把它们组合在一起并且处理各种边缘情况。我自己最初的教训就是只想着代码能跑通却忘了写出去的代码早晚要交给别人维护和调试。后来我在写循环时都会顺手写下退出条件写异常时都保留日志这个习惯帮我省下了大量排查时间。如果你刚学完这些内容建议不要光看一定动手把文章里的三个综合场景自己敲一遍改一改参数、加一加逻辑。比如给凯撒密码加上异常输入处理给猜数字游戏增加最短猜测次数记录给计算器增加保存历史记录的功能。这些扩展能帮你把循环和异常处理的边边角角都观察到、练扎实。写到这了再送一个小技巧在调试循环代码时如果觉得循环次数不好追踪可以临时在循环开头加一行print(f当前第 {i} 次循环)跑完再删掉。简单的小技巧但比动辄上断点调试器要明快得多。希望这篇文章对你有用也欢迎在评论区分享你自己的循环和异常处理实战经验。