顺序结构、选择结构、循环结构,这三个词几乎是所有编程入门材料的第一章标题,也是绝大多数人学完之后觉得“没什么可讲”的一章。我带过几个转行的朋友,也帮人改过不少半成品脚本和小工具,慢慢发现一个规律:代码写得乱、改不动、一改就崩的人,问题往往不出在框架不熟、语法糖不会用上,而是这三种最基础的结构没真正过关。他们能写出跑得通的代码,但写不出能读、能改、能扩展的代码。这篇东西就是把这三种结构拆开讲透,从“它到底在管什么”讲到“实际写的时候会在哪儿翻车”,再给一套可以直接照着做的排查方法。不管你是刚学完语法的初学者,还是写了两三年但总觉得代码不够干净的人,应该都能从中挑到几条立刻能用的东西。
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(n+m),构造集合后做交集 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 磨出来的。三种结构本身没什么门槛,门槛在于把它们当成“约束”而不是“语法”。约束意味着你写下的每一行都有明确的位置和理由,而不是靠试出来的。