Rust

ゼロコスト抽象化とは?Rustの単相化とdynの差をアセンブリと実測で解説

ゼロコスト抽象化とは?Rustの単相化とdynの差をアセンブリと実測で解説

ゼロコスト抽象化とは、イテレータやジェネリクスのような高水準の書き方を使っても、同じ処理を低水準に手書きした場合より実行時のコストが増えない性質のことです。Rustの公式書籍は、この考え方をC++の設計者Bjarne Stroustrupの「ゼロオーバーヘッド原則」になぞらえて説明しています。

ただし「ゼロ」の対象は実行時の追加処理だけです。最適化を切ったビルドでは成り立たないことがあり、ジェネリクスを使う型が増えるほどコンパイル時間とバイナリサイズは増えます。この記事では、定義と起源を一次資料で確かめたうえで、Rust 1.99.1(2026年10月15日リリースの安定版)で生成したアセンブリとベンチマークの数字を使い、どこまでがゼロでどこからが有料かを切り分けます。

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

  • 定義は「抽象化を使っても、手書きした場合より実行時の追加コストが生じない」こと。起源はC++のゼロオーバーヘッド原則「使わないものには払わない。使うものは手書きしてもそれ以上にはできない」。
  • Rustでは主にジェネリクスの単相化(型ごとに専用の関数を生成する静的ディスパッチ)で実現される。最適化後、イテレータ版と添字ループ版は同じ46命令になり、違いは命令の並び順だけだった。
  • dyn Trait による動的ディスパッチはゼロコストではない。vtableから関数ポインタを読んで間接ジャンプし、インライン化も止まる。実測では、同じBox経由のジェネリック版に対して約2.3倍の時間がかかった。
  • 最適化なし(debugビルド)では同じ集計が約40倍遅い。今回の比較では、抽象化の層を消すのに最適化が必要だった。
  • 代償はコンパイル時に払う。50型に単相化すると関数の実体が50個になり、コンパイル時間はdyn版の約3倍だった。膨張は「内側に非ジェネリック関数を置く」書き方で抑えられる。

ゼロコスト抽象化の意味とゼロオーバーヘッド原則

ゼロコスト抽象化における実行時オーバーヘッド

Rustの公式書籍『The Rust Programming Language』は、ループとイテレータの性能比較の節で、イテレータを「Rustのゼロコスト抽象化の一つ」と呼び、その意味を「抽象化を使っても追加の実行時オーバーヘッドが課されない(using the abstraction imposes no additional runtime overhead)」と定めています。

ここでのコストは、間接呼び出し、実行時の型判定、余分なヒープ確保、配列の境界チェックのような実行時に増える処理です。コンパイル時間、バイナリサイズ、ソースの読み書きの手間はこの定義に含まれません。後半で測るとおり、それらは実際に増えます。「ゼロコストなのに遅い・重い」という疑問の多くは、何をコストと呼んでいるかの食い違いから生まれます。

Stroustrupのゼロオーバーヘッド原則とC++の例外

同じ節は、Stroustrupが2012年のETAPS基調講演「Foundations of C++」で示した定義を逐語で引いています。

In general, C++ implementations obey the zero-overhead principle: What you don’t use, you don’t pay for. And further: What you do use, you couldn’t hand code any better.

前半は「使わない機能のために実行時の代価を払わない」、後半は「使う機能は、手書きしてもそれより効率よくは書けない」という意味です。後半の比較対象は「何もしないコード」ではなく「同じことを手で書いたコード」である点が要になります。境界チェック付きのベクタアクセスがチェックなしより遅いのは、原則違反ではありません。安全性という機能を使った分の代価です。

C++自身もこの原則を全機能で守れているわけではありません。Herb Sutterは2019年の標準化提案P0709R4で、当時の例外処理を「zero-overheadではない」と書き、RTTI(実行時型情報)を例外と並ぶ原則違反の機能に挙げています。多くのC++コンパイラに -fno-exceptions や -fno-rtti のような無効化オプションがあるのはこのためです。

Rustのasync設計に関わったwithoutboatsは、2019年の記事「Zero Cost Abstractions」で、Stroustrupの定義から2つの条件を取り出しています。使わないプログラムの性能を落とさない「No global costs」(重いランタイムを全プログラムに背負わせない)と、低水準の部品で書ける最良の実装にコンパイルされる「Optimal performance」です。そのうえで、利用者がプログラムを書きやすくなる「Improve users’ experience」を3つ目の条件に加えています。速くても使いにくい抽象化は、ゼロコストであっても良い抽象化ではない、という指摘です。

