🐳

トレイトオブジェクトとして有用なトレイトがオブジェクトセーフになっている

2022/10/24に公開約6,500字

タイトルの意味を理解できた人はこの記事を読む必要はありません。私は最初見たときさっぱり意味が分からなかったので調べてみました。

タイトルの文言はRust APIガイドライン(非公式日本語訳)に登場します。説明も書いてあるのですが、読んでも全く理解できないので単語の意味が理解できていないのではと思い、まずは単語から調べました。

単語を調べる

単語を調べるためにまずはRustの公式リファレンスThe book を参照しました。英語はよく分からないので翻訳を駆使します。

トレイト

特性は、型が実装できる抽象インターフェイスを記述します。

これは分かる。トレイトは翻訳に突っ込むと特性と呼ばれるのは知ってた。記法としてはtrait Trait{}こんなやつ。

トレイトオブジェクト

特性オブジェクトは、一連の特性を実装する別のタイプの不透明な値です

???うむ分からん。ちょっと読み進めます。

特性のセットは、オブジェクトセーフ基本特性と任意の数の自動特性で構成されます。

なるほど、ここでオブジェクトセーフが出てくるが、いったん置いておこう。

特性オブジェクトの目的は、メソッドの「遅延バインディング」を許可することです。
特性オブジェクトでメソッドを呼び出すと、実行時に仮想ディスパッチが行われます。
つまり、関数ポインタが特性オブジェクト vtable からロードされ、間接的に呼び出されます。
各 vtable エントリの実際の実装は、オブジェクトごとに異なる場合があります。

???まだよくわからんのでThe bookのほうも確認します

特性オブジェクトは、指定された特性を実装する型のインスタンスと、実行時にその型の特性メソッドを検索するために使用されるテーブルの両方を指します。
& 参照や Box<T> スマート ポインターなどの何らかのポインターを指定し、次に dyn キーワードを指定して、関連する特性を指定することにより、特性オブジェクトを作成します。
ジェネリック型または具象型の代わりに特性オブジェクトを使用できます。
どこでトレイト オブジェクトを使用しても、Rust の型システムはコンパイル時に、そのコンテキストで使用されるすべての値がトレイト オブジェクトのトレイトを実装することを保証します。
したがって、コンパイル時にすべての可能な型を知る必要はありません。

つまりトレイトを実装した具象型として使えるが、ポインターにしないといけないものという理解しました。記法としてはBox<dyn Trait>こんなやつ。

トレイト境界

トレイトオブジェクトを理解したらトレイト境界との違いがよく分からなくなったので考えました。

特性と有効期間の境界は、ジェネリック項目がパラメーターとして使用される型と有効期間を制限する方法を提供します。
境界は、where 句の任意の型に指定できます。特定の一般的なケースには、より短い形式もあります。

つまりジェネリックをトレイトで制限するものと理解しました。記法としてはfn func<T:Trait>(a:T)->Tこんなやつ。

implトレイト

ついでにimplトレイトについても調べました。

impl Traitでは、特定の特性を実装する名前のない具象型を指定する方法を提供します。
引数の位置にある impl Trait は、<T: Trait> のようなジェネリック型パラメーターのシンタックス シュガーですが、型が匿名で GenericParams リストに表示されないことを除きます。
impl Trait return 位置では、関数がボックス化されていない抽象型を返すことができます。これは、クロージャとイテレータで特に便利です。たとえば、クロージャには一意で書き込み不可能な型があります。
呼び出し元が戻り値の型を決定することを許可しません。代わりに、関数は戻り値の型を選択しますが、Trait を実装することを約束するだけです。

つまりトレイト境界のシンタックスシュガーだけどジェネリック引数が使えないものと理解しました。記法としてはimpl Traitこんなやつ。

オブジェクトセーフ

先ほど出てきたオブジェクトセーフについて調べます

オブジェクトセーフ特性は、特性オブジェクトの基本特性にすることができます。特性は、次の性質 (RFC 255 で定義) を持つ場合、オブジェクトセーフです。
  すべての超特性もオブジェクトセーフでなければなりません。
  Sized はスーパートレイトであってはなりません。つまり、Self: Sized を要求してはなりません。
  定数が関連付けられていてはなりません。
  関連するすべての関数は、特性オブジェクトからディスパッチ可能であるか、明示的にディスパッチ不可である必要があります。
  ディスパッチ可能な機能には、次のものが必要です。
    型パラメータを持たない(ただし、有効期間パラメータは許可されています)。
    レシーバーの型以外で Self を使用しないメソッドであること。
    次のいずれかのタイプの受信機を用意してください。
      &Self(すなわち、&self)
      &mut Self(すなわち、&mut self)
      Box<Self>
      Rc<Self>
      Arc<Self>
      Pin<P> は上記のタイプの 1 つです。P
    where Self: Sized バウンドがありません (Self の受信者タイプ (つまり、self) はこれを意味します)。
  明示的にディスパッチできない関数には、次のものが必要です。
    where Self: Sized バウンドを持ちます (Self のレシーバー タイプ (つまり、self) はこれを意味します)。

