你的文档、测试和代码是三份文件,讲着同一个故事,却讲得都很糟糕。

你在源码里更新了函数签名,忘了改 README 里的示例。一周后,新员工把过时的代码片段复制到了生产环境。你的测试文件仍然把旧的行为编码为预期结果。现在你有两个 bug 和一个文档工单。

这就是同步问题。每个代码库都有。大多数团队靠纪律来解决,但只要有人赶时间,就会失效。

Literate programming 的解决办法是让散文文件成为单一事实来源。你写一份人类可读的 Markdown 文档,一个小工具把代码块提取成可执行的 Python 文件。测试、实现和解释都存在于同一个地方。修改散文,就修改了代码。

什么是 literate programming?

Donald Knuth 在 1984 年创造了这个术语。想法很简单:像写散文一样写程序。用自然语言解释逻辑,穿插代码,然后让名为 tangle 的工具提取可执行源码,同时 weave 生成格式化文档。

经典工具(比如 Knuth 的 WEB 系统)与 Pascal 和 TeX 紧密耦合。它们在主流软件开发中从未流行起来。工作流感觉很陌生,工具很笨重,大多数程序员更喜欢在 IDE 里读代码,而不是在 PDF 里。

但核心洞察仍然有效。散文和代码应该共享单一事实来源。现代语言和 Markdown 让这比 Knuth 想象的更容易。你不需要特殊的编译器。你只需要大约三十行 Python 和一个 Markdown 解析器。

单个 Markdown 文件如何变成测试套件

机制很简单。你写一个带有 fenced code blocks 的 .md 文件。每个块内的头部注释告诉提取器它属于哪个文件。提取器把相同头部的块拼接成一个 Python 模块。然后对结果运行 pytest。

下面是一个完整的例子。保存为 calculator.md

# Calculator: addition and its properties

The `add` function is trivial. That is the point. Even trivial code deserves context about why it exists and what invariants it preserves.

```python
# calc.py
def add(a: int, b: int) -> int:
    """Return the sum of two integers."""
    return a + b

Addition commutes. We verify this explicitly because it is a property future maintainers might break by accident if they switch to a different implementation.

# test_calc.py
from calc import add

def test_addition_commutes():
    assert add(2, 3) == add(3, 2)

def test_identity():
    assert add(7, 0) == 7

`# calc.py` 和 `# test_calc.py` 注释不是魔法语法。它们是我们的脚本所理解的约定。

## 提取脚本

这就是整个工具。保存为 `tangle.py`:

```python
import re
import tempfile
import subprocess
from pathlib import Path


def tangle(md_path: Path, out_dir: Path) -> Path:
    """Extract code blocks from Markdown into runnable Python files."""
    content = md_path.read_text()
    files: dict[str, list[str]] = {}

    # Extract all python code blocks
    for block in re.findall(r"```python\n(.*?)```", content, re.DOTALL):
        lines = block.strip().split("\n")

        # First line like '# filename.py' sets the target file
        if lines and lines[0].startswith("# "):
            filename = lines[0][2:].strip()
            code = lines[1:]
        else:
            filename = "module.py"
            code = lines

        files.setdefault(filename, []).extend(code)

    # Write extracted files
    out_dir.mkdir(parents=True, exist_ok=True)
    for filename, code_lines in files.items():
        (out_dir / filename).write_text("\n".join(code_lines) + "\n")

    return out_dir


if __name__ == "__main__":
    out = tangle(Path("calculator.md"), Path("build"))
    result = subprocess.run(
        ["python", "-m", "pytest", str(out), "-v"],
        capture_output=False,
    )
    raise SystemExit(result.returncode)

运行:

$ python tangle.py
============================= test session starts ==============================
build/test_calc.py::test_addition_commutes PASSED
build/test_calc.py::test_identity PASSED
============================== 2 passed in 0.01s

build/ 目录现在包含 calc.pytest_calc.py。你可以导入它们,对它们做类型检查,或者把它们作为包发布。Markdown 文件是规范来源。其他一切都是生成的。

一个文件什么时候有帮助,什么时候有害

这种方法在库、算法以及任何 为什么是什么 同样重要的场景中表现优异。API 文档、研究代码和配置管道都受益于与实现紧密绑定的散文。

它在大量样板代码的应用程序代码中并不出彩。一个有十五个装饰器导入的 Django 视图不需要一篇散文。如果你的模块主要是框架管道,literate 结构的额外开销会增加摩擦,却不会带来清晰度。

另一个限制是工具支持。IDE 期望在 .py 文件中找到你的代码。跳转到定义、内联 lint、自动补全都需要生成的文件事先存在。你可以通过在 pre-commit hook 或构建步骤中运行 tangle.py 来解决这个问题。但这多了一步。如果你的团队已经在与构建复杂性作斗争,增加自定义提取流水线可能不值得。

实现这一点的真实工具

上面的三十行脚本足以让你开始。如果你想要 production-ready 的东西,有成熟的选项。

Entangled 是一个现代的 literate programming 工具,适用于任何语言。它使用稍有不同的语法,但想法完全相同。它跟踪代码块之间的依赖关系,并支持多个输出文件。

Jupyter notebooks 为数据科学解决了类似的问题。它们把散文、代码和输出混合在一个文件中。缺点在于笔记本在版本控制中表现很差。diff 不可读,合并冲突很常见。

Org-mode with Babel 是最强大的实现。如果你已经活在 Emacs 里,它是无与伦比的。如果不是,学习曲线很陡峭。

对于大多数团队来说,一个简单的 Markdown 提取器是务实的中间地带。它使用每个人都已理解的工具。

FAQ

这对类型检查器有用吗?

有用。先生成 .py 文件,然后针对构建目录运行 mypypyright

非 Python 语言呢?

tangle.py 脚本与语言无关。把正则表达式从 python 改成 rustgo,效果一样。你甚至可以在一个文档中混合多种语言。

如何处理跨块的导入?

提取器按照它们在 Markdown 中出现的顺序,拼接所有具有相同文件名的块。把导入语句放在该文件的第一个块中,或者重复它们。Python 会优雅地处理重复导入。

从一个模块开始

你不需要重写整个代码库。选一个文档似乎总是落后于代码的小模块。把它转换成 Markdown 文件,把提取脚本添加到你的 CI 流水线中,然后针对生成的输出运行测试。

如果文档开始在没有提醒的情况下保持最新,你就知道这种方法是有效的。如果它感觉像额外开销,就放弃它。Literate programming 是一种工具,不是宗教。目标不是打动 Knuth。目标是停止在三份不同的文件中对自己撒谎。