English

A program is a description of a process precise enough for a machine to execute without interpretation. Not approximate, not suggestive — every step spelled out, every case accounted for, every assumption made explicit. This is what distinguishes programming from most other intellectual activities: the machine has no patience for ambiguity. An instruction that would be clear enough in a conversation, or in prose, or even in mathematics, fails in a program the moment it leaves room for more than one reading. Writing a program forces the programmer to know exactly what they mean, which turns out to be much harder than knowing approximately what they mean.

A programming language is the notation in which that description is written. Think of it less like a spoken language and more like a coordinate system: it gives you a way to locate and express things, but the thing you’re expressing exists independently of the coordinates. Different languages make different things easy to say. A language designed around functions and composition makes it natural to express a process as a pipeline of transformations. A language designed around objects makes it natural to express a system as a collection of interacting agents. A language designed around types makes it natural to express correctness constraints as part of the program’s structure rather than as a separate afterthought. The language is not the program, but it shapes what the programmer can think fluently — and over years of working in one language, certain patterns of thought become automatic, as much an artifact of the notation as of the problems being solved. Working seriously in two or more substantially different languages is one of the best ways to discover which habits are essential and which are accidents of early exposure.

Programming as a discipline is the study of how to construct these descriptions well: correctly, efficiently, clearly, and in a form that others can read and modify. It is not the study of any particular language or system, though it is practiced through them. Every other subject in this guide is approached through programming — operating systems through kernel code, machine learning through numerical programs, type theory through implementations. This is why the subject appears first, and why the depth of competence it demands scales with the depth of everything downstream.

From Ada Lovelace to the Software Crisis

In a certain sense, programs appeared before programming languages, and programming languages appeared before computers. Al-Khwārizmī, the ninth-century mathematician whose name gives us the word “algorithm,” wrote down procedures for arithmetic that any careful reader could execute step by step — recognizable algorithms in the modern sense. But it was Ada Lovelace who, in 1843, wrote what is considered the first program: a method for computing Bernoulli numbers using Charles Babbage’s Analytical Engine, a machine that was never actually built. Lovelace’s notes contain a looping calculation, tabular intermediate state, and reusable operations recognizable as programming concepts. The computer that could have run her program would not exist for another century.

When it did arrive, it arrived fast. John von Neumann’s 1945 draft report on the EDVAC described the stored-program architecture — the computer executes instructions held in the same memory as its data — that remains the basis of every general-purpose computer today. The pioneers expected that once the machine existed, programming it would be straightforward. Within a decade, programmers at Manchester, Cambridge, and Princeton had discovered otherwise. The programs were short by later standards, but their behavior was unpredictable in ways that resisted easy diagnosis. The machine executed instructions exactly as written, and when the output was wrong, the fault was always in the writing. What nobody had fully reckoned with was that precision at the scale of a machine — not a sentence, not a paragraph, but thousands of interdependent steps — was a fundamentally different intellectual demand.

The first systematic response was the compiler. Grace Hopper’s A-0 system in 1952 demonstrated that a program could translate mathematical notation into machine instructions, freeing programmers from the details of binary encoding. FORTRAN followed in 1957, COBOL in 1959 — each raising the level of abstraction, making programs easier to write. But the compilers also revealed a new difficulty: programs written in high-level languages were longer and more complex, and the errors they contained were subtler. A FORTRAN program that produced correct output on every tested input might still fail on an input nobody had tried. The discipline had acquired, for the first time, the problem of correctness at scale.

The 1960s brought that problem to a head. Large software projects — airline reservation systems, operating systems, military command-and-control software — were routinely late, over budget, and incorrect. The IBM OS/360 project, documented by Frederick Brooks in The Mythical Man-Month (1975), employed over a thousand programmers and produced an operating system that arrived late, cost several times its original estimate, and shipped with thousands of bugs requiring years to repair. The experience forced a recognition that had no precedent in engineering: adding programmers to a late project made it later, because the coordination cost of a large team grew faster than its additional output. The 1968 NATO Software Engineering Conference in Garmisch gave the phenomenon a name — the software crisis — and declared that software development required systematic treatment as an engineering discipline, not just accumulated craft.

The intellectual response took two directions simultaneously. Edsger Dijkstra’s 1968 letter “Go To Statement Considered Harmful” argued that programs built around unrestricted jumps were impossible to reason about: control could arrive at any point from any other point, making it hopeless to track what was true at any given moment. Restricting programs to structured control flow — sequence, selection, iteration — made them comprehensible to the human who had to verify them. This became structured programming, and its adoption over the following decade changed how programs were organized at every scale. Simultaneously, Tony Hoare’s 1969 paper “An Axiomatic Basis for Computer Programming” introduced what became Hoare logic: a system of preconditions and postconditions that allowed the correctness of a program to be stated as a mathematical claim and proved by argument rather than observed by testing. The two directions were complementary — structured programming made Hoare-style reasoning tractable — but they reflected different beliefs about what programming ultimately was: an engineering discipline to be made reliable, or a mathematical discipline to be made rigorous.

Through the 1970s and 1980s, a deeper argument developed about what the right conceptual model of computation was. John McCarthy’s Lisp (1958) had established that programs could be data — a language could manipulate its own code — and had embedded a functional style in which programs were mathematical expressions rather than sequences of state-changing instructions. Simula (1967) and Smalltalk (1972) introduced objects: bundles of state and behavior that interacted by sending messages, providing a modular structure suited to interactive systems. Prolog (1972) introduced logic programming, in which a program was a set of logical facts and rules and computation was proof search. Each paradigm had advocates who believed it was the right foundation, and the language debates of this period were genuinely about what programming was, not merely which syntax to prefer.

What the paradigm debates eventually established — through the limits of each approach rather than any decisive victory — was that paradigms are configurations of choices about what to make easy, not competing theories of computation. A functional language makes composable transformations easy and mutable shared state difficult; an object-oriented language makes encapsulation easy and functional composition less natural; neither is universally superior. This insight took decades to arrive, and it is still not fully absorbed in programming education, which tends to introduce one paradigm as the default and treat others as specialized variations. A programmer who has worked seriously in at least two substantially different paradigms understands something about programming that a single-paradigm programmer does not.

The contemporary situation is shaped by two developments that would have seemed improbable to most practitioners fifteen years ago. Functional programming, long an academic concern, has become mainstream: languages designed primarily for industry — Scala, Swift, Kotlin, Rust — incorporate functional idioms as core features, and previously imperative languages like Python and JavaScript have acquired them. The reason is not aesthetic: functional programming’s emphasis on immutability and explicit data flow makes programs easier to parallelize and easier to reason about in distributed systems, two pressures that grew steadily through the 2010s as multi-core hardware and cloud computing became the default environment. The second development is the emergence of capable AI coding assistants, which by the mid-2020s could produce working code for most standard programming tasks from natural-language descriptions. The consequences for how programming is learned and what programming work consists of are not yet settled, but the direction is clear: the surface skills of programming — knowing syntax, recalling library APIs, producing code that compiles — are being commoditized, while the deeper skills — understanding what code means, judging what to build, reading and modifying unfamiliar systems — become more valuable rather than less.

Meaning, Abstraction, and Correctness

Programming as a discipline organizes itself around three problems that appear in every significant program and that no general solution has yet resolved.

What a Program Means

A program is a text. Its execution is a process that may run for milliseconds or years, produce a single output or interact continuously with the world, terminate cleanly or loop forever. The relationship between the text and the process is the question of semantics, and it admits several distinct formal answers, each useful in different contexts.

Operational semantics defines the meaning of a program as the sequence of states its execution passes through — the closest to how programmers naturally think: meaning is what happens when you run it, step by step. Denotational semantics defines the meaning as the mathematical function the program computes, abstracting away all detail about how the computation proceeds; two programs are equivalent if they compute the same function, even if they run very differently. Axiomatic semantics — Hoare’s approach — defines the meaning as the logical relationship between preconditions and postconditions: what must be true of the input for the program to guarantee something about the output. These are not competing answers but complementary ones suited to different purposes. Reasoning about correctness calls for axiomatic semantics. Reasoning about whether two implementations are equivalent calls for denotational semantics. Reasoning about performance calls for operational semantics. A programmer who can move among these framings has a conceptual flexibility that a programmer with only one does not.

Abstraction as the Central Design Activity

Every non-trivial program is built from abstractions: functions that hide the details of a computation behind a name, types that hide the details of a representation behind a set of operations, modules that hide the details of an implementation behind an interface. The purpose of every abstraction is the same — to make it possible to think about one part of a program without thinking about all the other parts simultaneously — and choosing the right abstractions is the central design activity of programming.

A good abstraction hides what does not matter and exposes what does. A file system abstraction hides the physical location of data on disk and exposes named files with read and write operations; the programmer need not know where on the disk the data lives, only that it can be retrieved by name. A bad abstraction hides something that matters, producing what Joel Spolsky called a “leaky abstraction”: the hidden details surface in the behavior the abstraction was supposed to conceal. A distributed file system that hides network latency behind the same interface as a local file system is leaky, because the latency is real and programs that ignore it fail in ways the interface does not predict.

The difficulty of abstraction design is that what matters and what does not depend on how the abstraction will be used — which is often not fully known when it is created. This is the source of most technical debt: abstractions adequate for the original use case begin to leak as the system grows into uses their designers did not anticipate. Refactoring — the systematic restructuring of abstraction boundaries without changing behavior — exists to address this accumulation. Recognizing when an abstraction is leaking, understanding why, and repairing it without breaking the programs that depend on it is among the harder skills in programming and one acquired only through maintaining systems that have grown beyond their original design.

Correctness: A Constraint That Cannot Be Fully Satisfied

Programs are required to be correct. The constraint is never fully satisfiable, but techniques for approximating it have accumulated over decades and constitute much of what software engineering means in practice.

Testing — running a program on chosen inputs and comparing output to expected values — is the dominant verification method and the one whose limitations are most persistently underestimated. A passing test suite is evidence that the program behaves correctly on the tested inputs; it is not evidence of correct behavior on all inputs. The gap between tested and untested inputs is where most serious bugs live. Property-based testing (generating random inputs and checking that properties hold) and fuzz testing (generating malformed or extreme inputs to trigger unexpected behavior) extend testing’s reach, but probabilistically rather than completely.

Type systems constrain the programs that can be written, rejecting at compile time programs that would fail in specific ways at runtime. A strong static type system eliminates entire classes of error — calling a function with the wrong argument type, accessing a field that does not exist, confusing integers with pointers — before execution begins. The tradeoff is expressiveness: a type system precise enough to reject all erroneous programs is too restrictive to accept all correct ones. Finding the right point on this curve is the core problem of type system design, which is why programming language research returns to it repeatedly.

Formal verification — mechanically checking a proof that a program satisfies a specification — provides the strongest available guarantee. The seL4 microkernel, verified correct in Coq in 2009, remains a landmark: a complete operating system kernel for which a machine-checked proof establishes correct implementation of its specification. The effort was substantial — person-years of proof work for a relatively small kernel — which is why formal verification remains confined primarily to safety-critical software where failure costs are high enough to justify it. The tools are improving and the effort is declining; the tradeoff may shift within a decade.

What Studying This Changes

The practitioner who has internalized programming as a conceptual practice — not merely the ability to write code that runs — acquires capacities that do not develop from experience alone.

The most durable is the ability to read unfamiliar code as text that means something. A mature programmer encountering an unknown codebase does not run it and observe behavior; they read it, inferring architecture, invariants, and design decisions from structure, naming, and organization. This is the capacity that makes it possible to maintain code written by others, to participate in large open-source projects, to learn by reading implementations. It develops through reading substantial amounts of high-quality code with sustained attention — not from writing exercises, which build only the ability to produce code.

A second capacity is the discipline of decomposition before construction. A mature programmer facing a complex problem identifies subproblems, representations, interfaces, and construction order before writing code. Typing is the last step, not the first. Programs produced through premature construction tend to be both larger and weaker than programs designed first — which eventually teaches the lesson that writing costs less than planning only until the first wrong turn.

A third capacity is paradigm fluency: the ability to see a problem in the mode most suited to it, and to switch modes when the problem changes character. A problem shaped like a transformation pipeline is easiest as a composition of functions; a problem shaped like interacting autonomous components is easiest as communicating objects; a problem structured as a search over logical possibilities is easiest in a constraint or logic framework. A programmer who can access only one mode forces every problem into it, producing solutions that work but are harder to understand and maintain than they need to be.

A fourth capacity is the epistemic discipline that programming enforces and that gradually transfers to other domains. Ambiguous intentions do not compile. Hidden assumptions surface as bugs. The programmer’s standard of understanding — being able to build a working version and explain its behavior under all inputs, not just the ones tested — is more demanding than the standard in most other fields, and it catches failures of comprehension that argument and observation alone do not. Practitioners who have absorbed this standard read specifications, contracts, and empirical claims differently: with an automatic eye for the unspecified case, the boundary condition, the assumption buried in the framing.

Resources

Books and Texts

The canonical entry to programming as conceptual practice has been, for forty years, Abelson and Sussman’s Structure and Interpretation of Computer Programs (SICP, 2nd ed., 1996, free online). Its distinctive feature is that it frontloads conceptual substance — recursion, higher-order functions, data abstraction, interpreters — before any conventional language features appear. Scheme is used not because Scheme matters but because its syntax is minimal enough to vanish, leaving the concepts unobscured. SICP’s reputation for producing programmers who understand what they are doing has held for four decades. The cost is real: it expects mathematical maturity and sustained engagement with abstract material. Learners who are not prepared find it discouraging.

Felleisen, Findler, Flatt, and Krishnamurthi’s How to Design Programs (HtDP, 2nd ed., free online) covers much of the same conceptual ground with a different emphasis: a systematic design methodology — from problem statement to data definition to examples to function template to implementation to testing — that makes the structure of good programs explicit. Where SICP is organized around concepts, HtDP is organized around a design process. The two complement each other; reading both is not redundant. Many learners who struggled with SICP found HtDP the right starting point; many who mastered HtDP found SICP the natural next step.

Kernighan and Ritchie’s The C Programming Language (K&R, 2nd ed., 1988) is the right text for understanding imperative programming at the level of the machine. C exposes what higher-level languages hide: memory layout, pointer arithmetic, the cost of operations, the relationship between program and hardware. This exposure is uncomfortable and necessary — operating systems, compilers, and performance-sensitive systems are written in C or in languages with C’s semantics, and reading them requires understanding what C is doing. The Rust Programming Language (free online) is the contemporary counterpart for new systems work, providing similar low-level control with a type system that prevents the memory errors C permits.

Van Roy and Haridi’s Concepts, Techniques, and Models of Computer Programming (CTM, 2004) is the most ambitious attempt to unify the paradigms under a single framework, presenting functional, imperative, object-oriented, logic, concurrent, and dataflow programming as configurations of a small set of underlying concepts. The book’s reception has been uneven — the unconventional language choice limited adoption — but its central thesis is correct and stated more clearly here than anywhere else: paradigms are not competing theories but different choices about which concepts to make primitive.

The three books on programming practice with the most durable insight are The Pragmatic Programmer (Hunt and Thomas, 20th anniversary ed., 2019), Refactoring (Fowler, 2nd ed., 2018), and The Mythical Man-Month (Brooks, anniversary ed., 1995): the first on the habits of individual practitioners, the second on systematic code improvement, the third on the organizational and human dynamics of software projects at scale. All three are discussed at more length in Chapter 7, where their concerns fit more naturally.

Book Role Type
Abelson & Sussman, SICP (free) Canonical conceptual entry Entry
Felleisen et al., How to Design Programs (free) Systematic design methodology Entry
Kernighan & Ritchie, The C Programming Language Imperative programming at machine level Depth
The Rust Programming Language (free) Modern systems programming Depth
Van Roy & Haridi, Concepts, Techniques, and Models Unified paradigm framework Depth
Hunt & Thomas, The Pragmatic Programmer Practitioner habits and craft Auxiliary
Fowler, Refactoring (2nd ed.) Systematic code improvement Auxiliary
Brooks, The Mythical Man-Month Software project dynamics Auxiliary
Seibel, Coders at Work How mature programmers actually think Auxiliary

Courses and Lectures

Berkeley CS 61A (current version) is the most refined free course on programming as conceptual practice. Built on SICP’s principles but using Python and providing substantial pedagogical scaffolding, it is paired with the free textbook Composing Programs (John DeNero). For learners who find SICP too abrupt as a first encounter, CS 61A covers the same destination more gradually. Full lecture recordings, assignments, and exams are freely available.

MIT 6.009 (Fundamentals of Programming) covers programming concepts — recursion, data structures, functional programming, interpreters — in Python with emphasis on implementation. Less conceptually ambitious than CS 61A but more practice-oriented; useful as a complement or alternative depending on how a learner engages with abstract material.

Nand to Tetris (Shimon Schocken and Noam Nisan, free at nand2tetris.org) is not a programming course in the conventional sense but one of the most effective ways to understand what programming is: it builds a complete computing system from logic gates up, implementing an assembler, virtual machine, compiler, and operating system. Working through it gives a grounded understanding of what a program is at every level of abstraction, from transistors to high-level code. Parts I and II are available on Coursera.

Harvard CS50x (free on the CS50 site and edX, with an optional paid certificate) is the most polished broad introduction to computer science for beginners. It is less conceptually focused than CS 61A or SICP, but its lectures, projects, and production quality are excellent for learners who need motivation, breadth, and a first pass through C, Python, SQL, and web programming before returning to deeper conceptual work.

Course Platform Type
Berkeley CS 61A + Composing Programs (free) Berkeley / course site Entry
MIT 6.009 Fundamentals of Programming (free) MIT OCW Entry
Nand to Tetris Parts I & II (free) nand2tetris.org / Coursera Depth
Harvard CS50x (free; optional paid certificate) Harvard / edX / CS50 site Auxiliary

Practice, Tools, and Code

Python Tutor (pythontutor.com) visualizes program execution step by step, showing the call stack, heap, and variable bindings at each moment. Especially useful for understanding recursion and for debugging the gap between what a programmer thinks a program does and what it actually does. Free for Python, JavaScript, C, C++, and Java.

Bret Victor’s “Inventing on Principle” (YouTube, 2012) is a 54-minute talk arguing that the gap between writing code and seeing its effect impedes thinking. It demonstrates live-coding environments where changes are immediately reflected in the running program — tools that were prototypes, but that change how programmers think about the relationship between notation and execution.

Reading substantial open-source codebases is one of the most concentrated forms of programming education available and one of the rarest — most programmers never do it deliberately. Recommended starting points in roughly increasing order of size and complexity: the Unix utilities wc, cat, grep (small, well-written C); Redis (moderate-sized, well-structured C, a complete production system); SQLite (exceptional code quality, extensively documented, a complete relational database in one file); CPython (the Python interpreter, larger but very readable). Working through a codebase — not just reading files but understanding the architecture, tracing data through the system — builds capacities that no textbook exercise produces.

Resource Platform Type
Python Tutor (free) pythontutor.com Practice
Bret Victor, “Inventing on Principle” (free) YouTube Depth
Open-source codebases: Unix utils, Redis, SQLite, CPython GitHub / distributed Practice

Traps

Trap Why it misleads Better response
Treating language choice as the central decision Programming education is saturated with debates about which language to learn first. The debates miss what matters: the conceptual content of programming is largely language-independent, and a programmer who has internalized it can learn a new language in days. One who has not will struggle indefinitely regardless of which language they chose. Choose any language with a serious pedagogical tradition (Python, Scheme, OCaml) and go deep on the concepts. Switch languages deliberately, not because a different language seems better, but to encounter a different paradigm. Language obsession is the most common form of avoiding the actual subject.
Mistaking syntax fluency for conceptual depth Syntax is visible; concepts are not. A learner who has memorized Python idioms can feel like they are making progress while remaining shallow on recursion, abstraction, and correctness. The exam that reveals the gap is any problem requiring novel decomposition, not a familiar one. Seek problems that require designing an abstraction, not just implementing a known algorithm. If every problem feels familiar, the problems are too easy. Difficulty with design is not a sign of insufficient practice; it is the signal that the relevant concepts have not been internalized.
Framework dependence before foundations Frameworks produce impressive artifacts quickly while hiding the mechanisms underneath. A learner whose first significant project is built on React, Django, or PyTorch learns the framework’s conventions and becomes capable within its assumptions and helpless outside them. The skill ceiling for framework-only practitioners is real. Build at least some things below the framework level — a small web server from sockets, a simple classifier from gradient descent, a toy parser from recursive descent. The programs will be worse than what frameworks produce; that is the point. The value is understanding what the framework is abstracting over.
Avoiding difficult bugs When a debugging session becomes hard, the temptation is to change approach, ask for help, or restart. Each response is locally rational. The difficult bug is where the most learning is concentrated: the moment when a mental model fails to match actual behavior is the moment when the model is most available for correction. Stay with the hard bug. After fixing it, write down what the actual mechanism was — not just the fix that made the symptom disappear, but the underlying cause. The written account is the learning; the fix is incidental.
Using AI assistance before foundations are in place Coding assistants can solve most introductory programming exercises correctly. Learners who use them at full strength during early learning are deprived of the cognitive struggle that builds the mental models the tools are supposed to amplify. The tools are professionally valuable; using them before the foundations are in place substitutes for thought rather than amplifying it. Do early programming work without AI assistance. Introduce coding assistants after the relevant concepts are operational — meaning one can solve the class of problem from scratch, debug failures, and explain what is happening at each step. The threshold is a competence threshold, not a time limit.
The intermediate plateau After one to two years of serious programming, a learner can produce working programs of moderate complexity. This feels like having learned to program. Real competence has many further levels — designing large systems, reading and modifying existing large systems, reasoning about correctness and performance, working fluidly in unfamiliar paradigms — that are invisible from the intermediate position. Deliberately seek harder contexts: contribute to a substantial open-source project, work on a problem in an unfamiliar paradigm, read a large codebase until its architecture is understood, attempt formal verification of a nontrivial property. The plateau ends when something is encountered that the current approach cannot handle.

中文

程序是对一个过程的描述,其精确程度必须足以让机器无需解释就能执行。它不能近似,不能暗示——每一步都要写清楚,每一种情况都要考虑到,每一个假设都要显式表达出来。这正是编程区别于多数其他智力活动的地方:机器没有耐心处理含糊。一个在对话中、散文中、甚至数学中已经足够清楚的指令,只要在程序里留下多于一种解读的空间,就会失败。写程序迫使程序员确切知道自己是什么意思,而这件事远比“大概知道自己是什么意思”困难得多。

编程语言就是书写这种描述的记号系统。与其把它想成一种口语,不如把它想成一种坐标系:它给你一种定位和表达事物的方式,但你要表达的东西并不依赖这个坐标系而存在。不同语言会让不同事情更容易说出来。围绕函数和组合设计的语言,会让人很自然地把过程表达为一条变换流水线。围绕对象设计的语言,会让人很自然地把系统表达为一组相互作用的行动者。围绕类型设计的语言,会让人很自然地把正确性约束表达为程序结构的一部分,而不是事后补上的检查。语言不是程序本身,但它会塑造程序员能够流畅思考的东西——在一种语言中工作多年之后,某些思维模式会变成自动反应,既是问题本身的产物,也是记号系统的产物。认真使用两种或更多实质上不同的语言,是发现哪些习惯是本质的、哪些习惯只是早期接触带来的偶然结果的最好方式之一。

作为一门学科,编程研究的是如何把这些描述构造得更好:正确、高效、清晰,并且能被他人阅读和修改。它研究的不是某一种特定语言或系统,虽然它总是通过这些语言和系统被实践。本指南中的其他每一个主题,都会通过编程进入——操作系统通过内核代码进入,机器学习通过数值程序进入,类型论通过实现进入。这就是为什么编程出现在最前面,也是为什么它所要求的能力深度,会随着后续所有主题的深度一起上升。

从 Ada Lovelace 到软件危机

在某种意义上,程序先于编程语言出现,编程语言又先于计算机出现。九世纪数学家 Al-Khwārizmī 的名字给了我们 “algorithm” 这个词;他写下的算术过程,任何认真读者都可以一步步执行——这已经是现代意义上可识别的算法。但被认为写下第一个程序的人,是 1843 年的 Ada Lovelace:她为 Charles Babbage 的分析机写出了一种计算伯努利数的方法,而这台机器实际上从未被建成。Lovelace 的笔记中包含循环计算、表格化的中间状态和可复用操作,这些都可以被识别为编程概念。真正能运行她程序的计算机,还要再等一个世纪才会出现。

当计算机真正到来时,它来得很快。John von Neumann 1945 年关于 EDVAC 的报告草案描述了存储程序架构——计算机执行与数据存放在同一内存中的指令——而这至今仍是所有通用计算机的基础。先驱者原本以为,一旦机器存在,给它编程应该很直接。不到十年,Manchester、Cambridge 和 Princeton 的程序员就发现事实并非如此。以后的标准来看,这些程序都很短,但它们的行为却以难以诊断的方式变得不可预测。机器会严格按照写下的指令执行;当输出错误时,错误总是在书写之中。此前没有人充分意识到:机器尺度上的精确性——不是一句话,不是一段话,而是成千上万个彼此依赖的步骤——是一种根本不同的智力要求。

第一个系统性回应是编译器。Grace Hopper 的 A-0 系统在 1952 年证明,程序可以把数学记号翻译成机器指令,从而把程序员从二进制编码的细节中解放出来。FORTRAN 于 1957 年出现,COBOL 于 1959 年出现——它们都提高了抽象层次,让程序更容易书写。但编译器也揭示了一种新的困难:用高级语言写出的程序更长、更复杂,其中的错误也更隐蔽。一个 FORTRAN 程序在所有测试过的输入上都产生正确输出,却仍然可能在某个没人试过的输入上失败。编程这门学科第一次获得了一个问题:大规模正确性。

1960 年代,这个问题集中爆发。大型软件项目——航空订票系统、操作系统、军事指挥与控制软件——经常延期、超预算,而且不正确。Frederick Brooks 在 The Mythical Man-Month(1975)中记录的 IBM OS/360 项目,雇用了超过一千名程序员,最终交付迟缓,成本是原先估计的数倍,并且发布时带有数千个需要多年修复的 bug。这一经历迫使人们承认一个在工程史上没有先例的事实:给一个已经延期的项目增加程序员,会让它更晚完成,因为大型团队的协调成本增长得比新增产出更快。1968 年在 Garmisch 召开的 NATO Software Engineering Conference 给这一现象命名为“软件危机”,并宣布软件开发需要被系统性地作为一门工程学科来处理,而不能只是积累手艺。

智力上的回应同时走向两个方向。Edsger Dijkstra 1968 年的信件 “Go To Statement Considered Harmful” 认为,围绕不受限制的跳转构建的程序无法被推理:控制流可以从任何地方到达任何地方,使人几乎不可能追踪某一时刻到底什么为真。把程序限制在结构化控制流中——顺序、选择、迭代——可以让必须验证程序的人理解它。这就是结构化编程;在接下来的十年中,它的采用改变了各个规模上程序的组织方式。与此同时,Tony Hoare 1969 年的论文 “An Axiomatic Basis for Computer Programming” 引入了后来被称为 Hoare 逻辑的东西:一套由前置条件和后置条件组成的系统,使程序正确性可以被陈述为数学命题,并通过论证来证明,而不只是通过测试来观察。这两个方向彼此互补——结构化编程使 Hoare 式推理变得可行——但它们反映了对编程本质的不同信念:编程究竟是一门需要变得可靠的工程学科,还是一门需要变得严密的数学学科。

整个 1970 年代和 1980 年代,一个更深层的争论逐渐展开:计算的正确概念模型到底是什么。John McCarthy 的 Lisp(1958)已经确立了“程序可以是数据”这一思想——一门语言可以操作自己的代码——并嵌入了一种函数式风格,在这种风格中,程序是数学表达式,而不是一连串改变状态的指令。Simula(1967)和 Smalltalk(1972)引入了对象:把状态和行为捆绑在一起的单元,通过发送消息相互作用,为交互式系统提供了一种模块化结构。Prolog(1972)引入了逻辑编程,在其中,程序是一组逻辑事实和规则,计算则是证明搜索。每种范式都有支持者,他们相信自己掌握了正确基础;这一时期的语言争论真正是在争论编程是什么,而不只是争论哪种语法更顺眼。

这些范式争论最终确立的结论,不是任何一方的决定性胜利,而是通过每种方法的局限得出的:范式是关于“让什么变得容易”的选择组合,而不是彼此竞争的计算理论。函数式语言让可组合的变换变得容易,让可变共享状态变得困难;面向对象语言让封装变得容易,但让函数式组合不那么自然;二者都不是普遍更优。这个洞见花了几十年才形成,而且至今还没有被编程教育完全吸收,因为编程教育往往把一种范式作为默认范式介绍,再把其他范式当作特殊变体。一个至少在两种实质上不同的范式中认真工作过的程序员,会比只接触单一范式的程序员更理解编程本身。

当代状况受到两个在十五年前多数实践者看来都难以想象的发展影响。其一,长期被视为学术关切的函数式编程已经进入主流:主要面向工业设计的语言——Scala、Swift、Kotlin、Rust——都把函数式习惯作为核心特性,过去偏命令式的语言如 Python 和 JavaScript 也吸收了这些特性。原因并不是审美,而是函数式编程强调不可变性和显式数据流,这让程序更容易并行化,也更容易在分布式系统中推理;随着多核硬件和云计算在 2010 年代成为默认环境,这两种压力持续增长。其二,是有能力的 AI 编码助手出现。到 2020 年代中期,它们已经能够根据自然语言描述,为多数标准编程任务生成可工作的代码。它对编程如何学习、编程工作由什么构成的影响尚未定型,但方向已经清楚:编程的表层技能——知道语法、记住库 API、写出能编译的代码——正在被商品化,而更深层的技能——理解代码意味着什么,判断应该构建什么,阅读和修改陌生系统——并没有贬值,反而变得更有价值。

意义、抽象与正确性

作为一门学科,编程围绕三个问题组织自身;它们出现在每一个重要程序中,而且至今没有任何通用解法能彻底解决它们。

程序意味着什么

程序是一段文本。它的执行是一个过程:这个过程可能运行几毫秒,也可能运行数年;可能产生一个输出,也可能持续与世界交互;可能干净地终止,也可能永远循环。文本与过程之间的关系,就是语义问题。这个问题有几种不同的形式化答案,每一种都适用于不同语境。

操作语义把程序的意义定义为执行过程中经过的一系列状态——这最接近程序员自然的思考方式:意义就是程序一步步运行时发生的事情。指称语义把意义定义为程序所计算的数学函数,抽象掉计算如何进行的所有细节;如果两个程序计算同一个函数,即使它们运行方式非常不同,也可以被视为等价。公理语义——Hoare 的方式——把意义定义为前置条件与后置条件之间的逻辑关系:输入必须满足什么条件,程序才能保证输出满足什么性质。它们不是互相竞争的答案,而是适合不同目的的互补答案。推理正确性时需要公理语义。推理两个实现是否等价时需要指称语义。推理性能时需要操作语义。能够在这些框架之间切换的程序员,拥有只有单一框架的程序员所没有的概念灵活性。

抽象是核心设计活动

每个非平凡程序都由抽象构成:函数把某段计算的细节隐藏在一个名称之后,类型把表示细节隐藏在一组操作之后,模块把实现细节隐藏在一个接口之后。每一种抽象的目的都相同——让人可以思考程序的一部分,而不必同时思考所有其他部分——而选择正确的抽象,就是编程中最核心的设计活动。

好的抽象隐藏不重要的东西,暴露重要的东西。文件系统抽象隐藏数据在磁盘上的物理位置,暴露带名称的文件以及读写操作;程序员不需要知道数据在磁盘的哪个位置,只需要知道可以通过名称取回它。坏的抽象会隐藏真正重要的东西,产生 Joel Spolsky 所说的“泄漏的抽象”(leaky abstraction):那些本该被抽象隐藏的细节,会在抽象本应遮蔽的行为中浮现出来。一个分布式文件系统如果用和本地文件系统相同的接口来隐藏网络延迟,就是泄漏的,因为延迟是真实存在的;忽视它的程序会以接口无法预测的方式失败。

抽象设计的困难在于,什么重要、什么不重要,取决于这个抽象以后会如何被使用——而这在抽象被创造时往往并不完全清楚。这正是多数技术债的来源:原本适合初始使用场景的抽象,会随着系统成长到设计者未曾预料的用途而开始泄漏。重构——在不改变行为的前提下系统性重组抽象边界——正是为了解决这种积累。识别一个抽象何时正在泄漏,理解为什么泄漏,并在不破坏依赖它的程序的前提下修复它,是编程中更难的技能之一;这种技能只有在维护那些已经超出原始设计的系统时才会获得。

正确性:一个无法完全满足的约束

程序被要求是正确的。这个约束永远无法被完全满足,但几十年来,人们已经积累出许多近似满足它的技术;这些技术构成了软件工程在实践中的很大一部分。

测试——在选定输入上运行程序,并把输出与期望值比较——是占主导地位的验证方法,也是其局限最经常被低估的方法。一个通过的测试套件,证明程序在被测试的输入上表现正确;它并不能证明程序在所有输入上都表现正确。已测试输入与未测试输入之间的空隙,正是多数严重 bug 生存的地方。性质测试(生成随机输入,并检查某些性质是否成立)和模糊测试(生成畸形或极端输入,以触发意外行为)扩展了测试的覆盖范围,但这种扩展是概率性的,而不是完全的。

类型系统会限制可以被写出的程序,在编译时拒绝那些会在运行时以特定方式失败的程序。强静态类型系统会在执行开始前消除整类错误——用错误参数类型调用函数、访问不存在的字段、混淆整数和指针。代价是表达力:一个精确到足以拒绝所有错误程序的类型系统,会严格到无法接受所有正确程序。在这条曲线上找到合适位置,是类型系统设计的核心问题,也正是编程语言研究反复回到这个问题的原因。

形式化验证——用机器检查证明某个程序满足规约——提供了目前最强的保证。2009 年在 Coq 中被验证正确的 seL4 微内核仍然是一个里程碑:这是一个完整的操作系统内核,其机器检查证明确立了它正确实现了自己的规约。这个工作量巨大——一个相对较小的内核需要以人年计的证明工作——这也是为什么形式化验证主要仍局限于安全关键软件,因为只有在这些场景中,失败代价才高到足以证明这种投入合理。工具正在进步,成本正在下降;这个权衡在未来十年内可能会发生变化。

学习这一部分会改变什么

一个把编程内化为概念实践的人——而不只是会写能运行代码的人——会获得一些仅靠经验本身无法发展出的能力。

