【Rust】dispatchの静と動
はじめに
Rustでtraitを使っていると、implやdynというキーワードが出てきて、さらに深追いして調査すると、動的ディスパッチとか静的ディスパッチという言葉が出てきて、結局どうすれば良いの?とよくわからない状態になっていきます。
今回は、この辺りを整理し、ベストプラクティスを考えていってみたいと思います。
結論
結論から言うと、できる限り静的ディスパッチで済ませる事を目指しましょう。
理由は静的ディスパッチのほうが処理が軽く、特殊なケースを除いて静的ディスパッチで書ける事は多いからです。
そもそもディスパッチとは?
〔書類・荷物などを〕送る、送り出す、発送する、急送する
などという意味があるようです。
プログラミング用語としては、複数ある選択肢から選び出して実行する、というニュアンスが近いと思います。
今回の題材としても、型ごとの振舞いがtraitによって定義され、複数ある型から実際にどの型の振舞いを実行するか?をどのように決定するか、が話の軸になってきます。
動的ディスパッチ
話を進める前に、動的ディスパッチというのがどういうものか具体的に示しておきます。
fn hoge(a:&dyn TraitA){
}
というように、dynキーワードを用いてトレイト型を渡すと動的ディスパッチになります。
dynはdynamicの略でそのまま「動的」という意味になります。
静的ディスパッチ
dynの代わりにimplを用いることで静的ディスパッチとなります。
fn hoge(a:&impl TraitA){
}
違いを確認するためのコード
struct HogeA{}
struct HogeB{}
trait TestTrait{
fn trait_func(&self);
}
impl TestTrait for HogeA{
fn trait_func(&self) {
}
}
impl TestTrait for HogeB{
fn trait_func(&self) {
}
}
fn hoge_impl(a:&impl TestTrait){
a.trait_func();
}
fn hoge_dyn(a:&dyn TestTrait){
a.trait_func();
}
fn main(){
let a=HogeA{};
hoge_impl(&a);
hoge_dyn(&a);
}
上記のコードでは、HogeA、HogeBに対して、同一のトレイトTestTraitをimplしています。
そして、
fn hoge_impl(a:&impl TestTrait)
fn hoge_dyn(a:&dyn TestTrait)
この二つがtraitオブジェクトを受け取る関数ですが、動的ディスパッチ版と静的ディスパッチ版とで分けています。
静的ディスパッチと動的ディスパッチの違い
具体的に言えば、関数がtraitオブジェクトを引数として渡された時に、元の型がHogeAかHogeBのどちらなのか?を特定するための処理の違いが両者の違いとなります。
HogeAとHogeBはそれぞれ同じtraitをimplしているので、fn trait_funcという同じ名前の関数を持ってはいますが、当然、それぞれ処理の内容は異なります。(そのためのtraitなので)
ですので、HogeAとHogeBのどちらが渡されたのかを特定してからtrait_funcを実行してやる必要があります。
動的ディスパッチの場合はvtableという仕組みを使ってそれを実現しているらしく、実行時に型を特定します。
それに対し、静的ディスパッチの場合、コンパイル時に型が特定されます。
というより、コンパイル時に型の種類毎に専用の関数が作られ、渡す型によって呼び出される関数が変わるとイメージした方が近いと思われます。
具体的には、↓のような関数が勝手に作成される、というイメージです。
fn trait_func_at_HogeA(hoge:&HogeA);
}
fn trait_func_at_HogeB(hoge:&HogeB);
}
動的ディスパッチと比べて表面的な動作には特に違いがありませんが、静的ディスパッチはコンパイル時に型毎に関数が生成され、それが直接実行されます。
それに対し、動的ディスパッチの場合は実行時にvtableから実行すべき関数を探すための処理が間に入ってしまうため、その分、動的ディスパッチの方が処理負荷が高い、という理屈なわけです。
ですので、可能な場合はできる限りimplを指定する静的ディスパッチを使った方が良いでしょう。
とはいうものの、
dynを使った方が楽な場合があります。
例えば、structにtraitオブジェクトを持たせたい場合、
struct Hoge{
trait_object:Box<dyn Trait>
}
impl Hoge{
pub fn new(trait_object:Box<dyn Trait>)->Self{
Self{trait_object}
}
}
このように書けば簡単にトレイトオブジェクトを構造体に持たせる事ができます。
ただし、この場合、
・Box(=ヒープ)を使用する必要があり非効率
・動的ディスパッチなので非効率
というデメリットがあります。
これで致命的に重くなるという事は無いと思いますのでこのままでも良いと言えば良いのですが、より効率化を目指すのであれば、静的ディスパッチになるように書き直しましょう。
このケースだと、Hogeをジェネリック型にする事で、Boxを使用せず、静的ディスパッチにする事ができます。
struct Hoge<T:Trait>{
trait_object:T
}
impl<T:Trait> Hoge<T>{
pub fn new(trait_object:T)->Self{
Self{trait_object}
}
}
<T:Trait>
という記述でtrait境界の指定をしています。
TにTraitが実装されている事を要求していて、これでtrait_objectにはTraitが実装されたオブジェクトのみが受け付けられます。
静的ディスパッチとはつまり、
dynキーワードを使わずに型を決定させる事 → そのために、型パラメーターにtrait境界を指定し、コンパイル時にコンパイラが型を特定できるようにする → 静的ディスパッチになる
という事と考えられます。
これと同じ機能がRustには標準で備わっています。
お馴染みのジェネリックな関数やジェネリックな型です。
それもそのはず、
fn hoge(a:&impl Trait)
という記述は、実のところ
fn hoge<T:Trait>(a:&T)
の糖衣構文なのです。
つまり、何が言いたいのかというと、静的ディスパッチとは結局のところ、ジェネリックな関数やジェネリックな型と理屈が全く同じだという事です。
ですので、動的ディスパッチを避け、静的ディスパッチで済ますためには、可能な限りジェネリックな型に落とし込んでいく、という志向で考えればよさそうです。
ちょっとややこしいケース: ジェネリックなtraitを扱う場合
traitがジェネリックな場合、
つまり
struct Hoge<A,T:Trait<A>>{
trait_object:T
}
のような事をしたい場合がごくまれにあります。
この場合、型Aそのものは構造体に存在しないのでコンパイルエラーになりますし、経験が浅い場合、慣例的な書き方もわからないので
struct Hoge<A>{
trait_object:Box<dyn Trait<A>>
}
と書きたくなります。
※私自身このケースに遭遇し、暫くこのように書いていました。
が、これも静的ディスパッチに書き直すことができます。
struct Hoge<A,T:Trait<A>>{
trait_object:T
_marker: PhantomData<A>,
}
PhantomDataというのは、コンパイル時にのみ参照される型で、実行コード生成時には消えてなくなり(メモリサイズが0バイトのデータ(=存在しないデータ)となり)、無視されます。
ですので、メモリ使用量や速度を気にせず使う事ができます。
enumにtraitオブジェクトを入れたい場合
pub enum Hoge<T: Trait> {
A,
B,
C(T),
}
fn hoge<T:Trait>(a:&Hoge<T>){
}
例えば、このような事をしたい場合、呼び出し側では
hoge::<FugaImplTrait>::();
のように、hogeの呼び出しごとに明示的に実在の型を指定する必要があります。
C(T)を使わない事が明らかな場合でも、このように書くことを強制され、関数の利用者側の視点に立つと、この型パラメーターなんか意味あんの?
(´・ω・`)
となります。
そのため、
pub enum Hoge {
A,
B,
C(Box<dyn Trait>),
}
fn hoge(a:&Hoge){
}
としてやる事で上記のモヤっと感は解消され、利用者に優しい構成になると思います。
しかし、ここまで読み進めてきた貴方であれば、
動的ディスパッチが気に食わねぇ
何が何でも静的ディスパッチ化したい
というような衝動にかられるかと思います。
そのような場合は下記のような手段でそれを実現可能です。
pub enum HogeInner<T: Trait> {
A,
B,
C(T),
}
type Hoge=HogeInner<DummyType>;
fn hoge<T:Trait>(a:&HogeInner<T>){
}
let a=Hoge::A;
hoge(&a);
ポイントとしては、ダミーの動作を定義した型を渡し、
type Hoge=HogeInner<DummyType>;
として、通常使用のためのtype Hogeを作成する事で、利用者的には動的ディスパッチの場合と変わらない使用感で関数を呼び出す事が出来ます。
↓コピペでとりあえず動くコード
trait Trait{
fn tfunc();
}
enum HogeInner<T: Trait> {
A,
B,
C(T),
}
struct DummyType{}
impl Trait for DummyType{
fn tfunc() {
unreachable!()
}
}
type Hoge=HogeInner<DummyType>;
fn hoge<T:Trait>(a:&HogeInner<T>){
}
let a=Hoge::A;
hoge(&a);
implすべきメソッドの処理内容については、適当な処理を書いても良いのですが、
unreachable!()
や
unimplemented!()
としておくのが良いかと思います。あくまで使用されない事が明確なケースですので、到達不可能である事や未実装である事を明示的に示しています。
それでも動的ディスパッチを使わざるを得ないケース
しかし、ここまでやっても動的ディスパッチを使った方が良いというケースもいくつか存在すると思います。
例えば、
let list:Vec<Box<dyn T>>=vec![];
のように、Vecに複数の異なるtraitオブジェクトを格納したい場合などです。
この場合、
struct StructA{}
impl Trait for StructA{}
struct StructB{}
impl Trait for StructB{}
enum Hoge{
A(StructA),
B(StructB)
}
let list:Vec<Hoge>=vec![];
などとして、enumに構造体を包んでしまえば静的ディスパッチ化は可能ですが、実際にメソッドを呼び出す際は
for l in list{
match(l){
A(a)=>{
a.trait_method()
}
B(a)=>{
b.trait_method()
}
}
}
のような記述をするはめになり、trait使う意味って?
(´・ω・`)
みたいな事になってしまう上に、matchでの分岐とvtableによるオーバーヘッドどっちが重いのかよくわかんない、という事になるので、このような場合は無理せず動的ディスパッチを使えば良いんじゃないかな、と思います。
まとめ
静的ディスパッチとはつまるところジェネリクスの別名であり、できるだけdynを使うような記述を避けてプログラミングしていけば自ずと静的ディスパッチが使われる事になります。
一部の特殊なケースを除き、静的ディスパッチ化できるケースは意外とありますので、速度重視のプログラミングが必要な場合、この記事のことを思い出して静的ディスパッチ化できるところを探してみていただければと思います。
Discussion