1. 三种基本结构到底在解决什么问题
刚入行那几年,我特别不理解为什么教材非要把顺序结构、选择结构、循环结构单独拎出来讲,感觉像是把“走路、拐弯、绕圈”这种常识重复三遍。直到带过几个新人、review 过一堆逻辑缠成一团的代码之后,我才明白这三样东西的价值不在概念本身,而在于它们是所有程序的最小骨架。任何一段你能想到的业务逻辑——下单、对账、报表、图像滤镜、爬取清洗,拆到最底层,无非就是“按顺序做几件事”“根据条件走不同分支”“把某件事重复做若干次”的组合。把顺序结构、选择结构、循环结构这三块积木练到条件反射,写代码时脑子里才会有结构感,而不是想到一行写一行。
这篇内容我想讲清楚三件事:这三种结构各自的本质边界在哪里、实际写的时候容易踩哪些坑、以及怎么把它们组合成一个能跑能维护的完整逻辑。适合刚学编程不久、能看懂语法但写出来总是差一口气的朋友,也适合已经工作但想回头梳理基本功的人。我不会照着教材复述定义,而是按我平时带人、改代码的真实顺序来讲,穿插一些坑位和判断依据。你可以把它当作一份“控制结构实战对照手册”,遇到具体写法拿不准的时候回来翻一翻。
1.1 顺序结构:程序世界的默认底色
顺序结构的定义简单到有点无聊:代码从上往下,一行执行完再执行下一行。但恰恰是这种“默认”,藏着最多人忽视的问题。我见过太多人把顺序结构理解成“随便排”,结果写出一堆依赖倒置的代码——变量还没赋值就被使用、资源还没准备好就被调用、本该先校验的参数放到了业务逻辑后面。顺序结构真正的含义不是“从上到下”,而是每一步之间存在明确的依赖关系,你必须保证执行到某一行时,它需要的前置条件已经成立。
打个生活化的比方,做饭的顺序是洗菜、切菜、下锅。你要是先下锅再切菜,不是不能做,但那叫返工。代码里的顺序错误很少直接报错,它往往表现为“偶尔出问题”“某些情况下结果不对”,排查起来极其费劲。所以我给新人的第一条建议永远是:写顺序结构的时候,先问自己每一行的输入从哪来、输出给谁用,把这条数据流画出来,再决定谁在前谁在后。
还有一点,顺序结构是唯一没有分支判断能力的结构。它不会帮你处理异常情况,也不会自动跳过失败步骤。这意味着在顺序结构里,任何一个环节失败,后面的代码都会在错误的状态上继续跑。这是后面选择结构存在的根本理由,也是为什么真实代码里纯粹的“一路向下”其实很少见——真正的顺序结构,往往被选择结构切成了一段段。
1.2 选择结构:让代码拥有分岔路口
选择结构解决的是一件事:同一段逻辑,因为输入不同要走不同的路。听起来简单,但它是三种结构里最容易写乱的一种。原因在于人会不自觉地往条件里塞太多东西,把判断和业务混在一起,最后写出三层嵌套、五个并列条件、每个条件里还有副作用的长函数。
我的经验是,选择结构的难点从来不在语法,而在条件边界的完整性。你写if (score >= 60),那 60 算不算及格?你写两个互斥分支,那有没有第三种情况被漏掉?你判断用户状态是“正常”和“冻结”,那“注销”去哪了?这类问题在测试阶段往往暴露不出来,等到线上出现一个谁都没预料到的输入,程序就走进了一个没有任何分支覆盖的空档。
选择结构还有一个隐蔽成本:分支越多,理解成本呈指数上升。两个分支还好,三个分支就要开始考虑顺序,五个分支基本就得靠表格才理得清。所以我一直建议,条件超过三个就考虑换写法——用查表、用策略函数、用提前返回把深层嵌套拍平。这不是炫技,是给人看的,三个月后回来改代码的那个“人”很可能就是你自己。
1.3 循环结构:把重复动作交给机器
循环结构的存在理由最充分:人不想重复,机器最擅长重复。但循环也是三种结构里出错后果最严重的一种——写错一次,轻则结果偏差,重则死循环把机器跑满、把服务拖垮。我遇到过最典型的事故是循环终止条件写错一位,一个本该跑 1000 次的任务跑了上亿次,直接把内存吃干净。
循环的核心从来不是for还是while的语法区别,而是三个必须同时想清楚的问题:循环变量从哪开始、什么时候停、每轮怎么变。这三件事只要有一件含糊,循环就不可靠。很多新人写循环的习惯是“先写个大概,跑不通再调”,这在本地小数据上能蒙混过去,一旦数据量上去或者边界值出现,就原形毕露。
另外,循环结构天然带着“累积”属性。你在循环体里改的每一个外部变量,都会随着轮次不断叠加。这意味着循环体的顺序、累加的位置、提前退出的时机,都会影响最终结果。这类 bug 有个共同特点:小数据看不出,大数据才暴露。所以写循环的时候,我习惯先用三五条手算得出的数据跑一遍,确认每一轮的中间值都符合预期,再去跑真实数据。
1.4 三者的边界与选型对照
很多人写代码卡壳,不是不会语法,而是不知道该用哪种结构。下面这张表是我平时总结的判断依据,遇到拿不准的情况直接对照着看。
| 场景特征 | 应该用 | 判断依据 | 常见误用 |
|---|---|---|---|
| 步骤之间有先后依赖,没有分岔 | 顺序结构 | 上一步的输出是下一步的输入 | 把校验放到业务之后 |
| 结果取决于输入的不同取值 | 选择结构 | 存在明确的互斥条件 | 条件覆盖不全、漏掉默认分支 |
| 同一动作要执行多次,次数或条件已知 | 循环结构 | 有重复模式且有明确终止条件 | 终止条件依赖循环体内的修改 |
| 既要重复又要按情况处理 | 循环 + 选择嵌套 | 外层管轮次,内层管差异 | 嵌套过深、职责混杂 |
| 重复次数不确定,靠条件退出 | while 类循环 | 退出条件在执行中才确定 | 忘记在体内推进条件 |
有一点要特别强调:这三种结构不是并列关系,而是嵌套关系。顺序结构是容器,选择结构和循环结构都是嵌在顺序流程里的“节点”。你写一个函数,整体是顺序的,中间某几步是循环,循环体里又有选择。理解这个层次关系,才不会写出“一个循环里塞了八个 if、每个 if 里又开一层循环”的怪物。
2. 顺序结构的细节坑位与实操要点
顺序结构看起来最没技术含量,但我在实际项目里发现,出问题的代码有很大比例栽在“顺序”上。不是逻辑多复杂,而是某一步该在前面的放到了后面,或者某一步的失败没有被感知,导致后续全盘建立在错误前提上。这一节我把几个高频坑位摊开讲。
2.1 依赖顺序:谁先谁后不是随便排的
判断顺序是否正确,有个很朴素的方法:把每一行当作一个“需要输入、产生输出”的节点,画出数据流向。如果某一行的输入在它之前没有产生,那这个顺序就是错的。听起来像废话,但实际写的时候,人很容易被“代码看起来顺”误导。
举个例子,一个处理订单的函数,常见写法是先查商品、再算价格、再扣库存、最后写订单。这个顺序背后有硬依赖:算价格需要商品信息,扣库存需要确认价格有效,写订单需要扣减成功。你要是把写订单提前,扣库存失败时就会留下一条脏数据。这类问题的本质是顺序结构承担了事务边界,顺序一乱,一致性就没了。
我的实操习惯是,在写顺序流程之前先列一张小清单,写清楚每一步的输入、输出、失败后果。清单不用正式,几个关键词就够。这一步花不了两分钟,但能省掉大量回头改的时间。
提示:判断两个步骤能否交换顺序,最直接的标准是——交换之后,如果前一步失败,后一步会不会受影响。会受影响,就不能换。
2.2 赋值、求值与副作用
顺序结构里另一个高频坑是表达式求值时机。很多人默认“写在后面的代码,值就是最新的”,但有些语言、有些场景下,值的快照发生在更早的时候。比如把变量传进一个函数,函数内部改的是副本;比如在循环里收集对象引用,最后发现所有引用指向同一个对象。
这类问题的根源是把“代码顺序”和“数据生效顺序”混为一谈。代码从上往下执行没错,但数据的生效时点取决于语言语义。我的建议是,凡是涉及引用、闭包、异步的地方,不要凭直觉判断,写完立刻用一组最小数据验证中间值。验证成本极低,猜错的成本很高。
还有一个容易忽视的点:副作用的位置。所谓副作用,就是除了返回值之外,还改变了外部状态的操作,比如写文件、改全局变量、发请求。副作用放在顺序流程的哪一步,决定了失败时的回滚难度。经验做法是把副作用尽量往后放,先完成所有能提前校验和计算的部分,最后再统一落地。这样即使中途失败,也不会留下半截脏数据。
2.3 顺序结构的“隐形断裂点”
顺序结构最危险的地方在于它不会主动告诉你哪里断了。循环写错会死循环,选择写错会走错分支,顺序写错什么都不报,只是结果悄悄不对。这就是所谓的隐形断裂点。
典型的隐形断裂点有三类:一是前置校验缺失,非法输入一路穿透到深处才炸;二是中间步骤的返回值被忽略,失败被当成成功继续跑;三是资源没有按顺序释放,比如先关了连接再用它查数据。这三类问题有个共同特征——在正常路径上完全看不出来,只有异常路径才会触发。
我的应对办法是给顺序流程加“检查点”。每个关键步骤之后,显式确认它的结果符合预期,不符合就立刻中断并给出清晰的原因。这段检查代码看起来冗余,但它把“隐形断裂”变成了“显式失败”,排查成本直接降一个数量级。很多老代码难维护,就是因为当年省了这几行检查。
3. 选择结构的判断逻辑与写法取舍
选择结构是我 review 代码时看得最多的部分,也是最容易写出“能跑但没法改”的地方。下面按条件写法、嵌套处理、多分支选型、边界处理四个角度讲,每个角度都给出我实际在用的判断标准。
3.1 条件表达式要写得“无须解释”
我判断一个条件写得好不好,标准很简单:能不能直接读成一句人话。if (user.age >= 18 && user.status === 'active' && !user.isBanned)这种,读起来就是“成年、状态正常、没被封禁”,一眼就懂。反过来,if (!(a || b) && c !== null)这种,读的人得在脑子里做布尔运算,就容易出错。
有两个具体技巧。第一个是正向表达优先,能用“是”就别用“不是”。双重否定在自然语言里都绕,在代码里更容易翻车。第二个是把复合条件抽成有名字的变量或函数,比如把上面那串判断抽成isEligible(user)。名字本身就是注释,条件和业务分离之后,改判断规则也不用动主流程。
还有一个细节:条件里的运算优先级。a || b && c到底是a || (b && c)还是(a || b) && c,不同语言规则一致但人脑经常记混。我的习惯是只要混用&&和||,一律加括号。多打两个字符,省掉一次线上事故。
3.2 嵌套层级的削减:卫语句与提前返回
三层以上的 if 嵌套,基本可以判定这段代码需要重构。嵌套深度的本质问题是读者的心智栈太深,每进一层都要记住之前的条件,到第四层的时候已经忘了第一层判断的是什么。
最有效的削减手段是卫语句——把异常情况、边界情况在最前面判断掉,直接返回或抛出,让主流程保持“一路平坦”。举个例子:
# 嵌套写法,读起来要一直记着外层条件 def settle(order): if order is not None: if order.status == "paid": if order.amount > 0: return do_settle(order) else: return "金额异常" else: return "状态不对" else: return "订单为空" # 卫语句写法,主流程只剩一行 def settle(order): if order is None: return "订单为空" if order.status != "paid": return "状态不对" if order.amount <= 0: return "金额异常" return do_settle(order)两种写法功能完全一致,但第二种读起来不需要在任何一层“记住”什么。这就是卫语句的价值:把嵌套变成顺序。它同时也让顺序结构和选择结构自然地衔接起来,而不是互相缠绕。
3.3 多分支的三种写法对比
分支数量上到四五个,写法就该换了。我常用的有三种:if-else 链、switch 类结构、查表法。它们各有适用场景,选错了会让代码越改越僵。
| 写法 | 适合分支数 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| if-else 链 | 2 到 4 个 | 灵活,条件可以是任意表达式 | 分支多了读起来累 | 条件复杂、有区间判断 |
| switch / match | 4 个以上离散值 | 结构整齐,一眼看出覆盖了哪些值 | 只适合等值判断 | 状态机、枚举分派 |
| 查表 / 字典分派 | 5 个以上且会持续增加 | 加分支不改主流程 | 需要额外定义映射关系 | 类型分派、配置驱动逻辑 |
这三者的取舍逻辑其实很清楚:分支会频繁增加,就用查表;分支是固定枚举,就用 switch;分支条件是区间或复合表达式,就用 if-else。我见过最糟的情况是把一个会不断新增的类型判断写成 if-else 链,每加一个类型就要动一次核心函数,改到最后没人敢碰。换成查表之后,新增类型只需要加一条映射,主流程一个字都不用改。
3.4 边界值与浮点比较
选择结构里最隐蔽的坑是边界。写score > 60和score >= 60差一个字符,业务含义完全不同。我的习惯是每次写完条件,立刻把边界值代进去念一遍:“59 分不及格、60 分及格”,念得通才算对。
浮点数比较是另一个雷区。0.1 + 0.2 == 0.3在很多语言里是 false,这不是语言 bug,是二进制表示的固有精度问题。涉及金额、比例这类需要精确比较的场景,我的做法是要么转成整数(以分为单位),要么用误差范围比较:
# 不要这样 if total == 0.3: ... # 这样更稳 EPS = 1e-9 if abs(total - 0.3) < EPS: ...误差阈值怎么定?看业务精度要求。金额精确到分,EPS 取 1e-9 足够;科学计算要看有效位数。原则是阈值必须小于业务能容忍的最小差异,否则会把真实差异误判成相等。
注意:判断“是否相等”用误差范围,判断“大于小于”一般可以直接比,但涉及累加结果的比较,同样建议留误差余量。
4. 循环结构的实现要点与复杂度估算
循环写得好不好,直接决定程序跑得快不快、稳不稳。这一节从形态选择、终止条件、变量修改、嵌套估算四个角度讲,最后给一个真实的耗时计算过程。
4.1 循环形态与适用场景
常见的循环形态有四种:计数型 for、条件型 while、先执行后判断的 do-while、以及遍历型 for-each。选哪种不看个人喜好,看你是否在开始前就知道循环的边界。
计数型 for 适合“次数已知”的场景,比如处理列表的前 N 条、生成固定数量的编号。它的优势是把初始化、条件、推进三件事写在一行,读者一眼就能看到全貌。条件型 while 适合“次数未知、靠条件退出”的场景,比如不断读取直到文件结束、不断重试直到成功。do-while 适合“至少执行一次”的场景,比如先尝试再判断是否需要重试。
遍历型 for-each 是我最推荐的一种,只要不需要索引,一律用它。原因很实在:它从根本上消除了越界和索引错误。手写for i in range(len(arr))再取arr[i],多一个少一个都会出问题;直接遍历元素,这类 bug 从源头上就不存在了。
4.2 终止条件的写法
终止条件是循环的命门。我见过的事故里,死循环排第一。写终止条件有个原则:条件里的变量,必须在循环体内被明确推进,而且推进方向指向终止。
反面例子很常见:while (count != 10),如果 count 每次加 2,从 0 开始永远到不了 10,直接死循环。改成while (count < 10)就安全了,因为大于等于 10 时必然退出。这就是我为什么建议用不等号而不是等号做终止条件——等号要求精确命中,不等号只要越过就行,容错性高得多。
还有一个细节是边界包含关系。for i in range(n)跑 n 次,for i in range(1, n+1)也跑 n 次,但语义不同。差一错误(off-by-one)是循环里最常见的 bug,我的应对办法是每次写完循环,立刻用最小规模验证:n=0 跑几次、n=1 跑几次、n=2 跑几次。三个数字一对,边界是否正确立刻见分晓。
4.3 循环变量在循环体内的修改禁忌
在循环体里修改循环变量,是我明确反对的做法。它会让“循环跑几次”这件事变得不可预测,读代码的人必须逐轮推演才能知道结果。更麻烦的是,很多语言对这种修改的处理方式不一致,同一段逻辑换个语言结果就变了。
如果确实需要在特定条件下跳过或提前结束,用语言提供的控制语句——continue跳过本轮、break提前退出。这两个语句的语义是明确的,读者一看就知道流程怎么走。相比之下,偷偷改循环变量属于“隐式控制流”,是维护者的噩梦。
还有一类容易忽略的情况:在遍历集合的过程中修改集合。往列表里追加元素、从字典里删除键,很多语言会直接抛异常,有些则静默产生未定义行为。稳妥做法是先收集要改的内容,循环结束后统一处理。
# 不推荐:边遍历边删 for item in items: if item.expired: items.remove(item) # 推荐:先标记,后统一处理 expired = [item for item in items if item.expired] for item in expired: items.remove(item)4.4 嵌套循环与耗时估算
嵌套循环的性能问题是“看起来没事,跑起来要命”的典型。两层各 1000 次的循环,总执行次数是 100 万,不是 2000。三层就是 10 亿。这个增长是指数的,人脑对指数天生不敏感。
我给个具体的估算过程。假设内层循环体每次执行耗时约 1 微秒(一个很乐观的估计,实际涉及字符串拼接、对象创建会慢得多):
- 单层 1000 次:1000 × 1μs = 1ms,毫无感觉
- 两层 1000 × 1000:10⁶ × 1μs = 1s,开始有感知
- 三层 1000³:10⁹ × 1μs = 1000s,约 17 分钟
- 四层 1000⁴:10¹² × 1μs ≈ 11.6 天
这个表每次给我看都挺震撼的。所以遇到嵌套循环,我的第一反应是问:能不能把内层循环提到外面,或者用空间换时间。最常见的优化是把内层的查找换成哈希表查询,把 O(n²) 降到 O(n)。这类优化不需要多高深的技术,只需要意识到嵌套的成本在哪里。
另一种思路是减少内层的实际执行次数。比如两层循环里,内层很多轮其实不满足条件,那就在进入内层之前先过滤,或者用break提前跳出。这类改动往往能带来数倍的提升,而且风险很低。
提示:优化嵌套循环之前先测量。凭直觉猜瓶颈,十次有八次猜错。先加计时,找到真正耗时的那个循环,再动手。
5. 完整实操:把三种结构拼成一个小工具
光讲结构有点虚,我用一个具体的小需求把三种结构串一遍。需求是:读取一批交易记录,逐条校验,把合法的记录下来,最后输出汇总。这个需求足够小,但顺序、选择、循环三种结构都用上了,很适合当练手样本。
5.1 需求与输入输出约定
输入是一组交易记录,每条包含金额、类型、时间戳。规则是:金额必须为正且不超过上限,类型必须是允许的几种,时间戳必须落在统计周期内。合法的记录进入汇总,非法的记录单独收集并标注原因。
这里能看出三种结构的分工:整体处理流程是顺序结构(先读取、再校验、再汇总、最后输出),校验环节是选择结构(不同违规原因走不同分支),逐条处理是循环结构(对每条记录重复校验动作)。这个划分想清楚了,代码基本就成型了。
我特意把“非法记录要收集原因”加进去,因为这逼着你不能用简单的过滤,必须在校验失败时产生额外信息。这是真实项目里很常见的需求,也是新手容易漏掉的地方——只想着过滤掉坏的,忘了记录为什么坏。
5.2 第一版实现
先写能跑的第一版,不追求优雅。这个阶段的重点是把逻辑走通,结构清晰比性能重要。
MAX_AMOUNT = 100000 ALLOWED_TYPES = {"payment", "refund", "transfer"} PERIOD_START = 1700000000 PERIOD_END = 1730000000 def process(records): valid = [] invalid = [] for r in records: # 循环结构 reason = None if not isinstance(r.get("amount"), (int, float)): # 选择结构 reason = "金额类型错误" elif r["amount"] <= 0: reason = "金额非正" elif r["amount"] > MAX_AMOUNT: reason = "金额超限" elif r.get("type") not in ALLOWED_TYPES: reason = "类型不支持" elif not (PERIOD_START <= r.get("ts", 0) <= PERIOD_END): reason = "时间越界" if reason: # 选择结构 invalid.append({"record": r, "reason": reason}) else: valid.append(r) total = 0 # 顺序结构:汇总 for r in valid: # 循环结构 total += r["amount"] return { # 顺序结构:输出 "count": len(valid), "total": total, "invalid": invalid, }这段代码有几个刻意的设计。第一,校验用 if-elif 链而不是并列 if,因为一旦某个条件失败,后面的判断就没意义了,链式结构能自然短路。第二,原因收集用变量而不是直接 return,这样一条记录的多个问题能按优先级报出第一个,同时不打断整批处理。第三,汇总单独一个循环,不复用校验循环,是为了让职责清晰——如果以后汇总规则变复杂,改一个循环就行。这些选择都牺牲了一点性能,换来的是可读性和可维护性。
5.3 走查验证与用例设计
代码写完不算完,必须走查。我用的方法是手工构造边界用例,逐条推演。下面这几个用例是必测的:
| 用例 | 输入特征 | 期望结果 | 验证的结构点 |
|---|---|---|---|
| 全合法 | 金额正、类型支持、时间在范围内 | 全部进 valid | 顺序路径,无分支触发 |
| 金额为零 | amount = 0 | 进 invalid,原因为非正 | 选择结构的分支命中 |
| 金额超限 | amount = MAX + 1 | 进 invalid,原因超限 | 边界值比较 |
| 类型非法 | type = "unknown" | 进 invalid,原因类型 | 有限集合判断 |
| 时间越界 | ts 早于起始 | 进 invalid,原因时间 | 区间判断的左侧边界 |
| 空列表 | records = [] | 返回 0 和 0 | 循环零次,边界情况 |
第六个用例最容易被忽略。很多人写循环时默认“至少有一条数据”,结果空列表进来就出问题。我现在的习惯是每个循环都先想一遍零次执行会怎样,这个动作只要三秒,能挡掉大量线上问题。
走查的时候我还会做一件事:把中间值打印出来。不是调试器断点,是直接在循环里加临时输出,看每一轮的 reason、valid 长度、total 累加值。小数据下这几行输出能让你立刻看清循环顺序、条件命中、累加位置是否符合预期。
5.4 第二版重构:从能跑到好维护
第一版跑通之后,我会问一个问题:这段代码以后会怎么变。如果规则会增加、类型会扩展、汇总口径会调整,那就要提前把易变的点抽出来。
RULES = [ ("金额类型错误", lambda r: not isinstance(r.get("amount"), (int, float))), ("金额非正", lambda r: r["amount"] <= 0), ("金额超限", lambda r: r["amount"] > MAX_AMOUNT), ("类型不支持", lambda r: r.get("type") not in ALLOWED_TYPES), ("时间越界", lambda r: not (PERIOD_START <= r.get("ts", 0) <= PERIOD_END)), ] def check(r): for reason, rule in RULES: # 循环 + 选择融合,规则表驱动 if rule(r): return reason return None改完之后,新增一条校验规则只需要在 RULES 里加一行,主流程完全不动。这就是前面说的查表法在真实场景里的价值。当然,这个改法有代价:规则变成了数据,调试时不能直接跳到某一行,堆栈也不那么直观。所以我的取舍标准是:规则少于三条,用 if-elif;超过三条且会增长,用规则表。没有绝对的好坏,只有适不适合当前阶段。
重构之后还要重新走一遍用例。特别注意规则表是按顺序匹配的,第一条命中的就返回,所以规则的排列顺序会影响最终报出的原因。这一点必须在注释里写清楚,否则后来的人往中间插一条规则,可能会改变已有记录的原因输出。
6. 常见问题与排查技巧实录
下面这些是我这些年反复遇到的、跟三种基本结构直接相关的问题。整理成表格,方便你遇到类似现象时快速对照。
6.1 问题速查表
| 现象 | 最可能的原因 | 定位手段 | 处理方式 |
|---|---|---|---|
| 结果偶尔不对,重跑又好了 | 顺序流程存在未定义行为或依赖外部状态 | 固定输入反复跑,对比中间值 | 找出不确定的依赖,显式化 |
| 某些输入直接跳过没处理 | 选择结构条件覆盖不全 | 列出所有分支,检查默认分支 | 补默认分支或断言兜底 |
| 程序卡死无响应 | 循环终止条件永远不满足 | 打印循环变量和条件值 | 改不等号判断,确保推进方向正确 |
| 结果多一条或少一条 | 边界判断差一 | 用 n=0/1/2 三个规模验证 | 统一边界约定,写清开闭区间 |
| 数据量大才出错 | 循环体内累积状态、溢出、性能 | 缩小数据量对比结果 | 检查累加、精度、复杂度 |
| 深层逻辑改一处坏一片 | 嵌套过深、职责混杂 | 统计单个函数的分支和嵌套层数 | 卫语句拍平、规则表拆分 |
6.2 排查的固定动作
结构类问题有个特点:报错信息通常指向症状,不指向病因。比如“空指针”可能是在循环里取了一个不存在的位置,“结果不符”可能是顺序反了。所以排查的时候不能盯着报错那行看,要往回追。
我的固定动作是三步。第一步,把输入固定下来。随机数据、时间戳、外部接口返回值,全部替换成常量,让程序变成确定性的。第二步,在最内层打印关键状态。循环变量、条件判断的结果、累加值的当前值,一次性打全。第三步,用手算结果对比。拿三条数据自己算一遍预期输出,跟程序输出逐项对。这三步做完,绝大多数结构问题都能定位到具体那一行。
还有个小技巧:把循环次数临时改小。跑 10000 条出错,就先跑 3 条。小规模下每一步都能看清,问题往往在头几条就暴露了。这个办法比加日志、上调试器都快,尤其适合那些“大数据才复现”的疑难问题。
注意:排查时不要把多个修改混在一起。一次只改一个地方,改完立刻验证。同时改三处,即使问题消失了,你也不知道是哪一处起的作用,下次还会踩。
6.3 几条保命习惯
第一,任何循环都先想零次和一次。空输入、单条输入,这两个边界能挡掉一大半问题。第二,任何选择结构都写出默认分支。哪怕默认分支只是抛一个明确的异常,也比静默走空要好。第三,顺序流程里的每一步都问一句“失败了会怎样”。想不出答案,就说明这一步缺检查。
第四,也是最有用的一条:把复杂的控制流画成一张表。行是步骤,列是输入、输出、失败处理。画完你会发现很多原本觉得没问题的顺序其实有依赖倒置,很多以为覆盖全的分支其实漏了情况。这个动作我用得最多,成本极低,收益极高。
最后分享一个我自己的体会。刚开始写代码的时候,我总觉得结构是给初学者准备的概念,真正的高手应该关注算法和架构。后来改的烂代码多了才明白,绝大多数难维护的代码,问题不在算法多复杂,而在结构多混乱。一个函数里顺序、选择、循环搅在一起,没有清晰的边界,谁接手都要重新推演一遍。反过来,结构清晰的代码,哪怕逻辑简单,也能让后来的人三分钟看懂、五分钟改对。这三种基本结构看着朴素,实际上决定了代码能不能活得久。我现在带人,第一件事就是让他们把这三样练到不用想就能写对,比学任何框架都值。