從未流行起來的最佳理念
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也是一個完美的候選者。它是一個具有穩定規範的batch 處理程式。演算法核心,特別是斷行和分頁常式,從廣泛的數學解釋中受益匪淺。程式碼變化緩慢。文件比實作更長壽。
大多數軟體並非如此。大多數軟體每天都在變化。規範是被發現的,而不是被設計的。受眾不是試圖理解演算法的讀者。而是試圖在站會前修復錯誤的開發者。
編輯問題
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錯了。而是這個想法有一個狹窄的生態位。當程式是一個旨在被研究的成品時,它蓬勃發展。當程式是一個旨在被改變的活體系統時,它窒息。
從中吸取什麼
你可能不會為你的下一個服務採用CWEB。沒關係。但Knuth的基本洞察仍然值得應用。
命名事物,使它們自我解釋。一個名為process_data的函數即使沒有接觸過WEB,也是literate programming的失敗。一個名為remove_expired_sessions_older_than的函數自帶文件。
寫下你希望擁有的解釋。如果一段程式碼需要一段散文才能理解,寫下這段散文。把它放在註解、docstring或設計文件中。媒介不如解釋的行為重要。
在有幫助的時候,將敘事結構與編譯器的結構分開。README-driven development、architecture decision records和RFCs都是在不與你工具鏈對抗的情況下,編寫面向人類的故事的方式。
Knuth希望我們像寫書一樣寫程式。我們最終寫的是關於程式的書。這是一個比他希望的更弱的勝利,但它仍然是一個勝利。