Tambourine作業メモ

主にスキル習得のためにやった作業のメモ。他人には基本的に無用のものです。

ケント・ベックの「テスト駆動開発」の写経をRustでやってみる(13)

14章に入る。いよいよ通貨の変更を実装する。2フランを1ドルに換算したい。 本で示されたテストはこうだ。

@Test
public void testReduceMoneyDifferentCurrency() {
    Bank bank = new Bank();
    bank.addRate("CHF", "USD", 2);
    Money result = bank.reduce(Money.franc(2), "USD");
    assertEquals(Money.dollar(1), result);
}

ぎゃふん。Bankが為替レートを保持している。いや、当然そうか。 私のRustのコードだと、bankはreduce関数だけを持ったモジュールになっている。 これでは為替レートを保持する場所が無い。さて、どうするか。

もちろん、為替レートをグローバルに保持するという考え方もあるかもしれないが、 為替レートは銀行(というか、両替の機能を持つ事業者)ごとにあってしかるべきだから、 複数の為替レートが持てるように銀行をオブジェクト化するべきだろう。

まず、bank::reduceを使っているテストから書き換えよう。今、こうなっている。

mod bank {
    use super::*;

    pub fn reduce(source: Box<Expression>, to: &'static str) -> Money {
        source.reduce(to)
    }
}
#[cfg(test)]
mod tests {
    //...

    #[test]
    fn test_reduce_sum() {
        let sum = Box::new(Sum {
            augend: Money::dollar(3),
            addend: Money::dollar(4),
        });
        let result = bank::reduce(sum, "USD");
        assert_eq!(Money::dollar(7), result);
    }
}

let result = bank::reduce(sum, "USD");の部分が

let bank = Bank {};
let result = bank.reduce(sum, "USD");

になる。まあ、大した変更じゃない。対応してBank構造体を作る。

pub struct Bank {}

impl Bank {
    pub fn reduce(&self, source: Box<Expression>, to: &'static str) -> Money {
        source.reduce(to)
    }
}

reduceメソッドはselfへの参照を引数に入れたこと以外、何も変えていない。 この程度の手間で修正が出来るのなら、必要となった今の時点まで構造体にしていなかったのは正しかったのかもしれない。

さて、では、冒頭のテストを追加してみよう。

#[test]
fn test_reduce_money_different_currency() {
    let bank = Bank {};
    bank.add_rate("CHF", "USD", 2);
    let result = bank.reduce(Box::new(Money::franc(2)), "USD");
    assert_eq!(Money::dollar(1), result);
}

特に悩ましいことは何もない。テストはちゃんと失敗する。

test tests::test_reduce_money_different_currency ... FAILED

failures:

---- tests::test_reduce_money_different_currency stdout ----
thread 'tests::test_reduce_money_different_currency' panicked at 'assertion failed: `(left == right)`
  left: `Money { amount: 1, currency: "USD" }`,
 right: `Money { amount: 2, currency: "CHF" }`', src/main.rs:142:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

さて、このテストを通すための実装は、ちょっと本を離れて自分の歩幅で進んでみようかと思う。その方が楽しそうだ。

Moneyのreduceの実装は、ダミーだ。

impl Expression for Money {
    fn reduce(&self, to: &str) -> Money {
        self.clone()
    }
}

これを本物にしたい。2フランが1ドルに変換されて返るんだから、

  • amountを2から1へ
  • currencyを"CHF"から"USD"へ

この両方ほ変更をしなくてはならない。下の方は簡単だ。変える先は引数で貰えているから。上は、変換レートを入手しなければならない。どこから貰うかというと、Bankからだ。そのためにBankを修正したんだから。

ということは、Bankにレートを提示する機能が必要になる。そっちから実装しよう。 まず、テストを書く。

#[test]
fn test_get_rate_from_bank() {
    let bank = Bank {};
    bank.add_rate("CHF", "USD", 2.0);
    assert_eq!(2.0, bank.get_rate("CHF", "USD"));
    assert_eq!(0.5, bank.get_rate("USD", "CHF"));
}

書いてみて思ったのだが、フランからドルへの換算レートをセットしたら、 当然のことながらドルからフランへの換算レートも教えて欲しい。 ということは、レートは整数ではダメだと言うことだ。

さて、Bankはどのようなデータ構造で為替レートを保持するのが良いだろうか。もちろん、ハッシュが使いたい。Rustにハッシュはあるのかというと、ある。std::collections::HashMapだ。ドキュメントを読むと、キーはEqトレイトとHashトレイトを実装していなければならない。ちゃんとチェックしてくれるのはありがたい。

