Rust のポリモルフィズムとトレイトオブジェクト
Rust で、「ある構造体のフィールドに、ある条件(=トレイト)を満たす他の構造体と動的に取り替え可能なフィールドを設定したい」と思うことがある。例えば、struct Binary で定義している、バイナリファイルを、struct BinaryReader 経由で読み込むときに、そのデータがstruct AであるかBであるかが、読み込むまで分からないとする。このAとBは、どちらもMyTraitを実装しているとする。この状況に対して、どう実装すべきかについて調査したことをまとめる。
例えば、以下のようなトレイトと型を定義したいhttps://doc.rust-lang.org/nomicon/exotic-sizes.htmlとする。
trait MyTrait pub struct MyStruct { my_trait: MyTrait }
しかし、こういう構造体のインスタンスを作る際に、 doesn't have a size known at compile-time といって怒られてしまう。また特に、
trait MyTrait { fn to_stream<W: io::Write>(&self, stream: &mut W) -> io::Result<()>; fn from_stream<R: io::Read>(&mut self, stream: &mut R) -> io::Result<bool>; }
さらに、MyTrait の定義を上記のように拡張すると、Test2 トレイトの内部の関数にもジェネリックがあるので、 trait cannot be made into an object と言われる。そこで、そのようなトレイトをフィールドに持つような構造体を定義する方法について、以下のように調査した。
ジェネリクス(静的な方法)
このとき、単純にトレイトそれ自体を型の定義に入れてしまうと、そのインスタンスに対して、コンパイル時に型を決める必要がある。例えば
trait MyTrait { fn new() -> Self; } pub struct MyStruct<T: MyTrait> { my_trait: T } impl<T: MyTrait> MyStruct<T> { pub fn new() -> C<T> { C{data: T::new()} } } fn main() { let struct = MyStruct::new(); }
これはコンパイルが通らない。なぜかというと、MyStruct の型が結局決まらないからだ。これがビルドできるようにするには、
struct A { data: Vec<u32> } impl MyTrait for A { fn new() -> A { return A{data: vec![]} } } fn main() { let struct: MyStruct<A> = MyStruct::new(); }
上記のように、コンパイル時に型を決定しなくてはならない。しかしある場合では、この型の決め方を動的にしたいという場合があるだろう。というところで、以下の方法を検討する。
dyn Trait
として持つ場合(動的サイズ型)
Rust では可変スタックの使用を許可しないため、基本的にトレイトオブジェクトをスタックに格納することができない。そのため、以下のようにする。
pub struct MyStruct<'a> { my_trait: &'a dyn MyTrait, } pub struct MyStruct { my_trait: Box<dyn MyTrait>, }
対応関係としては、Box<Trait>
はスマートポインタ、 &Trait
は参照型である。Box<dyn Trait>
とすることで、その struct の定義に、トレイトが登場しなくなる。そのため、このMyStruct のインスタンスを作る際には、具体的に内部のフィールドがどの型になるかがコンパイル時に決まっている必要はない。この方式では、Box を使うので、実体はヒープに格納されることになる。Rust では、Box や Arc、 Rc にトレイトを格納すれば、構造体にはそれへのポインタを持つだけでよいためだ。必ず、1回しか参照されない場合は Box<>
で領域を確保すれば良い。あるオブジェクトをスマートポイントによって、参照カウントによって共有する場合に、 Arc/Rc を使う。Rc はスレッド安全ではないが、Arc はスレッド安全という違いがある。
こうしたやり方は、 late-binding とも呼ばれる。しかしながらそうしてしまうと、2つほど懸念がある。1つ目は、静的に解決するのに比べて、vtable を利用してトレイトにアクセスするので、単なるポインタに比べて若干遅くなるということだ。データアドレスとvtableアドレスの両方をエンコードするファットポインタとなっている。2つ目は、「そもそもそこに格納されている型が何なのか?」の情報が失われてしまうので、別立てで持っておかないといけない。この問題について具体的に言及しているのが、以下のブログ記事だ。
.unwrap() .as_ref() .as_any() .downcast_ref::<A>() .unwrap();
このように、元の型(A)に戻すために、長いメソッドチェーンを記述する必要があり、きれいではない。
Enum Wrapper pattern
これに対して、Enum で元の型を対応するパターンマッチングを実装することを、先述のブログ記事の著者は推奨している。Rust での列挙型はタグ付きユニオンであるため、列挙型の最大のエントリ程度メモリフットプリント(およびタイプ情報用にもう少し)が必要で、サイズはコンパイル時に既知であるため、ヒープは使わなくてすむ。
enum Format { A(A), B(B), C(C), } pub struct MyStruct { my_trait: Format }
もちろんこの方法を使うと、至る所でパターンマッチを用いて、「この型がどの型だったら」という条件文を記述する必要がある。つまりメモリの消費量が多くなり、コードがわずかに多くなる。