静的ディスパッチと動的ディスパッチの違い

抽象化のコストが実行時に残るかどうかは、メソッド呼び出しの行き先をいつ決めるかで分かれます。コンパイル時に決めるのが静的ディスパッチ、実行時に決めるのが動的ディスパッチです。

単相化によるジェネリクスの型別展開

公式書籍のジェネリクスの性能の節は、単相化(monomorphization)を「ジェネリックなコードを、コンパイル時に使われる具体的な型を埋めた特定のコードに変換する処理」と説明し、そのおかげで「ジェネリクスを使っても実行時のコストを払わない(we pay no runtime cost for using generics)」と述べています。

pub trait Shape { fn area(&self) -> f64; }
pub struct Sq(pub f64);
impl Shape for Sq { fn area(&self) -> f64 { self.0 * self.0 } }

// 静的ディスパッチ:呼び出しに使われた型ごとに実体が作られる
fn area_generic<T: Shape>(s: &T) -> f64 { s.area() }

area_generic を Sq で呼ぶと、コンパイラは Sq 専用の area_generic を生成し、その中で Sq::area を直接呼びます。行き先が確定しているので、インライン化して呼び出しそのものを消すこともできます。ジェネリクスの実装方式は言語ごとに異なり、Javaの型消去やGoの方式との違いはジェネリクスとは?型パラメータの仕組みと7言語の書き方・実装方式の違いで比べています。

トレイトオブジェクトのvtable経由の間接呼び出し

&dyn Shape や Box<dyn Shape> のようなトレイトオブジェクトは、データへのポインタとvtable(仮想関数テーブル)へのポインタの2語で表されます。Rust Referenceのトレイトオブジェクトの項は、メソッド呼び出しを「vtableから関数ポインタを読み込み、間接的に呼び出す」と定めています。64ビット環境で size_of::<&dyn Shape>() を測ると16バイトで、参照(8バイト)の2倍です。

// 動的ディスパッチ:実行時にvtableから行き先を読む
fn area_dyn(s: &dyn Shape) -> f64 { s.area() }

公式書籍のトレイトオブジェクトの章は、この参照に「静的ディスパッチでは起きない実行時コストがかかる」こと、さらに「メソッドのコードをインライン化できなくなり、一部の最適化が妨げられる」ことを明記しています。動的ディスパッチはゼロコスト抽象化の外側にある仕組みで、異なる型を1つのコレクションに混ぜられる柔軟性と引き換えに、実行時のコストを払います。継承やインターフェースによる多態性全般との関係はポリモーフィズム(ポリモフィズム・多態性)とは?で扱っています。

使い分けの判断基準

観点 ジェネリクス(静的) dyn Trait(動的)
行き先の決定 コンパイル時 実行時(vtable)
インライン化 できる 原則できない
ポインタの大きさ 8バイト(&T) 16バイト(&dyn)
関数の実体の数 使った型の数だけ 1つ
異なる型の混在 不可 可

既定はジェネリクスにし、次の3つの場面でだけ dyn を選ぶのが実務での線引きです。異なる型を同じ Vec に入れる必要があるとき、プラグインのように実装を実行時に差し替えるとき、そして型の数が多くコンパイル時間やバイナリサイズが問題になったときです。1回の呼び出しで重い処理(I/O、ネットワーク)をするメソッドでは、間接呼び出しの相対的な影響は小さくなる傾向があります。採用時は、実際の負荷で許容できる差かを測定します。

Rust 1.99.1のアセンブリによる抽象化コストの比較

「手書きと同じ機械語になる」という主張は、コンパイラの出力を見れば確かめられます。以下は rustc 1.99.1 で --edition=2024 --crate-type=lib --emit asm を指定し、x86_64のmacOSで生成した結果です。

イテレータ版と添字ループ版の46命令の比較

#[unsafe(no_mangle)]
pub fn sum_iter(v: &[i32]) -> i32 {
    v.iter().filter(|&&x| x > 0).sum()
}

#[unsafe(no_mangle)]
pub fn sum_loop(v: &[i32]) -> i32 {
    let mut s = 0;
    for i in 0..v.len() {
        if v[i] > 0 { s += v[i]; }
    }
    s
}

-C opt-level=3 では、2つの関数はどちらも46命令になりました。差分は、ゼロクリアする2命令の順序と、カウンタを増やす incq の位置の2か所だけで、命令の種類と数は同じです。どちらも、4要素ずつ読み込み(movdqu)、負の値を0に置き換え(pmaxsd)、まとめて足す(paddd)SIMD命令にベクトル化されていました。filter の条件分岐が、分岐のない命令列に置き換わっています。添字アクセス v[i] には本来境界チェックが付きますが、ループ条件 i < v.len() から範囲内と証明できるため、最適化後は境界チェックの呼び出しが消えています。公式書籍が「境界チェックの除去のような最適化が働く」と書いているのはこの挙動です。