いろいろ書いてありますが、上記のような条件を満たすトレイトをオブジェクトセーフと呼び、オブジェクトセーフなトレイトはトレイトオブジェクトとして使えるとのこと。つまりトレイトオブジェクトとして使えるトレイトの条件と理解しました。The bookも探しましたがオブジェクトセーフについての記述は見つけられませんでした。(どこかにあるかもしれませんが。。)
条件がたくさんあり、覚えるのが大変ですが、トレイトオブジェクトとして使うということは実行時まで具象型が分からないのでSelfや型パラメータが使えないのでしょう。

検証

実際にトレイト境界、トレイトオブジェクト、implトレイトを使ったコードをコンパイルしアセンブリを確認してみます。

trait Mytrait {
    fn number(&self) -> i32;
}

struct Mystruct {}
struct Mystruct2 {}

impl Mytrait for Mystruct {
    fn number(&self) -> i32 {
        1
    }
}

impl Mytrait for Mystruct2 {
    fn number(&self) -> i32 {
        2
    }
}

fn call_number<T: Mytrait>(x: Box<T>) -> i32 {
    x.number()
}

fn main() {
    let a = Mystruct {};
    let b = Mystruct2 {};
    call_number(Box::new(a));
    call_number(Box::new(b));
}

上のようなコードを利用します。上はトレイト境界バージョンです。implトレイトバージョンとトレイトオブジェクトバージョンはそれぞれcall_number関数の引数を下記のようにします。トレイトオブジェクトバージョンではBoxに包む必要があり、差分を確認しやすくするため他のバージョンもあえてBoxで包んでいます。

fn call_number(x: Box<impl Mytrait>) -> i32 {
fn call_number(x: Box<dyn Mytrait>) -> i32 {

トレイト境界バージョンのアセンブリを見てみるとMystructを渡したcall_numberとMystruct2を渡したcall_numberがそれぞれ別の関数になっているのが分かります。

implトレイトバージョンもまったく同様でMystructを渡したcall_numberとMystruct2を渡したcall_numberがそれぞれ別の関数になっています。

トレイト境界、implトレイトはコンパイル時に具象型それぞれに対してコンパイル時に関数が生成されています。これが静的ディスパッチ・ゼロコスト抽象化と呼ばれるものだったんですね。

対してトレイトオブジェクトバージョンではMystructの時とMystruct2の時で同じ関数を呼び出すのですが呼び出すときにvtableなるものを一緒に渡しています

そういえばトレイトオブジェクトの説明にvtableという単語がでてきてました。

関数ポインタが特性オブジェクト vtable からロードされ、間接的に呼び出されます。
各 vtable エントリの実際の実装は、オブジェクトごとに異なる場合があります。

なるほど。この説明はそういう意味だったんですね。これが動的ディスパッチと呼ばれるものでvtableを利用する分処理が増える(ゼロコストでない)ということですね。

言語の記法を見るとトレイトオブジェクトとimplトレイトが対になっていてトレイト境界は別物のように感じていたが、実際はimplトレイトがトレイト境界のシンタックスシュガーでトレイトオブジェクトが別物のように感じた。

整理

トレイトオブジェクト、トレイト境界、implトレイトとあって何をいつ使えばいいかが分からなくなりそうなのでフローチャートを作成しました。基本的にはimpleトレイト>トレイト境界>トレイトオブジェクトの順番で優先すればよさそうです。また、トレイトオブジェクトを使うにはトレイトがオブジェクトセーフでなければいけなく、動的ディスパッチと静的ディスパッチで実行速度とファイルサイズのトレードオフがあります。

画像だと文字が小さくなってしまったのでmermaidでも記載

タイトル回収

ここまでくればタイトルの文言も理解できます。

トレイトオブジェクトとして有用なトレイトがオブジェクトセーフになっている

トレイトオブジェクトとして使うにはそのトレイトがオブジェクトセーフでなければならないということを言っているだけですね。ただ、トレイトオブジェクトとして有用なトレイトかどうかというのはトレイトオブジェクトをどのようなときに使うのかを知らないといけないので、そこがピンときてませんでした。トレイトオブジェクトトレイト境界implトレイトそれぞれの役割を整理することでどのようなときにトレイトオブジェクトとして利用されるかがイメージできるようになりました。

そしてなぜこの文言がAPIガイドラインに存在するのかも今なら想像できます。トレイトがオブジェクトセーフであるためにはたくさんの条件があり、パッと見ではオブジェクトセーフかどうかが確認できないからではないでしょうか。コンパイルエラーになるのはオブジェクトセーフでないトレイトを定義したときではなくオブジェクトセーフでないトレイトをトレイトオブジェクトとして使おうとしたときなので、定義した後にちゃんと確認しようということだと思います。

おわりに

今までなんとなくでしか理解していなかったトレイトオブジェクトやトレイト境界の意味をリファレンスから調べ直したり、実際にコンパイルしてアセンブリを確認したりしたことでより理解が深まった。
Rustは学習コストが高いですが、リファレンスがちゃんとしているので、勉強するのがそれほど苦でないのがとても素晴らしいと思いました。

Discussion

ログインするとコメントできます