Rustのtrait:共通の振る舞いを定義する

1. 概要

Rustでは、traitは複数のデータ型が共有する振る舞いを表す方法です。これはJavaのinterfaceに似ていますが、いくつかの機能があります。デフォルト実装、generics用のtrait bound、static dispatchとdynamic dispatchの選択肢です。

この記事を読むと、traitの定義方法、structへの実装、traitをパラメータと戻り値として使う方法、dyn Traitを使った異なる型のオブジェクトリストの作成方法がわかります。また、コンパイラがコードを拒否する理由も理解できます。

すべての例はRust stable(edition 2021以上)で動作し、外部ライブラリは必要ありません。

2. traitの定義と実装

オンラインストアシステムにOrderとCustomerという2つの型があるとします。両方とも自分自身を短い文字列に「要約」できるようにしたいとします。

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct Order {
    pub id: u32,
    pub total: u64, // 単位:ドン
}

pub struct Customer {
    pub name: String,
    pub email: String,
}

impl Summary for Order {
    fn summarize(&self) -> String {
        format!("注文 #{} - {} ドン", self.id, self.total)
    }
}

impl Summary for Customer {
    fn summarize(&self) -> String {
        format!("顧客 {} <{}>", self.name, self.email)
    }
}

fn main() {
    let order = Order { id: 42, total: 350_000 };
    let customer = Customer {
        name: "An".to_string(),
        email: "an@example.com".to_string(),
    };

    println!("{}", order.summarize());
    println!("{}", customer.summarize());
}

説明:

  • pub trait Summaryは1つのメソッドsummarizeを持つtraitを宣言します。ここには署名のみがあり、実装はありません。
  • impl Summary for OrderはOrderの具体的な実装を提供します。各型はtraitの本体がないメソッドをすべて実装する必要があります。
  • &selfはメソッドがオブジェクトを借用し、所有権を取得しないことを意味します。

結果:

注文 #42 - 350000 ドン
顧客 An <an@example.com>

注意: 別のモジュールからorder.summarize()を呼び出すには、traitSummaryがスコープに入っている必要があります(use crate::Summary;)。これはよくあるエラーです。コンパイラは「no method named summarize found」と報告しますが、実装は正しいのです。

3. デフォルト実装

Traitは事前に関数本体を提供できます。traitを実装する型はデフォルト実装を使うか、必要に応じてオーバーライドできます。

pub trait Summary {
    fn title(&self) -> String;

    fn summarize(&self) -> String {
        format!("[概要] {}", self.title())
    }
}

impl Summary for Order {
    fn title(&self) -> String {
        format!("注文 #{}", self.id)
    }

    // デフォルト実装をオーバーライド
    fn summarize(&self) -> String {
        format!("{} - {} ドン", self.title(), self.total)
    }
}

impl Summary for Customer {
    fn title(&self) -> String {
        format!("顧客 {}", self.name)
    }
    // summarizeをオーバーライドしない -> デフォルト実装を使う
}

第2節のmainを再度実行すると、結果は:

注文 #42 - 350000 ドン
[概要] 顧客 An

重要な点は、デフォルトメソッドsummarizeがtitle()を呼び出せることです。title()はまだtraitに本体がないのに。これは便利なパターンです。具体的な型に最小限の部分(title)を提供させるだけで、traitが残りをその上に構築します。

以下では、以降の例でこのバージョンのtraitを使用します。

4. パラメータとしてのtrait

4.1. impl Traitの構文

Summaryを持つ「何でも」を受け取る関数を書く最も簡単な方法:

fn print_summary(item: &impl Summary) {
    println!("通知: {}", item.summarize());
}

fn main() {
    let order = Order { id: 7, total: 120_000 };
    print_summary(&order);
}
通知: 注文 #7 - 120000 ドン

4.2. Trait bound

&impl Summaryは実際のところ、trait boundを持つgenericの短縮構文です:

fn print_summary<T: Summary>(item: &T) {
    println!("通知: {}", item.summarize());
}

複数のパラメータがある場合、2つの書き方は異なります:

// aとbは2つの異なる型である可能性があります
fn compare_loose(a: &impl Summary, b: &impl Summary) { /* ... */ }

// aとbは同じ型Tである必要があります
fn compare_strict<T: Summary>(a: &T, b: &T) { /* ... */ }

