🦕

Rustのopen traitシステム dyno (RFC3192) を読む

2022/11/06に公開

dyno (RFC3192) はopen traitのための仕組み (言い換えると、trait downcastingの仕組み) をライブラリレベルで実現する提案です。

できることのイメージ

例として、以下のようなトレイトを考えます。 (std::error::Error を説明のために簡略化したものです)

// エラー型はこれを実装する
pub trait Error {
    // エラーの文字列表現を取得する
    fn to_string(&self) -> String;
}

これを拡張可能トレイトにするのが本RFCの目的です。

実際の実装はライブラリレベルで行われていますが、わかりやすくするために「拡張構文として書くならこんな感じ」というイメージを先に説明します。

open traitとしての説明

次のように、定義済みのError traitを拡張できる仕組みであると説明できます。

⚠️このコード例の構文は架空の構文です

pub open trait Error {
    fn to_string(&self) -> String;
}

// ↓Errorを利用する別のライブラリ内の定義↓

extend trait std::error::Error {
    fn backtrace(&self) -> Option<Backtrace> {
        // 拡張メソッドは実装を強制できないため、必ずデフォルトの実装が必要
        None
    }
}

// ↓実装↓
impl Error for MyError {
    fn to_string(&self) -> String { ... }
    // 拡張メソッドに対しても実装できる
    fn backtrace(&self) -> Option<Backtrace> { ... }
}

これはいわゆる拡張トレイトパターンと似ているように見えますが、拡張メソッドの実装を個々の型で置き換えられるという点で本質的に異なります。言い換えると、既存の「拡張トレイトパターン」は実装レベルでの拡張なのに対し、open traitはインターフェースレベルでの拡張です。

trait-to-trait downcasting としての説明

同じ機能の別解釈として、「トレイトを拡張する」のではなく「別のトレイトを定義した」と解釈することもできます。この場合必要になるのは「トレイトからサブトレイトへのダウンキャスト」という処理です。

⚠️このコード例の構文は架空の構文です

#[allow_downcasting]
pub trait Error {
    fn to_string(&self) -> String;
}

// ↓Errorを利用する別のライブラリ内の定義↓

pub trait ErrorWithBacktrace: std::error::Error {
    fn backtrace(&self) -> Option<Backtrace>;
}

// ↓利用側↓

// dyn Errorをdyn ErrorWithBacktraceにダウンキャストする
// ErrorWithBacktraceを実装していなければNoneが返ってくる
let e = e.downcast_as_trait::<dyn ErrorWithBacktrace>().unwrap();

現在はトレイトから型へのダウンキャストはできますが、トレイトからトレイトへのダウンキャストはできません。

dynoはこのようなダウンキャストを実現するライブラリ機能と考えることもできます。GoやJavaなどになじみがあれば、こちらの考え方のほうがしっくりくるかもしれません。

なぜopen traitが必要なのか

ここでは dyn Error を主要な動機として説明します。 dyn Error は以下のような特徴を持ちます。

  • 多様なコードによって生成される。また、トレイトオブジェクト型のため、その表現は拡張可能である。
  • 多様なコードによって消費される。

このような特徴を持つデータ型はあまり多くありませんが、もし存在すれば同様の動機が成立するかもしれません。

関心の分離

dyn Error は多様なコードによって生成され、多様なコードによって消費させる、いわばバスのような性質をもったデータ型です。

本来、トレイトは型が満たすべき振る舞いを網羅するものですが、このようなケースで必要な振る舞いを全て Error traitに集約すると枝葉末節や特定のアプリケーションに固有の関心までが標準ライブラリに集められてしまい、デメリットのほうが大きくなってしまう危険性があります。

これを避けるためには、トレイトのインターフェースを拡張する機構が必要になります。

Errorをcoreに入れたい

もう1つ、Errorに固有の事情として、標準ライブラリの階層問題があります。

Rustでは実行環境に対する仮定の強さに応じて標準ライブラリが3段階に階層化されています。

  • core ... 全ての実行環境で使えるライブラリ
  • alloc ... メモリアロケーター (malloc/free) がある環境で使えるライブラリ
  • std ... メモリアロケーターに加え、OS相当のAPI (スレッドAPIやファイルシステムなど) がある環境で使えるライブラリ

ここで、 std::error::Error のコア機能は以下の2つです。

  1. エラーメッセージの表示
  2. エラーの原因データ

1は Display を経由して実現されています。 Display はストリーム書き込み型のAPIなのでメモリアロケーターに依存せず、 core で実現できます。

