読者です 読者をやめる 読者になる 読者になる

Rustの単一実装トレイトパターン

概要: ただ一つの実装をもつトレイトを定義するデザインパターンでできることとデメリットを紹介する。「単一実装トレイトパターン」という名前は今考えたもので、他に名前があるかもしれない。

既存の型や既存のトレイトにメソッドを追加する

Rubyというプログラミング言語では、既存のクラスにメソッドを追加できる。InfoQの2008年の記事がわかりやすい。

単一実装トレイトパターンを使うと、Rustで同じことができる。

// 文字列にfooメソッドを追加する
pub trait FooMixin {
    fn foo(&self);
}
impl FooMixin for str {
    fn foo(&self) {
        println!("foo {}", self);
    }
}
fn main() {
    "hoge".foo();
}

Rubyオープンクラスと異なり、Rustのこれは非常に保守的である。

  • メソッドの追加はできても、既存のメソッドの置き換え(モンキーパッチ)はできない。
  • この方法で追加したメソッドを使うには、 FooMixin がuseされた状態でないといけない。

このため、既存のプログラムの動作に遡って影響を与えることはないし、名前の衝突も回避しやすい。

特定の型だけではなく、トレイトに対してメソッドを追加することもできる。後出しのmixinのようなことができる。

// AsRef<str>にfooメソッドを追加する
pub trait FooMixin : AsRef<str> {
    fn foo(&self);
}
impl<T: AsRef<str>> FooMixin for T {
    fn foo(&self) {
        println!("foo {}", self.as_ref());
    }
}
fn main() {
    "hoge".foo();
}

これに関してもRustは保守的に振る舞うため、モンキーパッチの恐怖に怯える必要がない。

既存の型や既存のトレイトにメソッドを追加する の問題点

上記コードは、以下のコードをメソッドチェインで書けるようにしたに過ぎない。

// 文字列にfooメソッドを追加する(メソッドチェインしない版)
pub fn foo(this: &str) {
    println!("foo {}", this);
}
fn main() {
    foo("hoge");
}

ドット記号でメソッドチェインできなくなるが、それを除けば、普通の関数として実装するほうが手間もかからず、余計なトレイトと謎のデザインパターンを導入せずにすむ。

final methodを定義する

Rustのトレイトはデフォルト実装をもつことができる。Javaで例えるなら、多重実装できるという点ではinterfaceに近く、デフォルト実装をもつことができるという点ではabstract classに近い。

pub trait Order {
    fn unit_price(&self) -> u32;
    fn number(&self) -> u32;
    fn total_price(&self) -> u32 {
        self.unit_price() * self.number()
    }
}

pub struct DiscountedApple;
impl Order for DiscountedApple {
    fn unit_price(&self) -> u32 { 30 }
    fn number(&self) -> u32 { 3 }
    fn total_price(&self) -> u32 { 80 }
}

ただし、デフォルト実装は上記のように上書きできてしまう。Javaでいうところのfinal methodのように、上書きできない実装を提供するなら、次のようにすればよい。

pub trait Order {
    fn unit_price(&self) -> u32;
    fn number(&self) -> u32;
}
pub trait OrderExt : Order {
    fn total_price(&self) -> u32;
}
impl<T: Order> OrderExt for T {
    fn total_price(&self) -> u32 {
        self.unit_price() * self.number()
    }
}

struct DiscountedApple;
impl Order for DiscountedApple {
    fn unit_price(&self) -> u32 { 30 }
    fn number(&self) -> u32 { 3 }
    // fn total_price(&self) -> u32 { 80 } // Error
}

上のように書いて、 OrderExt をuseしておくと、 total_price が使える。

final methodを定義する の問題点

「既存の型や既存のトレイトにメソッドを追加する」と同様、ドット記号でのメソッドチェインが不要なら、関数で同じことができる。もちろん、トレイトを増やすこと自体が無駄な複雑性といえる。

そもそも、デフォルト実装の上書きをどうしても禁止したい理由があまりない。以下のように、パフォーマンス上の利点もおそらくない。

まず、トレイトを静的ディスパッチで使う場合。この場合、どちらの方法を使っても、 total_price に対応するコードが別途生成される(または、インライン化される)という点は変わらない。どの実装が使われるかは全てコンパイル時に解決されるから、最適化の妨げになるとも思えない。

次に、トレイトを動的ディスパッチで(つまり、trait objectとして)使う場合。この場合、まずvtableの大きさの違いがある。しかしvtableはコンパイル時に生成されるし、ちょっと長いからといって急激にアクセス効率が悪くなるとはあまり思えない。次に、関数ポインタによる間接呼び出しの回数が増えるというのが考えられる。それによる効率定価は実際にありえる。しかしその程度の難しい差が重要なら、そもそも間接呼び出しをなくすという方向で考えたほうがいいかもしれない。

まとめ

上記コードは、以下のコードをメソッドチェインで書けるようにしたに過ぎない。

// 文字列にfooメソッドを追加する(メソッドチェインしない版)
pub fn foo(this: &str) {
    println!("foo {}", this);
}
fn main() {
    foo("hoge");
}

ドット記号でメソッドチェインできなくなるが、それを除けば、普通の関数として実装するほうが手間もかからず、余計なトレイトと謎のデザインパターンを導入せずにすむ。