English
Professional programmers spend far more time reading code than writing it. The ratio is often estimated at ten to one, and whatever the exact figure, the direction is not in dispute: most of the work of software development is understanding code that already exists — to modify it, to debug it, to extend it, to integrate with it, to review it, or to learn from it. And yet reading code is almost never taught. Programming courses teach writing; they assign problems to be solved by writing new code, and they assess the code that is written. The skill that the professional will use most — entering an unfamiliar codebase and coming to understand it — is left to be acquired by accident, if at all. This asymmetry between what is taught and what is practiced makes reading code one of the highest-return skills to develop deliberately, precisely because so few people have developed it deliberately.
Reading code is a distinct skill from writing it, with its own techniques and its own difficulties. Writing code is generative: you start from a problem and produce a solution. Reading code is investigative: you start from an artifact and reconstruct the intentions, decisions, and constraints that produced it. The artifact is often large, often poorly documented, often the product of many hands over many years, and often structured in ways that made sense given context that is no longer visible. The reader’s task is to build a mental model of a system they did not design, well enough to work with it safely — and to do this efficiently, without reading every line, because the systems are far too large to read exhaustively.
The deeper value of reading code well is that it is how a programmer learns from others. The best way to learn how expert programmers solve problems, structure systems, and use a language idiomatically is to read code they have written. A programmer who only writes, never reading code better than their own, is limited to reinventing what they could have learned. A programmer who reads widely — the standard library of their language, well-regarded open-source projects, the systems they depend on — absorbs the accumulated craft of the field. Reading code is both a daily practical necessity and the primary channel through which the craft is transmitted.
Prerequisites: Programming (§2.1) — you must be able to write code to read it. Development environment and tooling (§8.2) — the navigation and search tools that make reading large codebases tractable. Software architecture (§7.1) — recognizing architectural patterns accelerates understanding of how a system is organized.
The Shape of the Skill: Building a Model You Did Not Design
Reading code is the activity of constructing a mental model of a system from its source. The difficulty, and the structure of the skill, both follow from the fact that the model must be built from an artifact that was produced by someone else’s mental model, which is not directly accessible.
When a programmer writes code, they have a mental model of what the system does and why — the intentions, the constraints, the decisions, the things that were tried and rejected. The code is a projection of that model into source text, and the projection is lossy: the intentions, the rejected alternatives, the reasons for specific choices are mostly not in the code. The reader sees only the projection and must reconstruct enough of the original model to work with the code. This reconstruction is the core difficulty. The code says what the system does; it rarely says why, and the why is often what the reader most needs.
The novice reader’s failure mode is to read code the way one reads prose: linearly, from the top, attempting to understand every line before moving on. This fails for any non-trivial codebase, because the codebases are too large to read linearly and because understanding is not built linearly — the meaning of an early piece often depends on later context, and most of the code is irrelevant to any particular question the reader is trying to answer. The expert reader does not read linearly; they read purposefully, with a specific question, navigating to the relevant parts, building a model of the structure before diving into details, and reading exhaustively only the small portion that their current question requires.
The two complementary strategies are top-down and bottom-up reading, and skilled readers combine them. Top-down reading starts from the high-level structure — the entry points, the major modules, the overall architecture — and progressively refines understanding, drilling into details only where needed. It answers “how is this system organized, and where is the part I care about?” Bottom-up reading starts from a specific piece of code — a function, a class, a line where an error occurred — and works outward, understanding what it does, what calls it, what it calls, until enough context is assembled. It answers “what does this specific thing do, and how does it fit in?” Most real code reading interleaves the two: a top-down pass to understand the structure and locate the relevant region, then bottom-up reading within that region to understand the details, with the structural understanding providing the context that makes the details meaningful.
The practice of reading code has been reshaped by tools. The text-based reading of the past (printouts, grep, reading files in an editor) gave way to IDE-supported reading (jump to definition, find all references, call hierarchy, type information on hover), which made navigating a large codebase dramatically more efficient by letting the reader follow the structure of the code rather than searching its text. The most recent development is AI-assisted reading: a language model can explain an unfamiliar function, summarize a module, trace how a value flows through a system, and answer questions about a codebase in natural language. This is genuinely useful for orientation in unfamiliar code, and it carries a specific risk addressed below: the explanation can be wrong, and accepting it without verification builds a mental model that may not match the actual code.
Entering, Navigating, and Understanding Unfamiliar Code
Entering an Unfamiliar Codebase
The first encounter with a large unfamiliar codebase is disorienting, and having a systematic approach turns the disorientation into a tractable process. The goal of the first pass is not to understand everything — that is impossible and unnecessary — but to build a map: a high-level model of how the system is organized, where the major pieces are, and where to look for whatever the reader actually needs.
Several orientation moves are reliably useful. Read the documentation that exists — the README, the architecture documents, the contributing guide — not because it will be complete or current, but because it conveys the maintainers’ mental model of the system’s structure. Identify the entry points — the main function, the request handlers, the public API, the test suite — because these are where the system’s behavior originates and they anchor top-down understanding. Examine the directory structure and the module organization, which usually reflect the architecture and reveal the major components. Look at the dependency manifest (the package file) to see what the system is built on, which indicates its technological character and major external dependencies. Run the tests, which both verify that the system works in your environment and serve as executable documentation of what the system is supposed to do. Build and run the system, because seeing it work grounds the abstract code in concrete behavior.
The test suite deserves emphasis as a reading resource. Tests are executable specifications: they show what the system is supposed to do, with concrete inputs and expected outputs, in a form that cannot drift out of sync with the code the way comments and documentation do (because tests that drift out of sync fail). Reading the tests for a module is often the fastest way to understand what the module does and how it is meant to be used, because the tests demonstrate the intended usage directly. For a reader trying to understand an unfamiliar component, the tests are frequently more informative than the implementation.
The history of the code, accessible through version control (covered as a tool in §8.5), is a reading resource for understanding why code is the way it is. The commit that introduced a piece of code, and its message, often explains the reason for a decision that the code itself does not. git blame shows when and by whom each line was last changed, and following a confusing piece of code back to the commit that introduced it — and reading that commit’s message and associated discussion — frequently explains the otherwise inexplicable. This is code archaeology: reconstructing the history that produced the current state, to understand the current state.
Navigating with Tools
The tools described in §8.2 are what make reading large codebases tractable, and using them fluently is central to reading code efficiently. Reading code is navigation as much as comprehension: following the connections between pieces is how the reader assembles understanding, and the tools that follow connections are what make this efficient.
The essential navigation operations: jump to the definition of a symbol (to see what a function or type actually is), find all references to a symbol (to see everywhere it is used, which reveals its role in the system), view the call hierarchy (to see what calls a function and what it calls), and search across the project (to find where a concept appears by name). A reader who performs these operations fluently follows the structure of the code at the speed of thought; a reader who searches text manually moves far more slowly and loses the thread. The language-server-powered navigation of modern editors — which understands the code’s structure rather than just its text, so that “find all references” finds actual references rather than textual matches — is what makes this precise.
Reading the flow of data through a system — how a value enters, what transforms it, where it ends up — is a common reading task that combines navigation operations. Starting from where a value is used and working backward to where it originates (or forward from origin to use), following it through the functions that transform it, builds an understanding of a slice of the system’s behavior. This data-flow reading is how a reader answers questions like “where does this configuration value come from?” or “what determines this output?” — questions that are common when modifying or debugging unfamiliar code.
Understanding Why: Code Archaeology and Intent Reconstruction
The hardest part of reading code is recovering intent — understanding not just what the code does but why it does it that way, which is what the reader needs in order to modify it safely. Code that looks wrong or unnecessarily complex is often the way it is for a reason that is not visible in the code: a bug it fixes, an edge case it handles, a constraint it satisfies, a performance problem it avoids. Modifying such code without understanding the reason reintroduces the problem the code was solving.
This is the deep meaning of Chesterton’s Fence (the principle that you should not remove a fence until you understand why it was put there): code that appears pointless should not be removed or “simplified” until its purpose is understood, because the appearance of pointlessness usually reflects the reader’s incomplete understanding rather than the code’s actual uselessness. The discipline that follows is to treat confusing or seemingly unnecessary code as a signal of missing context, and to recover the context — through the commit history, the issue tracker, the tests, the documentation, or asking the people who wrote it — before changing it.
Recovering intent uses every available source. The commit message that introduced the code may explain the reason. The issue or pull request associated with the commit may contain the discussion that motivated it. The test that exercises the code may reveal the case it handles. A comment, where one exists, may state the reason directly. And where these fail, the people who wrote or maintain the code carry the context in their heads, and asking them is often the fastest path — which is one reason the social environment of a codebase (whether it is easy to ask the maintainers) affects how readable it effectively is.
The limits of intent recovery are real: sometimes the reason is genuinely lost, the people who knew have left, the history is uninformative, and the reader must reconstruct the intent by reasoning about what the code must be handling. In these cases, the safe approach is to assume the code has a reason until proven otherwise — to add tests that capture the current behavior before changing it (characterization tests, §7.2), so that any change that alters behavior the code was relying on is caught. The combination of assuming intent and verifying with tests is how a reader works safely with code whose reasons cannot be fully recovered.
AI-Assisted Reading
Language models have become useful tools for reading code: they can explain an unfamiliar function in natural language, summarize what a module does, trace how a value flows through a system, and answer questions about a codebase conversationally. For orientation in unfamiliar code — getting a quick sense of what a component does before reading it in detail — this is genuinely valuable and accelerates the early stages of understanding.
The risk is specific and important: AI explanations of code can be wrong, and they are wrong in ways that are hard to detect, because a fluent, confident, plausible explanation of what code does is exactly as easy for a language model to produce when the explanation is incorrect as when it is correct. A reader who accepts the AI’s explanation without verifying it against the actual code builds a mental model that may not match reality — and then works with the code based on a wrong model, which is worse than having no model, because the errors are not flagged by uncertainty. The danger is greatest precisely where the reader most needs help: in code that is complex or unusual, where the AI is most likely to misunderstand and the reader is least able to catch the misunderstanding.
The productive use of AI for reading treats its explanations as hypotheses to verify rather than facts to accept. The AI’s summary of a module is a starting point that directs attention; the reader confirms it by reading the actual code, especially for the parts that matter to their task. The AI’s explanation of why code does something should be treated with particular skepticism, because intent is exactly what is not in the code, so the AI is inferring rather than reading — and its inference of intent is a guess, sometimes a good one and sometimes confidently wrong. Used as an orientation tool whose output is verified, AI accelerates reading; used as a substitute for reading, it builds confident misunderstanding.
What Changes With Mastery
Reading code well changes how a programmer works and learns.
The first change is the ability to work effectively in unfamiliar code, which is most code a professional encounters. The programmer who can enter a large unfamiliar codebase, orient quickly, locate the relevant parts, understand them well enough to work with them, and recover the intent behind confusing code, can be productive in environments that paralyze programmers without this skill. Since most professional work involves existing code, this capability is close to the center of professional effectiveness.
The second change is learning from the best code in the field. The programmer who reads widely — the standard library, well-regarded open-source projects, the systems they depend on — absorbs the craft of programmers better than themselves. They see how experts structure systems, handle errors, design interfaces, and use the language idiomatically, and they incorporate what they learn into their own writing. This is the primary channel through which programming craft is transmitted, and the programmer who reads only their own code is cut off from it.
The third change is the discipline of understanding before changing. The programmer who has internalized that confusing code usually has a reason, and that modifying code without understanding its purpose reintroduces solved problems, works more safely. They recover intent before changing, add characterization tests before modifying code whose reasons are unclear, and treat the appearance of pointlessness as a signal of missing context rather than as license to simplify. This discipline prevents a large class of regressions.
The fourth change is better writing. Reading code, especially reading code critically, teaches what makes code readable and what makes it opaque. The programmer who has struggled to understand badly written code, and has experienced the relief of well-written code, writes more readable code themselves, because they have experienced reading from the other side. Reading and writing code are complementary; the reader who has felt the cost of poor readability writes for the reader.
Resources
Books and Texts
Diomidis Spinellis’s Code Reading: The Open Source Perspective (Addison-Wesley, 2003) is the rare book devoted entirely to reading code as a skill. It works through real open-source code, demonstrating the techniques for understanding unfamiliar code: how to read functions, how to follow control and data flow, how to understand large structures, how to use tools. Its companion Code Quality: The Open Source Perspective addresses what makes code good or bad, which informs both reading and writing. These remain the most direct treatments of the skill.
Petzold’s Code: The Hidden Language of Computer Hardware and Software (2nd ed., Microsoft Press, 2022) — referenced in §2.1 — is not about reading source code specifically, but its bottom-up construction of understanding, from physical signals to working computer, models the kind of layered comprehension that reading complex systems requires.
Feathers’s Working Effectively with Legacy Code (§7.5 reference) is essential for the specific case of reading and safely modifying code that lacks tests and documentation — the hardest reading case. Its techniques for understanding code well enough to add characterization tests, and for finding seams where behavior can be observed and changed, are the practical methods for working with the most difficult code.
The broader literature on understanding programs — the cognitive science of program comprehension, studied by researchers like Spinellis and others — is mostly in research papers rather than books, but the practical distillation is in the books above.
| Book | Role | Type |
|---|---|---|
| Spinellis, Code Reading: The Open Source Perspective | Reading code as a skill; the central text | Depth |
| Spinellis, Code Quality: The Open Source Perspective | What makes code good or bad | Depth |
| Feathers, Working Effectively with Legacy Code | Reading and modifying undocumented code | Depth |
| Petzold, Code (2nd ed.) | Layered bottom-up comprehension model | Depth |
| Brown & Wilson, The Architecture of Open Source Applications (free; paid print optional) | Real-world codebase and architecture reading cases | Practice |
Practice, Tools, and Projects
Reading code is learned by reading code, deliberately and with increasing ambition. The single most effective practice is to read well-regarded open-source projects in your primary language — not to modify them, but to understand how expert programmers structure real systems. Start with the standard library of your language, which is usually well-written, well-tested, and demonstrates idiomatic use of the language by its experts. Progress to widely-used libraries and frameworks, then to complete applications.
Contributing to open source is reading code with a purpose, which sharpens the reading. To fix a bug or add a feature in an unfamiliar project, you must first understand the relevant part of the codebase well enough to change it safely — which exercises every reading skill: orientation, navigation, intent recovery, and verification. The first contribution to a project is mostly reading; the writing is a small part. The structured difficulty of understanding someone else’s system well enough to extend it is the best available exercise for reading code.
A specific exercise with high return: take a piece of software you use and depend on, and read its source to understand how a specific feature works. Pick a feature whose behavior you can observe, find where it is implemented, and trace the implementation until you understand how the observed behavior is produced. This connects reading to concrete behavior and builds the navigation and data-flow-following skills in a context where you can verify your understanding against the software’s actual behavior.
The “reading group” practice — a group of programmers reading the same codebase or paper-with-code together and discussing it — is an effective way to develop reading skill, because the discussion surfaces different readings and the need to explain one’s understanding sharpens it.
| Resource | Platform | Type |
|---|---|---|
| Reading your language’s standard library source | Language-specific | Practice |
| Open-source contribution | GitHub / GitLab | Practice |
| GitHub Open Source Guides (free) | opensource.guide | Entry |
| Tracing a feature in software you use | Local | Practice |
| Code reading groups | Local / community | Practice |
Navigation, Search, and History Tools
The navigation tools of §8.2 are the tools of reading: jump-to-definition, find-references, call-hierarchy, project-wide search, all powered by the language server for the language. Fluency with these is fluency in reading. The addition specific to reading is the version-control archaeology tools — git blame, git log on a file or region, and the ability to find the commit and associated discussion that introduced a piece of code (covered in §8.5) — which are the tools for recovering intent.
Sourcegraph and GitHub’s code navigation provide cross-repository code search and navigation, useful for reading code across many repositories or for reading code without checking it out locally. For understanding how a widely-used library is used in practice, searching for real usages across public repositories shows the idiomatic patterns.
| Resource | Platform | Type |
|---|---|---|
| Editor navigation (jump-to-def, find-refs, call hierarchy) | §8.2 tools | Practice |
| git blame / git log archaeology | Git | Practice |
| Sourcegraph / GitHub code search | sourcegraph.com / github.com | Practice |
| GitHub Code Search documentation (free) | docs.github.com | Reference |
Traps
| Trap | Why it misleads | Better response |
|---|---|---|
| Reading code linearly like prose | Attempting to read a codebase from top to bottom, understanding every line before moving on, fails for any non-trivial system: the codebases are too large, understanding is not built linearly, and most of the code is irrelevant to any particular question. The linear reader gets lost, exhausted, and learns little. | Read purposefully with a specific question. Build a top-down model of the structure first, locate the part relevant to your question, and read exhaustively only that part. Combine top-down (understand the structure, find the region) with bottom-up (understand the details within the region). Reading is navigation guided by a question, not linear consumption. |
| Assuming confusing code is bad code | Code that looks unnecessarily complex or wrong is often the way it is for a reason that is not visible: a bug it fixes, an edge case it handles, a constraint it satisfies. The reader who assumes confusing code is simply bad code, and “simplifies” it, reintroduces the problem the code was solving — a regression that may not be caught until production. | Apply Chesterton’s Fence: do not remove or simplify code until you understand why it is there. Treat the appearance of pointlessness as a signal of missing context. Recover the intent — through commit history, issue discussion, tests, or asking the maintainers — before changing. Where intent cannot be recovered, add characterization tests to capture current behavior before modifying. |
| Neglecting the tests as a reading resource | Readers focus on the implementation and ignore the tests, missing the most direct available statement of what the code is supposed to do. The implementation shows how; the tests show what and, through their examples, how it is meant to be used. Tests cannot drift out of sync with the code the way comments can, so they are a more reliable specification. | Read the tests for an unfamiliar component before or alongside the implementation. The tests demonstrate intended usage with concrete inputs and expected outputs, which is often the fastest route to understanding what the component does and how to use it. The test suite is executable documentation. |
| Trusting AI explanations without verification | AI explanations of code are fluent, confident, and plausible whether they are correct or not, and they are most likely to be wrong precisely where the code is complex or unusual — which is where the reader most needs help and is least able to catch the error. A reader who accepts AI explanations uncritically builds a confident mental model that may not match the actual code. | Treat AI explanations as hypotheses to verify, not facts to accept. Use them for orientation — to direct attention and get a first approximation — then confirm against the actual code, especially for the parts that matter to your task. Be especially skeptical of AI explanations of why code does something, since intent is not in the code and the AI is guessing. |
| Reading only your own code | A programmer who reads only code at or below their own level — their own code and that of their immediate peers — is cut off from the primary channel for learning the craft: reading code written by programmers better than themselves. They reinvent what they could have learned and never see how experts structure systems. | Read widely and deliberately above your level: the standard library of your language, well-regarded open-source projects, the systems you depend on. Reading expert code is how the craft is transmitted; the programmer who reads it absorbs patterns, idioms, and approaches that they would otherwise have to rediscover slowly or never discover at all. |
中文
职业程序员花在阅读代码上的时间,远远多于编写代码。这个比例常被估计为十比一;无论精确数字是多少,方向并无争议:软件开发的大部分工作,是理解已经存在的代码——为了修改它、调试它、扩展它、与它集成、审查它,或者从中学习。然而,阅读代码几乎从未被正式教授。编程课程教授写代码;它们布置问题,让学生通过编写新代码来解决,并评估写出来的代码。职业实践中最常使用的能力——进入一个陌生代码库并逐渐理解它——却被留给偶然获得,如果它真的会被获得的话。教学内容与实际实践之间的这种不对称,使阅读代码成为最值得刻意发展的高回报技能之一,恰恰因为很少有人真正刻意训练过它。
阅读代码是一种不同于编写代码的独立能力,有自己的技术,也有自己的困难。编写代码是生成性的:你从一个问题出发,产出一个解法。阅读代码则是侦查性的:你从一个已有产物出发,重建产生它的意图、决策和约束。这个产物往往很大,文档往往不足,常常由许多人历经多年共同写成,并且其结构常常是在某个如今已经不可见的语境中才显得合理。读者的任务,是为一个并非由自己设计的系统建立心智模型,达到足以安全地与它协作的程度——而且必须高效完成,不能读完每一行,因为系统太大,不可能穷尽阅读。
良好阅读代码的更深价值在于:这是程序员向他人学习的方式。学习专家程序员如何解决问题、组织系统、地道地使用一门语言,最好的方式就是阅读他们写过的代码。只写代码、从不阅读比自己更好的代码的程序员,会被限制在重新发明自己本可以学到的东西之中。广泛阅读的程序员——阅读自己语言的标准库、评价良好的开源项目、自己依赖的系统——会吸收这个领域已经积累下来的技艺。阅读代码既是日常实践中的必要能力,也是编程技艺传递的主要渠道。
前置知识:编程(§2.1)——必须会写代码,才能阅读代码。开发环境与工具(§8.2)——导航和搜索工具使阅读大型代码库变得可处理。软件架构(§7.1)——识别架构模式可以加速理解系统如何组织。
这项能力的形状:建立一个不是你设计的模型
阅读代码,就是从源代码中构建系统心智模型的活动。它的困难,以及这项能力的结构,都来自一个事实:这个模型必须从一个产物中被建立出来,而这个产物来自他人的心智模型;那个原始心智模型并不能被直接访问。
程序员写代码时,会拥有关于系统做什么、为什么这样做的心智模型——意图、约束、决策、曾经尝试又放弃的方案。代码是这个模型投射到源文本中的结果,而这种投射是有损的:意图、被放弃的替代方案、具体选择的理由,大多并不在代码里。读者看到的只是投影,必须重建足够多的原始模型,才能与代码协作。这种重建就是核心困难。代码说明系统做什么;它很少说明为什么,而读者最需要的,往往正是这个“为什么”。
新手读者的失败模式,是像读散文一样读代码:从头开始线性阅读,试图在继续往下之前理解每一行。对于任何非平凡代码库,这都会失败,因为代码库太大,不可能线性读完;而理解本身也不是线性建立的——前面某一段的意义常常依赖后面的语境,并且对于读者当前想回答的具体问题来说,大多数代码都是无关的。专家读者不会线性阅读;他们带着具体问题有目的地阅读,导航到相关部分,先建立结构模型,再进入细节,并且只对当前问题真正需要的那一小部分进行穷尽阅读。
两种互补策略是自顶向下阅读和自底向上阅读,熟练读者会把它们结合起来。自顶向下阅读从高层结构开始——入口点、主要模块、整体架构——然后逐步细化理解,只在必要处钻入细节。它回答的是:“这个系统如何组织?我关心的部分在哪里?”自底向上阅读则从一段具体代码开始——一个函数、一个类、一行出错的位置——向外展开,理解它做什么、谁调用它、它调用谁,直到组装出足够语境。它回答的是:“这个具体东西做什么?它如何嵌入整体?”真实的代码阅读大多交替使用二者:先用自顶向下的一遍理解结构并定位相关区域,再在该区域内用自底向上阅读理解细节;结构理解则为细节提供意义所需的语境。
阅读代码的实践已经被工具重塑。过去基于文本的阅读方式——打印代码、使用 grep、在编辑器中逐个文件阅读——让位于 IDE 支持的阅读方式:跳转到定义、查找所有引用、调用层级、悬停查看类型信息。这些能力使阅读大型代码库的效率大幅提高,因为读者可以沿着代码结构前进,而不是只搜索文本。最新的发展是 AI 辅助阅读:语言模型可以解释一个陌生函数,总结一个模块,追踪某个值如何流经系统,并用自然语言回答关于代码库的问题。这对于陌生代码中的定向确实有用,但它也带来下文会讨论的特定风险:解释可能是错的;如果不加验证地接受它,就会建立一个与真实代码不匹配的心智模型。
进入、导航和理解陌生代码
进入陌生代码库
第一次面对一个大型陌生代码库,会让人迷失;而系统化方法可以把这种迷失变成可处理的过程。第一遍阅读的目标不是理解一切——那既不可能,也没必要——而是建立一张地图:关于系统如何组织、主要部分在哪里、以及读者真正需要寻找的东西应当去哪里找的高层模型。
有几种定向动作稳定有效。阅读已有文档——README、架构文档、贡献指南——不是因为它们一定完整或最新,而是因为它们会传达维护者关于系统结构的心智模型。识别入口点——main 函数、请求处理器、公共 API、测试套件——因为这些地方是系统行为的起点,也能锚定自顶向下的理解。查看目录结构和模块组织,它们通常反映架构,并揭示主要组件。查看依赖清单,也就是 package 文件,看系统建立在什么之上,这会显示它的技术特征和主要外部依赖。运行测试,这既能验证系统在你的环境中是否能工作,也能作为关于系统应当做什么的可执行文档。构建并运行系统,因为看见它运作,会把抽象代码落到具体行为上。
测试套件值得特别强调,因为它是一种阅读资源。测试是可执行规约:它们用具体输入和预期输出,展示系统应该做什么,并且不像注释和文档那样容易与代码漂移不同步,因为一旦测试与代码不同步,它们就会失败。阅读一个模块的测试,通常是理解这个模块做什么、应当如何使用它的最快方式,因为测试直接展示了预期用法。对于试图理解陌生组件的读者来说,测试往往比实现本身更有信息量。
代码历史可以通过版本控制访问(§8.5 会把它作为工具处理),它是理解代码为什么是现在这样的一种阅读资源。引入某段代码的 commit 及其提交信息,常常会解释代码本身没有说明的决策理由。git blame 显示每一行最后一次由谁在什么时候修改;把一段令人困惑的代码追溯到引入它的 commit,并阅读提交信息和相关讨论,往往可以解释那些否则无法理解的东西。这就是代码考古:重建产生当前状态的历史,以理解当前状态。
使用工具导航
§8.2 中描述的工具,是让阅读大型代码库变得可处理的东西;流畅使用它们,是高效阅读代码的核心。阅读代码既是理解,也是导航:沿着不同部分之间的连接前进,是读者组装理解的方式;而能够跟随连接的工具,才使这种过程高效。
基本导航操作包括:跳转到符号定义,以查看某个函数或类型到底是什么;查找某个符号的所有引用,以查看它在系统中所有被使用的位置,从而揭示它的角色;查看调用层级,以了解哪些东西调用这个函数,它又调用哪些东西;跨项目搜索,以根据名称找到某个概念出现的位置。能够流畅执行这些操作的读者,可以以接近思维速度的方式沿着代码结构前进;而手动搜索文本的读者会慢得多,并且更容易丢失线索。现代编辑器由语言服务器支持的导航能力,理解的是代码结构,而不只是文本,因此“查找所有引用”找到的是真正的引用,而不是单纯的文本匹配;这正是其精确性的来源。
阅读数据在系统中的流动——一个值如何进入、经历哪些转换、最终到达哪里——是常见阅读任务,并且会结合多种导航操作。从某个值被使用的位置开始,反向追踪到其来源;或者从来源向前追踪到使用处,沿着转换它的函数前进,可以建立对系统某一切片行为的理解。这种数据流阅读可以回答诸如“这个配置值从哪里来?”或“这个输出由什么决定?”的问题,而这些问题在修改或调试陌生代码时非常常见。
理解为什么:代码考古与意图重建
阅读代码最困难的部分,是恢复意图——不仅理解代码做什么,还要理解它为什么这样做;而这正是读者安全修改代码所需要的东西。看起来错误或不必要复杂的代码,常常是因为某个在代码中不可见的理由才变成现在这样:它修复了一个 bug,处理了一个边界情况,满足了某个约束,避免了某个性能问题。没有理解这个理由就修改这类代码,会重新引入它原本正在解决的问题。
这就是“切斯特顿栅栏”(Chesterton’s Fence)的深层含义:在你理解一堵栅栏为什么被设置在那里之前,不应该拆掉它。看起来无意义的代码,在其目的被理解之前,也不应该被删除或“简化”,因为“看起来无意义”通常反映的是读者理解不完整,而不是代码真的没用。由此产生的纪律是:把令人困惑或似乎不必要的代码当成缺失语境的信号,然后在修改之前通过提交历史、issue 追踪、测试、文档,或者询问写它的人,恢复相关语境。
恢复意图会用到一切可用来源。引入这段代码的提交信息,可能说明理由。与该 commit 关联的 issue 或 pull request,可能包含推动这项修改的讨论。覆盖这段代码的测试,可能揭示它处理的情况。如果存在注释,注释可能直接说明理由。而当这些都失败时,写过或维护这段代码的人,其头脑中可能仍然保存着语境;询问他们往往是最快路径——这也是为什么一个代码库的社会环境,也就是是否容易向维护者提问,会影响它实际可读性的原因之一。
意图恢复有真实边界:有时候理由确实丢失了,知道的人已经离开,历史信息也没有帮助,读者只能通过推理重建代码可能在处理什么。在这些情况下,安全方法是假设代码有理由,直到被证明并非如此——在修改之前添加测试来捕捉当前行为,也就是特征测试(characterization tests,§7.2),这样如果修改改变了代码所依赖的行为,就会被测试捕捉到。假设存在意图,并用测试进行验证,这二者结合起来,就是读者在无法完全恢复理由的代码中安全工作的方式。
AI 辅助阅读
语言模型已经成为阅读代码的有用工具:它们可以用自然语言解释陌生函数,总结模块做什么,追踪某个值如何流经系统,并以对话方式回答关于代码库的问题。对于陌生代码的定向——在详细阅读之前快速获得某个组件大致做什么的感觉——这确实有价值,也会加速理解的早期阶段。
风险则具体而重要:AI 对代码的解释可能是错的,而且这种错误很难发现,因为无论解释正确还是错误,语言模型都同样容易生成流畅、自信、貌似合理的说明。如果读者不把 AI 解释与实际代码核对,而是直接接受,就会建立一个可能与现实不匹配的心智模型,然后基于错误模型与代码协作。这比没有模型更糟,因为错误并不会被不确定感标记出来。危险最大之处,恰好是读者最需要帮助的地方:复杂或不寻常的代码。在这些地方,AI 最容易误解,而读者又最不容易发现这种误解。
有效使用 AI 阅读代码,应当把它的解释当成待验证的假设,而不是可直接接受的事实。AI 对模块的总结,可以作为起点,引导注意力;读者再通过阅读实际代码,尤其是与自己任务相关的关键部分,确认它是否正确。AI 关于代码为什么这样做的解释,尤其应当保持怀疑,因为意图恰恰不在代码中,所以 AI 不是在读取意图,而是在推断意图——这种推断是猜测,有时很好,有时则自信地错误。把 AI 当作定向工具,并验证其输出,能够加速阅读;把它当作阅读替代品,则会制造自信的误解。
熟练之后会发生什么变化
良好阅读代码会改变程序员的工作方式和学习方式。
第一种变化,是能够在陌生代码中有效工作,而职业程序员遇到的大多数代码都是陌生代码。能够进入大型陌生代码库、快速定向、定位相关部分、理解到足以协作,并恢复令人困惑代码背后意图的程序员,可以在会让缺少这项能力的程序员瘫痪的环境中保持生产力。由于大多数职业工作都涉及已有代码,这种能力几乎处在职业有效性的中心。
第二种变化,是从领域中最好的代码学习。广泛阅读的程序员——阅读标准库、评价良好的开源项目、自己依赖的系统——会吸收比自己更优秀程序员的技艺。他们会看见专家如何组织系统、处理错误、设计接口,以及地道使用语言,并把学到的内容吸收到自己的写作中。这是编程技艺传递的主要渠道;只读自己代码的程序员会被切断这条渠道。
第三种变化,是先理解再修改的纪律。真正内化“令人困惑的代码通常有原因,并且不理解其目的就修改代码会重新引入已解决问题”的程序员,会更安全地工作。他们会在修改之前恢复意图,会在代码理由不清时先添加特征测试,再进行修改;也会把“看起来没用”理解为缺失语境的信号,而不是简化代码的许可证。这种纪律能够防止大量回归。
第四种变化,是写得更好。阅读代码,尤其是带着批判意识阅读代码,会教会人什么让代码可读,什么让代码晦涩。曾经努力理解糟糕代码,并体验过良好代码带来轻松感的程序员,会自己写出更可读的代码,因为他们已经从读者一侧体验过阅读。读代码和写代码是互补的;感受过可读性差的代价的读者,会为未来的读者写代码。
资源
书籍与文本
Diomidis Spinellis 的 Code Reading: The Open Source Perspective(Addison-Wesley,2003)是少见的、专门把阅读代码作为一项能力来讨论的书。它通过真实开源代码展示理解陌生代码的技术:如何阅读函数,如何跟随控制流和数据流,如何理解大型结构,如何使用工具。它的姊妹书 Code Quality: The Open Source Perspective 讨论什么让代码好或坏,而这会同时影响阅读和写作。它们至今仍是对这项能力最直接的处理。
Petzold 的 Code: The Hidden Language of Computer Hardware and Software(第 2 版,Microsoft Press,2022)——§2.1 中也提到过——并不是专门讨论阅读源代码的书,但它从物理信号到可工作的计算机所展开的自底向上理解方式,示范了阅读复杂系统所需的那种分层理解。
Feathers 的 Working Effectively with Legacy Code(§7.5 参考)对于阅读并安全修改缺乏测试和文档的代码这一特定情况是必读的,而这也是最困难的阅读场景。它提出的技术,用于把代码理解到足以添加特征测试,并寻找可以观察和修改行为的接缝,是处理最困难代码的实践方法。
关于理解程序的更广泛文献——程序理解的认知科学,Spinellis 等研究者研究过这一方向——主要存在于研究论文中,而不是书籍中;但其实践提炼集中在上面这些书里。
| 书籍 | 作用 | 类型 |
|---|---|---|
| Spinellis,Code Reading: The Open Source Perspective | 把阅读代码作为能力;核心文本 | 深入 |
| Spinellis,Code Quality: The Open Source Perspective | 什么让代码好或坏 | 深入 |
| Feathers,Working Effectively with Legacy Code | 阅读并修改无文档代码 | 深入 |
| Petzold,Code(第 2 版) | 分层的自底向上理解模型 | 深入 |
| Brown & Wilson,The Architecture of Open Source Applications(免费;纸质版可选付费) | 真实代码库与架构阅读案例 | 实践 |
实践、工具与项目
阅读代码要通过阅读代码来学习,而且要有意识地、逐步提高难度地阅读。最有效的单项实践,是阅读自己主要语言中评价良好的开源项目——不是为了修改它们,而是为了理解专家程序员如何组织真实系统。可以从自己语言的标准库开始;标准库通常写得较好、测试充分,并展示了该语言专家的地道用法。随后再进入广泛使用的库和框架,最后进入完整应用。
参与开源贡献,是带着目的阅读代码,因此会 sharpen 阅读能力。要在一个陌生项目中修复 bug 或添加功能,必须先把代码库相关部分理解到足以安全修改——这会训练每一种阅读技能:定向、导航、意图恢复和验证。对一个项目的第一次贡献,主要是阅读;写代码只是很小的一部分。理解他人的系统并达到能够扩展它的程度,这种结构化难度,是目前最好的代码阅读练习。
一个回报很高的具体练习是:选择一个自己正在使用并依赖的软件,阅读其源码,理解某个具体功能如何实现。选择一个行为可以被观察的功能,找到它的实现位置,并追踪实现,直到理解观察到的行为是如何产生的。这会把阅读连接到具体行为,并在可以用软件实际行为验证理解的语境中,建立导航和数据流追踪能力。
“阅读小组”实践——一组程序员一起阅读同一个代码库,或者阅读带代码的论文,并进行讨论——是发展阅读能力的有效方式,因为讨论会暴露不同理解;而解释自己理解的需要,会让理解变得更清晰。
| 资源 | 平台 | 类型 |
|---|---|---|
| 阅读自己语言的标准库源码 | 各语言专用 | 实践 |
| 开源贡献 | GitHub / GitLab | 实践 |
| GitHub Open Source Guides(免费) | opensource.guide | 入门 |
| 追踪自己使用的软件中的某个功能 | 本地 | 实践 |
| 代码阅读小组 | 本地 / 社群 | 实践 |
导航、搜索和历史工具
§8.2 中的导航工具,就是阅读代码的工具:跳转到定义、查找引用、调用层级、全项目搜索,全部由对应语言的语言服务器支持。熟练使用这些工具,就是代码阅读的流利度。阅读代码中特别新增的一项,是版本控制考古工具——git blame、针对某个文件或区域的 git log,以及寻找引入某段代码的 commit 和相关讨论的能力(§8.5 会讨论)——这些是恢复意图的工具。
Sourcegraph 和 GitHub 的代码导航提供跨仓库代码搜索和导航,适合阅读许多仓库中的代码,或者在不把代码检出到本地的情况下阅读代码。若要理解一个广泛使用的库在实践中如何被使用,搜索公共仓库中的真实用法,可以展示地道模式。
| 资源 | 平台 | 类型 |
|---|---|---|
| 编辑器导航(jump-to-def、find-refs、call hierarchy) | §8.2 工具 | 实践 |
git blame / git log 考古 |
Git | 实践 |
| Sourcegraph / GitHub code search | sourcegraph.com / github.com | 实践 |
| GitHub Code Search documentation(免费) | docs.github.com | 参考 |
陷阱
| 陷阱 | 为什么会误导 | 更好的回应 |
|---|---|---|
| 像读散文一样线性阅读代码 | 试图从头到尾阅读一个代码库,并在继续之前理解每一行,对于任何非平凡系统都会失败:代码库太大,理解不是线性建立的,而且对于任何具体问题来说,大部分代码都是无关的。线性阅读者会迷失、疲惫,并且学不到多少东西。 | 带着具体问题有目的地阅读。先建立自顶向下的结构模型,定位与问题相关的部分,然后只对那部分进行穷尽阅读。结合自顶向下阅读(理解结构,找到区域)和自底向上阅读(理解区域内细节)。阅读代码不是线性消费,而是由问题引导的导航。 |
| 以为令人困惑的代码就是坏代码 | 看起来不必要复杂或错误的代码,常常之所以那样,是因为存在某个不可见理由:它修复了 bug,处理了边界情况,满足了约束。读者如果假设令人困惑的代码只是坏代码,并对其进行“简化”,就可能重新引入它原本解决的问题——这种回归可能直到生产环境才被发现。 | 应用切斯特顿栅栏原则:在理解代码为什么存在之前,不要删除或简化它。把“看起来无意义”当成缺失语境的信号。通过提交历史、issue 讨论、测试,或询问维护者来恢复意图,然后再修改。在无法恢复意图时,先添加特征测试,捕捉当前行为,再进行修改。 |
| 忽视测试作为阅读资源 | 读者常常只关注实现而忽视测试,从而错过最直接的“代码应该做什么”的说明。实现展示如何做;测试展示做什么,并通过例子展示它应该如何被使用。测试不像注释那样容易与代码不同步,因此是更可靠的规约。 | 在阅读一个陌生组件时,在阅读实现之前或同时阅读测试。测试用具体输入和预期输出展示预期用法,这往往是理解组件做什么、如何使用它的最快路径。测试套件是可执行文档。 |
| 不验证地相信 AI 解释 | AI 对代码的解释无论正确与否,都可以流畅、自信且貌似合理;而它最容易出错的地方,恰恰是代码复杂或不寻常的地方——也就是读者最需要帮助、也最不容易发现错误的地方。不加批判地接受 AI 解释,会建立一个可能与真实代码不匹配的自信心智模型。 | 把 AI 解释当作待验证的假设,而不是事实。用它做定向——引导注意力,获得第一近似——然后用实际代码确认,尤其要确认与当前任务相关的部分。尤其要怀疑 AI 对代码为什么这样做的解释,因为意图不在代码里,AI 只是在猜测。 |
| 只读自己的代码 | 如果程序员只阅读与自己水平相当或低于自己水平的代码——自己的代码和身边同事的代码——就会被切断学习技艺的主要渠道:阅读比自己更优秀的程序员写的代码。他们会重新发明本可以学会的东西,也永远看不到专家如何组织系统。 | 有意识地阅读高于自己水平的代码:自己语言的标准库、评价良好的开源项目、自己依赖的系统。阅读专家代码,是编程技艺传递的方式;程序员通过阅读这些代码,吸收模式、惯用法和方法,否则只能缓慢重新发现,甚至永远发现不了。 |