2は Option<&dyn Error + 'static> を返すAPIです。内部にあらかじめ存在するフィールドへの参照を返すだけであるため、メモリアロケーターには依存しておらず core で実現できます。

実はもうひとつ、エラー型の共通APIとして望ましいものがあります。それはエラースタックトレース情報を返すAPIです。しかし、スタックトレースを保存するためのデータ型 Backtrace は普通に定義しようとするとメモリアロケーターに依存してしまうなどの問題があり、そのままでは core に置くことはできません。 (→ rust-lang/rust#53487, rust-lang/rust#77384, rust-lang/project-error-handling#3 なども参照)

Backtrace の定義を工夫するという方法もありますが、これは Backtrace の定義側に負担を強いることになります。 Error::backtrace をErrorの拡張部分として切り離すことができれば、Errorの本体をcoreに移すのは簡単になります。

何が難しいのか

Rustの動的ディスパッチの仕組み (トレイトオブジェクト) は、デフォルトでは高級な実行時型情報 (RTTI)を持ちません。仮想関数テーブルに記されているのは以下の情報のみです。

  • 元データのサイズとアラインメント
  • 各トレイトメソッドの実装を指す関数ポインタ
  • dropの実装を指す関数ポインタ

これらは宣言済みの振る舞いを再現するのに必要な最低限の情報にすぎません。「ダウンキャスト」や「ある追加メソッドが実装されているかどうかの判定」などは、この仮想関数テーブルから読み取れる情報だけでは行えません。このような高級な判定が必要ならば、そのためのプロトコルを設計してトレイトのメソッドとして陽に表現する必要があります。

前提知識

Type ID

RustでRTTIのようなことをしたいときは基本的に "Type ID" と呼ばれるIDを基本パーツとして使うことになります。

Type IDはTypeId::of で取得できます。Type IDは(現時点では) 'static な型に対してのみ定義されています。

Rustではジェネリクスを 単相化 (monomorphization) という方式で実現していて、型引数とconstジェネリクス引数はコンパイル時に全て具体化した状態でコード生成を行います。 (これはC++のテンプレートと同様の方式です。) そのため、必要な型の組み合わせは全てコンパイル時に列挙された状態になっています。Type IDはこの型に対して、コンパイラ固有のルールでハッシュ値を取ったものになっているため、全ての具体的な型に対して一意になっていることが保証されます。

ただし、ライフタイム引数は単相化の対象ではなく 型消去 (type erasure) 相当の方法で処理されます。そのため Type IDにはライフタイム引数の違いは反映されません。現時点ではType IDを取得できるのは 'static な型に限られるため、この問題を気にする必要はありません。

トレイトから型へのダウンキャスト

トレイトから具体的な型へのダウンキャストは昔から Any に実装されており、 Error にも同等の実装が存在しています。

これは、「元になった型のType IDを返すメソッド」をトレイトメソッドとして定義しておくことで、ダウンキャストの安全性をチェックする仕組みになっています。安全性が確認できたら、unsafeコードでポインタを強制的にキャストして元のデータを取り出します。

dynoプロトコル (Provider/Demand) を理解する

dynoのプロトコルであるProvider/Demandを理解するために、ここでは素朴な実装から少しずつ改善してみます。

素朴な実装

open traitのプロトコルを素朴にエンコードすると、たとえば以下のようになると考えられます。

pub trait Extendable {
    // 拡張メソッドに幽霊型を割り当て、そのtype idを使ってメソッドを識別する。
    // idで指定したメソッドがあれば実行してSomeを返す。なければNoneを返す。
    fn call_extended(&self, id: TypeId, args: &[Box<dyn Any>]) -> Option<Box<dyn Any>>;
}

// 呼び出し方法
let backtrace = e.call_extended(&self, GetBacktrace, &[]).unwrap();
let backtrace = *backtrace.downcast::<Backtrace>().unwrap();

しかしこれにはいくつかの問題があります。

  • 引数に渡せる値に制限がある。
  • 戻り値のダウンキャストを自力で行う必要がある。
  • Anyを返すためにBoxが必要になり、アロケーターへの依存が発生する。

そこでこれらの問題を解決する設計を考えてみます。

引数の削除

まず、引数を渡すための仕組みは削除します。大抵のユースケースでは引数つきの拡張メソッドは必要ないですし、どうしても必要であれば2段階の呼び出しプロトコルにする (e.g. クロージャを返すようにする) ことで対応できます。

pub trait Provider {
    // 拡張フィールドに幽霊型を割り当て、そのtype idを使ってフィールドを識別する。
    // idで指定したフィールドがあればそれをSomeとして返す。なければNoneを返す。
    fn provide(&self, id: TypeId) -> Option<Box<dyn Any>>;
}

// 呼び出し方法
let backtrace = e.provide(&self, BacktraceField).unwrap();
let backtrace = *backtrace.downcast::<Backtrace>().unwrap();

このAPIは「トレイトの拡張」というよりも「拡張フィールドを提供する仕組み」という風体になるため、名前を Extendable::call_extended から Provider::provide に変更しました。

戻り値スロット

次に、戻り値を書き戻すためのスロットを呼び出し元で確保するようにします。呼び出し元は期待する戻り値型を知っているため、呼び出し元は上記の get_extended よりも生存期間が長いため、呼び出し元スタックに静的にスロットを確保すれば動的アロケーションの問題は解決します。

pub trait Provider {
    // 拡張フィールドに幽霊型を割り当て、そのtype idを使ってフィールドを識別する。
    // idで指定したフィールドがあればそれをSomeとしてretvalに入れて返す。なければ何もしない。
    // retvalのdyn Anyの中身は必ず Option<T> (Tは拡張フィールドの型) の形であり、はじめはNoneが入っている。
    fn provide(&self, id: TypeId, retval: &mut dyn Any);
}

// 呼び出し方法
let mut backtrace: Option<Backtrace> = None;
e.provide(&self, BacktraceField, &mut backtrace);
let backtrace = backtrace.unwrap();

TypeIdの統合・型ベースの識別への移行

AnyはTypeIdを返すAPIを持っています。Anyを戻り値スロット化したことで、戻り値スロットの一部としてもTypeIdが得られるようになりました。これは拡張フィールド名を識別するための id: TypeId と冗長であると考えられます。

そこで、拡張フィールドをそのフィールドの型で識別することにしてしまいます。これは同じ型の拡張フィールドを2つ作ることができないことを意味しますが、必要に応じてnewtype patternで新しい型を作ればよいため、これはあまり問題ではないと考えられます。

こうするとAny由来のTypeIdをそのまま使えるようになるため、APIの引数が以下のように1つ減ります。

pub trait Provider {
    // 拡張フィールドは、その型のtype idによって識別する。
    // 指定した型の拡張フィールドがあればそれをSomeとしてretvalに入れて返す。なければ何もしない。
    // retvalのdyn Anyの中身は必ず Option<T> (Tは拡張フィールドの型) の形であり、はじめはNoneが入っている。
    fn provide(&self, retval: &mut dyn Any);
}

// 呼び出し方法
let mut backtrace: Option<Backtrace> = None;
e.provide(&self, &mut backtrace);
let backtrace = backtrace.unwrap();

戻り値スロットのカプセル化

ここまでで定義したProvide::provideの実装者は通常、「全ての拡張フィールドを順に調べ、該当するフィールドがあればそれを代入する」という実装方法を取ります。そこで、 dyn Any をラップして、これを定型的に行えるためのAPIを整備します。

pub trait Provider {
    // 拡張フィールドは、その型のtype idによって識別する。
    // 指定した型の拡張フィールドがあればそれをSomeとしてretvalに入れて返す。なければ何もしない。
    // retvalのdyn Anyの中身は必ず Option<T> (Tは拡張フィールドの型) の形であり、はじめはNoneが入っている。
    fn provide(&self, demand: &mut Demand);
}

// このAnyは Option<T> に由来する。
#[repr(transparent)]
pub struct Demand(dyn Any);

// 呼び出し方法
let mut backtrace: Option<Backtrace> = None;
e.provide(&self, &mut *(&mut backtrace as &mut dyn Any as *mut dyn Any as *mut Demand));
let backtrace = backtrace.unwrap();

Demandは dyn Any をラップした構造体で、以下のようなAPIを提供します

impl Demand {
    pub fn provide_value<T>(&mut self, value: T) -> &mut Self
    where
        T: 'static,
    {
        if let Some(this) = self.0.downcast_mut::<Option<T>>() {
	    if this.is_none() {
	        *this = Some(value);
	    }
	}
        self
    }
}

Providerの実装者は、各拡張フィールドを引数に provide_value を繰り返し呼ぶことになります。

参照返しへの対応

ここまで、拡張フィールドは常に値として返されるという前提になっていましたが、参照として返すのが望ましい場合もあります。

ここまでは Any を使う都合上 T: 'static が仮定されており、同じAPIで参照返しはできないという制限がありました。

T: 'static のような制約を完全に撤廃することは困難ですが、うまく工夫することで以下の2種類のAPIを両立することはできます。

  • T を返すAPI (T: 'static)
  • &'self T を返すAPI (ただし、 T: 'static)

そのためには以下の変更が必要です。

  • 返される参照のライフタイムを表すために DemandDemand<'a> としてジェネリックにする
  • Demand<'a>内部表現を書き換えて、値と参照の両方に対応させる

Any は参照を入れるのには使えないため、かわりに Erased<'a> という類似のトレイトを内部的に用意して使います。 Erased<'a> はAnyによく似た type_id メソッドを持ちますが、以下のような特殊な挙動をします。

  • T 型の値を受け取りたいときは tag::Value<T> という幽霊型のtype idを返す。
  • &'a T 型の参照を受け取りたいときは tag::Ref<T> という幽霊型のtype idを返す。

またDemandの中身は常に Option<_> でラップされた型であることがわかっているので、 Erased<'a>Option<_> のみが実装すればよいことになります。ただし、 Option<_> をそのまま使ってしまうと上記の2種類のtype idを区別できないため、 Option<_> をラップした TaggedOption<'a, I> 型を使います。この Itag::Value<T>tag::Ref<T> のどちらかを指しています。

このように実装を変更すると、Demandは値と参照の2種類のAPIを提供できるようになります。

  • Demandを作るAPI (非公開)
    • 値ベースのDemandを作るAPI
    • 参照ベースのDemandを作るAPI
  • Demandに値を供給するAPI
    • Demand::provide_value
    • Demand::provide_ref

要求に合致する種類の provide_* が呼ばれたときだけ意味がある、という点は今までと同じです。

ここまでの説明で、実際のdynoの実装と同じものを組み立てることができました。

Providerの使い方

Providerは拡張可能なトレイトが実装するべきAPIです。たとえば以下のようにサブトレイトとして継承して使うことができます。

pub trait MyExtendableTrait: Provider {
    // ...
}

別の方法として、個々のトレイトに同等のAPIを定義しておき、そこからProviderを導出するという手もあります。

pub trait MyExtendableTrait {
    // ...
    fn provide<'a>(&'a self, demand: &mut Demand<'a>) {}
}

impl<T: MyExtendableTrait> Provider for T {
    fn provide<'a>(&'a self, demand: &mut Demand<'a>) {
        MyExtendableTrait::provide(self, demand);
    }
}

