はじめに
異なる型の値を許容するトレイトオブジェクトの使用方法へようこそ。この実験は、Rust Book の一部です。LabEx で Rust のスキルを練習することができます。
この実験では、ライブラリ内で、特にグラフィカルユーザーインターフェイス (GUI) ツールのコンテキストで、異なる型の値を許容するためにトレイトオブジェクトをどのように使用するかを探ります。
This tutorial is from open-source community. Access the source code
💡 このチュートリアルは英語版からAIによって翻訳されています。原文を確認するには、 ここをクリックしてください
異なる型の値を許容するトレイトオブジェクトの使用方法へようこそ。この実験は、Rust Book の一部です。LabEx で Rust のスキルを練習することができます。
この実験では、ライブラリ内で、特にグラフィカルユーザーインターフェイス (GUI) ツールのコンテキストで、異なる型の値を許容するためにトレイトオブジェクトをどのように使用するかを探ります。
第 8 章では、ベクターの 1 つの制限として、1 つの型の要素のみを格納できることを述べました。リスト 8-9 では、整数、浮動小数点数、文字列などを保持するための変数を持つ SpreadsheetCell
列挙型を定義することで回避策を考えました。これにより、各セルに異なる型のデータを格納し、セルの行を表すベクターを保持することができました。これは、コードをコンパイルするときに知っている固定の型のセットで交換可能な項目を扱う場合には、非常に良い解決策です。
ただし、場合によっては、ライブラリのユーザーに特定の状況で有効な型のセットを拡張できるようにしたい場合があります。これをどのように実現できるかを示すために、グラフィカルユーザーインターフェイス (GUI) ツールの例を作成します。このツールは、項目のリストを反復処理し、それぞれに draw
メソッドを呼び出して画面に描画するもので、GUI ツールの一般的な手法です。gui
という名前のライブラリクレートを作成し、GUI ライブラリの構造を含めます。このクレートには、Button
や TextField
など、人々が使用できるいくつかの型が含まれる場合があります。さらに、gui
のユーザーは、描画できる独自の型を作成したいでしょう。たとえば、あるプログラマーは Image
を追加し、別のプログラマーは SelectBox
を追加するかもしれません。
この例では、完全な GUI ライブラリを実装することはできませんが、各部品がどのように組み合わされるかを示します。ライブラリを書くときには、他のプログラマーが作成したいすべての型を知り、定義することはできません。しかし、gui
は多くの異なる型の値を追跡する必要があり、これらの異なる型の値それぞれに対して draw
メソッドを呼び出す必要があります。draw
メソッドを呼び出したときに正確に何が起こるかは知る必要はありません。ただ、その値にそのメソッドがあり、私たちが呼び出せることがわかっていれば十分です。
継承を持つ言語でこれを行うには、Component
という名前のクラスを定義し、その上に draw
メソッドを持たせます。他のクラス、たとえば Button
、Image
、SelectBox
は、Component
から継承し、それにより draw
メソッドを継承します。それぞれが独自のカスタム動作を定義するために draw
メソッドをオーバーライドすることができますが、フレームワークはすべての型を Component
インスタンスとして扱い、それらに対して draw
を呼び出すことができます。しかし、Rust には継承がないため、gui
ライブラリを構造化して、ユーザーが新しい型で拡張できるようにする別の方法が必要です。
gui
に持たせたい振る舞いを実装するには、Draw
という名前のトレイトを定義します。このトレイトには draw
という名前の 1 つのメソッドがあります。そして、トレイトオブジェクト を持つベクターを定義することができます。トレイトオブジェクトは、指定したトレイトを実装する型のインスタンスと、実行時にその型のトレイトメソッドを検索するために使用されるテーブルの両方を指します。トレイトオブジェクトは、&
参照や Box<T>
スマートポインタなどのある種のポインタを指定し、その後 dyn
キーワードを指定し、その後関連するトレイトを指定することで作成します。(「動的にサイズ指定される型と Sized トレイト」で、トレイトオブジェクトがポインタを使用する必要がある理由について説明します。)トレイトオブジェクトを使用してジェネリック型または具体的な型の代わりに使用することができます。トレイトオブジェクトを使用する場所では、Rust の型システムがコンパイル時にそのコンテキストで使用されるすべての値がトレイトオブジェクトのトレイトを実装することを保証します。したがって、コンパイル時にすべての可能な型を知る必要はありません。
Rust では、他の言語のオブジェクトと区別するために、構造体や列挙型を「オブジェクト」と呼ぶことは控えていることを述べてきました。構造体または列挙型では、構造体フィールドのデータと impl
ブロックの振る舞いが分離されていますが、他の言語では、データと振る舞いが 1 つの概念にまとめられたものが多く、オブジェクトと呼ばれます。ただし、トレイトオブジェクトは、データと振る舞いを組み合わせた点で、他の言語のオブジェクトに似ています。ただし、トレイトオブジェクトは、トレイトオブジェクトにデータを追加することはできませんという点で、従来のオブジェクトとは異なります。トレイトオブジェクトは、他の言語のオブジェクトほど一般的に有用ではありません。その主な目的は、共通の振る舞いに対する抽象化を可能にすることです。
リスト 17-3 は、draw
という名前の 1 つのメソッドを持つ Draw
という名前のトレイトを定義する方法を示しています。
ファイル名:src/lib.rs
pub trait Draw {
fn draw(&self);
}
リスト 17-3: Draw
トレイトの定義
この構文は、第 10 章でトレイトを定義する方法についての議論からおなじみのものです。次に新しい構文が登場します。リスト 17-4 は、components
という名前のベクターを保持する Screen
という名前の構造体を定義しています。このベクターは Box<dyn Draw>
型で、これはトレイトオブジェクトです。つまり、Box
内の Draw
トレイトを実装する任意の型の代用となります。
ファイル名:src/lib.rs
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
リスト 17-4: Draw
トレイトを実装するトレイトオブジェクトのベクターを保持する components
フィールドを持つ Screen
構造体の定義
Screen
構造体では、リスト 17-5 に示すように、components
それぞれに対して draw
メソッドを呼び出す run
メソッドを定義します。
ファイル名:src/lib.rs
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
リスト 17-5: 各コンポーネントに対して draw
メソッドを呼び出す Screen
の run
メソッド
これは、トレイト境界付きのジェネリック型パラメータを使用する構造体を定義する場合とは異なる動作をします。ジェネリック型パラメータは、1 回に 1 つの具体的な型でのみ置き換えることができますが、トレイトオブジェクトは、実行時にトレイトオブジェクトを埋めるために複数の具体的な型を許容します。たとえば、リスト 17-6 のように、ジェネリック型とトレイト境界を使用して Screen
構造体を定義することができます。
ファイル名:src/lib.rs
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();
}
}
}
リスト 17-6: ジェネリックとトレイト境界を使用した Screen
構造体とその run
メソッドの代替実装
これにより、Button
型のコンポーネントのリストまたは TextField
型のコンポーネントのリストを持つ Screen
インスタンスに制限されます。同じ型のコレクションのみを持つ場合、ジェネリックとトレイト境界を使用する方が好ましいです。なぜなら、定義はコンパイル時にモノモーフィック化されて具体的な型を使用するようになるからです。
一方、トレイトオブジェクトを使用するメソッドでは、1 つの Screen
インスタンスには、Box<Button>
と Box<TextField>
を含む Vec<T>
を保持できます。これがどのように機能するか見てみましょう。その後、実行時のパフォーマンスの影響について説明します。
次に、Draw
トレイトを実装するいくつかの型を追加します。Button
型を用意します。もう一度申しますが、本書の範囲外で GUI ライブラリを実際に実装することはできませんので、draw
メソッドの本体には役に立つ実装はありません。実装がどのようになるかを想像すると、Button
構造体には width
、height
、label
のフィールドがあるかもしれません。リスト 17-7 を参照してください。
ファイル名:src/lib.rs
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}
impl Draw for Button {
fn draw(&self) {
// ボタンを実際に描画するコード
}
}
リスト 17-7: Draw
トレイトを実装する Button
構造体
Button
の width
、height
、label
のフィールドは、他のコンポーネントのフィールドとは異なります。たとえば、TextField
型にはこれらと同じフィールドに加えて、placeholder
フィールドがあるかもしれません。画面に描画したい各型はすべて、Draw
トレイトを実装しますが、draw
メソッドでは、その特定の型をどのように描画するかを定義するために異なるコードを使用します。ここでは Button
がそうです(前述の通り、実際の GUI コードはありません)。たとえば、Button
型には、ユーザーがボタンをクリックしたときに何が起こるかに関連するメソッドを含む追加の impl
ブロックがあるかもしれません。この種のメソッドは、TextField
のような型には適用されません。
ライブラリを使用する人が、width
、height
、options
のフィールドを持つ SelectBox
構造体を実装することを決定した場合、彼らは SelectBox
型にも Draw
トレイトを実装します。リスト 17-8 を参照してください。
ファイル名:src/main.rs
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// セレクトボックスを実際に描画するコード
}
}
リスト 17-8: もう 1 つのクレートが gui
を使用し、SelectBox
構造体に Draw
トレイトを実装する
ライブラリのユーザーは、これで main
関数を書いて Screen
インスタンスを作成することができます。Screen
インスタンスには、それぞれを Box<T>
に入れてトレイトオブジェクトにすることで、SelectBox
と Button
を追加できます。そして、Screen
インスタンスの run
メソッドを呼び出すことができます。これにより、各コンポーネントの draw
が呼び出されます。リスト 17-9 にこの実装を示します。
ファイル名:src/main.rs
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();
}
リスト 17-9: 同じトレイトを実装する異なる型の値を格納するためのトレイトオブジェクトの使用
ライブラリを書いたとき、誰かが SelectBox
型を追加するかもしれないことはわかりませんでしたが、Screen
の実装は、SelectBox
が Draw
トレイトを実装しているため、つまり draw
メソッドを実装しているため、新しい型で動作し、描画することができました。
この概念は、値が応答するメッセージのみに関心を持ち、値の具体的な型には関心を持たないという点で、動的型付け言語の ダックタイピング の概念に似ています。「ダックが歩き、ガガッと鳴くなら、それはダックであるに違いない!」リスト 17-5 の Screen
の run
の実装では、run
は各コンポーネントの具体的な型を知る必要がありません。コンポーネントが Button
のインスタンスか SelectBox
のインスタンスかをチェックする必要はありません。コンポーネントの draw
メソッドを単に呼び出します。components
ベクターの値の型として Box<dyn Draw>
を指定することで、Screen
が draw
メソッドを呼び出せる値を必要とするように定義しました。
ダックタイピングを使用したコードに似たコードを書くために、トレイトオブジェクトと Rust の型システムを使用する利点は、実行時に値が特定のメソッドを実装しているかどうかをチェックする必要がないこと、また、値がメソッドを実装していない場合でも呼び出してしまうときのエラーを心配する必要がないことです。値がトレイトオブジェクトが必要とするトレイトを実装していない場合、Rust はコードをコンパイルしません。
たとえば、リスト 17-10 は、String
をコンポーネントとして持つ Screen
を作成しようとしたときに何が起こるかを示しています。
ファイル名:src/main.rs
use gui::Screen;
fn main() {
let screen = Screen {
components: vec![Box::new(String::from("Hi"))],
};
screen.run();
}
リスト 17-10: トレイトオブジェクトのトレイトを実装していない型を使用しようとする
String
が Draw
トレイトを実装していないため、次のエラーが表示されます。
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`
|
= note: required for the cast to the object type `dyn Draw`
このエラーは、何かを意図しないものを Screen
に渡しているため、別の型を渡す必要があるか、または Screen
が draw
を呼び出せるようにするために String
に Draw
を実装する必要があることを知らせてくれます。
「ジェネリックを使用したコードのパフォーマンス」では、ジェネリックにトレイト境界を使用する場合、コンパイラが行うモノモーフィック化プロセスについて説明しました。コンパイラは、ジェネリック型パラメータの代わりに使用する各具体的な型に対して、関数とメソッドの非ジェネリックな実装を生成します。モノモーフィック化の結果として生成されるコードは、静的ディスパッチ を行っています。これは、コンパイル時にどのメソッドを呼び出しているかをコンパイラが知っている場合です。これは、コンパイル時にどのメソッドを呼び出しているかをコンパイラが判断できない 動的ディスパッチ とは対照的です。動的ディスパッチの場合、コンパイラは実行時にどのメソッドを呼び出すかを判断するコードを生成します。
トレイトオブジェクトを使用する場合、Rust は動的ディスパッチを使用しなければなりません。コンパイラは、トレイトオブジェクトを使用するコードで使用されるすべての型を知っていないため、どの型で実装されたどのメソッドを呼び出すかを知りません。代わりに、実行時には、Rust はトレイトオブジェクト内のポインタを使用して、どのメソッドを呼び出すかを知ります。この検索には、静的ディスパッチでは発生しない実行時のコストがかかります。動的ディスパッチはまた、コンパイラがメソッドのコードをインライン展開する選択を妨げ、それによりいくつかの最適化を妨げます。ただし、リスト 17-5 で書いたコードには追加の柔軟性があり、リスト 17-9 でサポートできましたので、検討すべきトレードオフです。
おめでとうございます!異なる型の値を許容するトレイトオブジェクトの使用の実験を完了しました。LabEx でさらに実験を行って、スキルを向上させることができます。