同じソースを -C opt-level=0 でコンパイルすると様子が変わります。sum_iter は iter・filter・sum をそれぞれ関数として呼び出し、sum_loop には境界チェック失敗時の panic_bounds_check の呼び出しが2つ残ります。抽象化の層を消しているのは最適化であり、言語機能そのものではありません。

ジェネリクス版の乗算1命令とdyn版の間接ジャンプ

前の章の area_generic と area_dyn に #[inline(never)] を付け、関数の中身が残るようにして出力を比べました。

# area_generic::<Sq>(静的ディスパッチ)
pushq   %rbp
movq    %rsp, %rbp
mulsd   %xmm0, %xmm0      # self.0 * self.0 がそのまま入っている
popq    %rbp
retq

# call_dyn(動的ディスパッチの呼び出し側)
pushq   %rbp
movq    %rsp, %rbp
movq    24(%rsi), %rsi    # vtableの24バイト目から関数ポインタを読む
popq    %rbp
jmp     area_dyn

# area_dyn
pushq   %rbp
movq    %rsp, %rbp
popq    %rbp
jmpq    *%rsi             # 読んだアドレスへ間接ジャンプ

ジェネリック版では Sq::area の中身(掛け算1命令)が展開され、呼び出しが残っていません。dyn版はvtableから関数ポインタを読み、そのアドレスへ間接ジャンプしています。オフセットが24バイトなのは、rustcのvtableの先頭3語にdrop関数・サイズ・アラインメントが入るためです(rustcのソースの COMMON_VTABLE_ENTRIES)。std::ptr::DynMetadataのドキュメントもvtableがこの3つとメソッドへのポインタを持つと説明していますが、並び順はコンパイラの実装で、Rust Referenceが仕様として保証しているものではありません。

実行時間とメモリ表現の実測

アセンブリの違いが時間にどう出るかを、同じマシン(Intel Core i9-9880H・macOS)で測りました。時間計測は各条件を5回実行した最短値で、10,000,000要素を処理しています。集計ベンチマークではオーバーフローを避けるため合計をi64とし、前章のi32版とは別のコードを使っています。

イテレータ集計の最適化レベル別実行時間

処理(1,000万要素) opt-level=0 opt-level=3
イテレータ(filter+sum) 245.28ms 5.34〜6.58ms
添字ループ 201.66ms 5.77〜10.68ms

最適化ありでは両者の差は計測のばらつきの範囲で、3回の計測で速い側が入れ替わりました。最適化なしでは同じ集計が約40倍遅く、イテレータ版のほうが2割ほど遅くなります。cargo build の既定(devプロファイル)は opt-level = 0 なので、性能を測ったり比べたりするときは cargo build --release か cargo run --release を使います。debugビルドでイテレータが遅いのを見て「ゼロコストは嘘だ」と判断するのは、前提条件の取り違えです。

面積集計のディスパッチ方式・メモリ配置別実行時間

データ(1,000万要素・面積の合計) 時間(opt-level=3・3回)
Vec<Sq>+ジェネリクス 21.30〜27.68ms
Vec<Box<Sq>>+ジェネリクス 44.16〜48.36ms
Vec<Box<dyn Shape>> 100.14〜114.22ms

dyn版はジェネリック版より4〜5倍遅く見えますが、この差は2つに分けられます。Box で要素を個別にヒープへ置いた構成では、この測定で約2倍の時間がかかりました(1行目→2行目)。この差にはポインタ参照だけでなく、データ配置やキャッシュ効率の違いも含まれます。同じ Box 経由のまま静的ディスパッチを動的ディスパッチに替えた分が、さらに約2.3倍です(2行目→3行目)。Sq と別の型(Circle)を交互に入れた場合と Sq だけの場合で時間はほぼ同じでした。ただし、分岐予測ミスやキャッシュミスは測定していないため、時間差の原因別の寄与までは特定できません。

この比は「1要素あたりの処理が掛け算1回」という極端に軽い関数での値です。メソッドの中身が重くなるほど、間接呼び出しの比率は小さくなります。ヒープとスタックのどちらに値が置かれるかはRustのスタックとヒープで詳しく扱っています。

Option・Result・ゼロサイズ型の大きさ

