顺序、选择、循环:三种控制结构如何写出可维护代码 顺序结构、选择结构、循环结构这三个词几乎是所有编程入门材料的第一章标题也是绝大多数人学完之后觉得“没什么可讲”的一章。我带过几个转行的朋友也帮人改过不少半成品脚本和小工具慢慢发现一个规律代码写得乱、改不动、一改就崩的人问题往往不出在框架不熟、语法糖不会用上而是这三种最基础的结构没真正过关。他们能写出跑得通的代码但写不出能读、能改、能扩展的代码。这篇东西就是把这三种结构拆开讲透从“它到底在管什么”讲到“实际写的时候会在哪儿翻车”再给一套可以直接照着做的排查方法。不管你是刚学完语法的初学者还是写了两三年但总觉得代码不够干净的人应该都能从中挑到几条立刻能用的东西。1. 先搞清楚这三大结构到底在管什么1.1 从一段“能跑但没人敢改”的代码说起我见过一段很典型的初学者代码功能是统计一批订单里满足条件的金额总和大概长这样total 0 i 0 while i len(orders): if orders[i][status] paid: if orders[i][amount] 0: if orders[i][user] is not None: total total orders[i][amount] i i 1 else: i i 1 else: i i 1 else: i i 1这段代码能跑结果也对。但问题在于i i 1这个步进动作被复制了四份散落在四个分支里。哪天要在循环体里加一个新判断就得再复制一份哪天某个分支漏写了这一行程序立刻变成死循环。这类代码的毛病不在语法而在结构——顺序结构里的“步进”被拆散了选择结构里的分支嵌套过深循环结构的终止条件被隐藏在多个出口里。三种结构各自的职责其实非常清楚顺序结构决定“事情按什么次序发生”选择结构决定“哪条路走、哪条路不走”循环结构决定“同一件事重复多少遍、什么时候停”。绝大多数难维护的代码本质都是这三件事里的某一件被搞混了。1.2 三种结构的本质把执行路径说清楚换个角度看程序就是一条执行路径。顺序结构是一条直线选择结构是在直线上出现岔路口循环结构是把一段直线卷成一个圈。三种结构组合起来理论上可以表达任何逻辑——这不是我瞎说的结构化程序设计的基本结论就是这个任何算法都可以只用顺序、选择、循环三种控制结构表达不需要goto。这件事的实际意义在于当你觉得某段逻辑“怎么写都别扭”的时候通常是因为你在试图用错误的组合方式表达它。比如把本该用循环的地方写成了一长串重复语句把本该用选择的地方塞进了一个循环里靠break跳出把本该拆成函数的顺序逻辑硬挤在一个大括号里。我一般会用一张表来对照这三种结构在真实代码里的表现形式结构类型核心职责典型语句出问题时的症状顺序结构确定执行次序与依赖关系赋值、函数调用、返回结果依赖“碰巧写对了顺序”换个顺序就错选择结构在岔路口做唯一决策if/elif/else、switch、三元表达式分支漏写、条件重叠、边界值落空循环结构重复执行并控制终止for、while、do-while死循环、少跑一次、多跑一次、循环内改集合记住这张表的第三列。排查问题时先判断这段逻辑属于哪一类再对照症状效率比盲目打印高得多。1.3 为什么新手最容易在结构上翻车新手在结构上翻车原因通常不是“不懂”而是“懂的是表面”。举几个我反复见到的例子。第一种把顺序结构当成“随便排”。变量在使用前必须先有值这是硬约束但很多人写脚本时习惯“先算再说”等到运行时抛出未定义错误才回头补。第二种把选择结构当成“条件写全就行”。条件写全了但覆盖的区间有缝隙或者有重叠比如score 60和score 60并列恰好等于 60 的那批数据就掉进了虚空。第三种把循环结构当成“能跑出结果就行”。循环跑出了结果但那个结果是靠输入数据的特殊性凑出来的换一组数据立刻原形毕露。这三种情况的共同点是代码在特定输入下是对的但结构本身没有保证正确性。而结构的意义恰恰是用来保证“在所有输入下都对”。2. 顺序结构最不起眼却最容易出事2.1 顺序结构不只是“从上往下写”很多人对顺序结构的理解停留在“代码从上往下执行”。这句话本身没错但它漏掉了关键的一半顺序结构真正约束的是依赖关系。哪一步必须在哪一步之前完成这才是重点。举个生活化的例子。做一杯咖啡磨豆、烧水、萃取、加奶这四步有明确的依赖没烧水就没法萃取没磨豆也没法萃取。但“烧水”和“磨豆”之间没有依赖可以并行。顺序结构做的事就是把有依赖的步骤排好把没依赖的步骤识别出来。写代码也一样一段业务逻辑里哪些操作存在先后约束、哪些不存在先想清楚这个代码的次序就自然排出来了。我见过一段计算订单最终价格的代码逻辑是先按会员等级打折再抵扣优惠券最后加上运费。作者写的时候把“加运费”放在了“打折”前面结果运费也被打了折。功能上线没人发现直到某天大促运费调高财务对账才发现每单少收了几毛钱。这就是典型的顺序依赖没理清——运费参数参与不参与折扣是业务规则决定的不是代码随便排的。2.2 变量初始化与依赖顺序的实操要点顺序结构上最容易踩的坑几乎都跟“值什么时候产生、什么时候被使用”有关。我整理了几条实操时可以直接对照的检查点。第一累加器和计数器必须在循环之前初始化且初始化值要符合语义。求和初始化为 0求积初始化为 1求最小值初始化为“正无穷”或者集合里的第一个元素。这个细节看着小但把求积初始化为 0 之后整个结果永远是 0这种错误排查起来极其费时间因为程序不会报错。第二先校验后计算。输入的合法性检查应该放在所有计算之前别算到一半才发现参数是空的。我一般会把“参数检查”统一放在函数开头几行这样读代码的人一眼就能看到边界在哪。第三注意副作用的发生位置。打印日志、写文件、发请求、修改全局状态这些都叫副作用。副作用一旦发生就收不回来所以它的位置很关键。比如在循环内部打印日志循环跑一千次就打印一千行在写数据库之前打印成功日志万一写入失败日志会误导排查方向。经验做法是日志写在“动作确认完成”之后而不是之前。第四浮点数的累加顺序会影响结果。这个坑比较隐蔽。浮点数在计算机里是近似表示加法不满足结合律(a b) c和a (b c)的结果可能差一个极小值。大部分业务场景无所谓但涉及金额、科学计算、统计数据时累加顺序、是否先排序再累加都可能影响最终结果。稳妥的做法是金额统一用“分”为单位做整数运算需要显示时再转成元。# 不推荐浮点直接累加金额 total 0.0 for item in items: total item[price] # 推荐统一转成整数分累加 total_cents 0 for item in items: total_cents int(round(item[price] * 100)) total total_cents / 1002.3 副作用与顺序几个典型翻车现场顺序结构出问题往往发生在“有副作用”的地方。我列三个亲身遇到过的场景都是看着不起眼、后果不小的。场景一先删除后读取。某同学写了个工具从列表里挑出符合条件的元素处理处理完再从原列表里删掉。他的写法是先遍历原列表、边遍历边删——这就把顺序结构和循环结构搅在一起了遍历到一半列表长度变了后面的元素直接被跳过。正确做法是先标记、遍历完再统一删除或者直接生成新列表。场景二先写日志再执行。日志的作用是记录“发生了什么”如果写成“即将发生什么”出问题时日志会骗人。这个习惯改起来很快但很多人一开始就是这么写的。场景三先改配置再备份。备份的意义是保留改动前的状态顺序反了备份出来的就是改完的版本等于没备份。这类错误在运维脚本里特别常见一旦出事就是不可逆的。提示判断顺序结构是否正确有个简单方法——把这段代码的每一步写成一句话然后问自己“如果把这句和上一句调换会不会出问题”。会出问题的那些相邻句子就是存在依赖的地方需要重点检查。3. 选择结构条件判断怎么写才不出漏洞3.1 if/else 的三种写法对比同一个判断至少有三四种写法写法的差异直接决定了可读性和出错概率。拿“根据分数给出等级”这个经典例子来说三种常见写法是这样的# 写法一嵌套式 def grade_v1(score): if score 90: return A else: if score 80: return B else: if score 60: return C else: return D # 写法二平铺式推荐 def grade_v2(score): if score 90: return A if score 80: return B if score 60: return C return D # 写法三区间枚举式规则表驱动 def grade_v3(score): rules [(90, A), (80, B), (60, C), (0, D)] for threshold, label in rules: if score threshold: return label return D写法一嵌套三层读的时候要脑内压栈层数一多就没人愿意看写法二用提前返回把嵌套压平每一条规则独立成立读起来是线性的写法三把规则抽成数据将来加一个“A”只需要往列表里加一行属于结构升级。我的偏好是规则不超过四条、条件简单用写法二规则多、需要频繁调整、可能需要配置化的用写法三。写法一基本只在需要“两个分支都做一堆事”的时候才用而且层数不超过两层。3.2 边界条件与空值判断写反的高发区选择结构出事故十有八九是边界没覆盖到。判断条件本质上是给数轴做切割切割必须满足两个要求不重叠、不留缝。以“18 到 60 岁之间算成年劳动者”为例age 18 and age 60是常见的正确写法。而age 18 and age 60就漏掉了 18 岁age 18 or age 60则几乎覆盖了所有人因为或运算只要一边成立就进。这类错误在代码评审里极难用眼睛看出来因为符号长得太像了。浮点数的相等判断是另一个高发区。0.1 0.2 0.3在很多语言里返回假因为浮点表示不精确。凡是涉及浮点比较一律改成“差值绝对值小于某个极小值”EPS 1e-9 if abs(a - b) EPS: print(视为相等)空值判断也要格外小心。很多语言里空值和“假值”是两回事空字符串、数字 0、空列表在某些语言里会被判为假但它们的业务含义完全不同——一个商品的库存是 0和这个商品没有库存记录处理方式能一样吗我习惯的做法是显式判断类型和空值不做隐式转换宁可多写两个字符。还有一个特别容易忽略的点if后面的条件如果很长运算符优先级可能不符合直觉。a or b and c到底怎么结合不同人记忆不同。最简单的对策是一律加括号不依赖记忆。3.3 多分支场景switch 与 if-elif 怎么选分支一多就会面临“用一串if-elif还是用switch/match”的问题。两者能力上有重叠选择依据主要在三点分支数量、分支是否需要范围判断、以及语言本身的支持程度。对比维度if / elif 链switch / match分支条件类型任意布尔表达式多数语言要求等值匹配范围判断支持如0 x 100一般不直接支持分支数量多时的可读性超过 5 个开始变差相对整齐漏写分支的处理靠最后的 else部分语言强制穷举能提前报错扩展方式改代码部分语言支持模式匹配改代码或改数据我的实际做法是等值判断、分支超过四个用switch/match涉及范围、组合条件用if-elif。如果分支数量继续膨胀到十几个那说明这段逻辑大概率该抽成规则表或者查表而不是继续往下堆条件。Python 从 3.10 开始支持match语句写多分支枚举时确实清爽不少def handle(event): match event[type]: case create: return create(event) case update: return update(event) case delete: return delete(event) case _: raise ValueError(未知事件类型)最后那个case _就是兜底别省。新手最常见的疏漏就是把所有已知情况都列了然后假设“不会有别的”结果线上冒出一个没见过的类型整条流水线直接崩掉。兜底分支该抛异常就抛异常让问题早暴露。3.4 用提前返回把嵌套压平嵌套超过三层的选择结构可维护性会断崖式下降。判断嵌套有多深我一般用“缩进达到了几级”来粗估——超过四级缩进基本就该重构了。压平嵌套最有效的手段叫守卫语句就是把“不满足条件就退出”的判断全部提前# 重构前三层嵌套 def process_order(order): if order is not None: if order.get(status) paid: if order.get(items): return do_something(order) else: return 订单没有商品 else: return 订单未支付 else: return 订单不存在 # 重构后守卫语句一眼看清所有拦截条件 def process_order(order): if order is None: return 订单不存在 if order.get(status) ! paid: return 订单未支付 if not order.get(items): return 订单没有商品 return do_something(order)重构后的版本有个明显好处所有异常路径都在前面列出来了读的人不用再往下猜“还有什么情况会失败”。主流程只剩最后一行逻辑一目了然。这个技巧在业务校验密集的场景里价值特别大一个函数开头七八行守卫语句比嵌套七八层要清爽百倍。4. 循环结构把重复交给机器4.1 for、while、do-while 的选型依据循环选型判断起来其实很简单就一个问题循环次数是预先知道的还是由条件决定的。次数已知用for。遍历列表、遍历字典、跑固定次数的重试都属于这一类。次数未知、要靠条件判断是否继续用while。读取数据流直到结束、轮询状态直到就绪、游戏主循环都属于这一类。do-while的特点是先执行后判断保证循环体至少跑一次适合“至少要做一次、做完再看要不要继续”的场景比如菜单交互、输入重试。循环类型适用场景至少执行一次常见误用for次数确定、遍历集合否空集合时零次在遍历中修改被遍历的集合while次数未知、条件驱动否忘记更新循环变量导致死循环do-while先做后判、至少要跑一次是条件写反导致永远循环一个细节Python 没有do-while一般用while True加break模拟或者先执行一次再进while。模拟时要确保每条退出路径都能走到break否则就是死循环。4.2 循环三要素与“少一次多一次”的经典问题任何循环都离不开三样东西初值、条件、步进。三者缺一循环就是有问题的。初值决定从哪开始条件决定什么时候停步进决定每一步走多远。新手最常犯的错误是边界差一也就是俗称的“off-by-one”。看下面这三个循环arr [10, 20, 30, 40, 50] # 正确遍历全部五个元素 for i in range(len(arr)): print(arr[i]) # 错误漏掉最后一个元素 for i in range(len(arr) - 1): print(arr[i]) # 错误越界访问最后一个 i 会报错 for i in range(len(arr) 1): print(arr[i])第二种写法在“数组长度正好是偶数”之类的特定数据下可能碰巧不报错只是少处理一个元素结果不对但不崩排查起来极其头疼。我的建议是能不用手动索引就不用直接用for item in arr遍历元素需要索引时用enumerate。让语言帮你管边界比自己算len - 1靠谱得多。while循环的三要素更容易出错因为它不会自动步进# 典型死循环忘了更新 i i 0 while i 10: print(i) # 少了 i 1写while时我习惯先写完整骨架再填内容骨架就是三行初值、条件、步进。骨架写完先跑一遍确认能正常退出再往循环体里加业务逻辑。这个习惯能省掉大量调试时间。4.3 嵌套循环的复杂度和优化方向两层循环的代码执行次数是乘积关系。外层跑 1000 次、内层跑 1000 次就是一百万次操作。三层就是十亿次。这个数量级的增长非常吓人而写的时候完全无感因为代码看起来只多了几行。我遇到过一个真实场景两个列表求交集某同学的写法是双层循环逐个比对列表各有一万条时执行了一亿次比较页面直接卡死。改成用集合求交集耗时从几十秒降到毫秒级list_a [...] # 一万条 list_b [...] # 一万条 # 慢O(n*m)一亿次比较 result [] for a in list_a: for b in list_b: if a b: result.append(a) # 快O(nm)构造集合后做交集 result list(set(list_a) set(list_b))减少嵌套循环的思路主要有三条。第一把内层循环能提前算好的部分挪到外层比如把内层反复计算的常量提到循环外。第二用哈希结构把查找从线性降到常数上面这个例子就是典型。第三能拆成两次单层循环就别硬凑双层先分组再处理通常更清晰也更快。还有一个容易被忽略的点循环里的字符串拼接。在很多语言里字符串是不可变对象每次都要重新分配内存循环一万次就是一万次分配。改成先收集到列表、最后用join合并性能差距能有几十倍。# 慢循环内反复拼接 result for word in words: result word , # 快先收集再合并 result ,.join(words)4.4 循环里修改集合、break 与 continue 的坑遍历集合时修改集合是最隐蔽的一类 bug。以 Python 为例在for循环里删除列表元素会导致跳过元素甚至索引错乱nums [1, 2, 2, 3, 2, 4] # 危险边遍历边删 for n in nums: if n 2: nums.remove(n) # 结果可能是 [1, 3, 2, 4]漏删了一个 2 # 安全生成新列表 nums [n for n in nums if n ! 2]同样的道理适用于字典、集合。原则很简单遍历的时候不要改被遍历的对象。需要过滤就生成新对象确实要原地改就先收集索引再统一处理或者遍历副本。break和continue也有讲究。break只跳出当前这一层循环嵌套两层时想跳出外层得靠标志位或者把内层包成函数。这个点很多人搞错以为break能一次性跳出去。# 想跳出两层用标志位 found False for row in matrix: for cell in row: if cell target: found True break if found: break更清爽的做法是把内层逻辑抽成函数用return直接返回。这也是我一直推荐的做法——函数返回值比 break 标志位清晰得多。continue的问题在于容易造成“后面的代码被跳过”的意外。如果循环体比较长continue出现在中间读的人很难一眼看出哪些代码在什么情况下不执行。这种情况下用if把主体逻辑包起来反而更清楚。5. 三种结构怎么组合成一个能维护的程序5.1 一个从烂到能看的小重构我们拿一个具体例子走一遍完整过程。需求是从一批记录里筛出有效记录计算每类金额的合计跳过非法数据并统计跳过数量。先看初始版本这是典型的“三种结构全用上了但全都用拧了”def summarize(records): result {} skipped 0 i 0 while i len(records): if records[i] is not None: if type in records[i]: if amount in records[i]: if records[i][amount] 0: if records[i][type] in result: result[records[i][type]] result[records[i][type]] records[i][amount] i i 1 else: result[records[i][type]] records[i][amount] i i 1 else: skipped skipped 1 i i 1 else: skipped skipped 1 i i 1 else: skipped skipped 1 i i 1 else: skipped skipped 1 i i 1 return result, skipped这段代码的问题一眼能看出来步进写了六份嵌套五层跳过逻辑重复四次。任何一处漏改都是 bug。重构分三步走。第一步把while换成for步进交给语言管第二步用守卫语句把校验逻辑前移把嵌套压平第三步把重复的跳过逻辑合并。def summarize(records): result {} skipped 0 for record in records: if not record or type not in record or amount not in record: skipped 1 continue if record[amount] 0: skipped 1 continue result[record[type]] result.get(record[type], 0) record[amount] return result, skipped行数从三十多行降到十来行逻辑从五层嵌套变成三个平铺的判断。再进一步还可以把校验抽成一个函数is_valid(record)主循环就更干净了。这次重构里三种结构各承担了一件事for负责顺序遍历顺序 循环两个if负责拦截非法数据选择result.get(..., 0)负责累加顺序。职责一分开代码立刻能读了。5.2 用圈复杂度判断结构是否过度复杂圈复杂度是个很好用的量化指标用来衡量一段代码里独立路径的数量。算法不复杂从 1 开始每遇到一个if、elif、for、while、and、or就加 1。算出来的数字越大代码越难测。大致可以这么理解复杂度 1 到 5代码简单测起来容易6 到 10中等需要针对每条分支都设计用例超过 10基本就是该拆函数的信号超过 20几乎不可能靠人工测试覆盖全部路径。某团队的代码规范里定的是单函数不超过 10超过就必须拆。这个阈值不是拍脑袋定的——一个函数如果有 10 条独立路径要覆盖全部分支至少需要 10 个测试用例再乘上不同参数的组合测试成本会迅速失控。拆函数的方向通常有两个按阶段拆比如“校验、计算、输出”分成三个函数按规则拆比如把一堆同类的条件判断抽成规则表主函数只负责遍历规则。第二种尤其适合业务校验密集的场景。5.3 三种结构的常见组合模式写多了会发现三种结构的组合其实就那么几种固定套路认识它们能省很多思考成本。第一种校验 遍历 累加。前面那个summarize就是。骨架是先做整体校验再for遍历循环体内用if决定是否累加。第二种遍历 早退。在集合里找第一个满足条件的元素找到就返回。这种模式最忌讳写成“遍历全部再取第一个”白白浪费时间。用for加return找到立刻走。第三种状态机。用while加一个状态变量每轮根据当前状态和输入决定下一个状态。适合解析、流程编排这类场景。写状态机的关键是把“状态转移”写成表而不是一堆嵌套if否则分支数量会指数增长。第四种分批处理。外层while负责取下一批内层for处理这一批直到取不到数据为止。爬取、分页读取、批量任务都是这个模式。这种模式必须写好终止条件否则容易变成无限拉取。6. 常见问题速查与排查技巧6.1 结构类问题速查表调试写多了我慢慢攒了一张速查表遇到结构相关的问题先对一遍能解决七八成症状大概率原因排查动作程序卡死不退出循环变量没更新或退出条件永远不成立在循环体末尾打印循环变量确认它在变化结果总是少一个 / 多一个边界差一和用错用小数据集手动推演0 个、1 个、2 个元素时分别跑一遍某个分支永远进不去条件被前面更宽的条件截胡检查if-elif顺序宽条件放后面结果依赖数据顺序顺序结构里的依赖没理清把关键中间变量打印出来核对每一步的取值遍历时漏元素循环内修改了被遍历的集合改成生成新对象或先收集再处理浮点比较结果莫名其妙浮点精度问题改用差值绝对值与极小值比较嵌套深到看不下去缺守卫语句把异常判断提前主流程只留最后一步6.2 打印、断点和最小用例三件套排查结构问题我主要靠三个手段按使用频率排序。打印关键变量。最土但最快。在循环开头打印循环变量在分支里打印进了哪一支在函数出口打印返回值。关键是要打在结构转折点上而不是随便撒。比如循环体第一行打印当前元素和索引分支内部打印条件表达式的实际值。断点单步。适合逻辑复杂、涉及多层调用的情况。单步时重点看两样循环变量是否按预期变化条件表达式求值是否符合预期。很多语言的条件断点还能直接写“当 i 等于某个值时停下”非常适合定位边界问题。构造最小用例。这个手段被严重低估。一段逻辑跑不通时别急着看代码先构造一个只有两三个元素的数据集手动推演一遍正确结果再对着程序的实际输出比对。差异出现在哪一步问题就在哪一步。我处理过的边界 bug 里八成都是靠“空集合、单元素、两元素”这三组最小用例暴露出来的。提示给循环写测试时至少覆盖五种输入——空集合、单元素、正好两个元素、全部满足条件、全部不满足条件。这五组能覆盖绝大多数边界问题成本又极低。7. 最后聊几句实操体会三种结构的内容说深了能讲一整本书说浅了一页纸也能画完。真正拉开差距的不是知不知道它们而是写代码时有没有下意识地用它们去拆问题。我自己养成的一个习惯是动手写函数之前先用几句话把这几个问题说清楚——数据从哪来、要不要判空、有没有多条路子要走、有没有需要重复的动作。这几个问题答完代码结构的骨架基本就出来了剩下的只是填细节。另一个习惯是写完立刻回头看缩进。一段代码如果缩进超过四层我基本会停下来重写不管它跑得多对。缩进深度是我用过最直观的结构健康度指标比什么工具都快。还有一个踩了很多次坑才记住的点循环里的每一次修改都要有明确的目的。很多人写循环时会在里面顺手改一堆状态变量改到最后自己都说不清哪些变量在循环结束后是什么值。我的做法是循环体内尽量只做一件事需要维护多个状态时把它们集中放在循环开头声明循环里只更新不再新增。这些经验谈不上多高明都是被 bug 磨出来的。三种结构本身没什么门槛门槛在于把它们当成“约束”而不是“语法”。约束意味着你写下的每一行都有明确的位置和理由而不是靠试出来的。