Errorは後者の方法を取っているようです。後者には以下の利点があります:

  • 既存のtraitに足しても壊れにくい。
    • ただし、blanket implを足すこと自体は破壊的変更になる余地がある。Error自体はまだProviderの安定化前から実装を提供しているので、問題にならない。
  • 特に必要がなければ、provideの実装を書かなくてもよいので、実装側の手間は小さくなる。

provide関数は以下のように、提供したいフィールドごとに provide_valueprovide_ref を呼ぶ形で実装されます。

impl Error for MyError {
    // ...
    fn provide<'a>(&'a self, demand: &mut Demand<'a>) {
        demand
	    .provide_ref(&self.backtrace)
	    .provide_value(self.log_level);
    }
}

フィールドの取得

拡張フィールドを取得するには request_ref / request_value を使います。これをラップした同機能のメソッドがErrorにも存在します。

trait downcastingとの関係

以下の意味で、dynoはtrait downcastingと能力的に同等です。

  • provide_refでサブトレイトへの参照を提供すれば、trait downcastingを実現できる。
  • 逆に、trait downcasting相当の機能があれば、拡張フィールドをサブトレイトとして提供してdowncastingすることで拡張フィールドを実現できる。

オープン構造体との関係

オープントレイトはインターフェースレベルでのフィールド拡張を可能にするのに対し、Request::extensionsに代表されるオープン構造体パターンはデータ定義レベルでのフィールド拡張を可能にします。

より具体的な例を挙げると、オープントレイトには「フィールドを追加する」というランタイム操作は存在しません。オープントレイトにおいては、実際のフィールド管理は個々の具体的な型に任せられており、それらは(トレイトレベルでの)拡張フィールドを(構造体レベルでは)単なる通常のフィールドとして提供することも可能だからです。

まとめ

  • RFC3192は、トレイトにインターフェースを動的に追加する仕組みをライブラリレベルで提供する。
  • 主な恩恵として、Errorインターフェースを利用側で拡張して使うことができる。特にバックトレース情報はこの方法で管理される予定である。
  • 基本的なアイデアは「RTTIを使って、必要なフィールド名を動的に識別する」というものだが、内部的にはRust特有の課題を解決するためにプロトコル面での工夫がなされている。

Discussion