共通の振る舞いを抽象化するためにトレイトオブジェクトを使う
第8章では、ベクタの制限の1つとして、格納できる要素が1つの型に
限られることに触れました。リスト 8-9 では、この制限に対する回避策として、
整数、浮動小数点数、テキストを保持するバリアントを持つ SpreadsheetCell
enum を定義しました。これにより、各セルに異なる型のデータを格納しつつ、
セルの1行を表すベクタを持つことができました。これは、相互に入れ替えて使う
項目が、コードのコンパイル時に分かっている固定の型の集合である場合には、
まったく問題のない解決策です。
しかし、状況によっては、ある特定の場面で有効な型の集合を、ライブラリの
利用者が拡張できるようにしたいことがあります。これをどのように実現できる
かを示すために、画面上に描画するために各項目に対して draw メソッドを
呼び出しながら項目のリストを反復処理する、グラフィカルユーザー
インターフェイス (GUI) ツールの例を作ります。これは GUI ツールで一般的な
手法です。gui という名前のライブラリクレートを作成し、その中に GUI
ライブラリの構造を含めます。このクレートには、Button や TextField の
ような、利用者が使えるいくつかの型が含まれるかもしれません。さらに、
gui の利用者は、自分で描画可能な型も作りたいと思うでしょう。たとえば、
あるプログラマは Image を追加し、別のプログラマは SelectBox を追加する
かもしれません。
ライブラリを書いている時点では、ほかのプログラマが作りたい型をすべて
知ることも、定義することもできません。しかし、gui がさまざまな型の多く
の値を追跡し、それらの異なる型の値それぞれに対して draw メソッドを
呼び出す必要があることは分かっています。draw メソッドを呼び出したときに
具体的に何が起こるかを正確に知る必要はなく、その値に、呼び出せるその
メソッドが用意されていることだけが必要です。
これを継承のある言語で行うなら、draw という名前のメソッドを持つ
Component というクラスを定義するかもしれません。Button、Image、
SelectBox などのほかのクラスは Component を継承し、その結果として
draw メソッドも継承します。それぞれが draw メソッドをオーバーライドして
独自の振る舞いを定義できますが、フレームワークはすべての型を Component
インスタンスであるかのように扱い、それらに対して draw を呼び出せます。
しかし Rust には継承がないため、ライブラリ利用者がライブラリと互換性のある
新しい型を作れるように gui ライブラリを構成する別の方法が必要です。
共通の振る舞いのためのトレイトを定義する
gui に持たせたい振る舞いを実装するために、draw という1つのメソッドを
持つ Draw という名前のトレイトを定義します。そうすれば、トレイト
オブジェクトを受け取るベクタを定義できます。トレイトオブジェクト は、
指定したトレイトを実装する型のインスタンスと、その型に対するトレイト
メソッドを実行時に参照するためのテーブルの両方を指します。トレイト
オブジェクトは、参照や Box<T> スマートポインタのような何らかのポインタ型、
続けて dyn キーワード、さらに対象となるトレイトを指定することで作成
します。(トレイトオブジェクトがポインタを使わなければならない理由については、
第20章の 「動的サイズ付き型と Sized トレイト」 で説明します。)
トレイトオブジェクトは、ジェネリック型や具体的な型の代わりに使えます。
トレイトオブジェクトを使う場所ではどこでも、その文脈で使われる値がその
トレイトオブジェクトのトレイトを実装していることを、Rust の型システムが
コンパイル時に保証します。その結果、コンパイル時に取り得る型をすべて
知っておく必要はありません。
Rust では、struct や enum をほかの言語のオブジェクトと区別するために、
それらを「オブジェクト」と呼ぶのは避ける、とこれまでに述べてきました。
struct や enum では、struct のフィールドにあるデータと impl ブロックに
ある振る舞いは分離されています。一方、ほかの言語では、データと振る舞いが
結び付いた1つの概念はしばしばオブジェクトと呼ばれます。トレイト
オブジェクトは、ほかの言語のオブジェクトとは異なり、トレイトオブジェクトに
データを追加することはできません。トレイトオブジェクトは、ほかの言語の
オブジェクトほど汎用的に有用ではありません。トレイトオブジェクトの具体的な
目的は、共通の振る舞いに対する抽象化を可能にすることです。
リスト 18-3 は、draw という1つのメソッドを持つ Draw という名前の
トレイトをどのように定義するかを示しています。
pub trait Draw {
fn draw(&self);
}
この構文は、第10章でトレイトをどのように定義するかを説明したときのものと
見覚えがあるはずです。次に新しい構文が出てきます。リスト 18-4 では、
components という名前のベクタを保持する Screen という名前の struct を
定義しています。このベクタの型は Box<dyn Draw> で、これはトレイト
オブジェクトです。つまり、Draw トレイトを実装する任意の型を Box の中に
入れたものの代わりになります。
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
Screen struct には、リスト 18-5 に示すように、それぞれの components に
対して draw メソッドを呼び出す run という名前のメソッドを定義します。
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
これは、トレイト境界を持つジェネリック型パラメータを使う struct を定義する
場合とは異なる動作をします。ジェネリック型パラメータは、一度に1つの具体的な
型でしか置き換えられませんが、トレイトオブジェクトでは、実行時に複数の
具体的な型をそのトレイトオブジェクトに当てはめることができます。たとえば、
リスト 18-6 のように、ジェネリック型とトレイト境界を使って Screen struct
を定義することもできました。
pub trait Draw {
fn draw(&self);
}
pub struct Screen<T: Draw> {
pub components: Vec<T>,
}
impl<T> Screen<T>
where
T: Draw,
{
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
この方法では、Button 型だけ、あるいは TextField 型だけからなる
コンポーネントのリストを持つ Screen インスタンスに制限されます。常に
同種のコレクションしか扱わないのであれば、ジェネリクスとトレイト境界を
使うほうが望ましいです。なぜなら、定義はコンパイル時に具体的な型を使うよう
単相化されるからです。
一方、トレイトオブジェクトを使うメソッドでは、1つの Screen インスタンスが
Box<Button> と Box<TextField> の両方を含む Vec<T> を保持できます。
これがどのように機能するのかを見てから、実行時性能への影響について説明
しましょう。
トレイトを実装する
ここで、Draw トレイトを実装するいくつかの型を追加します。Button 型を
用意しましょう。繰り返しになりますが、実際に GUI ライブラリを実装することは
この本の範囲を超えているので、draw メソッドの本体には役に立つ実装は
含めません。実装がどのようなものになるかを想像するために言えば、Button
struct には、リスト 18-7 に示すように、width、height、label の
フィールドがあるかもしれません。
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}
impl Draw for Button {
fn draw(&self) {
// code to actually draw a button
}
}
Button の width、height、label フィールドは、ほかの
コンポーネントのフィールドとは異なります。たとえば、TextField
型はそれらと同じフィールドに加えて、placeholder
フィールドを持つかもしれません。画面上に描画したい各型は Draw
トレイトを実装しますが、その特定の型をどのように描画するかを定義する
ために、draw メソッドでは異なるコードを使います。ここで Button
がそうしているようにです(前述のとおり、実際のGUIコードは省いています)。
たとえば Button 型には、ユーザーがボタンをクリックしたときに何が
起こるかに関するメソッドを含む、追加の impl
ブロックがあるかもしれません。この種のメソッドは、TextField
のような型には当てはまりません。
私たちのライブラリを使う人が、width、height、options
フィールドを持つ SelectBox 構造体を実装すると決めた場合、
リスト18-8に示すように、SelectBox 型にも Draw
トレイトを実装することになります。
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// code to actually draw a select box
}
}
fn main() {}
これで、私たちのライブラリの利用者は Screen
インスタンスを作成する main 関数を書けます。各値を Box<T>
に入れてトレイトオブジェクトにすることで、Screen
インスタンスに SelectBox と Button を追加できます。そして、
Screen インスタンスに対して run
メソッドを呼び出せば、各コンポーネントに対して draw
が呼び出されます。リスト18-9はこの実装を示しています。
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// code to actually draw a select box
}
}
use gui::{Button, Screen};
fn main() {
let screen = Screen {
components: vec![
Box::new(SelectBox {
width: 75,
height: 10,
options: vec![
String::from("Yes"),
String::from("Maybe"),
String::from("No"),
],
}),
Box::new(Button {
width: 50,
height: 10,
label: String::from("OK"),
}),
],
};
screen.run();
}
ライブラリを書いたときには、誰かが SelectBox
型を追加するかもしれないとはわかっていませんでした。しかし、
SelectBox は Draw トレイトを実装しており、つまり draw
メソッドを実装しているため、Screen
の実装はその新しい型に対しても動作し、それを描画できました。
この概念、つまり値の具体的な型ではなく、その値がどのメッセージに応答
するかだけを気にするという考え方は、動的型付け言語における
ダックタイピング の概念に似ています。アヒルのように歩き、
アヒルのように鳴くなら、それはアヒルに違いない、というわけです!
リスト18-5の Screen に対する run の実装では、run
は各コンポーネントの具体的な型が何であるかを知る必要がありません。
コンポーネントが Button のインスタンスか SelectBox
かを確認することはせず、ただそのコンポーネントに対して draw
メソッドを呼び出します。components ベクタ内の値の型として
Box<dyn Draw> を指定することで、Screen は draw
メソッドを呼び出せる値を必要とするように定義されています。
ダックタイピングを使うコードに似たコードを、トレイトオブジェクトと Rustの型システムを使って書く利点は、ある値が特定のメソッドを実装して いるかどうかを実行時に確認する必要がなく、また、値がそのメソッドを 実装していないのに呼び出してしまってエラーになる心配もないことです。 値がトレイトオブジェクトに必要なトレイトを実装していなければ、Rust はコードをコンパイルしません。
たとえば、リスト18-10は、コンポーネントとして String を持つ
Screen を作成しようとするとどうなるかを示しています。
use gui::Screen;
fn main() {
let screen = Screen {
components: vec![Box::new(String::from("Hi"))],
};
screen.run();
}
String は Draw トレイトを実装していないため、このエラーが
表示されます。
$ cargo run
Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
--> src/main.rs:5:26
|
5 | components: vec![Box::new(String::from("Hi"))],
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
|
= help: the trait `Draw` is implemented for `Button`
= note: required for the cast from `Box<String>` to `Box<dyn Draw>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` (bin "gui") due to 1 previous error
このエラーによって、Screen
に渡すつもりのなかったものを渡してしまっているので別の型を渡すべき
なのか、あるいは Screen がそれに対して draw
を呼び出せるように、String に Draw
を実装すべきなのかがわかります。
動的ディスパッチを行う
第10章の「ジェネリクスを使うコードの パフォーマンス」で、 コンパイラがジェネリクスに対して行う単相化プロセスについて議論したことを 思い出してください。コンパイラは、ジェネリック型パラメータの代わりに 使う各具体的な型ごとに、関数やメソッドの非ジェネリックな実装を生成 します。単相化の結果として得られるコードは 静的ディスパッチ を行います。これは、コンパイラがコンパイル時にどのメソッドを呼び出して いるかを把握している場合のことです。これに対するのが 動的ディスパッチ で、コンパイラがコンパイル時にはどのメソッドを 呼び出しているかわからない場合のことです。動的ディスパッチの場合、 コンパイラは、実行時にどのメソッドを呼び出すべきかがわかるコードを 生成します。
トレイトオブジェクトを使うとき、Rust は動的ディスパッチを使わなければなりません。コンパイラは、トレイト オブジェクトを使うコードでどの型が使われる可能性があるかをすべて 把握していないため、どの型に実装されたどのメソッドを呼べばよいかを 知ることができません。代わりに、実行時にRustはトレイトオブジェクト 内部のポインタを使って、どのメソッドを呼ぶべきかを知ります。この 探索には、静的ディスパッチでは発生しない実行時コストがかかります。 また、動的ディスパッチはコンパイラがメソッドのコードをインライン化 することも妨げ、その結果として一部の最適化もできなくなります。さらに Rustには、動的ディスパッチを使える場所と使えない場所に関する、 dyn互換性 と呼ばれるいくつかのルールがあります。これらのルールは この議論の範囲を超えますが、詳しくはリファレンス を読んでください。しかしその一方で、リスト18-5で書いたコードには 追加の柔軟性が得られ、リスト18-9で示したようなことをサポート できました。したがって、これは検討すべきトレードオフです。