13
11

More than 3 years have passed since last update.

TDD in Rust (Rustでドメインモデリング)

Last updated at Posted at 2021-07-03

Kent Beckのテスト駆動開発第一章の多国通貨を題材にRustでのドメインモデリングをしてみました。

以前Goでもやっていて、新しい言語を学ぶときの定番となっています。

今回はRustで書いた中での発見を中心にまとめていきます。
書いたコードは以下のリポジトリから見れます。

仕様と書籍での実装(Java)

本題に入ります。
多国間通貨の題材で中心となるのは以下の仕様です。

$5 + 10 CHF = $10 (ドル : フラン = 1 : 2 のレートの場合)

題材としてフランが使われているのに時代を感じます。
ポイントは違う通貨単位の計算ができることにあります。

書籍ではテスト駆動開発を用いて設計を洗練させる中で最終的に式(Expression)のメタファーを用いて設計することになります。
書籍でのJavaのExpressionのインタフェースはこのような形です。

interface Expression {
    Expression times(int multiplier); // N倍の演算 ※今回の話では割愛します。
    Expression plus(Expression addend); // 足し算(Expressionが引数になっていて再帰的に演算できる)
    Money reduce(Bank bank, String to); // 式の演算 toには変換先の通貨を表す文字が入る"USD"など
}

そして、これをお金を扱うクラスのMoneyと足し算を扱うクラスのSumがimplementすることで、実装しています。

class Money implements Expression {
...
    Expression plus(Expression addend) {
        return new Sum(this, addend);
    }
    Money reduce(Bank bank, String to) {
        // bank.rateは変更元の通貨(currency)と変更先の通貨(to)を渡すことで変換レートを返す
        int rate = bank.rate(currency, to);
        return new Money(amount / rate , to);
    }
...
}

class Sum implements Expression {
...
    Expression plus(Expression addend) {
        return new Sum(this, addend);
    }
    Money reduce(Bank bank, String to) {
        // 予め変換先の通貨にすることで同じ通貨同士の足し算にしている
        int amount = augend.reduce(bank, to).amount + addend.reduce(bank, to).amount;
        return new Money(amount , to);
    }
...
}

Rustでの実装

Rustでインタフェース相当のことをするといえばトレイトということで、まずはトレイトでの実装を考えました。
すると、すぐにRustのトレイトはJavaのインタフェースのようにオブジェクトとしては扱えないことが分かります。
Rustでもトレイトオブジェクトを使えば実現できますが、トレイトオブジェクトはできるだけ避けた方がいいという主張が目立ちます。
調べてみるとRustのEnumは代数的データ型として複数の型をまとめることができるということが分かりました。
今回はこれを利用してExpressionを組み立てました。

2021/07/05 追記
ジェネリクスを用いてimpl Traitで返すことでトレイトオブジェクトを避けられます。
今回の例ではExpressionがSumの中で再帰的に使われていて、コンパイル時点でExpressionの型をジェネリクスで指定できないため、Enumを用いたやり方が良いかと思いました。
Expression(式)とSumとMonneyの関係がうまく表せているという点もEnumを使う動機になっています。

実際のコードがこちらです。

#[derive(Clone, Debug, Eq, PartialEq)]
enum Expression {
    Money(Money),
    Sum(Sum),
}

impl From<Money> for Expression {
    fn from(money: Money) -> Self {
        Expression::Money(money)
    }
}

impl From<Sum> for Expression {
    fn from(sum: Sum) -> Self {
        Expression::Sum(sum)
    }
}

impl Add for &Expression {
    type Output = Expression;

    fn add(self, rhs: Self) -> Self::Output {
        Sum::new(self.clone(), rhs.clone()).into()
    }
}

impl Expression {
    fn reduce(&self, bank: &Bank, to: Currency) -> Money {
        match self {
            Expression::Sum(sum) => sum.reduce(bank, to),
            Expression::Money(money) => money.reduce(bank, to),
        }
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Money {
    amount: i32,
    currency: Currency,
}

impl Money {
    fn new(amount: i32, currency: Currency) -> Self {
        Self { amount, currency }
    }
    fn dollar(amount: i32) -> Self {
        Self { amount, currency: Dollar.into() }
    }
    fn franc(amount: i32) -> Self {
        Self { amount, currency: Franc.into() }
    }
    fn reduce(&self, bank: &Bank, to: Currency) -> Self {
        let rate = bank.rate(self.currency, to);
        Money::new(self.amount / rate, to)
    }
}

impl Add for Money {
    type Output = Expression;

    fn add(self, rhs: Self) -> Self::Output {
        &Expression::Money(self) + &Expression::Money(rhs)
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
struct Sum {
    augend: Box<Expression>,
    addend: Box<Expression>,
}

impl Sum {
    fn new(augend: Expression, addend: Expression) -> Self {
        Sum {
            augend: Box::new(augend),
            addend: Box::new(addend),
        }
    }
    fn reduce(&self, bank: &Bank, to: Currency) -> Money {
        Money::new(
            self.augend.reduce(bank, to).amount + self.addend.reduce(bank, to).amount,
            to,
        )
    }
}

テストコード

    #[test]
    fn test_mixed_addition() {
        let five_bucks: Expression = Money::dollar(5).into();
        let ten_francs: Expression = Money::franc(10).into();
        let mut bank = Bank::new();
        bank.add_rate(Franc.into(), Dollar.into(), 2);
        let result = bank.reduce(&five_bucks + &ten_francs, Dollar.into());
        assert_eq!(Money::dollar(10), result);
    }

振り返り

RustのEnumを用いて(この例では)トレイトオブジェクトを作らなくても、Javaでのインタフェースを返すこと相当のことができました。
reduceではRustのEnumの代数的データ型でmatch式を用いることで、データ型に合わせてそれぞれのメソッドが呼び出せるということが分かりました。

impl Expression {
    fn reduce(&self, bank: &Bank, to: Currency) -> Money {
        match self {
            Expression::Sum(sum) => sum.reduce(bank, to),
            Expression::Money(money) => money.reduce(bank, to),
        }
    }
}

JavaでのインタフェースではSumとMoneyのplusが完全に重複していたが、Expressionに実装することでこの重複をなくすことができました。
そして、Sumでplusを直接呼ぶことがなくなったので、Sumにはplusを実装していません。

impl Add for &Expression {
    type Output = Expression;

    fn add(self, rhs: Self) -> Self::Output {
        Sum::new(self.clone(), rhs.clone()).into()
    }
}

impl Add for Money {
    type Output = Expression;

    fn add(self, rhs: Self) -> Self::Output {
        &Expression::Money(self) + &Expression::Money(rhs)
    }
}

Enumのデータ型に合わせたFromトレイトを実装しておくと、into()メソッド呼ぶだけで変換してくれるので便利でした。

impl From<Sum> for Expression {
    fn from(sum: Sum) -> Self {
        Expression::Sum(sum)
    }
}
impl Add for &Expression {
    type Output = Expression;

    fn add(self, rhs: Self) -> Self::Output {
        Sum::new(self.clone(), rhs.clone()).into()
    }
}

最後に

Rustでは今までのオブジェクト指向プログラミングでやっていた方法が素直にできる部分とそうでない部分があって、実装の方法に試行錯誤しました。
結果としてRustのもつEnumの柔軟性に気づけたのは面白い発見でした。
まだまだ始めたばかりなので、誰かの気付きの手助けになれば幸いです。

フィードバックなどありましたら、QiitaのコメントかGithubに連絡もらえると嬉しいです。

13
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
11