Rustコトハジメ

プログラミング言語Rustに関する情報をお届けします。

ゼロコスト抽象化とは一体何なのか

「ゼロコスト抽象化」という言葉を最初に聞いた時、正直にいうと何のことだかわかりませんでした。これは別途記事にしようと思いますが、freeしなくていいのにGC不要と書いてあって何を言ってるのかさっぱりわからなかったのと同様に、抽象化のコストがゼロとは何のことだ?と混乱しました。C++のゼロオーバーヘッド原則やテンプレートプログラミングに精通している場合、すんなり理解出来るかも知れないが、きっと読者の中にはそういう人ばかりではないから、というかそうでない人を対象にして記事を書いているつもりなので、説明します。

色々ドキュメントを読んだ中で、ゼロコスト抽象化についてダイレクトに解説しているものは、以下の公式ブログだから、この記事から要点をつまみ食いする形で説明していきます。

blog.rust-lang.org

C++のゼロオーバーヘッド原則

C++ implementations obey the zero-overhead principle: What you don't use, you don't pay for [Stroustrup, 1994]. And further: What you do use, you couldn't hand code any better.

は、一言でいうと「実行することに対して余計なコード(GCや間接参照)でCPUを消費するな」という意味です。

Rustのトレイトは静的ディスパッチにも動的ディスパッチにも対応している

The cornerstone of abstraction in Rust is traits

これをRustで実現する方法として、Rustのトレイトは重要な役目を果たしている。トレイトについて箇条書きでこう説明されています。

  • Traits are Rust's sole notion of interface トレイトはRustにおいてインターフェイスを記述する唯一の概念である
  • Traits can be statically dispatched トレイトは静的ディスパッチすることが出来る
  • Traits can be dynamically dispatched 静的ディスパッチすると困る場合がある。そういう場合には動的ディスパッチすることも出来る

これらについて説明していきましょう。

静的ディスパッチとは

fn print_hash<T: Hash>(t: &T) {
    println!("The hash is {}", t.hash())
}

たとえば、このようなコードがあるとして、boolとi32がHashを実装していて、実際にprint_hash関数に対して、boolやi32を入れたとする。この時、Rustでは以下のようなコードが生成される。これを単相化という。

fn __print_hash_bool(&bool)
fn __print_hash_i32(&i32)

つまり、実際に使ってる具体的な型を当てはめて、具体的な関数を作ってくれる。print_hashの呼び出し箇所はこれらの具体的な関数を呼び出す形で置換されるから、余計なオーバーヘッドがなくなる。

これはクラスやトレイトにおける型制約に拡張しても同じことが起こる。AとかTとかを一切使わずに型をべた書きしたかのようなコードに展開されて、さらにトレイトも全部消去されて、具体的なクラスだけを使用してむちゃくちゃ冗長に書いたのようなコードが生成される。関数を呼ぶ時に、そのアドレスをコンパイル時に知っておけるためこれを静的ディスパッチと言います。

動的ディスパッチとは

静的ディスパッチだと困る場面があります。例えば以下のようなコードです。

trait ClickCallback {
    fn on_click(&self, x: i64, y: i64);
}
struct Button<T: ClickCallback> {
    listeners: Vec<T>,
    ...
}

これは、Javaなどではとても正常なコードに見えますが、Rustでは問題になります。静的ディスパッチされてしまい、Tが何か具体的な一つの型で固定されてしまうからです。やりたいことは、ClickCallbackインスタンスを色々と実装して(例えばAとBとする)、listenersに登録していくことなのですが、AのみあるいはBのみしか入れられなくなります。なぜならば、Rustは静的ディスパッチによって、

struct ButtonA {
   listeners: Vec<A>
}

のようなクラスを生成しようとするからです。

では、Javaで出来ていたことをRustでやるにはどうすればいいか?そこで以下のように書き、動的ディスパッチを使います。

struct Button {
    listeners: Vec<Box<ClickCallback>>,
    ...
}

Actually, in Rust, traits are types, but they are "unsized", which roughly means that they are only allowed to show up behind a pointer like Box (which points onto the heap) or & (which can point anywhere).

Boxが必要なのは、トレイトが[T]やstrのようにUnsizedだからですが、これについては別途記事を書こうと思います。

このようにすると、JavaのinterfaceやC++のvirtual関数のように、vtableを経由するランタイムコストを払う代わりに、柔軟性を手に入れることが出来ます。

静的ディスパッチと動的ディスパッチは補完的な存在

Static and dynamic dispatch are complementary tools, each appropriate for different scenarios. Rust's traits provide a single, simple notion of interface that can be used in both styles, with minimal, predictable costs.

Rust言語によるプログラミングでは、基本的には静的ディスパッチを考えますが、それが適用出来ない場面では動的ディスパッチが使えます。つまり、トレイトという一つの概念によってすべてのシナリオをカバーしています。

ゼロコスト抽象化とは

Trait objects satisfy Stroustrup's "pay as you go" principle: you have vtables when you need them, but the same trait can be compiled away statically when you don't.

ゼロコスト抽象化とは、抽象化に関して理想的な量しかコストを払わないということです。Rustのトレイトでは、静的ディスパッチによって一切の抽象化コストを払わなくていいし、しょうがなく払う必要がある時にはコストを払って動的ディスパッチによって書くことも出来ます。これは、動的ディスパッチしかない言語に比べて、抽象化のコストが低いことを意味していて、それが理想的な量なので、余計なコストが「ゼロコスト」だと言っているということです。