Skip to content
Published on

如何阅读别人写的代码

分享
Authors

引言 — 我们读得比写得多

回想一下加入新团队的第一周。50 万行代码,一个你从没见过的框架,Wiki 停留在 2 年前,写这些代码的人早已离职。你的任务是修一个藏在里面的 bug。该从哪儿开始?

我们常把编程想成"写代码这件事",但开发者实际花时间的比例恰恰相反。一个被广泛引用的经验值是,读与写的比例大约是 10 比 1。加一个功能,得先读懂它周边的既有代码;修一个 bug,得读出问题的那段代码;做 code review,读的是别人的代码。与其说我们是写代码的人,不如说是读代码的人

然而奇怪的是,读代码这件事从来没人系统地教过。写代码有讲不完的课程和书,读代码却被丢给一句"读多了自然就会了"。这篇文章整理了面对陌生代码库时能派上用场的具体策略。如果要压缩成一句话:不要试图读完每一行,而是去追踪数据流动的路径。

找到入口点

读代码时第一件该做的事,是找到入口点(entry point)——程序真正开始执行的地方。随手打开一个文件从头读起,就像没有地图被扔进森林中央。

入口点因程序种类而异。

  • CLI 工具:main() 函数,或者 package.jsonbin 字段、pyproject.toml 里的脚本定义。
  • Web 服务器:路由表。"这个 URL 收到请求就交给这个处理函数"这样的映射就是地图。想理解某个具体功能,先找它的 API 路径,再钻进对应的处理函数。
  • 前端应用:根组件(比如 App)和路由配置。好奇某个页面,就从连接到那条路由的组件开始。
  • :公开 API。index 文件或包清单里的 exports 就是"外部使用的门",从这里往里挖。

找入口点有个实用技巧:grep 错误信息、日志文案、UI 上出现的文字。 如果画面上出现"支付失败"这样的文案,就在代码里搜这个字符串。它通常会把你瞬间传送到你关心的那个功能所在处。这个技巧真的很有效,却常常被忽略。

不逐行阅读,而是追踪数据

试图从头到尾一行不漏地读完陌生代码,几乎总会失败。花上好几天,脑子里也拼不出图景。原因很简单:代码的大部分内容,和你现在想解决的问题毫无关系。

好得多的策略是追踪数据(follow the data)。选定一个具体的值,只追踪它的流向——它在哪里诞生(输入、生成)、经过什么变换(加工)、最终去了哪里(存储、输出)。其余代码暂且忽略。

比如要调查"为什么上传的图片保存后会旋转?":

  1. 找到图片进入的地方(上传处理函数)。
  2. 看这份图片数据被装进哪个变量,然后追踪这个变量。它被传给了哪个函数?
  3. 在那个函数内部,图片经历了怎样的变形(缩放、格式转换、EXIF 处理……)?
  4. 最终它被保存在哪里?

只追这一条链,50 万行里实际读到的也就几十行。如果是旋转问题,元凶大概率出现在第 3 步的 EXIF 处理附近。无关的认证逻辑、支付逻辑、管理后台,一行都不用读。

追踪数据时有个好用的思考工具,叫数据的形状(shape)。在每一步都不断问自己:"现在这个值是什么类型、什么结构?"这里是文件路径字符串,这里是字节缓冲区,这里是解码后的像素数组……追踪形状的变化,会让整条流程变得清晰。

把测试和类型当作文档

官方文档通常过时或者干脆不存在。但代码里已经藏着两种不会说谎的文档:测试和类型

测试 = 可执行的规格说明

想知道某个函数做了什么,先读它的测试。 测试是一本可执行的使用说明书,用具体例子告诉你"给这样的输入,会得到这样的输出"。而且只要 CI 是绿的,这份说明在此刻就是的。和过时的 Wiki 不同,测试会随代码一起被验证。