最持久的能力,是把陌生代码当作有意义文本来阅读的能力。成熟程序员面对一个未知代码库时,不会只是运行它并观察行为;他们会阅读它,从结构、命名和组织中推断架构、不变量和设计决策。正是这种能力,使维护他人写的代码、参与大型开源项目、通过阅读实现来学习成为可能。它来自持续专注地阅读大量高质量代码,而不是来自写练习题;后者只会建立生产代码的能力。

第二种能力,是先分解再构造的纪律。成熟程序员面对复杂问题时,会在写代码之前识别子问题、表示方式、接口和构造顺序。打字是最后一步,不是第一步。过早构造产生的程序,通常既更大也更弱;而这最终会教会人一个道理:写代码的成本低于规划,只在第一次走错路之前成立。

第三种能力,是范式流利度:能够以最适合问题的模式看待问题,并在问题性质变化时切换模式。一个形状像变换流水线的问题,最容易表达为函数组合;一个形状像多个自主组件相互作用的问题,最容易表达为通信中的对象;一个结构上是在逻辑可能性中搜索的问题,最容易放进约束或逻辑框架。只能使用一种模式的程序员,会把每个问题都塞进这种模式里,产生的方案也许能工作,但会比必要的更难理解和维护。

第四种能力,是编程强制形成的认识论纪律,并且它会逐渐迁移到其他领域。含糊的意图无法编译。隐藏的假设会以 bug 的形式浮现。程序员的理解标准——能构建一个可工作的版本,并解释它在所有输入下的行为,而不仅仅是测试过的输入——比多数其他领域的标准更严格;它能捕捉到仅靠论证和观察无法发现的理解失败。吸收了这种标准的实践者,会用不同方式阅读规约、合同和经验主张:他们会自动注意未说明的情况、边界条件,以及藏在问题框架中的假设。

资源

书籍与文本

四十年来,把编程作为概念实践来学习的经典入口,一直是 Abelson 和 Sussman 的 Structure and Interpretation of Computer Programs(SICP,第 2 版,1996,可免费在线获取)。它的独特之处在于,它把概念实质放在最前面——递归、高阶函数、数据抽象、解释器——而不是先讲传统语言特性。书中使用 Scheme,并不是因为 Scheme 本身重要,而是因为它的语法极简,几乎可以隐退到背景中,让概念不被遮蔽。SICP 培养“知道自己在做什么的程序员”的名声已经维持了四十年。代价也是真实的:它要求数学成熟度和对抽象材料的持续投入。没有准备好的学习者会觉得它令人沮丧。

Felleisen、Findler、Flatt 和 Krishnamurthi 的 How to Design Programs(HtDP,第 2 版,可免费在线获取)覆盖了许多相同的概念地带,但强调方式不同:它提供一套系统性的设计方法论——从问题陈述,到数据定义,到例子,到函数模板,到实现,再到测试——把好程序的结构显式化。SICP 围绕概念组织,HtDP 围绕设计过程组织。二者相互补充,并不重复。许多在 SICP 中受挫的学习者,会发现 HtDP 才是正确起点;许多掌握 HtDP 的学习者,也会发现 SICP 是自然的下一步。

Kernighan 和 Ritchie 的 The C Programming Language(K&R,第 2 版,1988)是理解机器层面命令式编程的正确文本。C 会暴露高级语言隐藏的东西:内存布局、指针运算、操作成本、程序与硬件之间的关系。这种暴露令人不舒服,但也必要——操作系统、编译器和性能敏感系统要么用 C 写成,要么用具有 C 语义的语言写成,而阅读这些系统需要理解 C 正在做什么。The Rust Programming Language(可免费在线获取)是当代新系统工作的对应物:它提供类似的底层控制,同时用类型系统防止 C 所允许的内存错误。

Van Roy 和 Haridi 的 Concepts, Techniques, and Models of Computer Programming(CTM,2004)是最雄心勃勃的范式统一尝试。它在一个框架中呈现函数式、命令式、面向对象、逻辑、并发和数据流编程,把它们视为少数底层概念的不同配置。这本书的接受度并不均衡——不常规的语言选择限制了它的采用——但它的核心论点是正确的,而且没有其他地方比这里说得更清楚:范式不是彼此竞争的理论,而是关于哪些概念应被设为原语的不同选择。

关于编程实践,最具有持久洞见的三本书是 The Pragmatic Programmer(Hunt 和 Thomas,20 周年版,2019)、Refactoring(Fowler,第 2 版,2018)和 The Mythical Man-Month(Brooks,周年版,1995):第一本讨论个体实践者的习惯,第二本讨论系统性的代码改进,第三本讨论大规模软件项目中的组织与人类动态。这三本书会在第七章中更详细讨论,因为它们的关注点在那里更自然。

书籍 作用 类型
Abelson & Sussman, SICP(免费) 经典概念入口 入门
Felleisen et al., How to Design Programs(免费) 系统性设计方法论 入门
Kernighan & Ritchie, The C Programming Language 机器层面的命令式编程 深入
The Rust Programming Language(免费) 现代系统编程 深入
Van Roy & Haridi, Concepts, Techniques, and Models 统一范式框架 深入
Hunt & Thomas, The Pragmatic Programmer 实践者习惯与技艺 辅助
Fowler, Refactoring(第 2 版) 系统性代码改进 辅助
Brooks, The Mythical Man-Month 软件项目动态 辅助
Seibel, Coders at Work 成熟程序员实际如何思考 辅助

课程与讲座

Berkeley CS 61A(当前版本)是关于编程作为概念实践的最精炼免费课程。它建立在 SICP 原则之上,但使用 Python,并提供大量教学脚手架;它与免费教材 Composing Programs(John DeNero)配套。对于觉得 SICP 作为第一接触过于陡峭的学习者,CS 61A 会以更渐进的方式抵达同一目的地。完整讲课录像、作业和考试都可免费获取。

MIT 6.009Fundamentals of Programming)用 Python 覆盖编程概念——递归、数据结构、函数式编程、解释器——并强调实现。它不如 CS 61A 在概念上雄心勃勃,但更偏实践;根据学习者如何处理抽象材料,它可以作为补充,也可以作为替代。

