じゃあ、おうちで学べる

本能を呼び覚ますこのコードに、君は抗えるか

自分のクレート外の型にメソッドを生やす — extension trait と sealed pattern

はじめに

「String に独自メソッドを追加したい」「外部クレートの型に1メソッドだけ生やしたい」場面があります。Rust の orphan rule のせいで、外部クレートの型に外部クレートのトレイトを直接実装するのは無理です。代わりに 自分のトレイトを定義して、その型に対する impl を書く のが extension trait パターンです。

doc.rust-lang.org

さらに sealed trait pattern を組み合わせると、「このトレイトは外部から実装できない」ことを保証できます。公開 API の進化を破壊的変更にしないための小技です。

まず動くコード

String や &str に「指定文字数で切り詰めて末尾に … を付ける」メソッドを生やす例。

mod sealed {
    pub trait Sealed {}
}

pub trait StrExt: sealed::Sealed {
    fn truncate_with_ellipsis(&self, max_chars: usize) -> String;
}

// 全ての &str / String / Cow<str> 等に対して一気に sealed トレイトを実装する
impl<T: AsRef<str>> sealed::Sealed for T {}

// 同じ blanket で extension trait の本体も実装
impl<T: AsRef<str>> StrExt for T {
    fn truncate_with_ellipsis(&self, max_chars: usize) -> String {
        let s = self.as_ref();
        if max_chars == 0 {
            return String::new();
        }
        let count = s.chars().count();
        if count <= max_chars {
            return s.to_owned();
        }
        let mut out: String = s.chars().take(max_chars - 1).collect();
        out.push('…');
        out
    }
}

fn main() {
    let title = "rust 2024 edition で書く idiomatic な話";
    println!("{}", title.truncate_with_ellipsis(10));

    let owned = String::from("hello world");
    println!("{}", owned.truncate_with_ellipsis(20));
}

実行結果は次のようになります。

rust 2024…
hello world

String でも &str でも、自分が定義したわけではない型に .truncate_with_ellipsis(...) というメソッドが生えました。

extension trait の構造

extension trait のレシピは3ステップです。

  1. 公開トレイトを定義する: pub trait StrExt { fn ...; }
  2. 対象の型に impl する: 単一の型 (impl StrExt for String) でも、blanket impl (impl<T: AsRef<str>> StrExt for T) でも
  3. 利用側で use する: use crate::StrExt; を書くと型にメソッドが現れる

利用側はこう使います。

use my_crate::StrExt;

fn main() {
    let s = "hello";
    println!("{}", s.truncate_with_ellipsis(3));
}

メソッド名前空間が trait に紐付くので、use を書かないとメソッドが見えません。これが意図しない名前衝突を防ぐ仕組みです。

blanket impl で対象を広げる

impl<T: AsRef<str>> StrExt for T のように generic で書くと、 AsRef<str> を実装する すべての型 が StrExt を持ちます。

これに含まれるのは次のとおりです。

  • &str (固定)
  • String (AsRef<str> を実装)
  • Box<str>
  • Cow<'_, str>
  • 自作の newtype (pub struct Title(String) で AsRef<str> を実装すれば自動)

利用者は意識せずに extension trait の恩恵を受けられます。

sealed trait pattern とは

extension trait の問題点として、 外部の利用者が自分の型に impl を書ける ことがあります。これは普通良いことなのですが、ときに困ります。たとえば次のような場面です。

  • 「このトレイトは将来メソッドを追加する予定」: 利用者が impl していると追加が破壊的変更になる
  • 「内部実装の詳細を漏らしたくない」: 利用者の impl で意図しない使われ方をされる

sealed trait pattern を使うと、 そのクレート外からはトレイトを impl できない ようにできます。

仕組みは「private モジュールに Sealed トレイトを置いて、自分のトレイトの super trait にする」というものです。

mod sealed {
    // pub だが、parent module 経由でしか参照できない private モジュールにある
    pub trait Sealed {}
}

pub trait StrExt: sealed::Sealed {  // sealed::Sealed が super trait
    fn truncate_with_ellipsis(&self, max_chars: usize) -> String;
}

// 自分のクレート内でだけ Sealed を impl できる
impl<T: AsRef<str>> sealed::Sealed for T {}
impl<T: AsRef<str>> StrExt for T { ... }

外部の利用者は sealed::Sealed にアクセスできません。モジュール sealed を pub にしていないので外から見えないからです。そのため StrExt を impl しようとしても super trait Sealed が満たせません。

これにより、次の2つが得られます。

  • 必須メソッドを追加しても、外部の実装者に実装追加を求めずに済む
  • 自分のクレートだけが「StrExt を実装する型を増やす」権限を持つ

外部での実装を許可する必要がなければ、sealedにする選択肢があります。ただし、メソッド名の追加が利用側の別トレイトと衝突する可能性は残ります。Cargoの互換性ガイドにあるように、実装者への影響と呼び出し側への影響は分けて確認します。

いつ extension trait を使うか

判断目安を表にしておきます。

使う場面 使わない方がよい場面
標準ライブラリ型に1〜2メソッド足す 自前の型に普通のメソッドを足す (素直に impl で OK)
外部クレートの型に独自処理を生やす 全く同じ機能のフリー関数で済む
利用者が use を書いて opt-in する設計 コア機能で常に有効にしたい

「フリー関数 (fn truncate_with_ellipsis(s: &str, ...)) で済まないだろうか」を一度自問するのが良いです。s.truncate(...) のメソッド呼び出しスタイルが UX として明確に勝る場面でだけ extension trait の価値があります。

orphan rule との関係

ここで「なぜ extension trait が必要か」を一度整理します。

Rust の orphan rule は「トレイト or 型のどちらかが自分のクレートに属していなければ impl を書けない」というルールです。これにより次のような制約が生まれます。

  • impl Display for MyType (自分の型) → OK
  • impl MyTrait for String (自分のトレイト) → OK
  • impl Display for String (どちらも他人) → コンパイルエラー

「外部の String に外部の Serialize を impl する」みたいなことはできません。これは依存関係グラフが入り組んだときの impl 衝突を防ぐための設計です。

extension trait は「自分のトレイトを定義して、それを外部の型に impl する」ことで、 orphan rule の制約内で「外部の型に機能を足す」を実現するパターンです。

まとめ

  • extension trait は「外部の型に自分の機能を足す」 ためのトレイト
  • レシピ: トレイト定義 → 対象型に impl → 利用側で use
  • blanket impl で String / &str / Cow などに一括展開できる
  • sealed pattern で「外部クレートからは impl できない」を保証できる
  • 外部の型にも利用者自身が実装すべきかを考え、sealedにするか決める
  • 「フリー関数で済むだろうか」を自問してから extension trait を選ぶ

関連