場合わけロジックを整理する
概要
この記事は、現場で役立つシステム設計の原則〜変更を楽で安全にするオブジェクト指向の実践技法を読み学習した内容を Ruby on Rails のコードに例を変換してまとめ直したものです。
また、書籍では Java のコードを例にしており、Ruby on Rails の慣習とは一部異なる部分があるので、その部分はオミットした内容となっています。
ソフトウェアを複雑にし、変更を大変にする原因の一つに、場合わけロジックがある。
例えば業務アプリケーションには顧客区分や料金種別といった区分・分類が存在するが、それらには区分ごとに計算や特別な処理ロジックが存在する。
これらの区分ごとのロジックを書き分ける基本的な手段に if 文/switch 文がある。
しかし、複雑な区分体系を組み合わせる業務ロジックの場合、if 文/switch 文の条件分岐はどんどん肥大化し、見通しが悪く変更が大変となる。
この記事では、このような場合わけロジックを整理する設計方法を紹介する。
場合わけの条件やその後の処理をメソッドに抽出する
場合わけロジックを整理する方法として、まずは基本的な「メソッドの抽出」がある。
if customer_type == "child"
fee = BASE_FEE * 0.5
end
例えば、上記のようなコードの判断条件とその後の処理をメソッドに抽出すると以下のようになる。
if is_child?(customer_type)
fee = child_fee
end
def is_child?(customer_type)
customer_type == "child"
end
def child_fee(base_fee)
BASE_FEE * 0.5
end
このように、リファクタリングすると
- 元のコードはメソッドを帯び出すだけのシンプルなコードに変わる
- メソッド名を読むだけで「子供だったら子供料金にする」という意図がわかりやすくなる
- 子供判定や子供料金の計算は、それぞれのメソッドへ変更箇所と影響範囲を閉じ込められる
else 句を使用せず早期リターンを使用する
もう一つの場合わけロジックの整理方法としては、else 句を使用しないこと。
def fee(customer_type)
if is_child?(customer_type)
result = child_fee
elsif is_senior?(customer_type)
result = senior_fee
else
result = adult_fee
end
result
例えば、上記のコードはローカル変数 result は不要であり、 return で直ちに結果を返却できる。(早期リターン)
さらに else 句を使用せずに早期リターンする書き方(ガード節)にすることも可能。
def fee(customer_type)
if is_child?(customer_type)
return child_fee
elsif is_senior?(customer_type)
return senior_fee
else
return adult_fee
end
end
# ガード節を使用したコード
def fee(customer_type)
return child_fee if is_child?(customer_type)
return senior_fee if is_senior?(customer_type)
adult_fee
end
このように、 else 句を見つけたら早期リターンやガード節を使用することで、場合わけロジックのコードをスッキリさせ可読性が向上しバグの混入を防ぎやすくなる。
早期リターンやガード節を使用しやすくするために前項の「メソッドの抽出」を行なっておくことも重要。
区分ごとのロジックを別クラスに分ける
大人、子供、シニアといった区分ごとのロジックをさらに独立させるためには、別のクラスに分けるやり方がある。
以下のようにクラス分けすることで、料金計算のみでなく名前ラベルなど他のロジックの置き場所として利用できる。
class AdultFee
FEE = 100
LABEL = "大人"
def fee
Yen.new(FEE)
end
def label
LABEL
end
end
class ChildFee
FEE = 50
LABEL = "子供"
def fee
Yen.new(FEE)
end
def label
LABEL
end
end
インターフェース宣言で、区分ごとのクラスを同じ「型」として扱う
区分ごとにクラスを分けると、ロジックの整理がしやすくなるが、区分クラスを使う側が AdultFee, ChildFee をいつも意識して使い分けなければならない。
それらのクラスの使い分けをプログラムの各所で if 文を用いて記述すると、場合わけロジックを整理するために作成したクラスの目的が失われてしまう。
そこで、区分ごとのクラスを同じ「型」として扱うことで、使い分けを意識せずに利用できるようにする。
Java では interface を使用することで、同じ「型」として扱うことができるが、Ruby ではモジュールを使用することで同様に同じ「型」を表現できる。
# 「型」として扱うためのモジュールを定義
module Fee
# include先クラスのオブジェクトがyenメソッドを定義せず呼び出すとエラーをraiseする
def yen
raise NotImplementedError
end
def label
raise NotImplementedError
end
end
# モジュールをincludeしたクラス
class AdultFee
include Fee # Feeモジュールをincludし、Fee型として扱う
FEE = 100
LABEL = "大人"
def yen
Yen.new(FEE)
end
def label
LABEL
end
end
class ChildFee
include Fee
FEE = 50
LABEL = "子供"
def yen
Yen.new(FEE)
end
def label
LABEL
end
end
class Charge
def initialize(fee)
@fee = validate(fee) # Fee型のオブジェクト全てを扱える
end
def yen
@fee.yen
end
private
def validate(fee)
# is_a?でモジュールをincludeしたクラスであるかをチェックできる
raise TypeError, "Fee module required" unless fee.is_a?(Fee)
fee
end
end
この仕組みを利用すると、例えば以下のような「予約」クラスでは、大人、子供、シニアの場合わけの if 文を書かずに、それらを意識せずに Fee 型のオブジェクトを add メソッドで追加するだけで、料金の合計を計算できる。
また、SeniorFee クラスを追加した場合でも、追加したクラス内で Fee モジュールを include すれば、Reservation クラスは全く変更する必要がない。
class Reservation
def initialize(fees: [])
@fees = fees
end
# 区分ごとのクラスを意識せずにFee型のオブジェクトを追加できる
def add(fee)
raise TypeError, "Fee module required" unless fee.is_a?(Fee)
result = Array.new(@fees)
result << fee
Reservation.new(fees: result)
end
def total
total = Yen.new(0)
@fees.each { |fee| total = total.add(fee.yen) } # Yenクラスのaddは新しいYenオブジェクトを返す
total
end
end
このようにインターフェース宣言( Fee モジュール)と、区分ごとの専用クラス( AdultFee, ChildFee )を組み合わせて、区分ごとに異なるクラスのオブジェクトを「同じ型」として扱う仕組みを「多態」という。
上記の「予約」クラスのように、使う側のクラスが実際にどのような区分があるのか「知らない」ことが重要。
外部クラスについて「知らない」ことが多いほど、クラス間の結びつきが弱くなり、あるクラスの変更が他のクラスに影響せず独立性が高まる。
区分ごとのクラスのインスタンスを生成する
class FeeFactory
def self.fee_by_name(name)
if name == "adult"
AdultFee.new
elsif name == "child"
ChildFee.new
end
end
end
区分ごとのクラスのインスタンスを生成する際に上記のような if 文による場合分けを排除するには、以下のようなマッピングを利用する。
class FeeFactory
TYPES = {
"adult" => AdultFee.new,
"child" => ChildFee.new,
}.freeze
def self.fee_by_name(name)
TYPES[name]
end
end
# マッピングを利用したFeeFactoryクラスの利用例
FeeFactory.fee_by_name("adult")
区分オブジェクトで区分クラスの一覧を管理する
多態は区分ごとのロジックを整理する便利な仕組みだが、どういう区分体系であるかという区分ごとのクラスの一覧を管理するのには向いていない。
Java では enum クラス(列挙型)を使用することで、区分ごとのクラスの一覧を管理することができるが、Ruby では以下のようなクラスを作成することで、区分ごとのクラスを定数として一覧管理することができる。
このように区分クラスを単なる定数ではなく、「振る舞い」を持つオブジェクトとして管理するクラスを区分オブジェクトという。
class FeeType
# 定数として定義し、一覧を管理する
# FeeTypeクラスのインスタンスは以下の3つのみ存在する
# 各インスタンスは内部でFeeの具象クラス(AdultFee等)のインスタンスを保持
ADULT = new(AdultFee.new)
CHILD = new(ChildFee.new)
SENIOR = new(SeniorFee.new)
attr_reader :fee
def initialize(fee)
@fee = fee # feeはFeeの具象クラス(AdultFee等)のインスタンス
end
def yen
@fee.yen
end
def label
@fee.label
end
def self.value_of(name)
name_str = name.to_s.upcase
# 定数名で直接アクセスを試行
begin
const_get(name_str)
rescue NameError
raise ArgumentError, "No enum constant #{self}::#{name_str}"
end
end
# 外部からnewメソッドを呼び出せないようにする
# これにより定数管理しているインスタンス以外を生成できなくする
private_class_method :new
end
# 以下のように区分名から料金を計算できる
def fee_for(fee_type_name)
fee_type = FeeType.value_of(fee_type_name)
fee_type.yen
end
# 使用例
puts fee_for('adult') # => 1000
puts fee_for('CHILD') # => 500
puts fee_for(:adult) # => 1000
状態の遷移ルールをわかりやすく記述する
業務アプリケーションでは、状態の遷移を管理することも重要な関心ごとの一つ。
ある状態が遷移できる次の状態には制限があるが、区分オブジェクトを利用することで、if, switch 文を使用せずに状態の遷移ルールをわかりやすく記述できる。
具体的には以下のような手順を踏む。
- ある状態から遷移できる次の状態を Set で宣言する
- 遷移元の状態をキー、遷移可能な状態の Set を値としたマッピングを宣言する
class State
attr_reader :name
def initialize(name)
@name = name
freeze
end
UNDER_REVIEW = new("審査中")
APPROVED = new("承認済")
IN_PROGRESS = new("実施中")
COMPLETED = new("終了")
SENT_BACK = new("差し戻し")
SUSPENDED = new("中断中")
VALUES = [UNDER_REVIEW, APPROVED, IN_PROGRESS, COMPLETED, SENT_BACK, SUSPENDED].freeze
def self.values
VALUES
end
def self.value_of(name)
values.find { |value| value.name == name }
end
private_class_method :new
end
class StateTransitions
# 遷移元の状態をキー、遷移可能な状態の Set を値としたマッピングを宣言する
ALLOWED = {
State::UNDER_REVIEW => Set[State::APPROVED, State::SENT_BACK],
State::SENT_BACK => Set[State::UNDER_REVIEW, State::COMPLETED],
State::APPROVED => Set[State::IN_PROGRESS, State::COMPLETED],
State::IN_PROGRESS => Set[State::SUSPENDED, State::COMPLETED],
State::SUSPENDED => Set[State::IN_PROGRESS, State::COMPLETED],
State::COMPLETED => Set[],
}.freeze
# 遷移元の状態から遷移可能な状態かをチェックする
def self.can_transition?(from, to)
ALLOWED[from].include?(to)
end
end
def can_transition_to?(new_state_name)
new_state = State.value_of(new_state_name)
StateTransitions.can_transition?(current_state, new_state)
end
参考文献
この記事は以下の情報を参考にして執筆しました。