Lambda Prelude は強い「No」を積み重ねて作った言語。それぞれの No が、本番サービスのコードから一種類の混乱を取り除く。引き換えに、他言語で慣れた書き方のいくつかは書き直しが必要になる — それでも、出来上がるコードが SVO 軸 (subject, verb, object) 上で上から下に読め、サービスが育っても可読性が落ちないなら、引き換える価値はある。
本ページは「何を退けたか、なぜ退けたか」を残しておくためのもの。
表層にパターンマッチを置かない
match score > 90 with
| true -> print_endline "A"
| false -> print_endline "B"
ではなく、こう書く。
(score > 90)
ifTrue: ['A' printNl]
ifFalse: ['B' printNl].
Smalltalk 風のコードは上から下に一直線で読める — 各行が「誰に何をさせるか」のメッセージ送信。パターンマッチ行はこの一直線を割る — 読み手は 2 つの分岐を同時に追わなければならず、しかも分岐間の縦の対応関係を頭の中で組み立て直さないと意味が掴めない。
条件分岐はすべてメッセージ送信 — ifTrue:ifFalse:、ifPresent:ifAbsent:、ifEmpty:ifNotEmpty:。retry も timeout も Option の分解も同じ形で書ける。
代償として、ML 系の言語でよく書く「1 つの型に対して match で多数の分岐」というスタイルは、「複数のクラスに対して 1 箇所のディスパッチ」に書き換えることになる。これが実際に痛みになることは少ない — 常駐サービスで扱う「種類が増える」型 (HTTP レスポンス、JSON 値、RPC 結果、メッセージ型) は後から拡張されるのが普通で、クラス・タグ式の方が後付けの拡張が素直に通る。
クラス継承を置かない — プロトコルだけ
クラスは満たすプロトコルを宣言する。親から具体的な振る舞いを継承しない。
Object subclass: #Score
fields: (points)
protocols: (Comparable, Printable).
継承階層は設計の意図ではなく、その場その場の都合で伸びていく。今日追加したクラスが、去年のクラスのために書かれた振る舞いをそのまま継承する。やがて誰も親を捨てられなくなる — 何段も離れた別のクラス 3 つが、その親に間接的に依存しているかもしれないから。
プロトコルは関係が明示的。クラスは「自分が満たすプロトコル」を、プロトコルは「要求するセレクタ」を、それぞれ列挙する。プロトコル上のデフォルトメソッドは、そのプロトコルを満たすクラスすべてから見える — ただしその関係は名前で結ばれていて、継承チェーンを暗黙にたどる方式ではない。
代償として、複数クラスで使いたいヘルパーメソッドは、プロトコルに移すか、各クラスで重複定義する必要がある。これは実は健全な圧力 — 意味が同じでないのに同じコードを共有させるべきではないし、本当に意味が同じならプロトコルとして名前を付けた方が、継承の影に隠れていた設計判断を表に出せる。
インスタンス変数を置かない — fields: レコード
各オブジェクトは名前付きフィールドの閉じたレコード。フィールドの更新は新オブジェクトを返すこと。
Counter >> inc = Counter fields: { value: value + 1 }.
可変なインスタンス変数があると、オブジェクトの内側で状態がかき混ぜられる — フィールド A を読みながらフィールド B を書く、その順序に意味があるメソッドが量産される。順序を入れ替えると意味が変わる、しかしその順序はメソッドのシグネチャからは読み取れない。
不変レコードはデータの流れを表に出すことを強制する — 「状態を変える」メソッドは値を受け取って新しい値を返すだけ、それをどう使うかは呼び出した側が決める。関数型言語のプログラマが自然に採る書き方を、Lambda Prelude は表層の唯一のやり方として固定する。
代償として、大きなオブジェクトを少しずつ更新したい場面でも、毎回新しいオブジェクトを返すことになる。実際には永続データ構造 (HAMT Dict、32-way trie Array、レコード間の構造共有) がコピーをほぼ発生させないので、見た目ほどコストは出ない — 採用根拠は 内部実装 を参照。
メタクラスを置かない
クラスは型。クラスメソッドはメタクラス上のメソッドではなく associated function。
Counter class >> of: n = Counter fields: { value: n }.
Counter class >> of: はトップレベル関数 counter_of として生成される。Counter class class は存在せず、class of: someClass も存在しない。
クラスを走査するリフレクション (System allClasses、String methodSelectors、動的クラス探索) は通常のメソッドディスパッチとは別経路の API として、組込で提供する。リフレクションは ambient — これらの組込は今のところ通常の呼び出しであって、エフェクトの背後に区切られているわけではない。リフレクションを傍受可能なエフェクトとして表面化し、ハンドラが監査・拒否・仮想化できるようにするのは将来構想。
代償として、クラスをオブジェクトとして取り回したいコードはリフレクション API 経由で書く必要がある。一方で利点として、通常のプログラムフローは静的型システムが完全にカバーする — 「メタプログラミングで突然クラステーブルが書き換わったらどうする」と心配する必要がない。
非ローカルリターンを置かない
Smalltalk の ^ は囲んでいるメソッドそのものからリターンする — 別のメソッドに渡したブロックの中からでも効く。これがアクター境界を跨ぐと一気にバグの源になる — ブロックが別の fiber で走っているとき、「囲んでいたメソッド」はすでにリターンし終えているかもしれない。
Lambda Prelude は ^ を捨て、エフェクトベースの早期脱出に置き換える。
Loop with: 0 do: [:state |
(state > 10) ifTrue: [Break value: state].
state + 1
].
Break value: は、囲んでいる Loop with:do: が捕まえるエフェクトを発行する。fiber を跨いだ場合、そのエフェクトを処理するハンドラはあくまでその fiber 自身のもので、fiber の外へ脱出することは原理的にできない。脱出ルートが存在するときは、型システムがメソッドのシグネチャにそれを現してくれる。
代償として、^ に頼っていたイディオム (early-return ヘルパ、独自の制御フロー) は Break / Exit / Return エフェクトに書き換えが必要になる。一方で利点として、アクターを跨いでも筋が通ったまま動く — 「early return のつもりだったが、戻る先のメソッドはもう存在しなかった」という種類の故障が起きない。
nil を置かない — 不在は Maybe
表層言語に nil は存在しない。取得に失敗しうる操作は Maybe を返し、ifPresent:ifAbsent: で分解する。
port := (OS getenv: 'PORT')
ifPresent: [:p | p asInteger]
ifAbsent: [8080].
Maybe はパラメータ化されたビルトイン — none / some: / ifPresent:ifAbsent: / isPresent。意味のある結果を返さないメソッドは self を返す (Smalltalk に void は無い) ので、不在がセンチネルとして漏れ出ることはない。
代償として、これまで「不在」を生の nil で表していたコードは、不在を Maybe として名付けて分解する必要がある。一方で利点として、値が不在になりうるかどうかは型が語り、型チェッカが全ての呼び出し側に不在分岐の処理を強制する。「メソッドが nil を返し、その次の送信が 3 フレーム先で吹き飛ぶ」という種類の故障が消える。
OCaml 5 effects 直書き — Eio / Miou を使わない
Lambda Prelude は Windows (MinGW-w64) と Linux (x86-64) を第一級ターゲットとする。OCaml 5 が対応する他のターゲット (macOS、ARM64、MSVC) もビルド自体は通る想定だが、現時点では常時回帰の対象に入っていない。POSIX 専用プリミティブも Windows 専用プリミティブも、特定プラットフォーム前提の非同期ランタイムも持ち込まない。
Eio と Miou はどちらも独自ランタイムモデルを持つ本格的な非同期フレームワーク — どちらかに依存すれば、Lambda Prelude のライフタイム判断 (fiber の意味論、キャンセル方針、structured concurrency の作法、依存ライブラリの範囲) はそのフレームワーク側に握られる。アクタースケジューラは Effect.t と独自の実行キューを使った小さな自立した OCaml コードで、読める / 移植できる / デバッグできる規模に収めてある。
代償として、非同期まわりで新しいものを足したくなったとき (新しい I/O プリミティブ、新しいタイマー) は、素のエフェクトハンドラの上に自前で書くことになる。一方で利点として、学ぶべき第二のフレームワークがなく、外部スケジューラとバージョンを合わせ続ける手間もなく、並行モデル全体がエンジニア 1 人の頭に収まる規模で済む。
ツリーウォークインタプリタ + AOT — バイトコード VM を持たない
インタプリタは AST を直接ウォークする。AOT コンパイラは AST → OCaml ソース → ocamlopt → ネイティブバイナリの順に変換する。バイトコード VM も JIT も持たない。
バイトコード VM を持つということは、本番では実質一度しか走らないコンパイル工程のために、相応の規模のインフラを永久に維持し続けるということ。OCaml に変換する AOT 経路を選べば、ocamlopt が積み上げてきた技術 — クロージャ変換・インライン展開・レジスタ割り当て — を追加コストなしでそのまま使える。AOT ビルドはすでに dune の release プロファイルでコンパイルされるので、バイナリには -O3 がかかる。flambda2 や積極的な特殊化といった重い最適化は将来の方向性のひとつではあるが、ユーザ向けの独立した --release ビルドフラグがあるわけではない。
インタプリタは開発中の反復スピードのために残してある — ロードが速く、リトライも速く、組込の追加も容易。インタプリタと AOT で挙動が食い違ったときは、正しいのは AOT バイナリの側 — AOT と食い違うインタプリタ挙動はインタプリタのバグとして直す。
代償として、デプロイ単位はスクリプトではなくネイティブバイナリになる。インタプリタでは動くが AOT では動かないコードは「言語の機能」ではなく「インタプリタのバグ」と見なす。
分散には仮想アクター — Erlang クラスタリングしない
Lambda Prelude のローカルアクターモデル — spawn、link、monitor、trapExits:、スーパーバイザ — はマシン境界を跨がない。ノード間 spawn も分散リンク連鎖もグローバルスーパーバイザツリーも持たない。
代わりに用意してあるもの: リモート参照 (Remote at:for:id:) が JSON-RPC 越しのメソッド送信を中継する — リテラルのクラスを渡せば静的に型付けされ、未知セレクタはコンパイル時エラーになる (非リテラルの for: は動的型付け)。その上に仮想アクター (grain) 方式を実装済みで、状態を呼び出しごとに外部 KV (Redis 等) に置き、Redis リース+フェンストークンでクロスノードの単一活性化を保証する。各リモートメソッド呼び出しはステートレス RPC で、相関とスーパーバイザはあくまでローカルに留める。
分散アクターモデル (Erlang クラスタリング、Akka Cluster) はメッセージ順序・リンクの連鎖・ノードを跨いだプロセス同一性に対して強い正しさを保証している — ただしその保証は特定のネットワーク条件下でしか成り立たず、しかもアクターランタイムとクラスタマネージャという 2 つの絡み合うシステムを同時に運用しなければならない。
Lambda Prelude の対象ユーザは大規模クラスタの運用者ではなく、データベースを伴う常駐サービスを数本まわすチーム。仮想アクター方式はその規模に対して、より小さく無理のない約束で済む — リモート参照はクラスへの型付きプロキシ、状態の読み書きは外部 KV 経由、失敗モデルはネットワークと KV の失敗モデルそのまま。分散アクター専用の特殊な失敗モデルは持ち込まない。
代償として、Erlang 流のノード間 spawn とスーパーバイザが必須なコードは Lambda Prelude の射程外となる。一方で利点として、単一バイナリ内で動くコードはローカルアクターの機能をフルに使える (選択受信、リンク、モニタ、終了シグナル捕捉、スーパーバイザ)。他のバイナリと話す必要があるコードは契約 (JSON-RPC) 越しに話す — 相手が Lambda Prelude でも Java でも Go でも Python でも、契約の形は同じ。
最初から静的型 — 段階的型付けではない
型推論は必須、注釈は任意。型を外す逃げ道はない — Any 型もなく、「とりあえず書いて動かす」用の動的モードもない。
段階的型付けの言語では、型付き領域と型なし領域の境界を値が越えるたびに実行時コストがかかる (型タグ、narrowing チェック、ラッパオブジェクト)。このコストは滅多に計測されない — 動的モードが「本来の」モード扱いされ、型付きモードは「いつかそこへ持っていきたい未来」として書かれるから。
Lambda Prelude は逆を取る — 型付きが唯一のモード。インタプリタも AOT も評価前に型チェックを通す。型チェッカを通らないプログラムはそもそも走らない。
代償として、型チェッカを通さないと書けない。「とりあえず動かして様子を見る」ができない。
一方で利点として、コンパイルさえ通ればプログラムは走る。普通のメッセージ送信で実行時に型エラーが出る、という事故は原理的に起きない。バグが入り込める余地は I/O・外部サービス呼び出し・ユーザ自身が書いた不変条件 — この 3 か所に絞られる。
なぜこれらの No で、他は許すのか
他の関数型言語が拒否するパターンの一部は Lambda Prelude では維持される。
- I/O 上の副作用は禁じない。
File write:、Log info:、actor !は直接で、Resultモナドでラップしない。エフェクトシステムは非ローカル制御フローを扱う — I/O エフェクトはモナド配管を必要としない - アクター内部の可変クロージャは許す。状態変更はアクターのループ引数に閉じる。アクター本体を定義するクロージャは、通常の lexical scope で束縛名を参照してよい
判断基準は次のとおり。ファイルやメソッド呼び出しチェーンを跨いでデータフローを遠隔で隠す構造は禁じる。継承はどの親メソッドが実際に走るかを隠し、パターンマッチは値の分岐を非隣接の列に散らす — どちらも読み手に「いま読んでいる行とは別の場所」からコンテキストを再構成させる。
一方、効果がその行の上で見える構造は許す。printNl は標準出力に 1 行書く。actor ! msg は名指しのメールボックスに 1 件積む。アクターのループ引数は状態を捕捉して次のループに転送する。どれも、その場で何が起きるかが行を見れば判る — プログラムを歩き回って後から復元する必要がない。