Rust

ゼロコスト抽象化とは?Rustで抽象度と実行速度を両立する仕組み

ゼロコスト抽象化とは、ジェネリクスやトレイトのような高レベルな抽象化を使っても、実行時のオーバーヘッドが増えない性質を指します。Rustが「Cと同等の速度」と「型による安全性・表現力」を同時に成立させている核心がこれです。ただし「ゼロコスト」という言葉は誤解も招きやすく、何がゼロで何がゼロでないかを分けて理解する必要があります。この記事では、概念の起源であるC++のゼロオーバーヘッド原則から、静的ディスパッチと動的ディスパッチの違い、Rustが実際に用いる仕組み、そして見落とされがちなトレードオフまでを整理します。

まとめ:ゼロコスト抽象化の要点

結論から言うと、ゼロコスト抽象化とは「抽象化を使った実行時コストが、同じ処理を手書きした場合と変わらない」ことを保証する設計思想です。要点は次の4つに集約できます。

  • 起源はC++のゼロオーバーヘッド原則「使わない機能には支払わず、使う機能も手書きより遅くならない」。
  • Rustは主にジェネリクスの単相化(静的ディスパッチ)でこれを実現し、実行時の分岐や関数ポインタを消す。
  • トレイトオブジェクト(dyn)を使う動的ディスパッチは実行時コストが残るが、バイナリを小さく柔軟にできる。使い分けが判断ポイント。
  • 「ゼロ」なのは実行時オーバーヘッドだけ。コンパイル時間とバイナリサイズはむしろ増えることがあり、そこは無料ではない。

以下でそれぞれの仕組みと、実際のRustコードでどう現れるかを見ていきます。

ゼロコスト抽象化の定義と起源

ゼロコスト抽象化は、Rust独自の発明ではなくC++から受け継いだ設計哲学です。まず「コスト」が何を指すのかを固定すると、以降の議論がぶれません。

「コスト」とは実行時オーバーヘッドのこと

ここでいうコストは、抽象化を導入したことで発生する実行時の追加処理を指します。関数呼び出しの間接ジャンプ、実行時の型判定、ボックス化によるヒープ確保などです。ゼロコスト抽象化が成り立つとは、高レベルな書き方をしても、生成される機械語が低レベルに手書きした場合と同等になり、これらの追加処理が発生しないことを意味します。逆に言えば、コンパイル時間やソースの記述量はここでのコストに含みません。

起源はC++のゼロオーバーヘッド原則

この考え方の出所は、C++の設計者Bjarne Stroustrupが掲げたゼロオーバーヘッド原則です。原則は2つの主張から成ります。ひとつは「使わない機能に対しては何も支払わない」、もうひとつは「使う機能について、手で書いたコードよりうまくは書けない(=手書き同等の効率になる)」です。C++でこの原則を守れなかった代表例が実行時型情報(RTTI)と例外機構で、多くのコンパイラがこれらを無効化するスイッチを持つのはそのためです。Rustはこの原則を言語設計の初期から中心に据え、抽象化のほとんどをコンパイル時に解決する方向で徹底しました。

静的ディスパッチと動的ディスパッチ

抽象化のコストが実行時に出るかどうかは、メソッド呼び出しをどう解決するかで決まります。Rustには解決タイミングの異なる2方式があり、これがゼロコストか否かの分かれ目です。

静的ディスパッチ:ジェネリクスと単相化

ジェネリクス(型パラメータ)を使うと、Rustコンパイラは呼び出しに使われた具体的な型ごとに専用の関数を生成します。これを単相化(monomorphization)と呼びます。実行時には型がすでに確定した専用コードが走るため、間接ジャンプも型判定もなく、その型専用に手書きした関数と同じ機械語になります。これが静的ディスパッチで、Rustのゼロコスト抽象化の主役です。

// 静的ディスパッチ:型ごとに専用コードを生成(単相化)
fn print_area<T: Shape>(shape: &T) {
    println!("{}", shape.area());
}

コンパイル後は、呼び出した型の数だけ print_area の実体が展開され、それぞれが直接呼び出しになります。

動的ディスパッチ:トレイトオブジェクトとvテーブル

一方、&dyn Shape のようなトレイトオブジェクトを使うと、どの実装を呼ぶかを実行時に決めます。値は、データへのポインタとvテーブルへのポインタの2語からなるファットポインタで表現されます。vテーブルは各メソッドの関数ポインタに加え、drop・サイズ・アラインメントの情報を持つ表で、呼び出しごとにこの表を一段たどってから実際の関数へ飛びます。この間接参照が実行時コストで、インライン展開も効きにくくなります。

// 動的ディスパッチ:実行時にvテーブル経由で解決
fn print_area_dyn(shape: &dyn Shape) {
    println!("{}", shape.area());
}

つまり動的ディスパッチは厳密には「ゼロコスト」ではありません。ただしコストは関数ポインタ一段分と小さく、後述のとおりコード膨張を抑えられる利点と引き換えです。

どちらを選ぶかの判断基準

性能が最優先のホットパスや、型が静的に確定する場面ではジェネリクス(静的ディスパッチ)を選びます。異なる型を同じコレクションに混在させたい、あるいはプラグイン的に実装を差し替えたい場面では dyn(動的ディスパッチ)が適します。判断軸を表に整理します。

観点 静的ディスパッチ 動的ディスパッチ
解決タイミング コンパイル時 実行時
実行時コスト なし(手書き同等) vテーブル一段分
インライン化 効く 効きにくい
バイナリサイズ 型ごとに増える 抑えられる
異なる型の混在 不可

迷ったら静的ディスパッチを既定にし、混在や差し替えが必要になった箇所だけ dyn に切り替えるのが実務的です。

Rustがゼロコスト抽象化を体現する場面

