トレイト/型クラスと辞書渡しの仕組み
同じ関数名が型ごとに別実装へ届く「アドホック多相」を、トレイト/型クラスと辞書渡しの一語で見通せる。継承なしに後付けできる理由と、静的/動的ディスパッチの選び分けまで原理から解きほぐします。
- 1.型クラス/トレイトはアドホック多相の宣言で、コンパイラはメソッド実装を集めた辞書(vtable相当のレコード)を生成し、制約付き関数へ暗黙の引数として渡す辞書渡しに脱糖する。
- 2.静的ディスパッチは型ごとに辞書を確定させて単相化・インライン化でき、動的ディスパッチはトレイトオブジェクトとして辞書ポインタを実行時に持ち回る。前者は速く後者はコードが小さく多態的。
- 3.型クラスは型と実装を分離するため、自作型に既存の振る舞いを継承なしで後付けでき、戻り値型や複数型パラメータでのオーバーロードもサブタイピングより解像度高く表せる。
多相の三分類とアドホック多相
「多相(polymorphism)」と一口に言っても中身は分かれます。Vec<T> のように同じコードが任意の型で動くのが パラメトリック多相(ジェネリクス)、基底型の代わりに派生型を渡せるのが サブタイプ多相(部分型付け)です。これらに対し アドホック多相(ad-hoc polymorphism) は、同じ名前の演算が 型ごとに別々の実装を持つ ことを指します。a + b が整数では加算、文字列では連結になる、show x が型ごとに違う文字列化をする、といった「名前は同じだが中身が型依存」がアドホック多相です。
問題は「どの実装を呼ぶか」をどう決めるかです。素朴な関数オーバーロードは引数型で実装を選ぶだけで、これを 制約として抽象化 できません。sort を「比較できる任意の型」に対して一度だけ書きたいのに、比較の実装は型ごとに違う——この「ある操作ができる型」という制約を第一級に扱う仕組みが、Haskell の 型クラス(type class) と、Rust の トレイト(trait) です。
型クラス/トレイトは「制約の宣言」
型クラスは、ある型が満たすべき操作の集合に名前を付けた宣言です。Haskell の Eq は「等値比較ができる」という性質を表します。
class Eq a where
(==) :: a -> a -> Bool
instance Eq Int where
x == y = primEqInt x y -- Int 用の実装
elem :: Eq a => a -> [a] -> Bool -- Eq a => が制約
elem x = any (x ==)
class が制約の宣言、instance がある具体型に対する実装の登録、Eq a => が「a は Eq を満たすこと」という 制約付きシグネチャ です。Rust も構造は同じで、trait が宣言、impl Trait for Type が実装、T: Trait が制約に対応します。重要なのは、型と実装が 分離 されている点です。サブタイピングが「型を定義する瞬間に親を決める」のに対し、型クラスは型の定義とは独立に、後から instance/impl を足せます。これが「自作型に既存の振る舞いを継承なしで後付けできる」という型クラス最大の利点の源です。
オブジェクト指向のインターフェース実装は「クラス定義時に implements を書く」必要があり、ライブラリ側の既存型に後から能力を足せません。型クラス/トレイトは型と実装が別々なので、自分の型に標準の Show/Display を実装するのも、外部型に自作トレイトを実装するのも自由です(ただし孤児ルール=orphan rule で「型もトレイトも他人のもの」という組合せは制限されます)。
辞書渡しへの脱糖:制約は隠れた引数になる
ここからが内部動作の核心です。Eq a => a -> ... という制約付き関数を、コンパイラはどう実行コードに落とすのか。答えが 辞書渡し(dictionary passing) です。コンパイラは各 instance から、その型クラスのメソッド実装を 並べたレコード(辞書, dictionary) を生成します。辞書とは要するに「そのメソッドの関数ポインタを詰めた構造体」で、vtable とほぼ同じものです。
そして Eq a => という制約は、辞書を受け取る余分な引数 に変換されます。脱糖後の擬似コードはこうなります。
// 辞書の型:Eq クラスのメソッド表
EqDict a = { eq : a -> a -> Bool }
// インスタンス Eq Int は値(辞書)になる
dictEqInt : EqDict Int = { eq = primEqInt }
// elem :: Eq a => a -> [a] -> Bool は
// 辞書を明示引数に取る関数へ脱糖される
elem' : EqDict a -> a -> [a] -> Bool
elem' d x xs = any (\y -> d.eq x y) xs
// 呼び出し側は、型に対応する辞書を渡す
elem' dictEqInt 3 [1,2,3]
つまり Eq a => の => は「-> の隠れ版」で、制約一つにつき辞書引数一つが暗黙に挿入されます。制約が (Eq a, Show a) => のように複数あれば辞書も複数渡されます。スーパークラス(class Eq a => Ord a)がある場合、Ord の辞書は Eq の辞書(または取り出す手段)を内部に含み、辞書から辞書をたどれる構造になります。x == y という呼び出しは、最終的に「渡された辞書の eq フィールドを引いて間接呼び出しする」ことへ翻訳されます。
| ソース上の概念 | 脱糖後の正体 | 対応する実体 |
|---|---|---|
| class 宣言 / trait | メソッドを並べたレコード型 | 辞書(vtable)の型 |
| instance / impl | そのレコードの値 | 具体的な辞書 |
| 制約 Eq a => / T: Trait | 隠れた辞書引数 | 暗黙に渡るポインタ |
| メソッド呼び出し x.m() | 辞書のフィールド参照+呼び出し | 間接呼び出し(解決後は直呼びも) |
静的ディスパッチ:辞書をコンパイル時に消す
辞書渡しは概念モデルであって、実行時に必ず辞書を持ち回るとは限りません。呼び出し側で 具体型が分かっている なら、コンパイラはどの instance を使うか静的に決定でき、辞書引数の値も定数として確定します。ここで 単相化(monomorphization)が効きます。elem' を Int 専用に特殊化すれば、辞書経由の間接呼び出しは「primEqInt の直接呼び出し」へ畳み込まれ、辞書そのものが消えてインライン化の対象になります。
これが 静的ディスパッチ(static dispatch) です。Rust の fn f<T: Trait>(x: T) やジェネリック境界、Haskell の INLINABLE/特殊化が当てはまり、呼び先が確定するため間接参照ゼロ・インライン可・最速になります。代償は、型ごとにコードが複製されてバイナリが膨らむこと(コードの肥大化)です。
動的ディスパッチ:辞書を実行時に持ち回る
一方、要素の型が実行時まで決まらない、あるいは異なる型を一つのコレクションに混ぜたい場合は、辞書を 値として実行時に運ぶ 必要があります。Rust の &dyn Trait、Haskell の存在型(forall a. Show a => ... を箱に詰める)がこれで、(データへのポインタ, 辞書へのポインタ) という 2語の fat pointer になります。メソッド呼び出しは毎回「辞書のスロットを引いて間接呼び出し」で、まさに辞書渡しモデルがそのまま実行時に現れた姿です。
| 観点 | 静的ディスパッチ | 動的ディスパッチ |
|---|---|---|
| 辞書の扱い | コンパイル時に確定・消去 | 実行時に値として保持 |
| 呼び出しコスト | 直接呼び出し・インライン可 | 辞書経由の間接呼び出し |
| コードサイズ | 型ごとに複製で肥大 | 一本化され小さい |
| 異種を一括保持 | 不可(型が固定) | 可(dyn/存在型で混在) |
| 典型 | T: Trait / 単相化 | &dyn Trait / 存在型 |
ホットパスで型が静的に分かるなら静的ディスパッチでインライン化を取りに行く。プラグイン的に異種の実装を一覧へ混ぜたい、コードサイズを抑えたい、コンパイル時間を縮めたいなら動的ディスパッチ。Rust は両方を明示でき、impl Trait/ジェネリック境界が静的、dyn Trait が動的です。どちらも下敷きは同じ辞書渡しで、辞書を「消す」か「運ぶ」かの違いにすぎません。
オーバーロードとの差:戻り値型でも選べる
型クラスが単なる関数オーバーロードより強いのは、戻り値の型や引数に現れない型でも実装を選べる 点です。Haskell の read :: Read a => String -> a は、文字列を「どの型として読むか」を戻り値型で決めます。引数だけ見るオーバーロードでは表現できません。Num a => a を持つ fromInteger も同様で、リテラルがどの数値型になるかは文脈の型で決まります。
これは辞書渡しモデルなら自然に説明できます。どの辞書を渡すかは「呼び出し箇所で必要とされる型」から型推論(Hindley-Milner に制約解決を足したもの)が決めるため、戻り値型からでも辞書を選べるのです。複数の型パラメータをまたぐ制約(多引数型クラス)も、辞書を複数渡すだけで一様に扱えます。
「型クラスはどう実装されるか」には 辞書渡し——instance を関数表(辞書)にし、制約を隠れた辞書引数へ脱糖する、と答えます。「Eq a => の => の意味」は 辞書を受け取る -> の暗黙版。「静的と動的の違い」は 辞書をコンパイル時に消す(単相化・インライン)か、fat pointer として実行時に運ぶ(dyn/存在型)か。「サブタイピングとの差」は 型と実装が分離され、継承なしで後付けでき、戻り値型でも選べる まで言えれば上級として十分です。
まとめ
アドホック多相は「同じ名前が型ごとに別実装へ届く」性質で、型クラス/トレイトはその制約を第一級に宣言する仕組みです。コンパイラは各 instance をメソッドの関数表=辞書に変換し、Eq a => のような制約を辞書を受け取る隠れた引数へ脱糖します(辞書渡し)。呼び出し側で型が静的に分かれば辞書は定数化されて消え、単相化とインライン化で最速の静的ディスパッチになります。型が実行時まで不定なら辞書を fat pointer として運ぶ動的ディスパッチになり、異種の実装を混在させられます。型と実装が分離されているため継承なしで後付けでき、戻り値型でも実装を選べる——いずれも「制約=隠れた辞書引数」という一つの原理から導かれる帰結です。
プログラミング Article
トレイト/型クラスと辞書渡しの仕組みを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
型クラス
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 6
導入後に効く点
静的ディスパッチは型ごとに辞書を確定させて単相化・インライン化でき、動的ディスパッチはトレイトオブジェクトとして辞書ポインタを実行時に持ち回る。前者は速く後者はコードが小さく多態的。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 6
判断チェックリスト
- 自社の用途が「型クラス / トレイト」に近いか確認する。
- 強みである「型クラス/トレイトはアドホック多相の宣言で、コンパイラはメソッド実装を集めた辞書(vtable相当のレコード)を生成し、制約付き関数へ暗黙の引数として渡す辞書渡しに脱糖する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。