编程思维怎么练?我的答案可能有点反常识:先学会主动制造错误

🔑 关键词:编程思维,调试思维,二分法排查,编程思维训练,代码调试技巧

📖 摘要:所有讲编程思维的文章都在说抽象、分解、模式识别,但真写代码的时候这三句话基本没用。这篇讲我自己的一个观点:编程思维的核心是把错误的成本重新定价,以及三个能落地的笨办法。

上周三晚上十点,我盯着一台跑数据的机器。脚本已经跑了 40 分钟,在第 39 分钟的时候弹出 KeyError: 'sku_id',然后退出。日志里就这一行,前面全是正常的 INFO 输出。

图片

我没去读代码。我先干了一件事:把那个 dict 的 keys 打印出来。但我不能等 40 分钟,所以我在循环里加了一行 if i > 1000: raise SystemExit,让它跑到一千条就停。结果 8 秒就跑完了,我看清了 keys 长什么样。

后面的事不复杂。上游给的 JSON 里,字段名一部分叫 sku_id,一部分叫 skuId,比例大概是 0.3%。我最后用了个 row.get("sku_id") or row.get("skuId") 收尾,蠢,但能用。

我讲这个是想说一件事。从十点到十二点半,我做的事情里,没有一件是「把大问题分解成小问题」。我做的事情是——想办法让程序在我想让它出错的地方出错。

「分解问题」这四个字,我怀疑它其实没什么用

2017 年我刚转行写代码的时候,面试被问「你怎么理解编程思维」,我背的答案是:抽象、分解、模式识别。面试官点头,我自己也信了。后来写了几年,我发现这句话在实际工作里的信息量约等于零。

因为真正的难点从来不是「要不要分解」。是这些:

图片

  • 分解到什么粒度算完?拆成 5 块还是 50 块?
  • A 和 B 两块之间的数据怎么传,谁负责判空?
  • 我拆到一半发现假设是错的,是推倒还是打补丁?

这些都是课本不说的。我见过能把需求拆成 20 个 TODO 的人,卡在第 3 个 TODO 上三天,最后来问我「这个接口为什么返回 null」。

我自己的定义:编程思维是给「错误」重新定价的能力

日常生活的思维模型里,错误是坏的,是要避免的,是让事情停下来的。考试错一道题扣 5 分,发错一封邮件要道歉。我们对错误的默认反应是——停下来,检查,确保它不再发生。

编程思维是反过来的。在代码里,错误是信息,而且是唯一可信的信息。你写的代码跑通了不代表它对(可能只是没走到那个分支),跑挂了反而明明白白告诉你哪儿不对。所以高手的动作是:主动去撞那个错误。

举几个我天天在用的:

图片

  • mypy --strict 在一个 4 万行的仓库里第一次跑,报出 2300 多个类型错误。看着很崩溃,但这 2300 条不是「错误」,是一张地图。
  • 写新逻辑之前,先写一个必然失败的测试。看到红色心里才踏实。绿的测试没有信息量,红的才有。
  • 改线上 bug,第一步不是读代码,是写一个能复现它的脚本。

这跟「分解问题」完全不冲突,但顺序是反的。分解是把大变小,制造错误是把模糊变具体。

三个笨办法,我自己在用的

一、二分排除,不是二分查找。

代码 1000 行,报错。别一行行读。在第 500 行插一个 print("到这里了") 或者 raise,看它能不能走到。能,问题在后 500 行;不能,在前 500 行。

1000 行最多 10 次就能定位到行(2 的 10 次方是 1024)。我第一次算这个数的时候有点震撼——10 次操作干掉 1000 行代码,靠的不是聪明,是策略。

git 也有对应的工具:

图片

git bisect start
git bisect bad HEAD
git bisect good v1.2.0

然后 git 会 checkout 中间的 commit,你测一下,告诉它 good 还是 bad,它继续对半切。20 个 commit 大概 5 次就能找到是哪次提交引入的。

二、先让错误变得可读。

如果你用 Rust,你会发现它的编译器错误信息能读懂到有点感人。你写错一个类型,它会告诉你哪里错了、为什么错、建议怎么改,甚至给你一个 help: consider borrowing here 加代码片段。这不是巧合,是 Rust 团队在设计上的取舍——他们把「报错信息」当成产品的一部分来做。

Python 和 JS 在这块差得远。TypeError: 'NoneType' object is not iterable 这种信息,你得回到栈里一行行看是哪来的 None。所以我在 Python 项目里会随手加 assert,把错误往上游推:

def get_items(response, url):
    assert response is not None, f"response is None, url={url}"
    ...

图片

assert 在 -O 模式下会被干掉,生产环境别依赖它做校验,但调试阶段非常好用。它能让错误在你希望的位置爆掉,而不是在 8 层调用之后。

三、把「我可能理解错了」当成默认假设。

这条最难,也是我觉得编程思维和日常思维最大的分水岭。

日常对话里,你理解错了对方的意思,通常是对方的表述有问题。代码里不是。代码 100% 按你写的跑,它不存在「我没表达清楚」。所以每次不按预期走,第一反应必须是——我漏了什么,而不是这破东西有 bug。

我以前不这样。2019 年改一个库存扣减的逻辑,本地测了两百次没问题,上线之后大促当天超卖了 41 件。我第一反应是「Redis 锁有问题」,查了四个小时。最后发现是我自己的代码里,判断库存的那句 if stock > 0 用的是缓存里的值,缓存每分钟刷一次。代码没错,是我的理解少了一层。

为什么很多人练不出来

图片

我觉得大部分人练不出编程思维,不是因为笨,是因为教育把「犯错」这件事的成本定得太高了。

从小学到大学,错误基本只有一种用途:扣分。没有老师会说「这道题你错得很好,我们来看看你为什么会这么想」。所以很多人养成了一套自我保护机制——不确定的地方绕着走,宁可写 100 行确定的烂代码,也不碰那 5 行不确定的好代码。

在代码里,这套机制会要你的命。你不确定的地方,就是 bug 会出来的地方,迟早。

我现在的习惯有点极端:拿到一个不熟的系统,我会先想办法把它搞挂。看看它挂了之后什么样,重启要多久,日志里留下什么。搞挂一次学到的,比读一小时文档多。

回到开头那个晚上。要是按「分解问题」的路子走,我大概会把整个脚本拆成五个函数,加日志,重跑,等 40 分钟,再看。那天我可能得熬到三点。

实际我干的事可能更土:我什么都不改,只在第 12 行插了一个 raise。

🏷️ 标签: