从未流行起来的最佳理念

1984年,Donald Knuth发表了一篇提出根本性反转的论文。程序不应该是为编译器编写、为人类添加注释的。它们应该被写作为人类的文学作品,编译器从中提取可执行部分。他称之为literate programming,并以这种方式构建了TeX。

这个想法很美。它在现代软件开发中也几乎完全缺席。

TeX仍然是有史以来最可靠、最易于理解的大型程序之一。这一成功部分是因为Knuth将其写成了literate program。所以问题不在于literate programming是否可行。它显然可以。问题在于为什么它对Knuth有效,而对几乎其他所有人无效。

Literate Programming的真正含义

Knuth的WEB系统,后来是用于C语言的CWEB,通过将单个源文件拆分为两个不同的输出来工作。你编写一个叙事性文档,解释程序做什么、为什么这样做,以及各个部分如何组合在一起。嵌入在这个叙事中的是代码片段。

tangle工具提取代码片段,为编译器排序,并生成机器可以运行的文件。weave工具提取散文,将代码片段格式化为漂亮的打印块,并生成人类可以阅读的文档。

以下是一个小型CWEB程序的实际样子:

@* Introduction. This program computes the sum of a list of integers.
The algorithm is straightforward: iterate through the array and accumulate.

@c
#include <stdio.h>

@<Function prototypes@>;
@<Main program@>;

@*2 The core logic.
We define |sum_array| to take a pointer and a length, returning the total.

@<Function definitions@>=
int sum_array(const int *arr, size_t n) {
    int total = 0;
    for (size_t i = 0; i < n; i++) {
        total += arr[i];
    }
    return total;
}

@*2 Entry point.
A small driver to exercise the function.

@<Main program@>=
int main(void) {
    int data[] = {1, 2, 3, 4, 5};
    size_t len = sizeof(data) / sizeof(data[0]);
    printf("%d\n", sum_array(data, len));
    return 0;
}

注意正在发生什么。代码不是按编译顺序组织的。它是按叙事逻辑组织的。解释在前。实现紧随其后,当读者准备好时。@<Main program@>语法是一个命名块,可以在文档的任何地方定义,并由tangle组装成编译器需要的正确顺序。

这是核心构想:人类阅读自上而下的解释。编译器获得自下而上的依赖图。两者都得到它们偏好的格式。

为什么它对Knuth有效

Knuth不是典型的程序员。他是一位碰巧写程序的数学家和作家。他在接触键盘之前,会在纸上设计算法数月。他写的书在几十年后仍被阅读。当Knuth说代码应该是文学时,他指的是真正的文学,而且他具备实现它的技能。

TeX也是一个完美的候选者。它是一个具有稳定规范的批处理程序。算法核心,特别是断行和分页例程,从广泛的数学解释中受益匪浅。代码变化缓慢。文档比实现更长寿。

大多数软件并非如此。大多数软件每天都在变化。规范是被发现的,而不是被设计的。受众不是试图理解算法的读者。而是试图在站会前修复错误的开发者。

编辑问题

Literate programming假设的是一个写作过程。大多数编程是一个编辑过程。

当你写文章时,你起草、修改和润色。结构是计划好的。当你编写软件时,你探索、测试、重构和发布。结构是涌现的。literate program中的文档不是你事后涂抹的涂层。它是主要结构。每当你重命名变量、提取函数或重新排序逻辑时,你都在重写文学作品。

这创造了一个恶性循环。如果散文与代码紧密耦合,重构就变得昂贵。如果散文松散耦合,它就会偏离代码并变得错误。无论哪种方式,文档都会腐烂。

现代版本控制使情况更糟。literate program是一个单一的叙事文档。pull request是针对源代码树的diff。代码审查工具理解main.c中的行。它们不理解main.w中的叙事块。从语法高亮到静态分析再到CI管道,工具生态系统期望的是可编译的源文件。Literate programming要求你退出所有这些工具。

技能不匹配

Knuth假设程序员也是作家。大多数不是。

优秀的技术写作很少见,因为它很难。它需要对尚未理解你所理解的读者的同理心。它需要解释为什么,而不仅仅是解释什么的纪律。它需要编辑,这是一种与编码不同的技能。

当你要求团队编写literate programs时,你是在要求他们成为Knuth。你要求的是解释意图、探索替代方案、并引导读者贯穿推理过程的扩展散文。平均代码注释是// TODO: fix this。那与文学之间的差距不是工具问题。它是人的问题。

这就是为什么用更好的工具复兴literate programming的尝试始终失败。瓶颈从来都不是weave和tangle工具链。瓶颈是大多数程序员不想写散文,而且大多数代码库不奖励散文质量的文档。

真正取代它的东西

业界没有放弃Knuth的目标。它通过不同的手段实现了一个较弱的版本。

类型系统现在编码了以前需要段落解释的意图。当函数接受NonEmptyList<T>而不是List<T>时,type checker强制执行文档只能描述的保证。当Rust的borrow checker拒绝引用逃逸时,它传达了一种约束,用散文解释需要数页。

Docstrings和API文档工具如Javadoc、Rustdoc和TypeDoc创造了一个中间地带。它们将文档保留在代码旁边,而不要求它驱动结构。你可以阅读源代码或生成的文档,两者保持同步,因为它们存在于同一个文件中。

测试套件变成了可执行的文档。一个写得好的测试说”给定此输入,期望此输出”,比散文曾经能做到的更精确。Property-based tests编码不变量。Snapshot tests捕获意图。测试在每次提交时运行,所以它们不能像散文那样漂移。

也许最重要的是,编程语言本身变得更加文学化。Python读起来像伪代码。Rust的if let语法直接表达pattern matching的意图。当代码就是解释时,你不需要单独的解释。

真正成功的现代版本

有一个地方Knuth的愿景几乎完好无损地存活了下来:computational notebooks。

Jupyter notebooks、R Markdown和Observable允许散文和代码在单个文档中共存。散文解释。代码执行。输出内联显示。这就是literate programming,除了名字之外的一切。

Notebooks在WEB失败的地方成功了,因为背景不同。探索数据集的数据科学家从事的是一个叙事过程。代码变化缓慢。受众是试图理解决策的人类读者。编辑周期是探索,而不是重构。在软件工程中扼杀literate programming的约束,在数据科学中是特性。

教训不是literate programming错了。而是这个想法有一个狭窄的生态位。当程序是一个旨在被研究的成品时,它 thrive。当程序是一个旨在被改变的活体系统时,它窒息。

从中吸取什么

你可能不会为你的下一个服务采用CWEB。没关系。但Knuth的基本洞察仍然值得应用。

命名事物,使它们自我解释。一个名为process_data的函数即使没有接触过WEB,也是literate programming的失败。一个名为remove_expired_sessions_older_than的函数自带文档。

写下你希望拥有的解释。如果一段代码需要一段散文才能理解,写下这段散文。把它放在注释、docstring或设计文档中。媒介不如解释的行为重要。

在有帮助的时候,将叙事结构与编译器的结构分开。README-driven development、architecture decision records和RFCs都是在不与你工具链对抗的情况下,编写面向人类的故事的方式。

Knuth希望我们像写书一样写程序。我们最终写的是关于程序的书。这是一个比他希望的更弱的胜利,但它仍然是一个胜利。