Rust で型を渡すだけで実装が選ばれる仕組みを理解する
はじめに
こんにちは、ソフトウェアエンジニアの mosson です。株式会社estie(エスティ)では Rust でウェブアプリケーションの開発をしています。
The Rust Programming Language(日本語版)の「ジェネリクスを使用したコードのパフォーマンス」 という節を読んだことはあるでしょうか。Rust のジェネリクスは単相化 (monomorphization) でコンパイル時にコード展開されるため、実行時オーバーヘッドがない、という説明がさらりと書かれている節です。Rust 入門ではだいたいの方が一度は通る章だと思います。
ただ、あの数段落を「ジェネリクスは速い」だけで読み飛ばしてしまうと、もったいないとわたしは感じています。実はあの仕組みは、Rust を書いていると何度も出会う「型を書いただけで、対応する処理が走る」という、ある種の魔法のような体験の多くを支えているからです。
身近なところでは、標準ライブラリの parse() や collect() がそうです。
let n: i32 = "42".parse().unwrap();
let f: f64 = "3.14".parse().unwrap();
let v: Vec<i32> = (0..3).collect();
let s: HashSet<i32> = (0..3).collect();
parse() も collect() も、呼び出し側が指定する型に応じて違う処理を選んで実行します。parse() は FromStr の実装ごとに、collect() は FromIterator の実装ごとに、別の関数本体が呼び出されます。利用者は型を書いただけで、if も match も書いていません。
ウェブ系のフレームワークでも、同じ仕組みが繰り返し出てきます。たとえば async-graphql の DataLoader。1 つの DataLoader に対して複数のキー型を扱うこのパターンは、DataLoader の基本的な使い方というよりは、async-graphql の 公式ドキュメント "Implement multiple data types" で発展的な使い方として紹介されているものです。
struct UserId(usize);
struct TodoId(usize);
impl Loader<UserId> for MyLoader { type Value = User; /* ... */ }
impl Loader<TodoId> for MyLoader { type Value = Todo; /* ... */ }
impl Trait<K> for X を K 違いで並べておくと、loader.load_one(UserId(1)) と loader.load_one(TodoId(2)) で別々の load 実装が呼ばれます。やはり利用者側に分岐は書かれていません。
もしこの仕組みがなかったら、利用者は引数の型ごとに分岐コードを自分で書くことになります。Rust は関数オーバーロード(同名関数の別シグネチャ)を許していないので、たとえば u64 用と f64 用の hoge を並べて書くことはできません。
fn hoge(a: u64) -> String { format!("u64: {}", a) }
fn hoge(a: f64) -> String { format!("f64: {}", a) } // 同名関数の重複定義でコンパイルエラー
仕方がないので、関数名を分ける(hoge_u64 / hoge_f64)か、引数を enum でまとめて中で match で分岐する、という形になります。どちらにしても、利用者が手で分岐コードを書くことになります。
しかし、冒頭で並べた parse() や DataLoader の例には、その分岐コードがどこにもありません。
「型を渡しただけで、対応する処理が選ばれている」。よく見ると、これらは同じ発想のイディオムの上に立っています。その裏側で動いているのが、冒頭で挙げた Rust book の一節 — 単相化 (monomorphization) と、それと組み合わされる トレイト解決による静的ディスパッチ です。
この記事では、あの一節を膨らませる形で、自分でコードを組みながら「単相化が実際に何を可能にしているのか」を見ていきます。
環境情報
この記事のコードは以下の環境で動作確認しています。外部依存は不要で、標準ライブラリだけで動きます。
$ rustc --version
rustc 1.91.0
つくって理解する
DataLoader そのものを掘り下げるのは別の機会にして、ここでは同じ構造を持つコードを自分で組み立ててみます。
出発点として、引数の型ごとに違う処理を提供できるトレイトを 1 つ定義します。
trait Render<T> {
fn render(&self, value: T) -> String;
}
Render はジェネリックパラメータ T を持っているので、T ごとに別の render 実装を書き分けられます。
次に、Renderer という構造体を立てて、T = i32 用と T = bool 用の impl を並べてみます。
struct Renderer;
impl Render<i32> for Renderer {
fn render(&self, value: i32) -> String {
format!("int: {}", value)
}
}
impl Render<bool> for Renderer {
fn render(&self, value: bool) -> String {
format!("bool: {}", value)
}
}
i32 と bool は別の型なので、Render<i32> と Render<bool> も(ジェネリックパラメータが違う)別のトレイトとして扱われます。そのため Renderer という 1 つの型に 2 つの impl を並べても、Rust の coherence ルール(同じトレイト・同じ型の組み合わせで impl が重複してはいけない、というルール)には違反しません。
ここに、複数の impl への呼び出し口となるジェネリック関数を 1 つ用意します。
fn dispatch<T>(r: &Renderer, value: T) -> String
where
Renderer: Render<T>,
{
r.render(value)
}
中身は r.render(value) の一行ですが、この 関数の切り出し方 が今回の話の肝になります。T をジェネリックパラメータに取り、Renderer: Render<T> という境界で「T 用の Render 実装を持っている Renderer」を要求しています。
ここで効いてくるのは、呼び出し側が T を決めると、その後の選択がすべて連鎖的に決まる という構造です。T = i32 と決まれば「Renderer: Render<i32> を満たす実装」が要求され、それが impl Render<i32> for Renderer に一意に解決される。T = bool なら impl Render<bool> for Renderer に解決される。引数の型 1 つを決めるだけで、どの impl のどの render が呼ばれるかまで連鎖的に確定する、という設計です。
型ごとに並べた impl 群を、ジェネリック関数 1 つで呼び出せるようにする — この構造がこの数行に凝縮されています。あとはコンパイラがこの形を見て、必要な型ごとに関数を生成し、呼び先を解決してくれます。
呼ぶ側はこうなります。
fn main() {
let r = Renderer;
println!("{}", dispatch(&r, 42)); // -> "int: 42"
println!("{}", dispatch(&r, true)); // -> "bool: true"
}
dispatch の中身には分岐コードが一切書かれていないのに、i32 を渡したときは i32 用の render が、bool を渡したときは bool 用の render が呼ばれます。冒頭の parse() や DataLoader の例と同じ「型を渡しただけで呼び分けられる」体験です。
ここから先、なぜこれが動くのかを Rust の言語仕様まで降りて見ていきます。
単相化とは何か
ジェネリック関数がコンパイル時に具体型ごとのコードに複製されることを 単相化 (monomorphization) と呼びます。rustc-dev-guide の Monomorphization では次のように説明されています。
Rust takes a different approach: it monomorphizes all generic types. This means that compiler stamps out a different copy of the code of a generic function for each concrete type needed. For example, if I use a
Vec<u64>and aVec<String>in my code, then the generated binary will have two copies of the generated code forVec: one forVec<u64>and another forVec<String>. The result is fast programs, but it comes at the cost of compile time (creating all those copies can take a while) and binary size (all those copies might take a lot of space).
意訳すると、コンパイラは必要となる具体型ごとに、ジェネリック関数のコードを別々のコピーとして生成します。たとえば Vec<u64> と Vec<String> の両方を使うプログラムをコンパイルすると、生成されるバイナリには Vec のコードが 2 つ含まれることになります。Vec<u64> 用と Vec<String> 用です。結果として速いプログラムが得られる代わりに、コンパイル時間とバイナリサイズが増える、というトレードオフがあります。
では、先ほどの dispatch でも同じことが起きているはずです。確認してみます。ソース上の dispatch の定義は 1 つだけです。
fn dispatch<T>(r: &Renderer, value: T) -> String
where
Renderer: Render<T>,
{
r.render(value)
}
ところが dispatch(&r, 42) と dispatch(&r, true) の両方を呼ぶプログラムをコンパイルすると、コンパイラはこの 1 つの定義から、T = i32 用と T = bool 用の 2 つの具体的な関数を生成します(イメージとしての擬似コード)。
fn dispatch__i32(r: &Renderer, value: i32) -> String {
<Renderer as Render<i32>>::render(r, value)
}
fn dispatch__bool(r: &Renderer, value: bool) -> String {
<Renderer as Render<bool>>::render(r, value)
}
そして呼び出し側のコードも、それぞれの具体型版を直接呼ぶように書き換わります。
// 元のコード
println!("{}", dispatch(&r, 42));
println!("{}", dispatch(&r, true));
// 単相化後
println!("{}", dispatch__i32(&r, 42));
println!("{}", dispatch__bool(&r, true));
わたしはここが今回の話の 見どころ だと思っています。コンパイラが dispatch の本体に書かれた r.render(value) という 1 行を、呼び出し側の型に応じて <Renderer as Render<i32>>::render(r, value) や <Renderer as Render<bool>>::render(r, value) という具体的な関数呼び出しに展開してくれる。展開後のそれぞれの関数の中ではジェネリックパラメータ T がすっかり消えていて、すべての参照が具体型に置き換わっています。
この「ジェネリックパラメータが具体型に置き換わる」瞬間が、この記事のすべての出発点です。
マジックの正体
ここまでで、自分で組み立てたコードが動く理由は出揃いました。表面的には「型を渡したら、対応する処理が選ばれた」というシンプルな体験ですが、実態は「型ごとに別々の関数がコンパイル時に生成され、呼び出し側がどちらを呼ぶかもコンパイル時に確定している」です。少なくとも、どの impl を呼ぶかという分岐は実行時にはありません。 「分岐」というより、コンパイラが型ごとに専用のコードを書き分けてくれた、と表現したほうが近いかもしれません。
これが「型を書いただけで処理が分かれる」が魔法のように見える正体です。
この感覚を掴むと、Rust のコードの読み方も一段変わってきます。fn dispatch<T>(...) where Renderer: Render<T> のような定義を見たときに、頭の中で T = i32 用と T = bool 用の 2 つに 目で展開 して読む。マクロ展開を頭の中でしながらコードを読む、と言ってもいいかもしれません。動的型付けの言語から Rust に来た身としては、ここを掴んでから景色がだいぶ変わって見えるようになりました。
同じイディオムは至るところに
「引数(や呼び出し側)の型によって、対応する impl が選ばれて呼ばれる」というイディオムは、Rust のあちこちで繰り返し現れます。
標準ライブラリでは、冒頭で見た parse::<T>() や collect::<C>() がそうです。指定する型を変えるだけで、FromStr や FromIterator の対応する実装に振り分けられます。From / Into / TryFrom も同じです。
Rust の他のフレームワークでも、いたるところで同じ構造が顔を出します。
axum の extractor。 ハンドラの引数に並ぶ State<T>、Path<T>、Json<T> などは、それぞれ FromRequestParts や FromRequest というトレイトを型ごとに実装したものです。ハンドラの引数に書く型を差し替えるだけで、対応する impl が選ばれて違う処理が走ります。
なお、引数の 個数 を扱うために axum はタプルに対する blanket impl を組み合わせていますが、それは別の話なので今回は触れません。詳しくは 前回の記事 で書きました。
async-graphql の DataLoader。 loader.load_one(UserId(1)) と書けば User 用の load が、loader.load_one(TodoId(2)) と書けば Todo 用の load が呼ばれる。冒頭で並べた User と Todo の例は、まさにこの構造で動いています。内部のジェネリック関数 DataLoader::load_many<K> が、本記事の dispatch と同じ発想 — K を呼び出し側が決めれば対応する impl Loader<K> が静的に解決される — で利用者の実装を呼び分けています。
どれも「引数の型に応じて、対応する impl が選ばれる」という同じイディオムの上に立っています。ひとつ仕組みを理解すると、別のフレームワークのコードを読んだときに同じ構造が透けて見えるようになります。
まとめ
- ジェネリック関数
fn f<T>(...)は、コンパイル時にTの具体型ごとに別関数として複製される(単相化) - 複製された各関数の中では
Tが具体型に置き換わり、<Renderer as Render<i32>>::renderのような直接呼び出しにトレイト解決される(静的ディスパッチ) - 利用者に見える「型を渡しただけで処理が分かれる」体験は、実行時の分岐ではなく、コンパイラが型ごとに別のコードを生成した結果として現れる
- axum の extractor、async-graphql の DataLoader、標準ライブラリの
parse::<T>()/collect::<C>()など、Rust のあちこちで同じイディオムが使われている
「Rust は静的型付き言語なのに、なんだか動的に見える」と感じる場面の多くは、この単相化 + 静的ディスパッチで説明がつきます。仕組みを掴むと、フレームワークのソースコードがぐっと読みやすくなりますし、自分でこの種の API を設計するときの引き出しにもなります。
Discussion