Trait

Rust の型に共通の振る舞いを定義するインターフェース機構

Rust型システム

Trait とは

Trait は Rust の型に共通の振る舞い (メソッド) を定義するインターフェース機構である。Rust に型の継承はなく、共通の振る舞いは Trait の定義と impl ブロックによる実装の組み合わせで表す。The Rust Reference が挙げる言語の影響元一覧には Haskell (GHC) の型クラスが含まれており、Java や TypeScript の interface よりも型クラスに近い性質 (デフォルト実装、関連型、既存の型への後付け実装) を持つ。

実装が型の定義から切り離されている点が要点である。impl Summary for Article のように「型と Trait の組」ごとに実装を書くので、自分が定義していない型にも自分の Trait を後から実装できる。この自由度に歯止めをかけるのが後述の孤児ルールで、言語全体の位置づけは Rust、型パラメーター側の一般論は ジェネリクス にまとめている。

デフォルト実装

Trait 本体にはメソッドのシグネチャだけを並べる。本体まで書くとデフォルト実装になり、実装側で同名のメソッドを定義すれば置き換わる。

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

    // 本体を書くとデフォルト実装になる (実装側でオーバーライドできる)
    fn preview(&self) -> String {
        let s = self.summarize();
        let head: String = s.chars().take(50).collect();
        if head.len() < s.len() { format!("{head}...") } else { head }
    }
}

struct Article { title: String, content: String }

impl Summary for Article {
    fn summarize(&self) -> String {
        let head: String = self.content.chars().take(100).collect();
        format!("{}: {}", self.title, head)
    }
}

struct Tweet { username: String, text: String }

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("@{}: {}", self.username, self.text)
    }

    // デフォルト実装を差し替える
    fn preview(&self) -> String {
        format!("@{}", self.username)
    }
}

デフォルト実装から Trait 内の他のメソッドを呼べるので、preview は切り詰め方だけを持ち、内容の作り方は各型の summarize に任せられる。ここで文字列を切るときに &s[..50] のようなバイト添字のスライスを使うと、長さが 50 バイトに届かない場合と添字が文字境界に落ちない場合に panic する。日本語は 1 文字が 3 バイトなので後者を踏みやすく、&"日本語"[..2] は「byte index 2 is not a char boundary」で停止する。文字数で切りたいなら chars().take(n) を使う。

Trait 境界 (ジェネリクスの制約)

型パラメーターに「この Trait を実装している型だけ」という条件を付けるのが Trait 境界である。関数本体から呼べるのは境界に書いた Trait のメソッドに限られるので、境界は制約であると同時に、その関数が何に依存しているかの宣言でもある。

use std::fmt::Display;

// 引数位置の impl Trait: 匿名の型パラメータとして働く
fn notify(item: &impl Summary) {
    println!("新着: {}", item.summarize());
}

// where 句なら複数の境界を読みやすく並べられる
fn describe<T>(item: &T) -> String
where
    T: Summary + Display,
{
    format!("{item} / {}", item.summarize())
}

impl Trait は書く位置で意味が変わる。引数位置の impl Trait は Reference が「匿名の型パラメータ」と説明する糖衣構文で、<T: Trait> とほぼ同じだが型に名前が付かないため、2 つの引数に同じ型を要求する書き方はできない。戻り値位置の impl Trait は抽象戻り型で、具体型を決めるのは関数側であり呼び出し側は選べない。

// 戻り値位置: 関数が具体型を 1 つ決め、呼び出し側からは Trait のメソッドだけが見える
fn make_tweet(username: &str) -> impl Summary {
    Tweet { username: username.to_string(), text: String::new() }
}

したがって条件分岐で Article と Tweet を返し分けることはできない。返す型が実行時に変わるなら Box<dyn Summary> にする。

静的ディスパッチと動的ディスパッチ

型パラメーター経由の呼び出しは単相化 (monomorphization) される。使われた型ごとに関数の実体が複製され、呼び出し先がコンパイル時に確定するのでインライン化も効く。代償はコード量で、多くの型に対して使うジェネリック関数はコンパイル時間とバイナリを膨らませる。

dyn Trait を使うと実体は 1 つのままで、データへのポインターと vtable (メソッドのアドレス表) へのポインターを組にした値を通じ、実行時に呼び出し先が決まる。間接呼び出しが 1 段入りインライン化は原則できないが、異なる型を同じコレクションに混ぜて持てる。

// 静的ディスパッチ: T ごとに実体が作られる
fn print_static<T: Summary>(item: &T) { println!("{}", item.preview()); }

// 動的ディスパッチ: vtable 経由で呼び出し先が決まる
fn print_dynamic(item: &dyn Summary) { println!("{}", item.preview()); }

// 型を混ぜて保持できるのは dyn 側だけ
let feed: Vec<Box<dyn Summary>> = vec![Box::new(article), Box::new(tweet)];

