原則:クラスが単体で正常動作するよう設計する
これはSOLID原則のSingleResponsibilityの考え方かな?
「クラス設計とはインスタンス変数を不正状態に陥らせないための仕組みづくり」である。
クラスをインスタンス化して、それを他のクラスに初期化してもらったり、データの入力をチェックしてもらうようではいけないわけで、クラスは必ず自分の身は自分で守ると言う自己防衛責務を全てのクラスがそなえるわけです。
コンストラクタで確実に正常値を設定しているか
これを避けるために2章でも出てきましたが、コンストラクタで初期化することが大切です。
例えば、Money
クラスを作成するときの基本形は以下のような形ですが、これは問題があります。
なぜなら、引数に不正な値が渡ってきてもエラーが発生しないからです。
class Money
attr_accessor :mount, :currency
def initialize(mount, currency)
@mount = mount
@currency = currency
end
end
これを防ぐために1つの例として以下のように書くことができます。
これはmountやcurrencyの値が不正の場合にそこでエラーを投げています。
このようにすることでバグが混在することを防ぐことができるわけですね。
これをガード節と言ったりしますが、これの大きなメリットは後続のロジックがシンプルになることです。
もし、ガード節がなければ後続でわざわざnilの場合はmountの値を気にしながら処理を書かなければいけません。しかし、これらを先頭で除外できるわけです。
class Money
attr_accessor :mount, :currency
def initialize(mount, currency)
@mount = mount
@currency = currency
if mount <= 0 || currency.nil?
raise ArgumentError, "金額は0より大きく、通貨はnil以外にしてください。"
end
end
end
## インスタンス変数を不変にしているか
これも結構大事だなと思います。変数が気づかないうちに変わっていて想定の挙動通りに行かないこともあります。これを避けるためには変数を不変にすることが大切です。
先ほどのコードを修正するなら、rubyではfreeze
で不変にすることができますね。こうすれば、この変数に再代入しようとしたらエラーが発生し、思わぬバグを避けることができます。
class Money
attr_reader :mount, :currency
def initialize(mount, currency)
@mount = mount.freeze
@currency = currency.freeze
if mount <= 0 || currency.nil?
raise ArgumentError, "金額は0より大きく、通貨はnil以外にしてください。"
end
end
end
変更したい場合は新しいインスタンスを作成しているか
インスタンス変数を不変にしたら変更できなくなるように思えますが、そんなことはありません。
インスタンス変数の中身を変更するのではなく、変更値を持ったMoneyクラスのインスタンスをまた新たに生成すればよいのです。
class Money
attr_reader :mount, :currency
def initialize(mount, currency)
@mount = mount.freeze
@currency = currency.freeze
if mount <= 0 || currency.nil?
raise ArgumentError, "金額は0より大きく、通貨はnil以外にしてください。"
end
end
end
money = Money.new(100, "JPY")
money.mount = 200
少し分かりづらいですが、例えばfreezeした場合、money.amount = 200
はできません。なぜなら、amountを再代入することができないからです。
おそらくこんなエラーになってしまいます。
NoMethodError: undefined method `mount=' for #<Money:0x00007f8d2852a9c0 @mount=100, @currency="JPY">
この問題を解決するには、インスタンス変数 mount を変更するのではなく、変更値を持ったMoneyクラスのインスタンスをまた新たに生成します。
次のコードを実行すると、エラーが発生しなくなります。
money = Money.new(100, "JPY")
new_money = Money.new(200, "JPY")
インスタンス変数 mount は不変に設定されていますが、インスタンス変数 mount を変更するのではなく、変更値を持ったMoneyクラスのインスタンスをまた新たに生成することで、インスタンス変数 mount の値を変更することができます。
つまり、インスタンス変数を変えるのではなく、新しくクラスを作り直せってことですね。
引数に渡せる型を限定しているか
例えば、money.add(200)
みたいに使えるとこれはまだバグが混入する余地があります。
そこでメソッドの引数の方にもMoney型ではないと渡せないようにしておくとさらに頑強な構造になります。
class Money
def add(other)
if @currenct != other.currency
raise ArgumentError.new('通貨単位が違います')
end
# other は Money クラスのインスタンスである必要があります。
# 他の型のオブジェクトが渡された場合はエラーになります。
added_amount = @amount + other.amount
Money.new(added_amount, @currency)
end
end
プログラム構造の問題解決に役立つ設計パターン
設計パターン | 効果 |
---|---|
完全コンストラクタ | 不正状態から防護する |
値オブジェクト | 特定の値に関するロジックを高凝集にする |
ストラテジ | 条件分岐を削減し、ロジックを単純化する |
ポリシー | 条件分岐を単純化したり、カスタマイズできるようにする |
ファーストクラスコレクション | 値オブジェクトの亜種で、コレクションに関するロジックを高凝集にする |
スプラウトクラス | 既存のロジックを変更せずに安全に新機能を追加する |
## 完全コンストラクタ
インスタンス変数を全て初期化できるだけの引数を持ったコンストラクタを用意し、その中でガードせつで不正値を弾く。さらにインスタンス変数を不変にすることでより頑強な構造に仕上がる。
値オブジェクト
値をクラスとして表現する設計パターンのこと。アプリケーションでは、金額、日付、注文数、電話番号など様々な値を扱うが、こうした値をクラスとして表現することで、各値それぞれのロジックを高凝集にする効果がある。
完全コンストラクタと値オブジェクトはほぼセットで用いられる。
この2つはオブジェクト指向設計の最も基本形を体現している構造のひとつと言っても過言ではない。
まとめるとクラスは1つの責務を持っていて、その中で初期化や不正な値が入らないように実装することがことが大切ということです。