Rust: Unit-like構造体と関連型を用いたMixinのパターン


RustでもScalaとかのようにカッコよくMixinを決めたいよね。
MixinでDIやりたいよね。

やりかた

XxxServiceトレイト

まずサービスのインターフェースを定義する。

pub trait HelloService<T> where T: ?Sized {
    fn hello(this: &T, to: String) -> ();
}

このジェネリクスが重要になる。
サービスのインターフェースはSelfを使わず、代わりにジェネリクスで受け取った型を持つthisを第1引数に取る。thisを受け取らないメソッドは定義しない。

MixinXxxServiceトレイト

続いて、インターフェースと対になる、サービスをMixinするトレイトを定義する。

pub trait MixinHelloService {
    type Impl: HelloService<Self>;

    fn hello(&self, to: String) -> () {
        Self::Impl::hello(self, to);
    }
}

キーワードの一つ、関連型が現れた。
この関連型Implはサービスを実装した型を示している。しかも、先程定義したインターフェースのパラメータにSelfを渡している。知っての通りSelfはこのトレイトを実装した具象型だ。

また、このトレイトはサービスのインターフェースで定義したthisselfで置き換えたメソッドを定義し、デフォルト実装で関連型Implにプロキシしている。

JavaScriptを触ったことのある人なら、thisを退避したselfという名前の変数を作ったことがあるかもしれない。この二つのトレイトは、名前こそ逆だけれど同じことを、型レベルで行っていると考えていい。

なぜSelfを退避して渡す必要があるか? この二つのトレイトを実装する具象型は異なるからだ。

いよいよサービスを実装しよう。

XxxServiceImpl

pub struct HelloServiceImpl;

impl<T> HelloService<T> for HelloServiceImpl
{
    fn hello(_this: &T, to: String) -> () {
        println!("Hello, {}!", to);
    }
}

二つ目のキーワードであるUnit-like構造体が現れた。フィールドを持たない構造体は型レベルで何かするときに便利。Variantの無いenumも使用できるけれど、そこはまあ好みだよね。

この空の型にサービスを実装する。
thisを単に捨てているのは、この実装では不要だからだ。thisを参照する実装も作ることができる(割愛)。

Mixinと利用

ここまででサービスの実装を作り具象型を与えた。
あとはこのサービスをMixinして利用するだけだ。

struct Base;

impl MixinHelloService for Base {
    type Impl = HelloServiceImpl;
}

fn main() {
    let base = Base {};

    do_something(base);
}

fn do_something<S>(base: S) -> ()
where
    S: MixinHelloService,
{
    MixinHelloService::hello(&base, "World".to_string());
}

見ての通り、何か適当な型にimplで関連型として実装を与えるだけのお手軽仕様。実装を差し替えたい場合もここで関連型Implに別の型を指定する。Mixinとしては上々のゆるさだ。

利用するときも、Mixinした型のインスタンスを静的ディスパッチによって伝搬し、サービスを使いたい場所でトレイト境界にMixinHelloServiceを指定すればいい。実装が何であるかは意識せずサービスを利用できる。

サービスの依存関係

ここまでの説明ではSelfを伝搬する必要がまったくなかった。
この仕組みはサービスに依存関係が生じたときに威力を発揮する。

まず、先程同様にサービスのインターフェースとMixin用のトレイトを用意する。

pub trait WorldService<T>
where
    T: ?Sized,
{
    fn hello_world(this: &T) -> ();
}

pub trait MixinWorldService {
    type Impl: WorldService<Self>;

    fn hello_world(&self) -> () {
        Self::Impl::hello_world(self);
    }
}

そして実装を作る。
このとき、伝搬されたSelfに対して追加の要求を宣言できる。

pub struct WorldServiceImpl;

impl<T> WorldService<T> for WorldServiceImpl
where
    T: MixinHelloService,
{
    fn hello_world(this: &T) -> () {
        MixinHelloService::hello(this, "World".to_string());
    }
}

ここではMixinHelloServiceが実装されていることを要求し、実装の中でthisを使って呼び出している。
Selfが伝搬することによって、Mixinする対象に制約を掛けることができたわけだ。

WorldService自体はHelloServiceに依存していないことにも着目してほしい。実装次第で依存する対象は変わりうる。HelloServiceに依存しない実装も可能だ。

このサービスをMixinしたら、利用するときは単にMixinWorldServiceを要求すればいい。

fn do_something<S>(base: S) -> ()
where
    S: MixinWorldService,
{
    MixinWorldService::hello_world(&base);
}

そう、サービスの実装の依存関係が外に漏れ出さないのだ。
関連型は暗黙的に利用できるため、余計な型引数もなく非常にすっきりした形でwhere節を書ける。

もちろんこれは静的ディスパッチで型情報を全て伝搬できる時の話であって、動的ディスパッチが絡むと壊れる。そういうとこやぞRocket。