判断は単純で、静的ディスパッチを既定にし、型を混ぜて持つ必要が出た時点、またはコンパイル時間とバイナリ量が問題になった時点で dyn へ切り替える。呼び出し 1 回の差が問題になる場面は、内側のループで毎回呼ぶような箇所に限られる。

dyn 互換性 (旧称 オブジェクト安全性)

すべての Trait を dyn Trait にできるわけではない。条件は vtable を組めることで、2026 年 8 月時点の The Rust Reference はこの条件を dyn 互換性 (dyn compatibility) と呼び、以前の呼称であるオブジェクト安全性 (object safety) を旧称として注記している。コンパイラーの診断も E0038 で「is not dyn compatible」と伝える形になっている。Reference が挙げる主な条件は次のとおり。

  • 上位 Trait もすべて dyn 互換であること。Self: Sized を要求しないこと
  • 関連定数を持たないこと。ジェネリクスを伴う関連型を持たないこと
  • vtable から呼ぶメソッドは型パラメーターを持たず (ライフタイム引数は可)、レシーバーの型以外に Self を使わないこと
  • レシーバーが &self / &mut self / Box<Self> / Rc<Self> / Arc<Self> / Pin<P> のいずれかであること
  • 戻り型が不透明でないこと (async fn と戻り値位置の impl Trait は該当するため不可)

ジェネリックなメソッドを持たせたい Trait でも、そのメソッドに where Self: Sized を付ければ vtable から除外され、Trait 全体は dyn 互換を保てる。標準ライブラリの Iterator が map などを持ちながら dyn Iterator として使えるのはこの仕組みによる。

孤児ルール

外部クレートの型に外部クレートの Trait を実装することはできない。型と Trait の少なくとも一方が自分のクレートにある必要があり、これを孤児ルールと呼ぶ。同じ組み合わせの実装が複数のクレートから現れて衝突するのを防ぐための規則である。回避したいときはタプル構造体で包む newtype パターンを使う。

// 外部の型に外部の Trait を実装したい場合は、自分のクレートの型で包む
struct Lines(Vec<String>);

impl Summary for Lines {
    fn summarize(&self) -> String { self.0.join(" / ") }
}

TypeScript の interface との比較

観点Rust TraitTypeScript interface
デフォルト実装ありなし (抽象クラスで代替)
実装の宣言impl Trait for 型 と明示して書く構造的部分型 (形が合えば実装とみなされる)
孤児ルール外部の型に外部の Trait は実装できない該当する制限なし
静的ディスパッチ単相化で呼び出し先が確定する型が実行時に消えるため該当しない
動的ディスパッチdyn Trait (vtable)常に実行時の解決
関連型あり (type Output)なし

名前が似ていても成り立ちは違う。TypeScript は形が合致すれば実装とみなす一方、Rust は impl を書いた組み合わせだけが実装になるため、意図しない一致が起こらない。TypeScript 側の詳細は TypeScript を参照。

よく使う標準ライブラリの Trait

Trait用途例
Display人間向けの文字列表現println!("{}", value)
Debugデバッグ用の文字列表現println!("{:?}", value)
Clone明示的な複製value.clone()
Copy代入で複製される型の印整数や f64
Iteratornext を実装して反復処理を与えるiter.map(...)
IntoIteratorfor 式で回せるようにするfor item in collection
From / Into型変換String::from("hello")

変換は From だけ実装すればよい。標準ライブラリに U: From<T> なら T: Into<U> という包括実装があるため、Into は自動的に付いてくる。逆向きに Into を手で実装すると、この包括実装と衝突する形になりやすい。

derive マクロ

#[derive(...)] は Trait の実装を機械的に生成する。Debug や Clone はコンパイラー組み込みの派生で、クレートが派生マクロを提供する例もある (serde の Serialize / Deserialize は derive フィーチャを有効にして使う)。

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
struct User {
    id: String,
    name: String,
}

AWS Lambda を Rust で書く場合も、イベントとレスポンスの構造体に serde の派生を付けて JSON との相互変換を任せるのが定型になる。派生には条件があり、標準ライブラリの派生は全フィールドが同じ Trait を実装していることを前提に展開されるため、1 つでも Clone でないフィールドがあれば #[derive(Clone)] は通らない。ジェネリックな型では型パラメーターにも同じ境界が付いた実装が生成されるので、必要より厳しい境界になることがある。その場合は手で impl を書く。

Trait を設計するときに迷いやすいのは境界の置き場所である。関数側の境界を厚くすると呼び出せる型が狭まり、Trait 側にメソッドを足すと実装者全員に作業が回る。まずは具体型で動くコードを書き、同じ処理を 2 つ目の型へ広げる段階で共通部分を Trait に切り出すと、境界が過剰になりにくい。

共有:

この記事は役に立ちましたか?

関連用語

関連する記事