まえがき
前回しれっとバリューオブジェクト
なる単語を使ったのですが、ここで一度意味を整理しておきたいと思ったのでまとめます。
バリューオブジェクトとは何か
文字通り、バリューつまり値を表現するためのオブジェクトをバリューオブジェクトと呼びます。
でもこれだけだと、問題が「値とは何か」に置き換わるだけですね。では、アプリケーション内で「値」が持つべき特性を考えてみましょう。
- 同じ値は等しいものとして扱われる
- 異なる値同士は何らかの方法で比較できる
- 異なる値は異なるものとして扱われる(イミュータブル)
以下では、具体例としてMoney
クラスを使います。このクラスは金額と通貨単位を持っています。下に一番素朴な実装を示します。
# 素朴な実装
class Money
attr_accessor :amount, :currency
def initialize(amount, currency)
@amount, @currency = amount, currency
end
end
同じ値は等しい
1は当たり前といえばあまりに当たり前なことを言っています。Money
クラスでは、金額と通貨単位が等しいとき、そのオブジェクトたちは等しいと言っていいでしょう。
しかし、先ほどの素朴な実装では、==
が実装されていないため、異なるインスタンス同士は常に異なっていると判定されてしまいます。
m1 = Money.new(100, 'USD')
m2 = Money.new(100, 'USD')
m1 == m2 # => false
そこで、==
を実装します。
Class Money
def ==(other_money)
self.amount == other_money.amount && self.currency == other_money.currency
end
end
これで同じ値は等しくなりました。
異なる値を比較する
またもや当たり前なことですが、異なる値同士は比較できてほしいものです。Money
の例で言えば、異なる通貨での大小比較ができると最高ですね。もしMoney
を単なる文字列や数値として実装した場合、異なる通貨での大小比較には別クラスを用意する必要があるでしょう。実際には、その比較は貨幣と金額に密接に関連した計算ですので、バリューオブジェクトを使ったほうが凝集度が高くなります。
比較をするということですので、Comparable
モジュールをinclude
して、必要なメソッドを実装します。
class Money
include Comparable
EXCHANGE_RATES = {'USD' => {'USD' => 1, 'JPY' => 103}, 'JPY' => {'USD' => 0.0097, 'JPY' => 1}}
def <=>(other_money)
return nil unless other_money.is_a?(Money)
if self.currency == other_money.currency
self.amount <=> other_money.amount
else
self.amount <=> other_money.exchange_to(@currency).amount
end
end
def exchange_to(currency)
rate = EXCHANGE_RATES[@currency][currency].to_f
Money.new(@amount * rate, currency)
end
end
EXCHANGE_RATES
の部分には実際の為替レートを使うことを想定しています。
イミュータブル
ここで、exchange_to
に注目してください。自身のamount
を変更する代わりに、新しいオブジェクトを生成して返しています。では、もしexchange_to
が自身のamount
を変更する場合、何が起こるでしょうか。
# まずい実装
def exchange_to(currency)
rate = EXCHANGE_RATES[@currency][currency].to_f
self.amount = self.amount * rate
self.currency = currency
end
money1 = Money.new(1000, 'USD')
money2 = Money.new(1000, 'USD')
money1.amount == money2.amount # => true
money2.exchange_to('JPN')
money1.amount == money2.amount # => false
1つのコード内で、同じ比較式の返り値が変わってしまっています。しかし、「同じ1000ドル同士を比較した結果が変わる」というのは妙な話です。そもそも、1000ドルを円に両替したら、それはもはや別物として扱うべきでしょう。
この性質は、例えばリンゴとは決定的に違います。リンゴの場合、時間が経って腐ってしまうなどして属性が変わっても同じリンゴと言えますが、バリューオブジェクトの場合、属性が変わればもはや同じオブジェクトではありません。この性質が イミュータブルであるということです。この性質を実装するには、自身の属性を変えるようなメソッドでは常に新しいインスタンスを返すようにします。
また、amount=
メソッドがあってはマズイので、アクセサをゲッターのみにしましょう。
class Money
attr_reader :amount, :currency
end
いつ使うのか
さてでは、バリューオブジェクトはどんなときに使うべきなのでしょうか。
- 複数の値がまとまって一つの意味ある実体をなしているとき
- 複雑な比較が必要なとき
1の実例は座標などがあるでしょう。x座標とy座標の組み合わせがまとまって、座標という意味のある実体となっています。
2の実例は先ほどのお金がいい例です。異なる通貨間での比較を、1つのクラスにまとめることに成功しています。
ほかにもあるかもしれませんが、私にはちょっと思いつきません。何かありましたらコメントで教えてください。
まとめ
より良いオブジェクト指向を目指しましょう(`・ω・´)
# 最終的なコード
class Money
include Comparable
attr_reader :amount, :currency
EXCHANGE_RATES = {'USD' => {'USD' => 1, 'JPY' => 103}, 'JPY' => {'USD' => 0.0097, 'JPY' => 1}}
def initialize(amount, currency)
@amount, @currency = amount, currency
end
def <=>(other_money)
return nil unless other_money.is_a?(Money)
if self.currency == other_money.currency
self.amount <=> other_money.amount
else
self.amount <=> other_money.exchange_to(@currency).amount
end
end
def exchange_to(currency)
rate = EXCHANGE_RATES[@currency][currency].to_f
Money.new(@amount * rate, currency)
end
end
参考資料
http://www.sitepoint.com/value-objects-explained-with-ruby/
http://blog.codeclimate.com/blog/2012/10/17/7-ways-to-decompose-fat-activerecord-models/