異種値用のトレイトオブジェクト

RustRustBeginner
オンラインで実践に進む

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 ライブラリの構造を含めます。このクレートには、ButtonTextField など、人々が使用できるいくつかの型が含まれる場合があります。さらに、gui のユーザーは、描画できる独自の型を作成したいでしょう。たとえば、あるプログラマーは Image を追加し、別のプログラマーは SelectBox を追加するかもしれません。

この例では、完全な GUI ライブラリを実装することはできませんが、各部品がどのように組み合わされるかを示します。ライブラリを書くときには、他のプログラマーが作成したいすべての型を知り、定義することはできません。しかし、gui は多くの異なる型の値を追跡する必要があり、これらの異なる型の値それぞれに対して draw メソッドを呼び出す必要があります。draw メソッドを呼び出したときに正確に何が起こるかは知る必要はありません。ただ、その値にそのメソッドがあり、私たちが呼び出せることがわかっていれば十分です。

継承を持つ言語でこれを行うには、Component という名前のクラスを定義し、その上に draw メソッドを持たせます。他のクラス、たとえば ButtonImageSelectBox は、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 メソッドを呼び出す Screenrun メソッド

これは、トレイト境界付きのジェネリック型パラメータを使用する構造体を定義する場合とは異なる動作をします。ジェネリック型パラメータは、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 構造体には widthheightlabel のフィールドがあるかもしれません。リスト 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 構造体

Buttonwidthheightlabel のフィールドは、他のコンポーネントのフィールドとは異なります。たとえば、TextField 型にはこれらと同じフィールドに加えて、placeholder フィールドがあるかもしれません。画面に描画したい各型はすべて、Draw トレイトを実装しますが、draw メソッドでは、その特定の型をどのように描画するかを定義するために異なるコードを使用します。ここでは Button がそうです(前述の通り、実際の GUI コードはありません)。たとえば、Button 型には、ユーザーがボタンをクリックしたときに何が起こるかに関連するメソッドを含む追加の impl ブロックがあるかもしれません。この種のメソッドは、TextField のような型には適用されません。

ライブラリを使用する人が、widthheightoptions のフィールドを持つ 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> に入れてトレイトオブジェクトにすることで、SelectBoxButton を追加できます。そして、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 の実装は、SelectBoxDraw トレイトを実装しているため、つまり draw メソッドを実装しているため、新しい型で動作し、描画することができました。

この概念は、値が応答するメッセージのみに関心を持ち、値の具体的な型には関心を持たないという点で、動的型付け言語の ダックタイピング の概念に似ています。「ダックが歩き、ガガッと鳴くなら、それはダックであるに違いない!」リスト 17-5 の Screenrun の実装では、run は各コンポーネントの具体的な型を知る必要がありません。コンポーネントが Button のインスタンスか SelectBox のインスタンスかをチェックする必要はありません。コンポーネントの draw メソッドを単に呼び出します。components ベクターの値の型として Box<dyn Draw> を指定することで、Screendraw メソッドを呼び出せる値を必要とするように定義しました。

ダックタイピングを使用したコードに似たコードを書くために、トレイトオブジェクトと 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: トレイトオブジェクトのトレイトを実装していない型を使用しようとする

StringDraw トレイトを実装していないため、次のエラーが表示されます。

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 に渡しているため、別の型を渡す必要があるか、または Screendraw を呼び出せるようにするために StringDraw を実装する必要があることを知らせてくれます。

トレイトオブジェクトは動的ディスパッチを行う

「ジェネリックを使用したコードのパフォーマンス」では、ジェネリックにトレイト境界を使用する場合、コンパイラが行うモノモーフィック化プロセスについて説明しました。コンパイラは、ジェネリック型パラメータの代わりに使用する各具体的な型に対して、関数とメソッドの非ジェネリックな実装を生成します。モノモーフィック化の結果として生成されるコードは、静的ディスパッチ を行っています。これは、コンパイル時にどのメソッドを呼び出しているかをコンパイラが知っている場合です。これは、コンパイル時にどのメソッドを呼び出しているかをコンパイラが判断できない 動的ディスパッチ とは対照的です。動的ディスパッチの場合、コンパイラは実行時にどのメソッドを呼び出すかを判断するコードを生成します。

トレイトオブジェクトを使用する場合、Rust は動的ディスパッチを使用しなければなりません。コンパイラは、トレイトオブジェクトを使用するコードで使用されるすべての型を知っていないため、どの型で実装されたどのメソッドを呼び出すかを知りません。代わりに、実行時には、Rust はトレイトオブジェクト内のポインタを使用して、どのメソッドを呼び出すかを知ります。この検索には、静的ディスパッチでは発生しない実行時のコストがかかります。動的ディスパッチはまた、コンパイラがメソッドのコードをインライン展開する選択を妨げ、それによりいくつかの最適化を妨げます。ただし、リスト 17-5 で書いたコードには追加の柔軟性があり、リスト 17-9 でサポートできましたので、検討すべきトレードオフです。

まとめ

おめでとうございます!異なる型の値を許容するトレイトオブジェクトの使用の実験を完了しました。LabEx でさらに実験を行って、スキルを向上させることができます。