测试中特别有价值的部分:

  • 边界情况:describe/it 块的命名,罗列出了这个函数在意的那些边界。"数组为空时""数字为负时""并发请求时"——这些名字暴露了开发者当初担心的是什么。
  • 使用示例:测试的准备(arrange)部分,展示了这个函数实际是怎么被调用的。与其看文档,不如在这里学 API 用法。

如果想理解某个功能而它恰好有测试,挑一个用调试器单步跟踪。没有什么比跟着实际执行的代码去读更快了。

类型 = 契约

如果代码带静态类型(TypeScript、Rust、Go,或者带类型提示的 Python 等),类型签名本身就是契约。 在读函数体之前,光看签名就能理解一半——它明确写出了接受什么(输入类型)、返回什么(返回类型)。

类型也是把数据的形状用代码钉死下来。做前面说的"追踪数据"时,在 IDE 里确认每一步的类型,形状的变化就直接可见了。就算是没有类型的语言,函数开头的校验逻辑或文档注释,也扮演着类似的角色。

画出调用图 — grep 与 IDE 跳转

读代码时会不断遇到两个问题:"这个函数是在哪里定义的?"和"这个函数是在调用?"能多快回答这两个问题,决定了你读代码的速度。

  • 跳转到定义(go to definition):瞬间传送到某个符号被定义的地方。这是 IDE 提供的读代码基本功,用于自上而下地钻(从调用方到被调用方)。
  • 查找引用 / 查找调用者(find references / find callers):反过来,找出某个函数在哪些地方被使用。用于自下而上地追溯(从定义到使用处)。问"改了这个函数会影响到什么"时必不可少。

没有 IDE、或者多种语言混杂 IDE 跟不上、又或者只是在服务器上打开代码文件时,grep(或 ripgrep、rg)就是万能钥匙。

# 找出这个函数在哪些地方被调用
rg "processPayment\("

# 这个字符串(错误信息、标签)出现在代码的哪里
rg "支付失败"

# 谁读取了这个环境变量
rg "STRIPE_SECRET_KEY"

# 只看特定扩展名,并带上行号
rg -t ts "useAuth" -n

在脑子里反复做这些跳转,调用图就会浮现出来:"A 调用 B,B 调用 C 和 D,C 又被到处调用。"遇到复杂的流程,不妨真的拿纸或白板把这张图画出来。哪怕只画五六个节点,脑子也会清爽很多。

如果想把这套 grep、跳转的工作流,和 git 历史的探索一起练熟,可以在 Git 实训场 里边切换分支边追踪代码是如何演变的。用 git log -S"函数名" 找出"这段代码是什么时候加进来的",也是读代码时的一件利器。

实际跑起来,打印调试

只靠静态阅读,一定会有解不开的地方。代码"能做什么"读一读就知道,但"这次实际做了什么",得跑起来才知道。条件分支复杂、值在运行时才确定、或者流程被回调和事件打得七零八落时,尤其如此。

所以先跑起来。 然后在你好奇的地方打上 print(或日志),用自己的眼睛确认真实的值。

  • "这里真的会执行到吗?":在你怀疑是否会执行到的代码里打上 print("HERE reached")。如果没打印出来,说明那个分支没走,你对流程的理解是错的。
  • "这个值在这里到底是什么?":打印变量的值并带上标签,确认它的形状和内容。脑子里的猜测和实际情况不一样的次数,多到令人吃惊。
  • 调用顺序:在多个函数里打入口日志,能揭示真实的执行顺序。这在异步代码里尤其有用。

这其实是前面"追踪数据"的动态版本。静态追踪流程卡住时,就在那个点打个 print,把真实数据抓出来。读代码和跑代码交替进行,是理解陌生代码最快的路径。

改点什么,看看什么会坏

理解代码有一种出奇有效、却被低估的方法:故意改动一些东西,观察什么会坏。

