Rust のトレイトの解説:使い方から内部仕組みまで
Rust の設計目標の中で「ゼロコスト抽象」は非常に重要な要素であり、Rust が高水準な言語表現能力を持ちながらも、性能の劣化を伴わないことを可能にしています。ゼロコストの基盤はジェネリクスとトレイトにあります。これらはコンパイル時に高水準の構文を効率的な低レベルコードへと変換し、実行時の高性能を実現しています。本記事では、trait の使い方とよくある 3 つの問題について説明し、それらの問題を掘り下げることでその実装原理を解説していきます。
使用方法
基本的な使い方
trait の主な目的は動作を抽象化することで、他のプログラミング言語における「インターフェース」に似ています。以下は、trait の基本的な使い方を示す例です。
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
という trait を定義し、2 つの struct がそれを実装しています。関数の呼び出し方法に応じて、主に以下の 2 通りの使用方法があります:
- ジェネリクスに基づく静的ディスパッチ
- trait object に基づく動的ディスパッチ
ジェネリクスの概念は比較的一般的なので、ここでは trait object に焦点を当てて説明します。
A trait object is an opaque value of another type that implements a set of traits. The set of traits is made up of an object safe base trait plus any number of auto traits.
重要な点は、trait object は Dynamically Sized Types(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 におけるジェネリクスの実装はモノモルフィゼーション(monomorphization)という方式を採用しており、異なる型の呼び出し元ごとに、コンパイル時に異なるバージョンの関数を生成します。そのため、ジェネリクスは「型パラメータ」とも呼ばれます。利点は仮想関数呼び出しのオーバーヘッドがないこと、欠点は最終的なバイナリサイズが大きくなることです。上記の例では、print_greeting_static
は以下の 2 つのバージョンにコンパイルされます。
print_greeting_static_cat(Cat);
print_greeting_static_dog(Dog);
動的ディスパッチ
すべての関数呼び出しがコンパイル時に呼び出し元の型を確定できるとは限りません。よくある場面の 1 つは、GUI プログラミングにおけるイベント応答のコールバックです。一般的に、1 つのイベントに対して複数のコールバック関数が対応する可能性があり、これらの関数はコンパイル時には不確定です。このような場合、ジェネリクスは適用できず、動的ディスパッチを用いる必要があります。
trait ClickCallback {
fn on_click(&self, x: i64, y: i64);
}
struct Button {
listeners: Vec<Box<dyn ClickCallback>>,
}
impl trait
Rust 1.26 バージョンでは、新しい trait の使用法として impl trait
が導入されました。これは関数の引数および戻り値の 2 箇所で使用できます。この方法の主な目的は、複雑な trait の使用を簡略化することです。ジェネリクスの特殊ケースと見なされ、impl trait
を使う箇所は静的ディスパッチで処理されます。ただし、戻り値として使用する場合は、データ型が 1 種類に限定される点に特に注意が必要です。
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`
高度な使用方法
関連型(Associated Types)
先ほど紹介した基本的な使い方では、trait 内のメソッドの引数や戻り値の型はすべて固定されていました。Rust は「遅延バインディング」のメカニズムを提供しており、それが「関連型」です。これにより、trait を実装する際に型を指定できるようになります。代表的な例は標準ライブラリの 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>;
}
両者の違いは主に以下の通りです:
- 特定の型(例えば Cat)は、ジェネリック trait を複数回実装可能です。たとえば
impl From<&str> for Cat
やimpl From<String> for Cat
が共存できます。 - 一方、関連型を使う trait は 1 回しか実装できません。たとえば
FromStr
に対する実装はimpl FromStr for Cat
の 1 つだけです。同様の trait にはIterator
やDeref
があります。
Derive マクロ
Rust では、derive
属性を使って Debug
や Clone
などのよく使われる trait を自動的に実装できます。ユーザー定義の trait に対しても、プロシージャルマクロを使えば derive
のサポートが可能です。詳しくは以下を参照してください:How to write a custom derive macro?。ここでは詳細な説明は省略します。
よくある問題
アップキャスト(upcast)
trait SubTrait: Base
のように trait に継承関係がある場合、現在の Rust バージョンでは &dyn SubTrait
を &dyn Base
に変換(アップキャスト)することはできません。この制限は trait object のメモリ構造に起因しています。
Exploring Rust fat pointers という記事では、著者が transmute
を使って trait object の参照を 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;
// trait object に変換
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 pointer(2 つのポインタ)を用いて trait object の参照を表現しています。それぞれが data と vtable を指しており、これは Go における interface と非常によく似ています。
+---------------------+
| 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 pointer によってポインタのサイズが増えるため、Atomic
などの命令を使えないというデメリットはありますが、以下のような明確な利点もあります:
- 既存の型に対して trait を実装できる(たとえば blanket 実装など)
- vtable 内の関数を呼び出す際、参照は 1 回で済む。C++ では vtable がオブジェクトの内部にあるため、関数呼び出しのたびに 2 回の参照が必要になります:
object pointer --> object contents --> vtable --> DynamicType::method() implementation
trait に継承関係がある場合、vtable は異なる trait のメソッドをどのように格納するのでしょうか?現在の実装では、すべての trait のメソッドが 1 つの vtable に順番に格納されています:
Trait Object
+---------------+ +------------------+
| data | <------------ | data |
+---------------+ +------------------+
| vtable | ------------> +---------------------+
+------------------+ | destructor |
+---------------------+
| size |
+---------------------+
| align |
+---------------------+
| base.fn1 |
+---------------------+
| base.fn2 |
+---------------------+
| subtrait.fn1 |
+---------------------+
| ...... |
+---------------------+
このように、すべてのメソッドは trait ごとに区別されずに連続して格納されているため、アップキャストが不可能となっているのです。コミュニティではこの問題を追跡する RFC 2765 が提案されていますが、ここではその解決方法には深入りせず、一般的な代替策として AsBase
trait を導入する方法を紹介します:
trait Base {
fn base(&self) {
println!("base...");
}
}
trait AsBase {
fn as_base(&self) -> &dyn Base;
}
// blanket 実装
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();
}
ダウンキャスト(downcast)
ダウンキャストとは、trait object を元の具体的な型へと変換することを指します。Rust はこれを実現するために Any
という trait を提供しています。
pub trait Any: 'static {
fn type_id(&self) -> TypeId;
}
ほとんどの型は Any
を実装していますが、'static
でない参照を含む型は実装されていません。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
コードを用いて trait object の先頭ポインタ(data ポインタ)を具体的な型への参照へと変換しています。
オブジェクトセーフティ(Object Safety)
Rust では、すべての trait が trait object として使えるわけではなく、一定の条件を満たす必要があります。これを「オブジェクトセーフティ(object safety)」と呼びます。主な条件は以下の通りです:
- 関数の戻り値が
Self
(その trait を実装する具体型)であってはならない。trait object に変換された時点で元の型情報が失われるため、Self
が特定できなくなります。 - 関数にジェネリクス型引数を含めてはならない。モノモルフィゼーションにより多くの関数が生成され、trait 内のメソッド数が膨れ上がる可能性があるためです。たとえば:
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 にもなる
- trait は
Sized
を継承してはならない。Rust はデフォルトで trait object にもその trait を実装させようとしますが、Sized
を継承していると、trait object もSized
であることが要求され、これは不可能です。なぜなら trait object は?Sized
に分類される動的サイズ型(DST)だからです。
非オブジェクトセーフな trait を使用する必要がある場合は、可能であれば安全な方法に書き換えるのが最良です。それが難しい場合は、ジェネリクスを使う手段も検討できます。
以下は、trait object に対して自動的に生成される実装の一例です:
trait Foo {
fn method1(&self);
fn method2(&mut self, x: i32, y: String) -> usize;
}
// 自動生成される実装
impl Foo for TraitObject {
fn method1(&self) {
// `self` は `&Foo` trait object
// 適切な関数ポインタをロードし、data ポインタを使って呼び出す
(self.vtable.method1)(self.data)
}
fn method2(&mut self, x: i32, y: String) -> usize {
// `self` は `&mut Foo` trait object
(self.vtable.method2)(self.data, x, y)
}
}
まとめ
本記事の冒頭でも述べたように、trait
はゼロコスト抽象を実現するための基盤です。trait を用いることで、既存の型に新しいメソッドを追加することが可能となり、これは「表現力の問題」を解決する手段でもあります。例えば演算子のオーバーロード、インターフェース指向プログラミングといった手法が可能になります。
本記事を通じて trait の使用方法、静的/動的ディスパッチ、高度な使い方(関連型、impl trait、derive マクロなど)、さらに upcast・downcast やオブジェクトセーフティといったよくある問題とその背景にある実装原理までを解説しました。
この記事が trait を自在に使いこなす助けとなり、コンパイラエラーに直面した際も冷静に対処できるようになることを願っています。
私たちはLeapcell、Rustプロジェクトのホスティングの最適解です。
Leapcellは、Webホスティング、非同期タスク、Redis向けの次世代サーバーレスプラットフォームです:
複数言語サポート
- Node.js、Python、Go、Rustで開発できます。
無制限のプロジェクトデプロイ
- 使用量に応じて料金を支払い、リクエストがなければ料金は発生しません。
比類のないコスト効率
- 使用量に応じた支払い、アイドル時間は課金されません。
- 例: $25で6.94Mリクエスト、平均応答時間60ms。
洗練された開発者体験
- 直感的なUIで簡単に設定できます。
- 完全自動化されたCI/CDパイプラインとGitOps統合。
- 実行可能なインサイトのためのリアルタイムのメトリクスとログ。
簡単なスケーラビリティと高パフォーマンス
- 高い同時実行性を容易に処理するためのオートスケーリング。
- ゼロ運用オーバーヘッド — 構築に集中できます。
Xでフォローする:@LeapcellHQ
Discussion