compare_strict(&order, &customer)を呼び出すと、OrderとCustomerが同じ型ではないため、コンパイルエラーになります。

4.3. 複数のboundとwhere句

複数のtraitが必要な場合は、+で接続します。リストが長い場合は、読みやすくするためにwhereに切り替えます:

use std::fmt::{Debug, Display};

fn log_item<T>(item: &T)
where
    T: Summary + Display + Debug,
{
    println!("{item} | {item:?} | {}", item.summarize());
}

この関数は3つのtraitをすべて実装する型のみを受け入れます。1つのtraitが不足している場合、コンパイラはランタイムを待たずに、関数呼び出しの時点でエラーを報告します。

Rustのgenericsはmonomorphizationを使用します。コンパイラは具体的なそれぞれの型に対してlog_itemの別のバージョンを生成します。このため、呼び出しはランタイムコストがかかりません(static dispatch)。その代わり、バイナリはより大きくなる可能性があります。

5. impl Traitを返す

型の名前を明示することなく、「Summaryを実装している何らかの型」を返すこともできます:

fn latest_order() -> impl Summary {
    Order { id: 100, total: 999_000 }
}

fn main() {
    println!("{}", latest_order().summarize());
}
注文 #100 - 999000 ドン

このアプローチはcloseおよびiteratorで特に便利です。それらの真の型は通常非常に長いか、書き出すことができません。

注意: 戻り値の位置でのimpl Traitは依然として1つの具体的な型のみです。次のコードはコンパイルされません:

fn pick(vip: bool) -> impl Summary {
    if vip {
        Customer { name: "An".into(), email: "an@example.com".into() }
    } else {
        Order { id: 1, total: 0 } // error: `if` and `else` have incompatible types
    }
}

複数の異なる型を返したい場合は、Box<dyn Summary>が必要です(セクション6)。

6. dyn Traitを使ったtrait object

Genericsは型をコンパイル時に決定する必要があります。OrderとCustomerの両方を含むリストが必要な場合があります。その場合はtrait objectを使用してください:

fn main() {
    let items: Vec<Box<dyn Summary>> = vec![
        Box::new(Order { id: 1, total: 50_000 }),
        Box::new(Customer {
            name: "Bình".to_string(),
            email: "binh@example.com".to_string(),
        }),
    ];

    for item in &items {
        println!("{}", item.summarize());
    }
}
注文 #1 - 50000 ドン
[概要] 顧客 Bình

説明:

  • dyn Summaryはコンパイル時にサイズがわからない型です。そのため、Box、&、またはRcなどのポインタの後ろに置く必要があります。
  • 各trait objectは2つの部分で構成されます:データへのポインタとvtableへのポインタ。summarize()の呼び出しはランタイムのvtableで検索されます(dynamic dispatch)。
  • セクション5のpick関数は、戻り値の型をBox<dyn Summary>に変更し、各ブランチをBox::new(...)でラップすることで修正できます。

クイック比較:

基準 Generics / impl Trait dyn Trait
Dispatch Static、インライン可能 Vtableを通じたDynamic
複数の型のリスト いいえ はい
バイナリサイズ より大きい より小さい

注意: すべてのtraitがdynで使えるわけではありません。traitはdyn-compatibleである必要があります(以前はobject safeと呼ばれていました)。generic method(例:fn send<T>(&self, t: T))を持つtraitはdyn-compatibleではありません。また、where Self: Sizedが付いていないSelfを返すメソッドを持つtraitもこの特性を失います。理由は、vtableはgeneric methodの無限のバージョンを含むことができず、コンパイラが真のSelfが何であるかを知らないからです。

7. Deriveと標準trait

標準ライブラリには多くのよく知られたtraitがあります。一部のtraitは#[derive]で自動生成できます:

use std::fmt;

#[derive(Debug, Clone, PartialEq)]
pub struct Order {
    pub id: u32,
    pub total: u64,
}

impl fmt::Display for Order {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Order#{}", self.id)
    }
}

fn main() {
    let a = Order { id: 5, total: 10_000 };
    let b = a.clone();

    println!("{a}");          // Display
    println!("{a:?}");        // Debug
    println!("{}", a == b);   // PartialEq
    println!("{}", a.to_string().len());
}
Order#5
Order { id: 5, total: 10000 }
true
7