型による抽象化がメモリを増やさないかは、std::mem::size_of で確かめられます(x86_64・rustc 1.99.1)。

型 サイズ 元の型
Option<&u64> 8 &u64=8
Option<Box<u64>> 8 Box<u64>=8
Option<NonZeroU32> 4 NonZeroU32=4
Option<u32> 8 u32=4
Result<&u64, ()> 8 &u64=8
struct Meters(f64) 8 f64=8
PhantomData<S> 0 -

std::optionのドキュメントは「Representation」の節で、Box<U>・&U・&mut U・関数ポインタ・NonZero*・NonNull<U> などについて Option<T> が T と同じサイズ・アラインメント・呼び出し規約になることを保証しています。null(または0)を取り得ない型なので、その値を None の表現に使えるからです。

保証の外にある Option<u32> は、0も正当な値なので判別用の領域が要り、4バイトから8バイトに増えました。「Optionは常にゼロコスト」とは言えません。0にならない値を大量に持つなら NonZeroU32 に替えると半分に収まります。一方、newtype(struct Meters(f64))や状態を型で表す PhantomData は追加のメモリを使いません。状態ごとに型を分ける「型状態パターン」が組込み開発で好まれるのは、この性質で状態遷移の誤りをコンパイル時に弾けるためです。

ゼロコストでないもの:コンパイル時間とコード膨張

50型への単相化による関数数とコード領域の増加

中身が十数行あるジェネリック関数を用意し、1つの型だけで使う場合と50種類の型で使う場合、同じ処理を &[&dyn Shape] で書いた場合を比べました(-C opt-level=3)。

条件 関数の実体 __textセクション コンパイル時間
ジェネリクス・1型 1 266,832バイト 0.84秒
dyn・1型 1 266,944バイト 0.70秒
ジェネリクス・50型 50 299,168バイト 2.95〜3.22秒
dyn・50型 1 272,320バイト 1.01〜1.05秒

ジェネリクス版は使った型の数だけ関数が複製され、コード領域は1型のときより約32KB増えました。dyn版のコード領域の増加は約5KBでした。表の__textは機械語コードの領域であり、vtableなどのデータ領域の大きさは含めていません。コンパイル時間はジェネリクス版がdyn版の約3倍でした。実行時のゼロコストは、コンパイル時の仕事を増やすことで得ています。大規模なRustプロジェクトでビルド時間が問題になる一因がここにあります。

非ジェネリックな内側関数によるコード膨張の抑制

Rust Performance Bookのコンパイル時間の章は、ジェネリック関数の非ジェネリックな部分を、1回だけ具体化される別の関数に移す方法を勧めています。標準ライブラリ自身もこの形を使っており、std::fs::read_to_string は引数をジェネリック(P: AsRef<Path>)で受けつつ、本体を inner という非ジェネリックな内側関数に任せています。同じ形で行数を数える関数を書くと次のようになります。

use std::fs;
use std::io;
use std::path::Path;

pub fn count_lines<P: AsRef<Path>>(path: P) -> io::Result<usize> {
    // 型ごとに複製されるのは as_ref() の変換だけ
    fn inner(path: &Path) -> io::Result<usize> {
        let text = fs::read_to_string(path)?;
        Ok(text.lines().count())
    }
    inner(path.as_ref())
}

呼び出し側は &str でも String でも PathBuf でも渡せます。3種類の型で呼んだプログラムを -O でビルドすると、inner の実体は1つだけでした。型ごとに複製されるのは as_ref() で変換する部分だけで、重い本体は1つで済みます。引数の型変換だけをジェネリクスで受け、処理の本体は具体型で書く。これが使いやすさとビルドの軽さを両立させる定石です。

ゼロコスト抽象化の誤解と他言語での位置づけ

抽象化の追加コストと処理自体の計算コストの違い

ゼロコスト抽象化が約束するのは「手書きと同じ」までで、「速い」ではありません。手書きのアルゴリズムが遅ければ、抽象化した版も同じだけ遅くなります。collect() で途中に Vec を作る、clone() を重ねる、といった処理は抽象化のコストではなく、書いた処理そのもののコストです。逆に、イテレータでも Box<dyn Iterator> に包むと動的ディスパッチになり、最適化の前提が崩れます。

もう一つの誤解は、境界チェックや整数オーバーフロー検査が「ゼロコストでない」ことを理由にRustを批判するものです。境界チェックは get_unchecked で省略できますが、呼び出し側が添字の範囲内を保証する必要があり、範囲外アクセスは未定義動作です。整数オーバーフロー検査は別の仕組みで、ビルド設定や演算APIによって扱いが変わります。最適化後にチェックが消える場面は前章のアセンブリで見たとおりで、消えない場面だけが実際に払うコストです。

