【Rust】静的ディスパッチと動的ディスパッチの違いについて調査してみた
はじめに
こんにちは、ファスト株式会社のyutakです!
今回は、Rustにおける静的ディスパッチと動的ディスパッチについて解説していきます。
この2つの概念は、Rustでトレイトを扱う際に重要な概念だと思っています。
パフォーマンスを重視するのか、柔軟性を重視するのか。
そんな設計上の選択をする際に、この記事が参考になれば幸いです。
本記事について
本記事では以下の内容を中心に説明していきます:
- 静的ディスパッチと動的ディスパッチの基本概念とメリット・デメリット
- それぞれの実装方法と特徴
- パフォーマンスの違い
TL;DR
速度差
重い処理で約3.4%の差があり、静的ディスパッチの方が速い。
静的ディスパッチ
- メリット: 高速、ゼロコスト、最適化しやすい
- デメリット: バイナリサイズ増、異なる型を同一コレクションに格納不可、開発スピードが下がる可能性がある
- 適用例: 高頻度処理、型が事前確定の場合
開発スピードが下がる可能性
具体的には、柔軟性に欠けることで設計変更の手間が発生し、開発スピードが低下すると考えています。
動的ディスパッチ
- メリット: 柔軟、プラグイン対応、異なる型を同一コレクション格納可能
- デメリット: vtableのオーバーヘッド、インライン化不可
- 適用例: GUIアプリ、プラグインシステム
選択指針
- 極限の性能を求めるのであれば、静的ディスパッチ
- 実行時に型を決定したいプロジェクトなのであれば、動的ディスパッチ
- 迷ったら、とりあえず静的ディスパッチを採用するのが良い
ディスパッチとは?
IT用語辞典からだと、
ディスパッチ(dispatch)とは、発送(する)、派遣(する)などの意味を持つ英単語で、ITの分野では同種の複数の対象から一つを選び出したり、データの送信、資源の割り当て、機能の呼び出しなどを表すことが多い。
とのこと。
ECサイトやモバイルオーダーシステムなどの支払い方法を例とした時、以下のようなトレイトが用意できると思います:
trait PaymentMethod {
fn process_payment(&self, amount: f64) -> Result<String, String>;
fn get_fee(&self) -> f64;
}
struct CreditCard; // クレジットカード
struct QRCode; // QRコード (PayPayなど)
struct Cash; // 現金
struct EMoney; // 電子マネー
impl PaymentMethod for CreditCard {
fn process_payment(&self, amount: f64) -> Result<String, String> {
println!("クレジットカードで{}円を決済中...", amount);
Ok(format!("CREDIT-{}", uuid::Uuid::new_v4()))
}
fn get_fee(&self) -> f64 {
0.035 // 3.5%の手数料
}
}
impl PaymentMethod for QRCode {
fn process_payment(&self, amount: f64) -> Result<String, String> {
println!("QRコード決済で{}円を決済中...", amount);
Ok(format!("QR-{}", uuid::Uuid::new_v4()))
}
fn get_fee(&self) -> f64 {
0.025 // 2.5%の手数料
}
}
impl PaymentMethod for Cash {
fn process_payment(&self, amount: f64) -> Result<String, String> {
println!("現金で{}円を受領中...", amount);
Ok(format!("CASH-{}", uuid::Uuid::new_v4()))
}
fn get_fee(&self) -> f64 {
0.0 // 現金は手数料なし
}
}
impl PaymentMethod for EMoney {
fn process_payment(&self, amount: f64) -> Result<String, String> {
println!("電子マネーで{}円を決済中...", amount);
Ok(format!("EMONEY-{}", uuid::Uuid::new_v4()))
}
fn get_fee(&self) -> f64 {
0.020 // 2.0%の手数料
}
}
// ジェネリック関数
fn process_order_payment<T: PaymentMethod>(payment: &T, amount: f64) -> Result<String, String> {
let fee = amount * payment.get_fee();
let total = amount + fee;
println!("注文金額: {}円, 手数料: {}円, 合計: {}円", amount, fee, total);
payment.process_payment(total)
}
fn main() {
let credit_card = CreditCard;
let qr_payment = QRCode;
let cash = Cash;
let e_money = EMoney;
// コンパイル時にCreditCard用の関数が生成される
let _ = process_order_payment(&credit_card, 1000.0);
// コンパイル時にQRCode用の関数が生成される
let _ = process_order_payment(&qr_payment, 1000.0);
// コンパイル時にCash用の関数が生成される
let _ = process_order_payment(&cash, 1000.0);
// コンパイル時にEMoney用の関数が生成される
let _ = process_order_payment(&e_money, 1000.0);
}
process_payment
メソッドを呼び出す際、どの決済方法の実装を使うかを決定する必要があります。
この決定プロセスがディスパッチです。
Rustでは、この決定を コンパイル時に行う(静的ディスパッチ) か、 実行時に行う(動的ディスパッチ) かを選択できます。
静的ディスパッチ(Static Dispatch)
静的ディスパッチは、ジェネリクスを使用してコンパイル時に具体的な型が決定される方式です。
Rustではデフォルトでこの方式が採用されます。
静的ディスパッチのメリット
- 高速な実行速度: 関数呼び出しがインライン化可能
- ゼロコスト抽象化: 実行時のオーバーヘッドがない
- 最適化が効きやすい: コンパイラが型情報を完全に把握
-> これらのメリットにより、動的ディスパッチより実行速度が速いと言われています。
静的ディスパッチのデメリット
以下のデメリットが挙げられるのではないかと思います。
- バイナリサイズの増加: 型ごとに関数が生成される
- コンパイル時間の増加: 単相化による処理が必要
基本的な実装
関数
// 先述のStructは省略...
// ジェネリクスを使った静的ディスパッチ
fn process_order_payment<T: PaymentMethod>(payment: &T, amount: f64) -> Result<String, String> {
let fee = amount * payment.get_fee();
let total = amount + fee;
println!("注文金額: {}円, 手数料: {}円, 合計: {}円", amount, fee, total);
payment.process_payment(total)
}
fn main() {
let credit_card = CreditCard;
let qr_payment = QRCode;
let cash = Cash;
// コンパイル時にCreditCard用の関数が生成される
let _ = process_order_payment(&credit_card, 1000.0);
// コンパイル時にQRCode用の関数が生成される
let _ = process_order_payment(&qr_payment, 1000.0);
// コンパイル時にCash用の関数が生成される
let _ = process_order_payment(&cash, 1000.0);
}
この場合、コンパイラは使用された型ごとに別々の関数を生成します。これを 単相化(monomorphization) と呼びます。
実際にコンパイラが生成するイメージ:
// コンパイラが内部的に生成する関数(イメージ)
// CreditCard専用の関数が生成される
fn process_order_payment_CreditCard(payment: &CreditCard, amount: f64) -> Result<String, String> {
let fee = amount * 0.035; // 直接3.5%が埋め込まれる
let total = amount + fee;
println!("注文金額: {}円, 手数料: {}円, 合計: {}円", amount, fee, total);
// CreditCardのprocess_paymentが直接呼ばれる
println!("クレジットカードで{}円を決済中...", total);
Ok(format!("CREDIT-{}", uuid::Uuid::new_v4()))
}
// QRCode専用の関数が生成される
fn process_order_payment_QRCode(payment: &QRCode, amount: f64) -> Result<String, String> {
let fee = amount * 0.025; // 直接2.5%が埋め込まれる
let total = amount + fee;
println!("注文金額: {}円, 手数料: {}円, 合計: {}円", amount, fee, total);
// QRCodeのprocess_paymentが直接呼ばれる
println!("QRコード決済で{}円を決済中...", total);
Ok(format!("QR-{}", uuid::Uuid::new_v4()))
}
// Cash専用の関数も生成される
fn process_order_payment_Cash(payment: &Cash, amount: f64) -> Result<String, String> {
let fee = amount * 0.0; // 直接0.0が埋め込まれる
let total = amount + fee;
println!("注文金額: {}円, 手数料: {}円, 合計: {}円", amount, fee, total);
// Cashのprocess_paymentが直接呼ばれる
println!("現金で{}円を受領中...", total);
Ok(format!("CASH-{}", uuid::Uuid::new_v4()))
}
実際のコンパイラ出力で単相化を確認
実際にコンパイラが単相化を行っている様子を見てみましょう。
サンプルコード(payment_example.rs)
trait PaymentMethod {
fn process(&self, amount: f64) -> String;
}
struct CreditCard;
struct QRCode;
struct Cash;
impl PaymentMethod for CreditCard {
fn process(&self, amount: f64) -> String {
format!("Credit: {}", amount)
}
}
impl PaymentMethod for QRCode {
fn process(&self, amount: f64) -> String {
format!("QR: {}", amount)
}
}
impl PaymentMethod for Cash {
fn process(&self, amount: f64) -> String {
format!("Cash: {}", amount)
}
}
// ジェネリック関数
fn process_payment<T: PaymentMethod>(method: &T, amount: f64) {
println!("{}", method.process(amount));
}
fn main() {
process_payment(&CreditCard, 1000.0);
process_payment(&QRCode, 2000.0);
process_payment(&Cash, 3000.0);
}
1. シンボルテーブルで単相化を確認
実際にコンパイルしてみて、nmコマンドを使ってシンボルテーブルの内容を見てみましょう。
# コンパイル
rustc payment_example.rs -C opt-level=0 -o payment_example
# シンボルテーブルを確認 (macOS)
nm payment_example | grep process_payment
# 出力例(マングルされた名前)
0000000100003ccc t __ZN15payment_example15process_payment17h5fdfdd95b27025fbE
0000000100003d5c t __ZN15payment_example15process_payment17h74383e71fa1459bfE
0000000100003dec t __ZN15payment_example15process_payment17h7c5b3ebc055daeb5E
rustfiltでデマングル(人間が読める形式に):
マングルされているので、人間が読める形式にしてくれる rustfiltクレートでデマングルしてみましょう。
# rustfiltをインストール
cargo install rustfilt
# デマングルして確認
nm payment_example | rustfilt | grep process_payment
# 出力例(読みやすい形式)
0000000100003ccc t _payment_example::process_payment
0000000100003d5c t _payment_example::process_payment
0000000100003dec t _payment_example::process_payment
3つの異なる関数が生成されていることが確認できます!
動的ディスパッチ(Dynamic Dispatch)
動的ディスパッチは、トレイトオブジェクトを使用して実行時に具体的な型が決定される方式です。
dyn
キーワードを使って明示的に指定します。
基本的な実装
// トレイトオブジェクトを使った動的ディスパッチ
fn process_order_payment_dynamic(payment: &dyn PaymentMethod, amount: f64) -> Result<String, String> {
let fee = amount * payment.get_fee();
let total = amount + fee;
println!("注文金額: {}円, 手数料: {}円, 合計: {}円", amount, fee, total);
payment.process_payment(total)
}
fn main() {
let credit_card = CreditCard;
let qr_payment = QRCode;
// 実行時にCreditCardのメソッドを呼び出し
process_order_payment_dynamic(&credit_card, 1000.0);
// 実行時にQRCodeのメソッドを呼び出し
process_order_payment_dynamic(&qr_payment, 1000.0);
}
異なる型をコレクションに格納
動的ディスパッチの大きな利点は、異なる型を同じコレクションに格納できることです:
// 複数の決済方法を管理するシステム
struct PaymentSystem {
available_payments: Vec<Box<dyn PaymentMethod>>,
}
impl PaymentSystem {
fn new() -> Self {
PaymentSystem {
// 様々な決済方法を同じVecに格納
available_payments: vec![
Box::new(CreditCard),
Box::new(QRCode),
Box::new(Cash),
Box::new(EMoney),
],
}
}
fn show_payment_options(&self) {
println!("利用可能な決済方法:");
for (i, payment) in self.available_payments.iter().enumerate() {
println!(" {}. 手数料: {:.1}%", i + 1, payment.get_fee() * 100.0);
}
}
fn process_with_index(&self, index: usize, amount: f64) -> Result<String, String> {
self.available_payments
.get(index)
.ok_or("無効な決済方法です".to_string())
.and_then(|payment| {
let fee = amount * payment.get_fee();
let total = amount + fee;
println!("注文金額: {}円, 手数料: {}円, 合計: {}円", amount, fee, total);
payment.process_payment(total)
})
}
}
fn main() {
let payment_system = PaymentSystem::new();
// 利用可能な決済方法を表示
payment_system.show_payment_options();
// ユーザーが選択した決済方法で処理
match payment_system.process_with_index(0, 1000.0) {
Ok(transaction_id) => println!("決済成功: {}", transaction_id),
Err(e) => println!("決済エラー: {}", e),
}
}
動的ディスパッチのメリット
- 異なる型を同一コレクションに格納可能
- バイナリサイズが小さい: 関数は1つだけ生成
- 実行時の柔軟性: プラグインシステムなどに最適
動的ディスパッチのデメリット
- 実行時のオーバーヘッド: vtable(仮想関数テーブル)の参照が必要
- インライン化ができない: 最適化の機会が減る
- ヒープアロケーションが必要な場合が多い: Box<dyn Trait>など
速度の違い - ベンチマークで検証
静的ディスパッチと動的ディスパッチの速度差を実際に測定してみましょう。
ベンチマークコード
use std::time::Instant;
trait Calculator {
fn calculate(&self, x: i32) -> i32;
}
struct Adder;
struct Multiplier;
impl Calculator for Adder {
fn calculate(&self, x: i32) -> i32 {
x + 10
}
}
impl Calculator for Multiplier {
fn calculate(&self, x: i32) -> i32 {
x * 2
}
}
// 静的ディスパッチ版
fn benchmark_static<T: Calculator>(calc: &T, iterations: usize) -> u128 {
// ウォームアップ
for i in 0..1000 {
std::hint::black_box(calc.calculate(i));
}
let start = Instant::now();
let mut total: i64 = 0; // i64に変更してオーバーフローを防ぐ
for i in 0..iterations {
// 計算結果を累積して最適化を防ぐ(wrapping_addでオーバーフロー対応)
total = std::hint::black_box(total.wrapping_add(calc.calculate(std::hint::black_box(i as i32)) as i64));
}
let duration = start.elapsed().as_nanos();
// 結果を使って最適化を完全に防ぐ
std::hint::black_box(total);
duration
}
// 動的ディスパッチ版
fn benchmark_dynamic(calc: &dyn Calculator, iterations: usize) -> u128 {
// ウォームアップ
for i in 0..1000 {
std::hint::black_box(calc.calculate(i));
}
let start = Instant::now();
let mut total: i64 = 0; // i64に変更してオーバーフローを防ぐ
for i in 0..iterations {
// 計算結果を累積して最適化を防ぐ(wrapping_addでオーバーフロー対応)
total = std::hint::black_box(total.wrapping_add(calc.calculate(std::hint::black_box(i as i32)) as i64));
}
let duration = start.elapsed().as_nanos();
// 結果を使って最適化を完全に防ぐ
std::hint::black_box(total);
duration
}
fn main() {
const ITERATIONS: usize = 10_000_000;
let adder = Adder;
let multiplier = Multiplier;
println!("ベンチマーク開始({}回の反復)\n", ITERATIONS);
// 複数回実行して平均を取る
let mut static_times = Vec::new();
let mut dynamic_times = Vec::new();
for _ in 0..5 {
static_times.push(benchmark_static(&adder, ITERATIONS));
static_times.push(benchmark_static(&multiplier, ITERATIONS));
dynamic_times.push(benchmark_dynamic(&adder, ITERATIONS));
dynamic_times.push(benchmark_dynamic(&multiplier, ITERATIONS));
// 各回の間に少し待機
std::thread::sleep(std::time::Duration::from_millis(10));
}
// 平均値を計算
let static_avg: u128 = static_times.iter().sum::<u128>() / static_times.len() as u128;
let dynamic_avg: u128 = dynamic_times.iter().sum::<u128>() / dynamic_times.len() as u128;
// 結果の表示
println!("結果:");
println!("静的ディスパッチ: 平均 {} ns", static_avg);
println!("動的ディスパッチ: 平均 {} ns", dynamic_avg);
let ratio = dynamic_avg as f64 / static_avg as f64;
if ratio > 1.0 {
println!("\n速度差: 動的ディスパッチは静的ディスパッチの約 {:.2}倍遅い", ratio);
} else {
println!("\n速度差: 静的ディスパッチは動的ディスパッチの約 {:.2}倍遅い", 1.0/ratio);
}
println!("\n注意: 結果は環境、コンパイラ最適化、CPUの状態により変わります");
}
より正確なベンチマーク:Criterionクレートを使用
簡単な時間計測では、OSのスケジューリングやCPUキャッシュの影響を受けやすく、結果が不安定になることがあります。より正確な測定には criterion
クレートを使用することをお勧めします。
Cargo.tomlの設定
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "dispatch_bench"
harness = false
benches/dispatch_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
trait Calculator {
fn calculate(&self, x: i32) -> i32;
}
struct Adder;
struct Multiplier;
impl Calculator for Adder {
fn calculate(&self, x: i32) -> i32 {
// 適度に重い処理をシミュレート
let mut result = x;
for _ in 0..100 {
result = (result.wrapping_mul(1103515245).wrapping_add(12345)) % (1 << 31);
}
result + 10
}
}
impl Calculator for Multiplier {
fn calculate(&self, x: i32) -> i32 {
// 適度に重い処理をシミュレート
let mut result = x;
for _ in 0..100 {
result = (result.wrapping_mul(1103515245).wrapping_add(12345)) % (1 << 31);
}
result * 2
}
}
// 静的ディスパッチ版
fn static_dispatch<T: Calculator>(calc: &T, iterations: usize) {
for i in 0..iterations {
black_box(calc.calculate(black_box(i as i32)));
}
}
// 動的ディスパッチ版
fn dynamic_dispatch(calc: &dyn Calculator, iterations: usize) {
for i in 0..iterations {
black_box(calc.calculate(black_box(i as i32)));
}
}
fn benchmark_dispatch(c: &mut Criterion) {
let adder = Adder;
let multiplier = Multiplier;
let iterations = 10000;
let mut group = c.benchmark_group("dispatch");
group.bench_with_input(
BenchmarkId::new("static", iterations),
&iterations,
|b, &iter| {
b.iter(|| {
static_dispatch(&adder, iter);
static_dispatch(&multiplier, iter);
});
}
);
group.bench_with_input(
BenchmarkId::new("dynamic", iterations),
&iterations,
|b, &iter| {
b.iter(|| {
dynamic_dispatch(&adder as &dyn Calculator, iter);
dynamic_dispatch(&multiplier as &dyn Calculator, iter);
});
}
);
group.finish();
}
// ランダムなディスパッチパターンのベンチマーク
fn benchmark_random_dispatch(c: &mut Criterion) {
use rand::seq::SliceRandom;
use rand::thread_rng;
let mut group = c.benchmark_group("random_dispatch");
let iterations = 10000;
group.bench_function("dynamic_random", |b| {
let calculators: Vec<Box<dyn Calculator>> = vec![
Box::new(Adder),
Box::new(Multiplier),
];
let mut rng = thread_rng();
b.iter(|| {
for _ in 0..iterations {
let calc = calculators.choose(&mut rng).unwrap();
black_box(calc.calculate(black_box(42)));
}
});
});
group.finish();
}
criterion_group!(benches, benchmark_dispatch, benchmark_random_dispatch);
criterion_main!(benches);
ベンチマークの実行
cargo bench
このベンチマークでは:
- 統計的に有意な結果が得られるまで自動的に反復
- ウォームアップ期間を自動で設定
- 外れ値を検出して除外
実際の測定結果と考察
測定環境
- 機種: MacBook Air (M4)
- CPU: Apple M4チップ (10コア: 4P + 6E)
- メモリ: 32GB
- OS: macOS 15.5
- Rust: 1.89.0
-
コンパイラ最適化: release mode (
--release
)
ベンチマーク結果
❯ cargo bench
...
Running benches/dispatch_bench.rs (target/release/deps/dispatch_bench-6503018d498d3ecf)
Gnuplot not found, using plotters backend
dispatch/static/10000 time: [2.7566 ms 2.8010 ms 2.8636 ms]
change: [-3.7213% -1.6378% +0.7991%] (p = 0.19 > 0.05)
No change in performance detected.
Found 17 outliers among 100 measurements (17.00%)
7 (7.00%) high mild
10 (10.00%) high severe
dispatch/dynamic/10000 time: [2.8518 ms 2.8958 ms 2.9505 ms]
change: [-8.5041% -2.7473% +1.9980%] (p = 0.38 > 0.05)
No change in performance detected.
...
抜粋すると、以下のように静的ディスパッチが 約0.095ms 高速という結果となった。
dispatch/static/10000 time: [2.7566 ms 2.8010 ms 2.8636 ms]
dispatch/dynamic/10000 time: [2.8518 ms 2.8958 ms 2.9505 ms]
まとめ
今回は、Rustにおける静的ディスパッチと動的ディスパッチについて詳しく見てきました。
プロジェクトの要件に応じて、どちらを採用するのか、もしくはハイブリッド採用するのかを決めていくのが良いと思います。
静的ディスパッチは、使用する型の数に比例してコード量が増加するので、開発スピードは上がらなさそうですが、意図が明確になると考えています。
Claude Codeなどの Agentic Coding が主流になりつつある今では、とりあえずは静的ディスパッチで書いても良いのではと思っています。
Discussion