📑

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 の実装ごとに、別の関数本体が呼び出されます。利用者は型を書いただけで、ifmatch も書いていません。

ウェブ系のフレームワークでも、同じ仕組みが繰り返し出てきます。たとえば async-graphqlDataLoader。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)
    }
}

i32bool は別の型なので、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 a Vec<String> in my code, then the generated binary will have two copies of the generated code for Vec: one for Vec<u64> and another for Vec<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>() がそうです。指定する型を変えるだけで、FromStrFromIterator の対応する実装に振り分けられます。From / Into / TryFrom も同じです。

Rust の他のフレームワークでも、いたるところで同じ構造が顔を出します。

axum の extractor。 ハンドラの引数に並ぶ State<T>Path<T>Json<T> などは、それぞれ FromRequestPartsFromRequest というトレイトを型ごとに実装したものです。ハンドラの引数に書く型を差し替えるだけで、対応する 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 を設計するときの引き出しにもなります。

estie テックブログ

Discussion