C++・Go・Javaとの違い

C++のテンプレートもRustのジェネリクスと同じく型ごとに展開する方式で、ゼロオーバーヘッド原則の本家です。違いは型の制約を確かめる時点にあります。Rustでは型パラメータの値にトレイト固有の操作を行う場合、その操作を利用できるトレイト境界(T: Shape など)が必要で、関数の本体は境界に書いたメソッドしか使えないことを定義の段階で検査します。条件を満たさない型を渡すと、呼び出し側で「このトレイトを実装していない」というエラーになります。C++はテンプレートを展開してから検査するのが基本で、C++20のコンセプトで制約を書いた場合にRustに近い報告になります。

GoやJavaの一般的な処理系は、GCを備えたランタイムでメモリを管理する点でRustと異なります。ただし、GCの回収処理が常時実行されているわけではありません。GCを含むメモリ管理方式の比較と、イテレータやジェネリクスによる追加コストの比較は分けて考える必要があります。GCの存在だけで、個々の抽象化がゼロコストかどうかは決まりません。GCの仕組みと言語ごとの方式はガベージコレクション(GC)とは?で比べています。Rustの所有権と借用は、メモリ解放をコンパイル時に決めることで、この実行時の仕組みを持たずに済ませています。

よくある質問

ゼロコスト抽象化とは簡単に言うと何ですか?

高水準な書き方をしても、同じ処理を手で低水準に書いた場合より実行時に遅くならない、という性質です。Rustではイテレータ、ジェネリクス、Option などが代表例で、最適化後は、多くの場合で手書きのループや直接の関数呼び出しとほぼ同じ機械語になります(常に同一になる保証ではありません)。コンパイル時間やバイナリサイズは対象外です。

ゼロオーバーヘッド原則とゼロコスト抽象化は同じ意味ですか?

ほぼ同じ考え方です。ゼロオーバーヘッド原則はC++の設計原則で、「使わない機能には払わない」と「使う機能は手書きより悪くならない」の2つから成ります。Rustの公式書籍は、イテレータがゼロコスト抽象化である理由を、Stroustrupのこの定義を引いて説明しています。

dynを使うとどれくらい遅くなりますか?

処理の重さによります。掛け算1回の関数を1,000万回呼ぶ極端な条件では、同じ Box 経由のジェネリック版に対して約2.3倍の時間がかかりました。動的ディスパッチでは間接呼び出しが残り、インライン化などの最適化が妨げられます。ただし、この計測だけでは時間差の主因やベクトル化の有無までは特定していません。メソッドの中でI/Oのような重い処理をする場合は、間接呼び出しの相対的な影響は小さくなります。

ゼロコストなのにRustのビルドが遅いのはなぜですか?

単相化で、ジェネリック関数を使った型の数だけ複製してコンパイルするためです。50種類の型で使った実測では、関数の実体が50個になり、コンパイル時間はdyn版の約3倍でした。重い本体を非ジェネリックな内側関数に分ける、性能が要らない箇所を dyn にする、といった方法で抑えられます。

zero cost abstractionはRust独自の機能ですか?

独自ではありません。起源はC++のゼロオーバーヘッド原則で、C++のテンプレートやインライン関数も同じ考え方に立っています。Rustはこれをトレイト境界による型検査や所有権と組み合わせ、安全性の検査をコンパイル時に済ませる形で広く適用しています。Rust全体の入門はRust入門にまとめています。

関連記事

お気に入りに入れた記事の一覧

一創の公式 SNS

X では毎朝の新着記事と IT ニュースの速報を、YouTube・TikTok・Instagram・LinkedIn・Facebook では記事の解説動画を配信しています。

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.10.29 テックブログ ソニー生命の不正アクセス:38.5万件の対象確認と窓口、停止中の手続き
  2. 2026.10.31 テックブログ 松屋の不正アクセス:237万件の対象確認と窓口、漏れたパスワードの守り方
  3. 2026.10.31 テックブログ 無印良品の情報漏洩:MUJIアプリ最大2,726万件の対象確認となりすましメール対策
  4. 2026.10.31 テックブログ osinaの情報漏洩:約23万人の対象確認とSNS連携の解除、分析ツールへ渡すデータの絞り方
  5. 2026.05.18 テックブログ マネーフォワードで連携できない・失敗するときの対処法|エラー別の原因と再連携のやり方

RELATED POSTS 関連記事

目次