値オブジェクトは以下の3つの要素を持ったオブジェクトだとされている。
1)不変である(一度インスタンスが作られたら、それが保有する属性の値は変化してはいけない)
2)交換が可能である(再代入=交換によってのみ値を変更することができる)
3)等価性によって比較される
このうち、3)は絶対に必要なんだろうか?
例えば、Date型にかなり近い、YearMonth(年と月をもったオブジェクト)を定義してみよう。
class YearMonth
attr_reader :date
def initialize(year, month)
@date = Date.new(year, month, 1)
end
end
一応値オブジェクトとして定義しているものの、「日付が1日のDate型オブジェクト」」としても外部から認識できる。なら、YearMonthそのものを比較できなくても、保有しているDate型(プリミティブ型)同士を比較すればいいんじゃないか?という疑問が生まれる。
実際にやってみよう。
year_month1 = YearMonth.new(2022, 7)
year_month2 = YearMonth.new(2022, 8)
# 等価性比較
year_month1.date == year_month2.date
# →false
パット見問題なさそうに見える。が、
0.value == 1.value
としているような気持ち悪さはある。両方Integer Objectなんだからシンプルに0 == 1
でええやん。それと全く一緒の考えから、
year_month1 == year_month2
year_month1 > year_month2
year_month1 < year_month2
で比較したらいい。
そして、もう一つ見逃してはならないこのルールの意図として、変更に柔軟であることが挙げられる。
例えば、この値オブジェクトにインスタンスが一つ追加されたことを考えてみる。
class YearMonth
attr_reader :date, :something
def initialize(year, month, arg)
@date = Date.new(year, month, 1)
@something = Something.new(arg)
end
end
year_month1 = YearMonth.new(2022, 7, arg1)
year_month2 = YearMonth.new(2022, 8, arg2)
# 等価性比較
year_month1.date == year_month2.date &&
year_month1.something == year_month1.something
この比較が一箇所ならいいが、等価性比較をしているすべての箇所で&& year_month1.something == year_month1.something
を追加しなければならない。
ということで、値オブジェクトを導入する際はオブジェクトそのものの等価性比較をできるようにすることがmustであると言える。
class YearMonth
include Comparable
def initialize(year, month, arg)
@date = Date.new(year, month, 1)
@something = Something.new(arg)
end
# 外部から呼び出す値
def value
@date.strftime('%Y.%m')
end
# 等価性比較のために必要なメソッド
def <=>(other)
@date <=> other.to_date # to_dateというメソッドを持つオブジェクトなら比較できると定義
end
# 同じYearMonthを比較できるように自身にもto_dateメソッドを実装しておく。
def to_date
@date
end
end
今回の例だと、なにか変更があったとしてもdef <=>(other)
を変更するだけで、YearMonth同士の比較を実際にしているところのコードは全くいじらなくていい。
DDD全体の良さの一つは「拡張性が高い」ことだが、値オブジェクトでも等価性比較ができるようにしておくことでこのオブジェクト自身が拡張性の高い状態になる。