言語実装の謎を解く:ラムダ計算の7行から関数型インタプリタへ
プログラミング言語の実装は、コンパイラエンジニアや博士号保持者だけに許された困難な作業と見なされがちです。しかし、その核心において、そのプロセスは計算を理解するための演習です。言語をその絶対的な本質まで削ぎ落とすことで、数学的な表記と動作するインタプリタの間の隔たりが驚くほど小さいことがわかります。
このガイドでは、ラムダ計算に基づく、関数型でチューリング完全な言語の実装を探求します。ここでは、Structure and Interpretation of Computer Programs のような記念碑的なテキストに見られる、スケーラブルな eval/apply デザインパターンを利用します。
基礎:ラムダ計算
20世紀初頭に Alonzo Church によって開発されたラムダ計算は、計算を表現するためのミニマリストなシステムです。これは現代的な意味での「プログラミング言語」ではありません。組み込みの数値、ブール値、または I/O が欠けていますが、チューリング完全です。これは、チューリングマシンによって計算可能なあらゆる関数がラムダ計算で表現できることを意味します。
このシステムは、わずか3種類の式に依存しています:
- 変数参照 (Variable References): 値を参照する名前。
- 匿名関数 (Abstractions):
(λ v . e)と記述されます。ここでvは引数であり、eは返される式です。 - 関数呼び出し (Applications):
(f e)と記述されます。ここで関数fは引数eに適用されます。
チューリング完全性の達成
明示的なループや再帰なしに、このような希薄なシステムがどのように複雑なロジックを処理するのでしょうか?ラムダ計算は、2つの主要な「ハック」を採用しています:Church encodings(整数やブール値のようなデータを関数として表現すること)と Y Combinator(匿名関数における再帰を可能にすること)です。
このシステムの強力さを示す顕著な例は、「Omega」プログラムです:((λ f . (f f)) (λ f . (f f)))。このプログラムは決して終了せず、ラムダ計算が、あらゆる汎用言語に見られるのと同じ無限ループを表現できることを示しています。
7行のインタプリタ
R5RS Scheme を使用すると、ラムダ計算の指示的インタプリタをわずか7行のコードで実装できます。Scheme は、その read 関数が字句解析と s-expression へのパースを自動的に処理するため、ここでの理想的な選択肢です。
; eval takes an expression and an environment to a value
(define (eval e env) (cond
((symbol? e) (cadr (assq e env)))
((eq? (car e) 'λ) (cons e env))
(else (apply (eval (car e) env) (eval (cadr e) env)))))
; apply takes a function and an argument to a value
(define (apply f x)
(eval (cddr (car f)) (cons (list (cadr (car f)) x) (cdr f))))
; read and parse stdin, then evaluate:
(display (eval (read) '())) (newline)
仕組み:Eval/Apply パターン
このアーキテクチャは、相互再帰的な2つの関数に依存しています:
eval:ExpressionとEnvironmentをValueにマッピングします。apply:Value(関数) と別のValue(引数) をValueにマッピングします。
この実装では、開いた項を「閉じる」ために Closure が使用されます。これは、ラムダ式と、その自由変数を定義する環境をペアにするもので、、関数がどこで呼び出されてもそのコンテキストを保持することを保証します。
スケーリング:より豊かな言語の構築
7行のバージョンは概念実証ですが、eval/apply パターンは効果的にスケールします。eval 関数を拡張してより多くの式形式をハンドルできる形式に拡張することで、より強力な言語をビルドできます。Racket での100行の実装では、以下のようなものを導入できます:
- 定数 (Constants): 数値およびブール値のリテラル。
- 原始的演算 (Primitive Operations): 加算、減算、および比較。
- 制御フロー (Control Flow):
if条件分岐とbeginシーケンシング。 - 変数束縛 (Variable Bindings): ローカル束縛のための
letと、再帰的束縛のためのletrec。 - 状態 (State): 変数変異のための
set!。
構文的デザイン(コードがどのように書かれるか)と意味論的デザイン(コードがどのように評価されるか)を分離することで、開発者は、インタプリタが処理する s-expression を出力するパーサーを構築するだけで、異なる構文を実験的に試すことができます。
技術的視点と批判
これらの概念の実装は、関数型ミニマリズムの実現可能性について、しばしば議論を巻き起こします。Lisp 系の言語でラムダ計算を実装することは、本質的に「Lisp で Lisp を実装すること」であり、その簡潔さは、対象となる言語の単純さではなく、ホスト言語の固有の性質によるものであると主張する人もいます。
しかし、他の人々は、この演習は単なるコードについてだけではなく、計算の「魔法」を解明することについてであると指摘しています。ある貢献者が次のように述べています:
"Bedrock-level logic gates and logic gates should be part of any curriculum... People should have should a sense that yes, it's complex, but it's also NOT magic."
さらに、ラムダ計算は高レベルのインタプリタを even よりもさらに拡張できます。Binary Lambda Calculus (BLC) では、項はビットとして表現され、非常にコンパクトな自己インタプリタが可能になります。時には、ここで提供された 7 行の Scheme コードよりも小さくなることもあります。
結論
遊びでトイ・ランゲージをトイ・ランゲージを構築しているのか、あるいは製品レベルのコンパイラを設計しているのかに関わらず、ラムダ計算から始めることは、値、環境、および関数がどのように相互作用するかについての根本的な理解を提供します。eval/apply パターンは、スケーラブルな設計図として機能し、最も複雑なプログラミング言語であっても、わずか数個の単純で再帰的な基礎の上に構築されていることを証明しています。