Rustのトレイト解説:仕組みと重要性
Ethan Miller
Product Engineer · Leapcell

Rustの設計目標において、ゼロコスト抽象化は最も重要な原則の一つです。これにより、Rustはパフォーマンスを犠牲にすることなく、高水準言語の表現力を持ち続けることができます。これらのゼロコスト抽象化の基礎は、ジェネリクスとトレイトにあり、高水準の構文をコンパイル中に効率的な低水準コードにコンパイルすることで、ランタイム効率を実現します。この記事では、トレイトを紹介し、その使用方法と、3つの一般的な問題を分析し、これらの問題を考察することでその根本的なメカニズムを説明します。
使用法
基本的な使用法
トレイトの主な目的は、他のプログラミング言語の「インターフェース」と同様に、動作を抽象化することです。トレイトの基本的な使用法を示す例を次に示します。
trait Greeting { fn greeting(&self) -> &str; } struct Cat; impl Greeting for Cat { fn greeting(&self) -> &str { "Meow!" } } struct Dog; impl Greeting for Dog { fn greeting(&self) -> &str { "Woof!" } }
上記のコードでは、トレイトGreeting
が定義され、2つの構造体によって実装されています。関数の呼び出し方に応じて、主に次の2つの方法で使用できます。
- ジェネリクスに基づく静的ディスパッチ
- トレイトオブジェクトに基づく動的ディスパッチ
ジェネリクスの概念はより一般的に知られているため、ここではトレイトオブジェクトに焦点を当てます。
トレイトオブジェクトは、一連のトレイトを実装する別の型の不透明な値です。トレイトのセットは、オブジェクトセーフなベーストレイトと任意の数のオートトレイトで構成されます。
重要な詳細として、トレイトオブジェクトは動的サイズ型(DST)に属し、そのサイズはコンパイル時に決定できません。これらはポインタを介して間接的にアクセスする必要があります。一般的な形式には、Box<dyn Trait>
、&dyn Trait
などがあります。
fn print_greeting_static<G: Greeting>(g: G) { println!("{}", g.greeting()); } fn print_greeting_dynamic(g: Box<dyn Greeting>) { println!("{}", g.greeting()); } print_greeting_static(Cat); print_greeting_static(Dog); print_greeting_dynamic(Box::new(Cat)); print_greeting_dynamic(Box::new(Dog));
静的ディスパッチ
Rustでは、ジェネリクスは単相化を使用して実装されます。これにより、コンパイル時に異なる型に対して異なるバージョンの関数が生成されます。したがって、ジェネリクスは型パラメータとしても知られています。利点は、仮想関数呼び出しによるオーバーヘッドがないことですが、短所はバイナリサイズが増加することです。上記の例では、print_greeting_static
は2つのバージョンにコンパイルされます。
print_greeting_static_cat(Cat); print_greeting_static_dog(Dog);
動的ディスパッチ
すべての関数呼び出しで、コンパイル時に呼び出し元の型を特定できるわけではありません。一般的なシナリオは、GUIプログラミングのイベントのコールバックです。通常、1つのイベントが複数のコールバック関数に対応する可能性があり、それらはコンパイル時には不明です。したがって、このような場合にはジェネリクスは適しておらず、動的ディスパッチが必要です。
trait ClickCallback { fn on_click(&self, x: i64, y: i64); } struct Button { listeners: Vec<Box<dyn ClickCallback>>, }
impl Trait
Rust 1.26では、トレイトの新しい使用方法が導入されました。impl Trait
です。これは、関数のパラメータと戻り値の2か所で使用できます。これは主に複雑なトレイトの使用を簡素化するためのものであり、ジェネリクスの特殊なケースと見なすことができます。 impl Trait
を使用する場合でも、静的ディスパッチです。ただし、戻り値の型として使用する場合、データ型はすべての戻りパスで同じである必要があります。これは重要なポイントです!
fn print_greeting_impl(g: impl Greeting) { println!("{}", g.greeting()); } print_greeting_impl(Cat); print_greeting_impl(Dog); // 次のコードはコンパイルエラーになります fn return_greeting_impl(i: i32) -> impl Greeting { if i > 10 { return Cat; } Dog } // | fn return_greeting_impl(i: i32) -> impl Greeting { // | ------------- expected because this return type... // | if i > 10 { // | return Cat; // | --- ...is found to be `Cat` here // | } // | Dog // | ^^^ expected struct `Cat`, found struct `Dog`
高度な使用法
関連型
上記の基本的な使用法のセクションでは、トレイトメソッドのパラメータまたは戻り値の型は固定されています。Rustには、型の遅延バインディングと呼ばれるメカニズム、つまり関連型があります。これにより、トレイトを実装するときに具体的な型を指定できます。一般的な例は、標準ライブラリのIterator
トレイトで、next
の戻り値はSelf::Item
です。
trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; } /// 偶数のみを出力するサンプルイテレータ struct EvenNumbers { count: usize, limit: usize, } impl Iterator for EvenNumbers { type Item = usize; fn next(&mut self) -> Option<Self::Item> { if self.count > self.limit { return None; } let ret = self.count * 2; self.count += 1; Some(ret) } } fn main() { let nums = EvenNumbers { count: 1, limit: 5 }; for n in nums { println!("{}", n); } } // 出力:2 4 6 8 10
関連型の使用法はジェネリクスに似ています。 Iterator
トレイトは、ジェネリクスを使用して定義することもできます。
pub trait Iterator<T> { fn next(&mut self) -> Option<T>; }
2つのアプローチの主な違いは次のとおりです。
- 特定の型(上記の
Cat
構造体など)は、ジェネリックトレイトを複数回実装できます。たとえば、From
トレイトを使用すると、impl From<&str> for Cat
とimpl From<String> for Cat
の両方を持つことができます。 - ただし、関連型を持つトレイトは1回しか実装できません。たとえば、
FromStr
を使用すると、impl FromStr for Cat
を1つだけ持つことができます。Iterator
やDeref
のようなトレイトは、このパターンに従います。
Deriveマクロ
Rustでは、derive
属性を使用して、Debug
やClone
などの一般的なトレイトを自動的に実装できます。ユーザー定義のトレイトの場合、手続きマクロを実装してderive
をサポートすることもできます。詳細については、カスタムderiveマクロの作成方法を参照してください。ここでは詳細には立ち入りません。
よくある問題
アップキャスティング
SubTrait: Base
となるトレイトの場合、現在のバージョンのRustでは、&dyn SubTrait
を&dyn Base
に変換することはできません。この制限は、トレイトオブジェクトのメモリレイアウトに関連しています。
記事Exploring Rust fat pointersで、著者はtransmute
を使用してトレイトオブジェクト参照を2つのusize
値に変換し、それらがそれぞれデータとvtableを指していることを確認しました。
use std::mem::transmute; use std::fmt::Debug; fn main() { let v = vec![1, 2, 3, 4]; let a: &Vec<u64> = &v; // トレイトオブジェクトに変換 let b: &dyn Debug = &v; println!("a: {}", a as *const _ as usize); println!("b: {:?}", unsafe { transmute::<_, (usize, usize)>(b) }); } // a: 140735227204568 // b: (140735227204568, 94484672107880)
これは、Rustがfatポインタ(つまり、2つのポインタ)を使用してトレイトオブジェクト参照を表すことを示しています。1つはデータを指し、もう1つはvtableを指します。これは、Goでインターフェイスが処理される方法と非常によく似ています。
+---------------------+
| fat object pointer |
+---------+-----------+
| data | vtable |
+----|----+----|------+
| |
v v
+---------+ +-----------+
| object | | vtable |
+---------+ +-----+-----+
| ... | | S | S |
+---------+ +-----+-----+
pub struct TraitObjectReference { pub data: *mut (), pub vtable: *mut (), } struct Vtable { destructor: fn(*mut ()), size: usize, align: usize, method: fn(*const ()) -> String, }
fatポインタはポインタのサイズを大きくしますが(これにより、アトミック操作で使用できなくなります)、利点は重要です。
- 既存の型に対してトレイトを実装できます(例:ブランケット実装)
- vtableからメソッドを呼び出すには、1レベルの間接参照のみが必要です。対照的に、C++では、vtableはオブジェクト内に存在するため、各関数呼び出しには次のような2レベルの間接参照が含まれます。
オブジェクトポインタ --> オブジェクトの中身 --> vtable --> DynamicType::method()の実装
トレイトに継承関係がある場合、vtableは複数のトレイトからのメソッドをどのように格納するのでしょうか?現在の実装では、すべてのメソッドは、どのメソッドがどのトレイトに属しているかを区別せずに、単一のvtableに順番に格納されます。次のようにします。
Trait Object
+---------------+ +------------------+
| data | <------------ | data |
+---------------+ +------------------+
| vtable | ------------> +---------------------+
+------------------+ | destructor |
+---------------------+
| size |
+---------------------+
| align |
+---------------------+
| base.fn1 |
+---------------------+
| base.fn2 |
+---------------------+
| subtrait.fn1 |
+---------------------+
| ...... |
+---------------------+
ご覧のとおり、すべてのトレイトメソッドは、どのメソッドがどのトレイトに属しているかを区別せずに、順番に格納されます。これが、アップキャスティングができない理由です。この問題については、RFC 2765という進行中のRFCで追跡されています。ここではRFCで提案されている解決策については説明せず、AsBase
トレイトを追加することにより、より一般的な回避策を紹介します。
trait Base { fn base(&self) { println!("base..."); } } trait AsBase { fn as_base(&self) -> &dyn Base; } // ブランケット実装 impl<T: Base> AsBase for T { fn as_base(&self) -> &dyn Base { self } } trait Foo: AsBase { fn foo(&self) { println!("foo.."); } } #[derive(Debug)] struct MyStruct; impl Foo for MyStruct {} impl Base for MyStruct {} fn main() { let s = MyStruct; let foo: &dyn Foo = &s; foo.foo(); let base: &dyn Base = foo.as_base(); base.base(); }
ダウンキャスティング
ダウンキャスティングとは、トレイトオブジェクトを元の具体的な型に戻すことを指します。Rustは、これを実現するためにAny
トレイトを提供します。
pub trait Any: 'static { fn type_id(&self) -> TypeId; }
非'static
参照を含まない型を除き、ほとんどの型はAny
を実装します。 type_id
を使用すると、実行時に型を判別できます。次に例を示します。
use std::any::Any; trait Greeting { fn greeting(&self) -> &str; fn as_any(&self) -> &dyn Any; } struct Cat; impl Greeting for Cat { fn greeting(&self) -> &str { "Meow!" } fn as_any(&self) -> &dyn Any { self } } fn main() { let cat = Cat; let g: &dyn Greeting = &cat; println!("greeting {}", g.greeting()); // &Catに変換 let downcast_cat = g.as_any().downcast_ref::<Cat>().unwrap(); println!("greeting {}", downcast_cat.greeting()); }
ここでのキーはdowncast_ref
で、その実装は次のとおりです。
pub fn downcast_ref<T: Any>(&self) -> Option<&T> { if self.is::<T>() { unsafe { Some(&*(self as *const dyn Any as *const T)) } } else { None } }
示されているように、型が一致する場合、トレイトオブジェクトのデータポインタ(最初のポインタ)は、unsafe
コードを使用して具体的な型の参照に安全にキャストされます。
オブジェクト安全性
Rustでは、すべてのトレイトをトレイトオブジェクトとして使用できるわけではありません。対象となるには、トレイトは特定の条件を満たす必要があります。これはオブジェクト安全性と呼ばれます。主なルールは次のとおりです。
-
トレイトメソッドは
Self
(つまり、実装型)を返すことはできません。 これは、オブジェクトがトレイトオブジェクトに変換されると、元の型情報が失われるため、Self
が不定になるためです。 -
トレイトメソッドにジェネリックパラメータを含めることはできません。 理由は、単相化によって大量の関数実装が生成され、トレイト内のメソッドの肥大化につながる可能性があるためです。次に例を示します。
trait Trait { fn foo<T>(&self, on: T); // より多くのメソッド } // 10個の実装 fn call_foo(thing: Box<Trait>) { thing.foo(true); // これは上記の10個の型のいずれかである可能性があります thing.foo(1); thing.foo("hello"); } // 10 * 3 = 30の異なる実装になります
- トレイトオブジェクトとして使用されるトレイトは、
Sized
を継承(トレイト境界を持つ)してはなりません。 Rustは、トレイトオブジェクトがそのトレイトを実装していると想定し、次のようなコードを生成します。
trait Foo { fn method1(&self); fn method2(&mut self, x: i32, y: String) -> usize; } // 自動生成されたimpl impl Foo for TraitObject { fn method1(&self) { // `self`は`&Foo`トレイトオブジェクトです。 // 正しい関数ポインタをロードし、不透明なデータポインタを使用して呼び出します (self.vtable.method1)(self.data) } fn method2(&mut self, x: i32, y: String) -> usize { // `self`は`&mut Foo`トレイトオブジェクトです // 上記と同じように、他の引数を渡します (self.vtable.method2)(self.data, x, y) } }
Foo
がSized
を継承する場合、トレイトオブジェクトもSized
である必要があります。ただし、トレイトオブジェクトはDST(動的サイズ型)であり、?Sized
であるため、制約は失敗します。
オブジェクト安全性を侵害する安全でないトレイトの場合、最良のアプローチは、それらをオブジェクトセーフな形式にリファクタリングすることです。それが不可能な場合は、ジェネリクスを使用することが代替の回避策です。
結論
この記事の冒頭で、トレイトはゼロコスト抽象化の基礎であると紹介しました。トレイトを使用すると、既存の型に新しいメソッドを追加したり、式問題を解決したり、演算子のオーバーロードを有効にしたり、インターフェース指向プログラミングを可能にしたりできます。この記事が、読者にトレイトを効果的に使用する方法をしっかりと理解させ、Rustでトレイトを使用する際にコンパイラエラーを簡単に処理できる自信を与えることを願っています。
Leapcellは、Rustプロジェクトのホスティングに最適な選択肢です。
Leapcellは、Webホスティング、非同期タスク、およびRedis向けの次世代サーバーレスプラットフォームです。
マルチ言語サポート
- Node.js、Python、Go、または Rust で開発します。
無料で無制限のプロジェクトをデプロイ
- リクエスト数に関わらず、使用量に応じてのみ料金が発生し、課金は一切発生しません。
比類のないコスト効率
- アイドル時の課金なしで、使った分だけお支払いいただきます。
- 例:$25 で平均応答時間 60 ミリ秒で 694 万リクエストをサポートします。
合理化された開発者エクスペリエンス
- 簡単なセットアップのための直感的な UI。
- 完全に自動化された CI/CD パイプラインと GitOps の統合。
- 実用的な洞察のためのリアルタイムのメトリクスとロギング。
簡単なスケーラビリティと高いパフォーマンス
- 高い並行処理を容易に処理するための自動スケーリング。
- 運用上のオーバーヘッドをゼロにし、構築に集中できます。
詳細については、ドキュメントをご覧ください!
X でフォローしてください: @LeapcellHQ