型クラスとしてのジェネリック

説明

Rustの型システムは、(JavaやC++のような)命令型言語ではなく、(Haskellのような)関数型言語のように設計されています。その結果、Rustは多くのプログラミング上の問題を「静的型付け」の問題にすることができます。これは関数型言語を選択する最大の利点の1つでありまたRustのコンパイル時保証の多くにとって重要なものです。

この考え方の重要な部分は、ジェネリック型の動作方法です。例えばC++やJavaでは、ジェネリック型はコンパイラのメタプログラミングの構成要素です。C++ における vector<int>vector<char> は、 vector 型のボイラーテンプレート ( template として知られるもの) の (それに異なる2つの型を埋め込んだ)異なる2つのコピーにすぎません。

Rustでは、ジェネリック型パラメータは関数型言語で「型クラス制約」として知られているものを作成します。 またエンドユーザが埋め込むそれぞれの異なるパラメータは、 実際に型を変えます 。言い換えると、 Vec<isize> と Vec` は2つの 異なる型 で、これらは型システムのすべての箇所にて、別個の型として認識されます。

これは モノモーフィズム(単相化) と呼ばれ、 ポリモーフィック(多相の) コードから異なる型が作成されます。この特別な振る舞いには、implブロックにジェネリックパラメータを指定する必要があります。ジェネリック型に異なる値を与えれば、異なる型が生成されます。そして、異なる型は異なる impl ブロックを持つことができます。

オブジェクト指向言語では、クラスは親から振る舞いを継承することができます。しかしながらこれは、ある型クラスの特定のメンバに対して、追加的な振る舞いだけを付加することを可能にするにとどまらず、余分な振る舞いを付加することも同様に許容してしまいます。

最も近いのは、JavascriptやPythonのランタイムポリモーフィズムです。これらの言語では、あらゆるコンストラクタにより、思ったときにオブジェクトに新しいメンバを追加可能です。しかしながら、これらの言語と異なりRustでは、追加されたメソッドは使用時にすべて型チェックされます。なぜなら、それらジェネリックは静的に定義されているからです。これはより使いやすく、かつ安全性を確保したものになっています。

あなたは、一連のラボマシン用のストレージサーバを設計しているとします。関連するソフトウェアのため、次の2つの異なるプロトコルをサポートする必要があります: BOOTP (PXEネットワークブート用) とNFS (リモートマウントストレージ用) です。

あなたの目標は、Rustで書かれた1つのプログラムで、その両方を処理できるようにすることです。このプログラムは (2つの) プロトコルハンドラを持ち、両方のリクエストを待ち受けます。そしてメインのアプリケーションロジックは、ラボの管理者が、実際のファイルのための、ストレージとセキュリティ制御を設定できるようにします。

ラボのマシンからの、ファイルに対してのリクエストは同じ基本情報を含みます。どのプロトコルからであっても、認証方法と取得したいファイルの名前はあります。ストレートな実装はこのようになるでしょう:

enum AuthInfo {
    Nfs(crate::nfs::AuthInfo),
    Bootp(crate::bootp::AuthInfo),
}

struct FileDownloadRequest {
    file_name: PathBuf,
    authentication: AuthInfo,
}

この設計は十分に機能するかもしれません。しかし、次に プロトコル固有 のメタデータの追加をサポートする必要があるとします。例えば、NFSでは、追加のセキュリティルールを適用するために、そのマウントポイントが何であるかを確定したいとします。

現在の構造体の設計では、プロトコルは実行時まで決定されません。つまり、一方のプロトコルにて適用され、もう一方では適用されないメソッドは、プログラマが実行時にチェックを行う必要があります。

NFSマウントポイントの取得方法は以下のようになります:

struct FileDownloadRequest {
    file_name: PathBuf,
    authentication: AuthInfo,
    mount_point: Option<PathBuf>,
}

impl FileDownloadRequest {
    // ... その他のメソッド ...

    /// NFSリクエストであれば、NFSマウントポイントを取得する。
    ///そうでなければ None を返す。
    pub fn mount_point(&self) -> Option<&Path> {
        self.mount_point.as_ref()
    }
}

mount_point()のすべての呼び出し元は None をチェックして、それを処理するコードを書かなければなりません。これは、たとえそのコードパス上ではNFS リクエストしか使われないと知っていたとしてもです!

もし異なるリクエストタイプが混同されるのであれば、コンパイル時エラーを発生させる方がはるかに最適でしょう。結局のところ、ユーザコードのすべてのパスは、このライブラリのどの関数を使うのかも含めて、あるリクエストがNFSリクエストかBOOTPリクエストか、わかっていることでしょう。

Rustでは、実際にこれができます!解決策は、APIを分割するために ジェネリック型を追加 することです。

これは以下のようになります:

use std::path::{Path, PathBuf};

mod nfs {
    #[derive(Clone)]
    pub(crate) struct AuthInfo(String); // NFSセッション管理は省略
}

mod bootp {
    pub(crate) struct AuthInfo(); // bootp では認証なし
}

// 外部のユーザが独自のプロトコルを作れないようにするための、 private なモジュールです!
mod proto_trait {
    use super::{bootp, nfs};
    use std::path::{Path, PathBuf};

    pub(crate) trait ProtoKind {
        type AuthInfo;
        fn auth_info(&self) -> Self::AuthInfo;
    }

    pub struct Nfs {
        auth: nfs::AuthInfo,
        mount_point: PathBuf,
    }

    impl Nfs {
        pub(crate) fn mount_point(&self) -> &Path {
            &self.mount_point
        }
    }

    impl ProtoKind for Nfs {
        type AuthInfo = nfs::AuthInfo;
        fn auth_info(&self) -> Self::AuthInfo {
            self.auth.clone()
        }
    }

    pub struct Bootp(); // 追加のメタデータなし

    impl ProtoKind for Bootp {
        type AuthInfo = bootp::AuthInfo;
        fn auth_info(&self) -> Self::AuthInfo {
            bootp::AuthInfo()
        }
    }
}

use proto_trait::ProtoKind; // impl を防ぐため、非publicを維持
pub use proto_trait::{Bootp, Nfs}; // 呼び出し元に見えるよう再エクスポート

struct FileDownloadRequest<P: ProtoKind> {
    file_name: PathBuf,
    protocol: P,
}

// すべての共通のAPIは、ジェネリックの impl ブロックに入れます
impl<P: ProtoKind> FileDownloadRequest<P> {
    fn file_path(&self) -> &Path {
        &self.file_name
    }

    fn auth_info(&self) -> P::AuthInfo {
        self.protocol.auth_info()
    }
}

// すべてのプロトコル固有の実装は、それぞれのブロックに入れます
impl FileDownloadRequest<Nfs> {
    fn mount_point(&self) -> &Path {
        self.protocol.mount_point()
    }
}

fn main() {
    // ここにあなたのコード
}

このアプローチにて、もしもユーザが間違えて異なる型を使ってしまったとき;

fn main() {
    let mut socket = crate::bootp::listen()?;
    while let Some(request) = socket.next_request()? {
        match request.mount_point().as_ref() {
            "/secure" => socket.send("Access denied"),
            _ => {} // 続く...
        }
        // ここに残りのコード
    }
}

構文エラーが発生します。型 FileDownloadRequest<Bootp>mount_point() を実装していません。FileDownloadRequest<Nfs> 型だけが実装しています。またもちろん、これはNFSモジュールによって作成されるもので、BOOTPモジュールによるって作成されるものではありません!

長所

第一に、複数のステートに共通するフィールドの重複をなくすことができます。非共有のフィールドをジェネリックなものにすることで、それらは一度だけ実装されます。

第二に、状態別に分割されているため、impl ブロックが読みやすくなります。すべてのステートに共通するメソッドは1つのブロックに1回だけ記述され、あるステートに固有のメソッドは別のブロックに記述されます。

どちらも、コード行数がより少なく、よく整理されているということになります。

短所

現時点では、コンパイラの単相化の実装方法により、バイナリのサイズが大きくなります。将来的に実装が改善されることを願っています。

代替案

  • 型が、その構築や部分的な初期化のために「分割API」を必要とするようであれば、Builderパターンを検討してください。

  • 型間のAPIが変化しない場合 – 振る舞いだけが変化する場合 – は代わりにStrategyパターンを使うのがよいでしょう。

See also

このパターンは標準ライブラリ全体で使われています:

  • Vec<u8> は、他の全ての Vec<T> の型とは異なり、Stringからキャストすることができます。1
  • また、バイナリヒープにキャストすることもできます。ただしそれが Ord トレイトを実装している場合に限ります。2
  • to_string メソッドは、 str 型に対しての Cow について特殊化されています。3

また、APIの柔軟性のために、いくつかの人気のあるクレートで使用されています:

  • 組み込み機器のための embedded-hal エコシステムは、このパターンを多用しています。例えば、組み込みのピンの制御に使われるデバイスレジスタの設定を静的に検証することができます。ピンがあるモードになると、Pin<MODE> 構造体を返します。そのジェネリックはそのモードで使用可能な機能を決定します。 それら機能は Pin 自体には実装されません。 4

  • HTTPクライアントライブラリ hyper は、このパターンを使い、さまざまなプラグイン可能なリクエストに対して、リッチなAPIを公開しています。異なるコネクタを持つクライアントは、異なるメソッドと異なるトレイト実装を持ちますが、メソッドのコアセットはすべてのコネクタに適用されます。5

  • 「Type State」パターン – 内部状態や不変量に基づいてオブジェクトがAPIを得たり失ったりするパターン – は、Rustで同じ基本概念と少し異なる手法を使って実装されています。6

Last change: 2024-07-09, commit: 317c88e