你的文件、測試和程式碼是三份檔案,講著同一個故事,卻講得都很糟糕。
你在原始碼裡更新了函式簽名,忘了改 README 裡的範例。一週後,新進員工把過時的程式碼片段複製到了生產環境。你的測試檔案仍然把舊的行為編碼為預期結果。現在你有兩個 bug 和一個文件工單。
這就是同步問題。每個程式碼庫都有。大多數團隊靠紀律來解決,但只要有人趕時間,就會失效。
Literate programming 的解決辦法是讓散文檔案成為單一事實來源。你寫一份人類可讀的 Markdown 文件,一個小工具把程式碼區塊提取成可執行的 Python 檔案。測試、實作和解釋都存在於同一個地方。修改散文,就修改了程式碼。
什麼是 literate programming?
Donald Knuth 在 1984 年創造了這個術語。想法很簡單:像寫散文一樣寫程式。用自然語言解釋邏輯,穿插程式碼,然後讓名為 tangle 的工具提取可執行原始碼,同時 weave 生成格式化文件。
經典工具(比如 Knuth 的 WEB 系統)與 Pascal 和 TeX 緊密耦合。它們在主流軟體開發中從未流行起來。工作流感覺很陌生,工具很笨重,大多數程式設計師更喜歡在 IDE 裡讀程式碼,而不是在 PDF 裡。
但核心洞察仍然有效。散文和程式碼應該共享單一事實來源。現代語言和 Markdown 讓這比 Knuth 想像的更容易。你不需要特殊的編譯器。你只需要大約三十行 Python 和一個 Markdown parser。
單個 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.py 和 test_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 檔案,然後針對建構目錄執行 mypy 或 pyright。
非 Python 語言呢?
tangle.py 腳本與語言無關。把正規表示式從 python 改成 rust 或 go,效果一樣。你甚至可以在一個文件中混合多種語言。
如何處理跨區塊的匯入?
提取器按照它們在 Markdown 中出現的順序,拼接所有具有相同檔案名的區塊。把匯入陳述式放在該檔案的第一個區塊中,或者重複它。Python 會優雅地處理重複匯入。
從一個模組開始
你不需要重寫整個程式碼庫。選一個文件似乎總是落後於程式碼的小模組。把它轉換成 Markdown 檔案,把提取腳本新增到你的 CI 流水線中,然後針對生成的輸出執行測試。
如果文件開始在沒有提醒的情況下保持最新,你就知道這種方法是有效的。如果它感覺像額外開銷,就放棄它。Literate programming 是一種工具,不是宗教。目標不是打動 Knuth。目標是停止在三份不同的文件中對自己撒謊。