ドキュメント、テスト、コードは、同じことをまともに語れない3つのファイルだ。
ソースの関数シグネチャを更新する。READMEのサンプルを忘れる。1週間後、新入社員が古くなったスニペットを本番にコピーする。テストファイルはまだ古い動作を期待結果としてエンコードしている。これでバグが2つ、ドキュメントチケットが1つだ。
これが同期問題だ。どのコードベースにもある。ほとんどのチームは規律で解決しようとするが、誰かが焦った時点で失敗する。
Literate programmingは、散文ファイルをsingle source of truthにすることでこれを修正する。人間が読むMarkdownドキュメントを書く。小さなツールがコードブロックを実行可能なPythonファイルに抽出する。テスト、実装、説明がすべて同じ場所に存在する。散文を変更すれば、コードも変更される。
Literate programmingとは?
Donald Knuthが1984年に造語した。アイデアはシンプルだ。エッセイを書くようにプログラムを書く。自然言語でロジックを説明し、コードを挟み込み、ツールtangleが実行可能なソースを抽出し、weaveが整形されたドキュメントを生成する。
KnuthのWEBシステムのような古典的ツールは、PascalとTeXに密接に結びついていた。主流のソフトウェア開発では普及しなかった。ワークフローが異質に感じられ、ツールが重く、多くのプログラマはPDFではなくIDEでコードを読みたかった。
しかし、核心的な洞察は今も有効だ。散文とコードはsingle source of truthを共有すべきだ。現代の言語とMarkdownは、Knuthが想像した以上にこれを容易にする。特別なコンパイラは不要だ。Python30行とMarkdownパーサーで十分だ。
1つのMarkdownファイルがテストスイートになる仕組み
メカニズムは単純だ。.mdファイルにfenced code blocksを書く。各ブロック内のヘッダーコメントが、抽出ツールにそのブロックがどのファイルに属するかを伝える。抽出ツールは同じヘッダーを持つブロックを1つの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ファイルが正規のソースだ。それ以外はすべて生成される。
1つのファイルが助ける時と、害する時
このアプローチは、ライブラリ、アルゴリズム、そしてなぜが何と同じくらい重要なもの全てで輝く。APIドキュメント、研究用コード、設定パイプラインはすべて、実装に結びついた散文から恩恵を受ける。
ボイラープレートが多いアプリケーションコードには輝かない。15個のデコレーターインポートを持つDjangoビューにエッセイは不要だ。モジュールが主にフレームワークの配管なら、literate構造のオーバーヘッドは明確さなしに摩擦を加えるだけだ。
もう1つの制限はツールサポートだ。IDEは.pyファイルにコードがあることを期待する。ジャンプトゥディフィニション、インラインリンティング、オートコンプリートはすべて、生成されたファイルが事前に存在する必要がある。これはtangle.pyをpre-commitフックやビルドステップの一部として実行することで解決できる。しかし、それは追加のステップだ。チームがすでにビルドの複雑さに苦労しているなら、カスタム抽出パイプラインを追加する価値はないかもしれない。
これを実現する実際のツール
上記の30行スクリプトで始めるのに十分だ。プロダクション対応のものが欲しいなら、成熟したオプションがある。
Entangledは、どの言語でも動作するモダンなliterate programmingツールだ。構文は少し異なるが、アイデアは同じだ。コードブロック間の依存関係を追跡し、複数の出力ファイルをサポートする。
Jupyter notebooksは、データサイエンスにおいて同様の問題を解決する。1つのファイルに散文、コード、出力を混在させる。欠点は、ノートブックがバージョン管理に不向きなことだ。差分は読めないし、マージコンフリクトはよく起きる。
Org-mode with Babelが最も強力な実装だ。すでにEmacsを使っているなら、比類がない。そうでなければ、学習曲線は急だ。
ほとんどのチームにとって、シンプルなMarkdown抽出ツールが実用的な中道だ。誰もがすでに理解しているツールを使う。
FAQ
型チェッカーでも動作するか?
はい。まず.pyファイルを生成し、ビルドディレクトリに対してmypyやpyrightを実行する。
Python以外の言語はどうか?
tangle.pyスクリプトは言語に依存しない。正規表現をpythonからrustやgoに変更すれば、同じように動作する。1つのドキュメントで言語を混在させることもできる。
ブロック間のインポートはどう扱うか?
抽出ツールは、Markdown内に表示された順序で、同じファイル名を持つすべてのブロックを連結する。そのファイルの最初のブロックにインポートを保持するか、繰り返す。Pythonは重複したインポートを優雅に処理する。
1つのモジュールから始める
コードベース全体を書き換える必要はない。ドキュメントが常にコードの後れを取っているように見える小さなモジュールを1つ選ぶ。Markdownファイルに変換し、抽出スクリプトをCIパイプラインに追加し、生成された出力に対してテストを実行する。
ドキュメントがリマインダーなしで最新のままなら、アプローチが機能していると分かる。オーバーヘッドのように感じるなら、放棄すればいい。Literate programmingはツールであり、宗教ではない。目標はKnuthを感動させることではない。3つの異なるファイルで自分に嘘をつくのをやめることだ。