Nand to Tetris(Shimon Schocken 和 Noam Nisan,可在 nand2tetris.org 免费获取)不是传统意义上的编程课程,但它是理解“编程是什么”的最有效方式之一:它从逻辑门开始构建一个完整计算系统,实现汇编器、虚拟机、编译器和操作系统。完成它,会让学习者在每个抽象层次上理解程序是什么,从晶体管到高级代码。Part I 和 Part II 可在 Coursera 上获取。

Harvard CS50x(可在 CS50 官网和 edX 免费学习,付费证书可选)是面向初学者最精致的广义计算机科学入门课程。它不如 CS 61A 或 SICP 那样集中于概念,但它的讲座、项目和制作质量非常适合需要动机、广度,以及先接触 C、Python、SQL 和 Web 编程,再回到更深概念工作的学习者。

课程 平台 类型
Berkeley CS 61A + Composing Programs(免费) Berkeley / 课程网站 入门
MIT 6.009 Fundamentals of Programming(免费) MIT OCW 入门
Nand to Tetris Parts I & II(免费) nand2tetris.org / Coursera 深入
Harvard CS50x(免费;付费证书可选) Harvard / edX / CS50 官网 辅助

实践、工具与代码

Python Tutor(pythontutor.com)会逐步可视化程序执行,展示每一刻的调用栈、堆和变量绑定。它尤其适合理解递归,也适合调试程序员以为程序会做什么与程序实际做什么之间的差距。它免费支持 Python、JavaScript、C、C++ 和 Java。

Bret Victor 的 “Inventing on Principle”(YouTube,2012)是一场 54 分钟的演讲,主张写代码与看见代码效果之间的间隔会阻碍思考。它展示了一些实时编码环境:修改会立刻反映到正在运行的程序中。这些工具当时只是原型,但它们会改变程序员理解记号与执行之间关系的方式。

阅读大型开源代码库,是最浓缩的编程教育形式之一,也是最罕见的一种——多数程序员从未有意识地这样做。推荐的起点按规模和复杂度大致递增:Unix 工具 wccatgrep(小而精良的 C 程序);Redis(中等规模、结构良好的 C,完整生产系统);SQLite(代码质量极高、文档极充分、一个文件中的完整关系数据库);CPython(Python 解释器,更大但非常可读)。真正读懂一个代码库——不是只看文件,而是理解架构、追踪数据在系统中的流动——会建立任何教材练习都无法产生的能力。

资源 平台 类型
Python Tutor(免费) pythontutor.com 实践
Bret Victor, “Inventing on Principle”(免费) YouTube 深入
开源代码库:Unix utils、Redis、SQLite、CPython GitHub / 分发源码 实践

陷阱

陷阱 为什么会误导 更好的回应
把语言选择当成核心决定 编程教育中充满了“第一门语言该学什么”的争论。这些争论错过了真正重要的东西:编程的概念内容很大程度上独立于语言;一个真正内化这些内容的程序员,可以在几天内学会一门新语言。没有内化这些内容的人,无论选择哪门语言,都会长期挣扎。 选择任何有严肃教学传统的语言(Python、Scheme、OCaml),深入学习概念。有意识地切换语言,不是因为另一门语言看起来更好,而是为了接触另一种范式。语言执念是逃避真正主题的最常见形式。
把语法熟练误认为概念深度 语法是可见的;概念不是。一个熟记 Python 惯用写法的学习者,可能感觉自己在进步,却在递归、抽象和正确性上仍然浅薄。揭示这个差距的考试,是任何需要新颖分解的问题,而不是熟悉题型。 寻找需要设计抽象的问题,而不只是实现已知算法的问题。如果每个问题都感觉熟悉,说明问题太容易。设计上的困难不是练习不足的标志;它表明相关概念还没有被内化。
在基础之前依赖框架 框架可以很快产生令人印象深刻的作品,同时隐藏底层机制。一个人的第一个重要项目如果建立在 React、Django 或 PyTorch 之上,他学到的是框架的约定,并且只会在这些假设之内显得能干;离开这些假设就会无能为力。只会框架的实践者确实存在能力上限。 至少构建一些低于框架层的东西——用 socket 写一个小型 Web 服务器,用梯度下降写一个简单分类器,用递归下降写一个玩具解析器。它们会比框架做出来的东西差;这正是重点。价值在于理解框架抽象掉了什么。
回避困难 bug 当调试变难时,人的诱惑是换一种方法、求助,或者重来。每种回应在局部上都是理性的。但困难 bug 正是学习最集中的地方:心智模型与实际行为不匹配的时刻,也正是模型最容易被修正的时刻。 留在困难 bug 里。修复之后,写下真实机制是什么——不只是让症状消失的修复方式,而是底层原因。写下来的说明才是学习;修复本身只是附带结果。
在基础尚未建立前使用 AI 辅助 编码助手可以正确解决多数入门编程练习。如果学习者在早期学习中火力全开地使用它们,就会失去建立心智模型所必需的认知挣扎,而这些心智模型本来是工具应该放大的东西。这些工具在专业场景中有价值;但在基础尚未建立之前使用它们,是用工具替代思考,而不是放大思考。 早期编程练习应在没有 AI 辅助的情况下完成。等相关概念已经可操作之后,再引入编码助手——也就是说,学习者能够从零解决这类问题,调试失败,并解释每一步发生了什么。这个门槛是能力门槛,不是时间限制。
中级平台期 在一到两年的认真编程之后,学习者可以写出中等复杂度的可工作程序。这会让人觉得自己已经学会了编程。真正的能力还有许多更高层级——设计大型系统,阅读和修改已有的大型系统,推理正确性和性能,在陌生范式中流畅工作——而这些层级从中级位置上往往是不可见的。 有意识地寻找更困难的语境:参与一个实质性的开源项目,处理一个陌生范式中的问题,阅读一个大型代码库直到理解其架构,尝试形式化验证一个非平凡性质。当学习者遇到当前方法无法处理的东西时,平台期才会结束。