ディスパッチの仕組みは、日常的に書くRustコードの随所に埋め込まれています。代表例を3つ挙げます。

トレイトとジェネリクスによる抽象化

トレイトは「型が満たすべき振る舞い」を宣言し、ジェネリクスと組み合わせることで、共通のインターフェースを保ちながら型ごとに最適化されたコードを生成します。C++のテンプレートに近い発想ですが、Rustはトレイト境界(T: Shape のような制約)を型検査で強制するため、実装漏れや型不一致をコンパイル時に弾けます。抽象度を上げても実行時コストが増えないうえ、安全性まで担保できる点が特徴です。

イテレータが手書きループと同じ機械語になる

Rustのイテレータは、mapfilter を連ねた宣言的な記述が、最適化後には手書きの for ループとほぼ同じ機械語にコンパイルされます。公式ドキュメント「The Rust Programming Language」でも、イテレータと手書きループの性能差はほぼないと示されています。中間コレクションを作らず、単相化とインライン化によってチェーン全体が一枚のループに畳み込まれるためです。

// チェーン記述でも最適化後は手書きループ相当になる
let total: u32 = values.iter().filter(|&&x| x > 0).sum();

読みやすい高レベルな書き方を、速度を犠牲にせず選べるのがゼロコスト抽象化の実利です。

Option型・Result型のコスト

nullや例外の代わりに使う OptionResult も、多くの場合コストを増やしません。特に Option<&T> はニッチ最適化により、参照 &T と同じサイズに収まります。参照が取り得ない値(null)を None の表現に流用するため、余分なフラグ用のメモリが不要になるからです。

use std::mem::size_of;

// Option<&T> は &T と同じサイズ(None を null で表現)
assert_eq!(size_of::<Option<&i32>>(), size_of::<&i32>());

エラー処理も、例外のスタック巻き戻しのような実行時機構ではなく通常の値の分岐で表現するため、安全性と速度を両立できます。

「ゼロコスト」の誤解とトレードオフ

ここが最も誤解される部分です。ゼロコスト抽象化は「あらゆるコストがゼロ」を意味しません。ゼロなのは実行時オーバーヘッドに限られ、別のコストは確かに支払っています。ここを取り違えると、ビルドが遅い・バイナリが太るといった現象を「Rustの想定外の劣化」と誤認してしまいます。

コンパイル時間とバイナリサイズは無料ではない

静的ディスパッチの正体である単相化は、使った型の数だけコードを複製します。その結果、コンパイル時間が伸び、バイナリサイズも膨らみます(コード膨張)。多数の型に対して大きなジェネリック関数を展開すると、この影響は無視できません。対策として、性能が要らない箇所を dyn の動的ディスパッチに寄せてコード量を削る、ジェネリック関数の本体を薄くして内部で非ジェネリックなヘルパーを呼ぶ、といった設計が使われます。つまり「実行時のゼロコスト」と「コンパイル時間・サイズ」はトレードオフの関係にあります。

他言語との比較で位置づける

概念の起源はC++で、Rustはそれを所有権システムと型検査に統合し直しました。一方、ガベージコレクション(GC)を持つGoやJavaは、実行時にGCやインターフェースの動的ディスパッチが介在するため、同じ意味でのゼロコスト抽象化は成立しません。抽象化のたびに実行時コストを払うか、払わない代わりに人間が低レベルに書くか――その二択に「抽象化しても実行時コストを払わない」という第三の道を用意したのが、C++とRustに共通する設計上の強みです。

よくある質問(FAQ)

ゼロコスト抽象化とは結局何ですか?

高レベルな抽象化(ジェネリクスやトレイトなど)を使っても、実行時のオーバーヘッドが増えず、同じ処理を手書きした場合と同等の機械語になる性質のことです。コストとして問題にしているのは実行時の追加処理だけで、コンパイル時間やソースの記述量は含みません。抽象度と実行速度を同時に得られる点が、RustやC++の設計思想の核になっています。

静的ディスパッチと動的ディスパッチはどちらが速いですか?

実行速度は静的ディスパッチが有利です。コンパイル時に型ごとの専用コードへ展開(単相化)され、間接ジャンプもインライン阻害もないためです。動的ディスパッチ(dyn)はvテーブルを一段たどるぶんの実行時コストが残ります。ただしバイナリサイズを抑えられ、異なる型を混在させられる柔軟性があるため、性能が要らない箇所では有力な選択肢です。

ゼロコストなのにコンパイルが遅いのはなぜですか?

「ゼロ」なのは実行時コストだけだからです。静的ディスパッチは単相化で型ごとにコードを複製するため、コンパイル時間とバイナリサイズはむしろ増えます。これはゼロコスト抽象化の想定内のトレードオフで、ビルドが重いと感じる場合は一部を動的ディスパッチに寄せる、ジェネリック関数の本体を薄くするといった対策でコード膨張を抑えられます。

ゼロコスト抽象化はRust独自の機能ですか?

いいえ、起源はC++のゼロオーバーヘッド原則です。「使わない機能には支払わない、使う機能も手書きより遅くならない」という原則をRustが所有権システムと型検査に統合し直したものです。GCを持つGoやJavaは実行時にGCや動的ディスパッチが介在するため、同じ意味でのゼロコスト抽象化は成立しにくい言語です。

Option型やResult型を使うと遅くなりませんか?

多くの場合、遅くなりません。特に Option<&T> はニッチ最適化により参照 &T と同じサイズに収まり、余分なメモリを使いません。エラー処理も例外のスタック巻き戻しではなく通常の値の分岐で行うため、実行時機構のコストを避けられます。nullや例外より安全でありながら、実行速度を落とさずに使えます。

関連記事

資料請求

RELATED POSTS 関連記事