さて、何をキーとするかといえば、通貨の組だ。この組は順書がある。単純に考えると、タプルでいいと思うんだが、タプルがEqトレイトとHashトレイトを実装しているのかは自明じゃ無い。とりあえず、書いてみようかな。

pub struct Bank {
    rate: HashMap<(&'static str, &'static str), f64>,
}

別にエラーにはならないみたい。ずっと&strを使っているのは気分が良くないけど、どうなんだろう。

さて、まずは、get_rateの方から考えよう。登録されていない為替レートを要求されたときどうするかがいきなり気になるけど、それはTo Doにして進めることにする。

pub fn get_rate(&self, from: &'static str, to: &'static str) -> f64 {
    self.rate.get((from, to))
}

怒られる。getの戻りはOptionだからだ。パターンマッチで展開しなければならない。

pub fn get_rate(&self, from: &'static str, to: &'static str) -> f64 {
    match self.rate.get(&(from, to)) {
        Some(r) => *r,
        None => 0.0,
    }
}

もちろん、見つからなかった場合は0じゃダメなんだけど、とりあえず。

add_rateも暫定の実装をしておこう。

pub fn add_rate(&self, from: &'static str, to: &'static str, r: f64) {
    self.rate.insert((from, to), r);
}

おっと、怒られた。insertをすると自分自身が変更されるのだからmutableな参照を借用しないといけない。

error[E0596]: cannot borrow `self.rate` as mutable, as it is behind a `&` reference
  --> src/main.rs:71:9
   |
