型クラスとしてのジェネリクス
説明
Rust の型システムは、命令型言語(Java や C++ など)というより、 関数型言語(Haskell など)に近い形で設計されています。その結果、Rust は 多くの種類のプログラミング上の問題を「静的型付け」の問題に変換できます。 これは関数型言語を選択することによる最大の利点の 1 つであり、Rust の 多くのコンパイル時保証にとって不可欠です。
この考え方の重要な部分は、ジェネリック型の動作方法です。たとえば
C++ や Java では、ジェネリック型はコンパイラーのためのメタプログラミング
構成要素です。C++ における vector<int> と vector<char> は、
2 つの異なる型を当てはめた vector 型(template として知られる)の
同じ定型コードの別々のコピーにすぎません。
Rust では、ジェネリック型パラメーターは、関数型言語で「型クラス制約」として
知られているものを作成し、エンドユーザーによって埋められるそれぞれ異なる
パラメーターは実際に型を変えます。言い換えると、Vec<isize> と
Vec<char> は2 つの異なる型であり、型システムのすべての部分から
別個のものとして認識されます。
これは単相化と呼ばれ、多相的なコードから異なる型が作成されます。
この特殊な振る舞いにより、impl ブロックではジェネリックパラメーターを
指定する必要があります。ジェネリック型に対する値が異なると異なる型になり、
異なる型は異なる impl ブロックを持つことができます。
オブジェクト指向言語では、クラスは親から振る舞いを継承できます。 しかし、これにより、型クラスの特定のメンバーに対して追加の振る舞いだけでなく、 さらに別の振る舞いも結び付けることができます。
最も近い同等物は Javascript や Python における実行時ポリモーフィズムであり、 そこでは任意のコンストラクターによって、新しいメンバーをオブジェクトに無秩序に 追加できます。しかし、それらの言語とは異なり、Rust の追加メソッドはすべて、 使用時に型チェックできます。なぜなら、そのジェネリクスが静的に定義されているからです。 これにより、安全性を保ちながら、より使いやすくなります。
例
一連のラボマシン向けのストレージサーバーを設計しているとします。関係する ソフトウェアの都合上、サポートする必要があるプロトコルは 2 つあります。 BOOTP(PXE ネットワークブート用)と NFS(リモートマウントストレージ用)です。
目標は、Rust で書かれた 1 つのプログラムで、その両方を処理できるようにすることです。 そのプログラムはプロトコルハンドラーを持ち、両方の種類のリクエストを待ち受けます。 その後、メインのアプリケーションロジックにより、ラボ管理者が実際のファイルに対する ストレージとセキュリティ制御を設定できるようになります。
ラボ内のマシンからのファイル要求には、どのプロトコルから来たものであっても、 同じ基本情報が含まれます。認証方法と、取得するファイル名です。素直な実装は おおよそ次のようになります。
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 を防ぐため内部に保ちます
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 は、それぞれ専用のブロックに入ります
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("アクセスが拒否されました"),
_ => {} // 続行...
}
// ここに残りのコード
}
}
構文エラーが発生します。FileDownloadRequest<Bootp> 型は
mount_point() を実装しておらず、それを実装しているのは
FileDownloadRequest<Nfs> 型だけです。そしてもちろん、それは NFS モジュールによって
作成されるものであり、BOOTP モジュールではありません!
利点
第一に、複数の状態に共通するフィールドの重複を排除できます。共有されていない フィールドをジェネリックにすることで、それらは一度だけ実装されます。
第二に、impl ブロックが状態ごとに分解されるため、読みやすくなります。
すべての状態に共通するメソッドは 1 つのブロックに一度だけ型付けされ、
1 つの状態に固有のメソッドは別のブロックに置かれます。
これらはいずれも、コード行数が少なくなり、より適切に整理されることを意味します。
欠点
これは現在、コンパイラでモノモーフィゼーションが実装されている方法により、 バイナリのサイズを増加させます。将来的に実装が改善されることが期待されます。
代替案
-
構築や部分的な初期化のために、型に「分割 API」が必要に見える場合は、 代わりに ビルダーパターンを検討してください。
-
型の間で API が変わらず、振る舞いだけが変わる場合は、 代わりに ストラテジーパターンを使用する方が適しています。
関連項目
このパターンは標準ライブラリ全体で使用されています。
Vec<u8>は、他のすべてのVec<T>型とは異なり、String からキャストできます。1- イテレータはバイナリヒープにキャストできますが、それは
Ordトレイトを 実装する型を含んでいる場合に限られます。2 to_stringメソッドは、str型のCowに対してのみ特殊化されていました。3
これは、API の柔軟性を実現するために、いくつかの人気のあるクレートでも使用されています。
-
組み込みデバイスに使用される
embedded-halエコシステムでは、 このパターンが広範に使用されています。たとえば、組み込みピンの制御に使用される デバイスレジスタの構成を静的に検証できます。ピンがあるモードに設定されると、Pin<MODE>構造体が返され、そのジェネリックによって、そのモードで使用可能な関数が 決定されます。これらの関数はPin自体には存在しません。 4 -
hyperHTTP クライアントライブラリは、異なる差し替え可能なリクエスト向けの 豊富な API を公開するためにこれを使用しています。異なるコネクタを持つクライアントには、 異なるメソッドと異なるトレイト実装があり、一方で中核となる一連の メソッドは任意のコネクタに適用されます。 5 -
「型状態」パターン、つまりオブジェクトが内部状態や不変条件に基づいて API を 獲得したり失ったりするパターンは、同じ基本概念と、少し異なる技法を使って Rust で実装されます。 6