Rust 基礎講座 #8 トレイトとジェネリクス — 継承がない言語で共通の動作を定義する

読了 5分

この講座には、まだ返していない約束がいくつか積み上がっています。第 3 回の「Copy トレイトを実装した型」、第 5 回の #[derive(Debug)]、第 6 回の Box<dyn Error>。すべて今回の主題であるトレイト(trait)です。継承のない Rust で「異なる型が同じ動作をする」を表現する唯一の装置で、ジェネリクスと組み合わさって Rust の抽象化の骨格をなします。

トレイト — 動作の約束 #

トレイトは「この動作を提供する」という約束のリストです。インターフェースに似ていますが、すでに存在する型へ後から実装を付けられる点が違います。

src/main.rs
trait Summary {
    fn summarize(&self) -> String;
}

struct Article { title: String, body: String }
struct Comment { author: String, text: String }

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("[記事] {}", self.title)
    }
}

impl Summary for Comment {
    fn summarize(&self) -> String {
        format!("{}さんのコメント: {}", self.author, self.text)
    }
}

記事とコメントは何の関係もない型ですが、どちらも Summary を実装しているので、「要約できるもの」として一緒に扱えます。トレイト定義に本体を書いておくと既定実装になり、実装する型は必要なものだけ再定義すれば済みます。

ジェネリクスとトレイト境界 — どんな型でも、ただし条件付きで #

「要約できるものなら何でも受け取る関数」を書くには、ジェネリクスが必要です。

src/main.rs
fn print_summary<T: Summary>(item: &T) {
    println!("{}", item.summarize());
}

<T: Summary> がトレイト境界です。「T はどんな型でもよいが、Summary を実装していなければならない」という条件です。この条件がなければどうなるでしょうか?境界を消すと、コンパイラはこう答えます。

コンパイルエラー
error[E0599]: no method named `summarize` found for reference `&T`
  |
  = help: items from traits can only be used if the type parameter
          is bounded by the trait

他の言語のジェネリクス(あるいは動的型付け)では、「そのメソッドがあるか」はランタイムに行ってようやく分かる場合がありますが、Rust はジェネリック関数が使うすべての能力を境界として宣言させます。関数シグネチャがそのまま要求条件のドキュメントになります。条件が複数あるなら T: Summary + Clone のように足します。

derive — 標準トレイトの自動実装 #

第 5 回の #[derive(Debug)] は「Debug トレイトの実装を自動生成せよ」という指示でした。よく使う派生の一覧は、事実上、構造体宣言の常備薬です。

src/main.rs
#[derive(Debug, Clone, PartialEq)]
struct Point { x: i32, y: i32 }
  • Debug: {:?} 出力。デバッグの基本です
  • Clone: .clone() を提供。第 3 回の明示的なディープコピーがこれでした
  • PartialEq: == 比較。派生していない構造体は == すらコンパイルされません
  • Copy: 第 3 回の「move ではなくコピーされる型」の正体。すべてのフィールドが Copy のときだけ派生でき、String を含む構造体に付けるとコンパイルエラーになります

演算子や出力といった言語機能が、すべてトレイトで定義されているのが Rust の一貫した構造です。println!{}Display トレイト、+ 演算子は Add トレイト、第 7 回の for の反復は Iterator トレイトです。「この型にこの構文を使えるか」という問いが、すべて「このトレイトを実装しているか」に還元されます。

コストの話 — 単相化と dyn #

ジェネリクスが便利なら、次に気になるのは性能です。Rust のジェネリック関数は単相化(monomorphization) されます。コンパイラが、実際に使われた型ごとに専用バージョンを作り出す方式です。print_summaryArticleComment で呼ばれると、コンパイル結果にはそれぞれ専用の関数が 2 つ入り、呼び出しは普通の関数呼び出しと同じになります。ジェネリクスを使ってもランタイムコストはないという意味で、第 7 回でイテレータチェーンが手書きループと同等だと言ったのも同じ原理です。

逆に、「どの型かがランタイムになって初めて決まる」場合があります。第 6 回の Box<dyn Error> がそれです。

src/main.rs
let items: Vec<Box<dyn Summary>> = vec![
    Box::new(Article { /* ... */ }),
    Box::new(Comment { /* ... */ }),
];
for item in &items {
    println!("{}", item.summarize());
}

dyn Summary は「Summary を実装した何か」という意味で、記事とコメントを 1 つの Vec に混ぜて入れられるようにします。代わりに、どの summarize を呼ぶかをランタイムに探す動的ディスパッチのコストが生じます。基本はジェネリクス(コンパイル時に確定)を使い、異なる型を 1 つのコレクションに混ぜる必要があるときだけ dyn を使う、というのが実務の基準です。

まとめ #

  • トレイトは動作の約束です。無関係な型に同じ能力を与え、既存の型にも後から実装を付けられます。
  • ジェネリック関数は、使う能力をトレイト境界(<T: Summary>)としてすべて宣言しなければなりません。シグネチャが要求条件のドキュメントになります。
  • derive(Debug, Clone, PartialEq) は構造体の常備薬で、Copy はすべてのフィールドが Copy のときだけ可能です。第 3 回と第 5 回の約束がここで回収されます。
  • {} 出力は Display、+ は Add、for は Iterator というように、言語機能の大半がトレイト実装の有無に還元されます。
  • ジェネリクスは単相化されてランタイムコストがありません。型がランタイムに決まる dyn は、コレクションに混ぜて入れるときだけ使うのが基準です。
  • 最終回では、第 4 回が予告したライフタイム(lifetime)の実体を確かめ、モジュールと外部クレートでプロジェクトを構成する方法まで扱って講座を締めくくります。
X