70 |     pub fn add_rate(&self, from: &'static str, to: &'static str, r: f64) {
   |                     ----- help: consider changing this to be a mutable reference: `&mut self`
71 |         self.rate.insert((from, to), r);
   |         ^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be borrowed as mutable
pub fn add_rate(&mut self, from: &'static str, to: &'static str, r: f64) {
    self.rate.insert((from, to), r);
}

テストも、

let mut bank = Bank::new();

のようにしておく必要がある。ともかく、これでadd_rateして、そのままget_rateすることはできるようになった。

しかし、ここで考える。("CHF", "USD")と("USD", "CHF")が両方登録できるのはいかがなものだろうか。アプリケーションで登録時にどちらの組み合わせが登録されているかチェックするのは1つの方法だけど、バグっていて登録できてしまったらもう読むときにどうしていいのかわからないのは良くない気がする。

では、どちらかに保持するとして、どちらが適切かはどうやって決めよう。別にどう決めてもいい気もするけど、たぶんこういうのは慣例があって決まってるもののような気もする。わからないから、順番を決めるメソッドを用意して、それで保持することにしよう。

#[test]
fn test_get_pair() {
    assert_eq!(("CHF", "USD"), get_pair("CHF", "USD"));
    assert_eq!(("CHF", "USD"), get_pair("USD", "CHF"));
}

こんな感じで良いだろうか。とりあえず、Stringの比較に従うことにする。

実装はま、こんなもんで良いだろう。

pub fn get_pair(one: &'static str, another: &'static str) -> (&'static str, &'static str) {
    if one.to_string() < another.to_string() {
        (one, another)
    } else {
        (another, one)
    }
}

これを使って、add_rateを実装してみよう。まずは、テストだ。

#[test]
fn test_add_rate() {
    let mut bank = Bank::new();

    // 初回登録
    bank.add_rate("CHF", "USD", 2.0);
    assert_eq!(2.0, bank.rate[&("CHF", "USD")]);

    // 上書き登録すると書き換えられる
    bank.add_rate("CHF", "USD", 4.0);
    assert_eq!(4.0, bank.rate[&("CHF", "USD")]);

    // 逆数でも登録できる
    bank.add_rate("USD", "CHF", 4.0);
    assert_eq!(0.25, bank.rate[&("CHF", "USD")]);
}

とりあえず、こんな感じで良いだろうか。 今までも実装でも2つめのassetまではクリアする。 最後の例をクリアしなくては。こんな素直な実装で良いかと思う。テストは通過した。

pub fn add_rate(&mut self, from: &'static str, to: &'static str, r: f64) {
    let pair = Bank::get_pair(from, to);
    self.rate
        .insert(pair, if pair.0 == from { r } else { 1.0 / r });
}

最後に、もう一度、get_rateのテストを見直そう。 登録されていないときの動作と、逆数を返さなくてはいけないときを考慮する。

#[test]
fn test_get_rate() {
    let mut bank = Bank::new();

    // 未登録の場合にはNoneが返る
    assert!(match bank.get_rate("CHF", "USD") {
        None => true,
        _ => false,
    });

    // 登録したらそれが取れる
    bank.add_rate("CHF", "USD", 2.0);
    assert_eq!(2.0, bank.get_rate("CHF", "USD").unwrap());

    // 逆順に取得したら、逆数が取れる
    assert_eq!(0.5, bank.get_rate("USD", "CHF").unwrap());
}

未登録の場合の処理を考えると、get_rateの戻りはOptionになっているのが適切だろう。というわけで、未登録だとNoneが返り、登録済みの場合はunwrap()して取り出している。

実装しよう。実は、HashMapのgetの戻りはOption<&T>なので、ほとんどそのまま返してやれば良い。つまり、Some<&f64>だった場合にSomeに直して、Noneだった場合はそのまま戻せば良い。それはmap()を使うとできる。参照は単純に*で戻してやればいい(んだと思う。コピーしなきゃダメなのかな?f64なら勝手にコピーされる??)

pub fn get_rate(&self, from: &'static str, to: &'static str) -> Option<f64> {
    let pair = Bank::get_pair(from, to);
    self.rate
        .get(&pair)
        .map(|i| if pair.0 == from { *i } else { 1.0 / *i })
}

ついに材料は揃った。Money.reduceを実装すべき時だ・・・とその前に、Moneyの保持している金額も、f64に変更してしまおう。f64はEqトレイトを実装しないので、MoneyもEqトレイトを自動実装できなくなるので、そこは修正する。

#[derive(Debug, PartialEq, Clone)]
pub struct Money {
    amount: f64,
    currency: &'static str,
}

コンストラクタやテストも軒並み変更。めんどくさい。

さて、いよいよMoney.reduceだ。為替レートを取得するためにreduceにはBankを渡してやる必要がある。そして、為替レートがセットされていなかったらエラーにならなければいけないので、戻り値はResultで包まれることになる。エラーの場合は今はエラーメッセージを返せば十分だと思うから、Result<Money, &'static str>にする。&strだとやっぱりライフタイム関連のエラーが出る。めんどくさい。

impl Expression for Money {
    fn reduce(&self, to: &'static str, bank: &Bank) -> Result<Money, &'static str> {
        let rate = bank.get_rate(self.currency, to);
        match rate {
            Some(r) => Ok(Money {
                amount: self.amount / r,
                currency: to,
            }),
            None => Err("Never set rate these currencies"),
        }
    }
}

これでExpressionトレイトのreduceメソッドの型を変えてしまったので、Sum.reduceも修正が必要だ。しかし、こっちはまだ何にもやってない。とりあえず、後回しにしよう。コンパイルエラーを無くすために全体をOk()で包んでおく。Bankの仮引数を_にしているのは、これは使われてませんよという警告を出なくするためだ。どう考えても使わないと本来はおかしいはずなので、このメソッドは次にちゃんと直さなくてはいけない。

impl Expression for Sum {
    fn reduce(&self, to: &'static str, _: &Bank) -> Result<Money, &'static str> {
        Ok(Money {
            amount: self.augend.amount + self.addend.amount,
            currency: to,
        })
    }
}

これでやっと、最初に作ったtest_reduce_money_different_currencyが通った。やれやれ。

・・・と思ったけど、このMoney.reduceはドルをドルに変更しようとするとマズいんじゃ無いだろうか。テストを追加しよう。

#[test]
fn test_reduce_money_different_currency() {
    let mut bank = Bank::new();
    bank.add_rate("CHF", "USD", 2.0);
    let result = bank.reduce(Box::new(Money::franc(2.0)), "USD").unwrap();
    assert_eq!(Money::dollar(1.0), result);

    // ドルをドルに変更する
    let result2 = bank.reduce(Box::new(Money::dollar(2.0)), "USD").unwrap();
    assert_eq!(Money::dollar(2.), result2);
}

案の定、失敗した。

thread 'tests::test_reduce_money_different_currency' panicked at 'called `Result::unwrap()` on an `Err` value: "Never set rate these currencies"', src/main.rs:180:23

("USD", "USD")という為替レートの登録を探しに行ってしまう。fromとtoが同じ時には、渡されたモノをそのまま返すことにしよう。

fn reduce(&self, to: &'static str, bank: &Bank) -> Result<Money, &'static str> {
    if self.currency == to {
        return Ok(self.clone());
    }
    let rate = bank.get_rate(self.currency, to);
    match rate {
        Some(r) => Ok(Money {
            amount: self.amount / r,
            currency: to,
        }),
        None => Err("Never set rate these currencies"),
    }
}

これでテストは通った。うーん、疲れた・・・。さて、本とはどのぐらい違っているだろうか。