Rustのジェネリック関連型:それって何?
ジェネリック関連型 (GAT) について少し理解する
名前が長い!これ何なの?
大丈夫、最初から整理してみよう。まずは Rust の構文構造をおさらいしよう。Rust のプログラムは何で構成されている? 答えは:アイテム(item)だ。
すべての Rust プログラムは一つ一つの「アイテム」で構成されている。たとえば、main.rs に構造体定義を書き、その後に実装定義を追加してメソッドを 2 つ定義し、最後に main 関数を書く。これは crate に対応するモジュールアイテムの中に 3 つのアイテムがあるということだ。
アイテムの説明が終わったところで、次に「関連アイテム」について話そう。関連アイテムはアイテムそのものではない! 重要なのは「関連」の 2 文字で、それは何と関連しているのかというと、「ある型に関連している」ということだ。 そしてそれによって、Self
という特別なキーワードを使うことができる。このキーワードはさっき言ったその型を指すために使われる。
関連アイテムは 2 箇所に定義できる。ひとつはトレイト定義の中の波括弧、もうひとつは実装定義の波括弧の中だ。
関連アイテムには 3 種類ある:関連定数、関連関数、関連型(エイリアス); これは通常のアイテムにある 3 種類:定数、関数、型(エイリアス)に一対一で対応する。
例を見てみよう!
#![feature(generic_associated_types)]
#![allow(incomplete_features)]
const A: usize = 42;
fn b<T>() {}
type C<T> = Vec<T>;
trait X {
const D: usize;
fn e<T>();
type F<T>; // これが新しく加わった部分!以前はここに<T>は書けなかった
}
struct S;
impl X for S {
const D: usize = 42;
fn e<T>() {}
type F<T> = Vec<T>;
}
これ、何の役に立つの?
けっこう役に立つんだけど、特定の場面に限られる。Rust コミュニティでは、ジェネリック関連型に対する典型的なユースケースが 2 つある。それを紹介してみよう。
でもその前に、もう一度ジェネリクスについておさらいしておこう。ジェネリック(generic)という単語は、英語で「一般的な」という意味。ジェネリック型とは何か? 簡単に言えば、何かしらのパラメータが欠けていて、利用者がそれを埋めることで完成する型のことだ。
ついでに言っておくと、先人たちはこれを意訳して「汎型(はんけい)」と名付けた。多くのシステムでは、埋めるパラメータが「型」だったからだ。ただし Rust では、型だけでなく「ライフタイム」や「定数」もジェネリックパラメータになれる。
さて、具体的なジェネリック型の例を見てみよう:Rc<T>
、これはジェネリックパラメータを一つ持つジェネリック型だ。 ジェネリック型 Rc
自体は型ではなく、そこに具体的なパラメータが提供されて初めて型になる。たとえば Rc<bool>
のように。
では、もしデータを共有する必要があるデータ構造を定義したいとして、利用者が Rc
を使いたいのか Arc
を使いたいのか、事前にはわからないとしたら、どうすればよいか? 最も簡単な方法は、コードを 2 通り書くこと。ちょっと不格好に聞こえるかもしれないが、確かに効果はある。ちなみに、crates.io
には im
と im-rc
という 2 つのクレートがあって、主な違いは中で使っているのが Arc
か Rc
かという点にある。
実はジェネリック関連型を使えば、この問題をきれいに解決できる。それではジェネリック関連型の最初の代表的な使用例を見てみよう:「タイプファミリー(type family)」だ。
タスク#1:ジェネリック関連型でタイプファミリーをサポートする
では、ここで「セレクタ」を作ってみよう。コンパイラがこのセレクタを使って、Rc<T>
を使うべきか Arc<T>
を使うべきかを判断できるようにする。コードはこんな感じだ:
trait PointerFamily {
type PointerType<T>;
}
struct RcPointer;
impl PointerFamily for RcPointer {
type PointerType<T> = Rc<T>;
}
struct ArcPointer;
impl PointerFamily for ArcPointer {
type PointerType<T> = Arc<T>;
}
簡単でしょう?こうして 2 つの「セレクタ型」を定義することで、Rc
を使うのか Arc
を使うのかを表現できる。実際に使ってみよう:
struct MyDataStructure<T, PointerSel: PointerFamily> {
data: PointerSel::PointerType<T>
}
これで、ジェネリックパラメータに RcPointer
または ArcPointer
を指定すれば、実際のデータ表現を選べるようになる。この機能があれば、先ほど言っていた 2 つのクレート(im
と im-rc
)を 1 つにまとめることも可能になる。
タスク#2:ジェネリック関連型でストリーミング処理イテレータを実装する
もう一つの問題は、実は Rust に特有のもので、他の言語ではこの問題自体が存在しない、あるいはこの問題を無視する(ゴホンゴホン)という選択をする。
この問題とは、API インターフェースで入力値同士、あるいは入力値と出力値の間に「依存関係」があることを表したいということだ。依存関係は表現が難しいものだ。Rust の解決方法は何か?
Rust では、誰もが知っているライフタイム記号 '_
がある。これを使って API 上でこうした依存関係を示すことができる。
実際にこのライフタイム記号を使ってみよう。標準ライブラリにあるイテレータトレイトはみんな見たことがあるだろう。次のようになっている:
pub trait Iterator {
type Item;
pub fn next(&'_ mut self) -> Option<Self::Item>;
// ...
}
これで問題なさそうに見えるが、ちょっとした問題がある。Item
型の値は Iterator
自体の型(Self
)と依存関係を持てない。なぜかというと、イテレータから値を取り出すという操作で生まれるこの一時的なスコープ(つまり '_
)は、next
という関連関数のジェネリック引数だからだ。定義されている Item
は別の独立した関連型であり、どうやって依存させるのかという問題がある。
ほとんどの場合これは問題にはならないが、特定のライブラリの API にとっては、この制限が足かせになる。たとえば、イテレータで一時ファイルを順にユーザーに渡すというような場合。ユーザーは好きなタイミングでそれをクローズできる。こういったケースでは Iterator
を使っても何の問題もない。
しかし、毎回一時ファイルを生成して何かデータをロードし、使い終わったらその一時ファイルを削除するような場合、このイテレータは「ユーザーが使い終わった」ことを知る手段が必要になる。そうすれば、一時ファイルを削除するなり、あるいは削除せずにそのストレージを再利用するなり、様々な最適化が可能になる。
このようなケースでは、ジェネリック関連型を使って API を設計することができる。
pub trait StreamingIterator {
type Item<'a>;
pub fn next(&'_ mut self) -> Option<Self::Item<'_>>;
// ...
}
このように実装すれば、Item
型をライフタイム依存のある型(例えば借用型)にすることができる。Rust の型システムが、次に next
を呼び出すか、このイテレータをムーブまたはドロップする前に、Item
がユーザーによってすでに使われなくなっていることを保証してくれる。
君の話はとてもわかりやすいんだけど、もう少し抽象的に説明してくれない?
よし、ではここから「人間の言葉」をやめて抽象的に話そう。まず最初に断っておくと、ここで話す内容は簡略化されたものであり、たとえばバインダ(binder)や述語(predicate)などの話は脇に置いておく。
まずは、ジェネリック型の名前と具体的な型との関係を構築しよう。これは当然ながら、マッピング(写像)関係である。
/// 疑似コード
fn generic_type_mapping(_: GenericTypeCtor, _: Vec<GenericArg>) -> Type;
たとえば Vec<bool>
の場合、Vec
はこのジェネリック型の名前であり構築子(constructor)でもある。 <bool>
はこの型に渡す引数のリストで、ここでは 1 つだけある。このマッピングを通すことで、Vec<bool>
という具体的な型が得られる。
さて次は「トレイト」だ。トレイトとは何か? 実はこれもマッピングである。
/// 疑似コード
fn trait_mapping(_: Type, _: Trait) -> Option<Vec<AssociateItem>>;
この Trait
は述語(predicate)のような働きをする。つまり、ある型に対して「このトレイトを満たしているかどうか」を判定するものだ。結果は None
(このトレイトを満たさない)か、Some(items)
(このトレイトを満たしていて、対応する関連アイテムを持っている)のどちらかになる。
/// 疑似コード
enum AssociateItem {
AssociateType(Name, Type),
GenericAssociateType(Name, GenericTypeCtor), // 今回新たに加わったもの
AssociatedFunction(Name, Func),
GenericFunction(Name, GenericFunc),
AssociatedConst(Name, Const),
}
この中で AssociateItem::GenericAssociateType
は、現在の Rust において唯一 generic_type_mapping
を間接的に実行する場所である。
trait_mapping
の最初の引数に異なる Type
を渡すことで、同じ Trait
によって異なる GenericTypeCtor
を得ることができる。
そしてそれを generic_type_mapping
に渡すことで、Rust の構文体系の中で、異なる GenericTypeCtor
と指定された Vec<GenericArg>
を組み合わせることができる、という仕組みになっている!
ちなみに、GenericTypeCtor
のようなものは、一部の文献などでは「HKT(Higher-Kinded Types、高階型)」として紹介されている。このようにして、Rust においてユーザーが利用可能な HKT の能力が初めて導入されたのだ。
現時点ではこの一形態しか存在しないが、他の使用形態もこの一形態を通じて実現可能である。つまり、よくわからないけどすごい力が増えたってことだ!
GAT の実践応用:他言語風構造を作る
よし、締めくくりとして、GAT を使って他の言語にあるような構造を模倣してみよう。
#![feature(generic_associated_types)]
#![allow(incomplete_features)]
trait FunctorFamily {
type Type<T>;
fn fmap<T, U, F>(value: Self::Type<T>, f: F) -> Self::Type<U>
where
F: FnMut(T) -> U;
}
trait ApplicativeFamily: FunctorFamily {
fn pure<T>(inner: T) -> Self::Type<T>;
fn apply<T, U, F>(value: Self::Type<T>, f: Self::Type<F>) -> Self::Type<U>
where
F: FnMut(T) -> U;
}
trait MonadFamily: ApplicativeFamily {
fn bind<T, U, F>(value: Self::Type<T>, f: F) -> Self::Type<U>
where
F: FnMut(T) -> Self::Type<U>;
}
では、これらの型ファミリーを「セレクタ」で実装してみよう:
struct OptionType;
impl FunctorFamily for OptionType {
type Type<T> = Option<T>;
fn fmap<T, U, F>(value: Self::Type<T>, f: F) -> Self::Type<U>
where
F: FnMut(T) -> U,
{
value.map(f)
}
}
impl ApplicativeFamily for OptionType {
fn pure<T>(inner: T) -> Self::Type<T> {
Some(inner)
}
fn apply<T, U, F>(value: Self::Type<T>, f: Self::Type<F>) -> Self::Type<U>
where
F: FnMut(T) -> U,
{
value.zip(f).map(|(v, mut f)| f(v))
}
}
impl MonadFamily for OptionType {
fn bind<T, U, F>(value: Self::Type<T>, f: F) -> Self::Type<U>
where
F: FnMut(T) -> Self::Type<U>,
{
value.and_then(f)
}
}
よし、こうすればこの「セレクタ」OptionType
を通じて、Option
を Functor
、Applicative
、Monad
として扱うことができるようになる。どう?新しい可能性の扉が開いたように感じない?
私たちはLeapcell、Rustプロジェクトのホスティングの最適解です。
Leapcellは、Webホスティング、非同期タスク、Redis向けの次世代サーバーレスプラットフォームです:
複数言語サポート
- Node.js、Python、Go、Rustで開発できます。
無制限のプロジェクトデプロイ
- 使用量に応じて料金を支払い、リクエストがなければ料金は発生しません。
比類のないコスト効率
- 使用量に応じた支払い、アイドル時間は課金されません。
- 例: $25で6.94Mリクエスト、平均応答時間60ms。
洗練された開発者体験
- 直感的なUIで簡単に設定できます。
- 完全自動化されたCI/CDパイプラインとGitOps統合。
- 実行可能なインサイトのためのリアルタイムのメトリクスとログ。
簡単なスケーラビリティと高パフォーマンス
- 高い同時実行性を容易に処理するためのオートスケーリング。
- ゼロ運用オーバーヘッド — 構築に集中できます。
Xでフォローする:@LeapcellHQ
Discussion