ToStringを実装していないのにa.to_string()を呼び出すことができます。これは標準ライブラリのblanket implementationのためです:

impl<T: Display + ?Sized> ToString for T { /* ... */ }

Displayを持つすべての型は自動的にToStringを持ちます。自分のtraitに対してblanket implを書くこともできます(例:impl<T: Display> Summary for T)。ただし、他のすべてのimplと競合するため注意してください。

8. Orphan ruleとnewtypeパターン

Rustはorphan ruleというルールを持っています。impl Trait for Typeを書くことができるのは、traitまたは型が自分のcrateで定義されている場合のみです。このルールは、2つのcrateが同じ型に対して異なる方法でtraitを実装することを防ぎます。

次の例は、DisplayとVecの両方が標準ライブラリに属しているため、拒否されます:

// error E0117: only traits defined in the current crate can be implemented for types defined outside of the crate
impl std::fmt::Display for Vec<Order> { /* ... */ }

解決策はnewtypeパターンです。外部の型を自分のstructでラップします:

pub struct Cart(pub Vec<Order>);

impl fmt::Display for Cart {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let total: u64 = self.0.iter().map(|o| o.total).sum();
        write!(f, "カート: {} 件、合計 {} ドン", self.0.len(), total)
    }
}

fn main() {
    let cart = Cart(vec![
        Order { id: 1, total: 100_000 },
        Order { id: 2, total: 250_000 },
    ]);
    println!("{cart}");
}
カート: 2 件、合計 350000 ドン

Cartはローカル型なので、Displayを実装できます。Newtypeはランタイムコストがかかりません:CartはVec<Order>と同じレイアウトを持ちます。

9. Traitのテスト作成

Rustのテストは同じファイル内の#[cfg(test)]モジュール内に置かれます。オーバーライドとデフォルト実装の両方、およびtrait objectを介した呼び出しの場合をテストします:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn order_overrides_default_summary() {
        let order = Order { id: 42, total: 350_000 };
        assert_eq!(order.summarize(), "注文 #42 - 350000 ドン");
    }

    #[test]
    fn customer_uses_default_summary() {
        let customer = Customer {
            name: "An".to_string(),
            email: "an@example.com".to_string(),
        };
        assert_eq!(customer.summarize(), "[概要] 顧客 An");
    }

    #[test]
    fn trait_objects_dispatch_to_correct_impl() {
        let items: Vec<Box<dyn Summary>> = vec![
            Box::new(Order { id: 1, total: 0 }),
            Box::new(Customer { name: "Bình".into(), email: "b@x.vn".into() }),
        ];
        let result: Vec<String> = items.iter().map(|i| i.summarize()).collect();
        assert_eq!(result, vec!["注文 #1 - 0 ドン", "[概要] 顧客 Bình"]);
    }
}

テストを実行:

cargo test
running 3 tests
test tests::customer_uses_default_summary ... ok
test tests::order_overrides_default_summary ... ok
test tests::trait_objects_dispatch_to_correct_impl ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

3番目のテストが重要です。これは、vtableを通過するときに、各要素が実際の型の正しい実装を呼び出していることを確認します。

10. 結論

Traitはrust での振る舞いの共有の基礎です。重要なポイント:

  • traitを使用して動作を宣言し、impl Trait for Typeで実装します。デフォルトメソッドは重複するコードを減らすのに役立ちます。
  • コンパイル時に型がわかっている場合は、impl Traitまたはtrait bound(T: Trait、where)を使用します。このアプローチはstatic dispatchを提供し、ランタイムコストがかかりません。
  • 複数の異なる型を含めたり返す必要がある場合は、Box<dyn Trait>を使用します。dyn-compatibleの要件を忘れずに。
  • traitのメソッドを呼び出す前に、useでtraitをスコープに入れることを忘れずに。
  • orphan ruleに遭遇したら、newtypeパターンを使用してください。

traitを作成するべきでない時期はいつですか?動作が1つの実装のみで、テスト時に置き換える必要がない場合は、struct上の通常のメソッドで十分です。Traitは、複数の型が実際に動作を共有する場合、またはシステムの部分を分離するために抽象化が必要な場合にのみ追加する価値があります。

rusttraitgenericspolymorphism