如果你不确定某个函数、某个配置、某个常量到底起什么作用,那就改一改它。把值反转、把某行注释掉、把常量改成一个离谱的值,然后跑起来。系统会用它的反应告诉你答案。

  • 删掉某一行什么都没变 → 这一行(至少在这条路径上)不重要,甚至可能是死代码。
  • 改了某个常量,某个特定画面坏了 → 这个常量和那个画面绑定在一起,你摸清了一条连接关系。
  • 把某函数的返回值写死后,三个测试失败了 → 这三个测试依赖这个函数,影响范围就此暴露。

这是一种主动实验。不是被动地盯着代码看,而是向系统抛出问题、拿到答案。当然,要安全地做——在本地而非生产环境,处在随时可以用 git 撤回的状态。有了 git stash 或分支,尽情弄坏之后再复原是不花钱的。"看不懂就弄坏它,再撤回"是读代码时一个强大的循环。

先自上而下地读,再往深处钻

理解陌生代码库的顺序,大体上自上而下(top-down)是对的。一开始就钻进细节,只会见树不见林。先抓住大局,再挑需要的部分深入。

推荐的顺序是这样:

  1. 浏览目录结构。 光看文件夹名字就能看出关注点是如何分离的。api/db/components/services/……先画出一张"谁住在哪里"的地图。
  2. 读 README、配置文件、清单文件。 package.json/pyproject.toml 的依赖列表,浓缩地展示了"这个项目用什么工具做什么事"。脚本定义则告诉你"该怎么构建、运行、测试它"。
  3. 摸清架构的层次。 从请求进来到响应出去,经过哪些层(比如路由器 → 控制器 → 服务层 → 存储层)?把这副骨架抓住之后,遇到新代码也能定位:"啊,这是服务层的东西"。
  4. 现在挑一个具体功能,顺着数据深入挖下去。 到这一步,前面提到的找入口点、追踪数据、print、grep 全部派上用场。

核心是先宽后浅、再窄后深的顺序。一开始就沉进某一个函数,会因为不知道它在全局中的位置而迷失方向。先画好地图,再在地图上挑一条路走下去。

综合演练 — 30 分钟搞定一个陌生 bug

把这些碎片编织成一条完整的流程。假设第一周,你接到一个 bug:"支付后的确认邮件有时候不会发出来"。

  1. 大局(自上而下):浏览目录,确认存在 services/email/services/payment/。原来邮件和支付是分开的。
  2. 入口点 + grep:grep 确认邮件里大概会出现的文案("您的订单已完成")。找到邮件模板,以及发送它的函数 sendOrderConfirmation
  3. 查找调用者:查找 sendOrderConfirmation 在哪里被调用。发现是在支付成功的处理函数里被调用的。
  4. 追踪数据:顺着"支付成功 → 发送邮件"这条链追下去,中途看到一个"把邮件发送放进队列"的步骤。
  5. 读测试:读这段入队逻辑的测试,发现有一个用例是"队列满了就悄悄丢弃"。这有点可疑。
  6. 改动 + print:在入队的地方打上日志,尝试复现。亲眼看到流量高峰时队列溢出,部分邮件被丢弃。

30 分钟前还是 50 万行未知代码,如今只读了相关的几十行就锁定了原因。要是想一行不落地读完,第一天恐怕就得全耗在这上面。

结语

读代码不是天赋,而是一门技术,既然是技术,就有方法可循。把核心原则再收拢一遍:

  • 不要随手打开文件,先找入口点
  • 不逐行阅读,只追踪数据流动的路径
  • 测试和类型当作不会说谎的文档来用。
  • grep 和 IDE 跳转画出调用图。
  • 静态阅读卡住时,跑起来打印调试
  • 拿不准的时候,改点东西看看什么会坏
  • 自上而下,先大局,再深入。

下次站在一个陌生的代码库前感到茫然时,忍住打开第一个文件从头读起的冲动。转而问自己:"入口点在哪里?我要追踪的数据是什么?"这两个问题,就是在未知的森林里劈出的第一条路。

参考资料