単一責任の原則で密結合を解消する
はじめに
本記事では「良いコード 悪いコードで学ぶ設計入門」で学んだ内容と別途気になって調べた内容や知識も含めアウトプットしています。
書籍ではJavaで説明されていたのですが本記事ではRubyに置き換えながら解説しています。
結合度
結合度(Coupling)は、モジュールやクラス間の相互依存の度合いを表す指標です。
密結合
密結合(Tight Coupling)とは、あるクラスが他の多くのクラスに強く依存している状態を指します。
密結合なコードは、他のクラスへの強い依存関係があるため、理解が難しくなります。
1つのクラスの変更が他のクラスにも影響を及ぼす可能性があり、修正や保守が困難になります。
また、テストや再利用性の面でも問題が生じることがあります。
疎結合
密結合と逆の意味である、疎結合(Loose Coupling)な構造では、クラス間の依存が少なく、互いに独立して変更が容易になります。
各クラスが独立して存在し、変更が局所的に行われるため、保守性や拡張性が高まります。
さらに、テストや再利用性も容易になります。
密結合と責務
密結合なコード例
書籍ではECサイトのコードを例に説明されていました。
- 商品1点につき300円割引する
- 上限20,000円まで商品を追加できる
上記の仕様のJavaのコードをRubyに置き換えして改変しています。
# 商品割引に関連するクラス class DiscountManager attr_reader :discount_products, :total_price def initialize @discount_products = [] @total_price = 0 end # 商品を追加する def add(product, product_discount) # 引数の妥当性をチェック if product.id < 0 raise ArgumentError, "無効な商品IDです" end if product.name.empty? raise ArgumentError, "商品名が空です" end if product.price < 0 raise ArgumentError, "無効な商品価格です" end if product.id != product_discount.id raise ArgumentError, "商品IDが割引情報と一致しません" end discount_price = get_discount_price(product.price) tmp = product_discount.can_discount ? total_price - discount_price : total_price + product.price # 上限金額のチェック if tmp <= 20000 @total_price = tmp @discount_products << product return true else return false end end # 割引価格を取得する def get_discount_price(price) discount_price = price - 300 if discount_price < 0 discount_price = 0 end return discount_price end end # 商品クラス class Product attr_reader :id, :name, :price def initialize(id, name, price) @id = id @name = name @price = price end end # 商品割引情報クラス class ProductDiscount attr_reader :id, :can_discount def initialize(id, can_discount) @id = id @can_discount = can_discount end end
add
メソッドは商品と割引情報を受け取り、割引を適用して商品を追加します。引数の妥当性をチェックし、割引価格を計算して上限金額のチェックを行います。get_discount_price
メソッドは商品価格から割引価格を計算します。Product
クラスは商品の情報を表すクラスで、id
、name
、price
の属性を持ちます。ProductDiscount
クラスは商品の割引情報を表すクラスで、id
、can_discount
の属性を持ちます。
問題点
通常割引以外に、夏季限定割引の仕様が追加されたとします。
夏季限定割引は上限30,000円まで商品追加が可能な仕様とする場合、以下のようにSummerDisountManagerクラスが実装します。
コード例
今回の場合、DiscountManagerのインスタンスを流用、割り引く仕様も通常割引と同じ300円であるため、DiscountManager.getDiscountPriceを流用して割引価格を計算しています。
addメソッドは金額上限が30,000円に変更されているため新たに定義しています。
# 夏季限定割引を管理するクラス class SummerDiscountManager def initialize @discount_manager = DiscountManager.new # 割引処理を担当するDiscountManagerのインスタンスを作成 end # 商品を追加する # @param product [Product] 追加する商品 # @return [Boolean] 追加に成功した場合は true、それ以外は false def add(product) if product.id < 0 raise ArgumentError, "商品IDが無効です" end if product.name.empty? raise ArgumentError, "商品名が空です" end tmp = if product.can_discount @discount_manager.total_price - DiscountManager.get_discount_price(product.price) else @discount_manager.total_price + product.price end if tmp < 30000 @discount_manager.total_price = tmp @discount_manager.discount_products << product return true else return false end end end # 商品クラス class Product attr_accessor :id, :name, :price, :can_discount def initialize(id, name, price, can_discount) @id = id @name = name @price = price @can_discount = can_discount end end
問題が発生するケース
「通常割引の割引価格を、300円から400円に変更する」というような仕様変更を行った場合にミスが発生しやすくなります。
def get_discount_price(price) discount_price = price - 400 # 流用元のコードを書き換えしまったことに気が付かないことでバグが発生する if discount_price < 0 discount_price = 0 end return discount_price end
DiscountManagerの実装担当者は、割引計算をするget_discount_priceの400円に変更した場合
夏季割引サービスでも割り引かれる価格が400円になってしまいます。
夏季割引サービスを担うSummerDiscountManagerで、DiscountManagerのget_discount_priceが流用されることで意図しないバグが発生しやすくなります。
一部のクラスに処理が集中していたり、一方別のクラスでは何も処理を持っていなかったり、ほかのクラスの一部のメソッドを無理矢理都合よく流用したりしています。
これらは責務が考慮されていないクラスといえます。
単一責任の原則
ソフトウェア設計の原則の一つです。
あるクラスやモジュールは1つの責任を持つべきであり、それ以上の責任を持つべきではないという考え方です。
密結合を解消
単一責任の原則では、クラスやモジュールは個別の責任を持つため、他のクラスやモジュールとの結合度を低くすることで変更が発生した場合に影響範囲を最小限に抑えることができます。
責任の明確化
クラスやモジュールは明確に特定の責任を持つよう設計されます。
責任は、そのクラスやモジュールの役割や目的に関連しています。
例えば、データベースアクセス、ユーザー認証、データの変換など、個々の責任が明確に分離されます。
責務が単一になるようにクラスを設計
改修の方針
- 商品の定価についてはRegularPriceクラスを用意
- 価格に不正が発生しないよう、バリデーションロジックを持たせる(定価に責任を持つクラス構造)
バリデーションロジックがRegularPriceクラス内に凝集しているため、バリデーションロジックの重複コードが生じにくくなります。
定価クラス
定価を表すクラスであるRegularPrice
を定義しています。
解説はコメントアウトに記述しています。
# 定価クラス class RegularPrice MIN_AMOUNT = 0 # 定価の最小値を表す定数 attr_reader :amount # **amount**インスタンス変数への読み取り専用アクセスを提供し def initialize(amount) # 指定された価格をもとに**RegularPrice**オブジェクトを初期化 if amount < MIN_AMOUNT raise ArgumentError, "価格が0以上ではありません。" # 引数**amount**が**MIN_AMOUNT**未満の場合は、**ArgumentError**例外をスロー end @amount = amount end end
通常割引・夏季割引は別個にわけて定義
通常割引価格、夏季割引価格については、それぞれ個別に責任を負うクラスをつくります。
以下RegularDiscountedPrice、SummerDiscountedPriceも値オブジェクトとして設計します。
# 通常割引価格クラス class RegularDiscountedPrice MIN_AMOUNT = 0 DISCOUNT_AMOUNT = 400 # 通常割引の割引額を表す定数 attr_reader :amount def initialize(price) discounted_amount = price.amount - DISCOUNT_AMOUNT discounted_amount = MIN_AMOUNT if discounted_amount < MIN_AMOUNT @amount = discounted_amount end end # 夏季割引価格クラス class SummerDiscountedPrice MIN_AMOUNT = 0 DISCOUNT_AMOUNT = 300 # 夏季割引の割引額を表す定数 attr_reader :amount def initialize(price) discounted_amount = price.amount - DISCOUNT_AMOUNT discounted_amount = MIN_AMOUNT if discounted_amount < MIN_AMOUNT @amount = discounted_amount end end
この設計では、SummerDiscountedPrice
クラスが夏季割引価格を表現する専用のクラスとして独立しています。
割引後の価格の計算や制約は、SummerDiscountedPrice
クラス内で完結していることにより、割引価格の計算ロジックが分散せず、単一の責務を持つクラスとなっています。
また、夏季割引の割引額や最低価格の設定を定数として別途定義しているため、変更が必要な場合には定数の値を変更するだけで済みます。
以上のような設計を心がけることで、クラスの役割が明確になり、単一責任の原則に基づいたコードを実現できます。
参考
続く…
コメント
本記事の内容は以上になります!
書籍の続きのアウトプットも随時更新したいと思います。
プログラミングスクールのご紹介 (卒業生より)
お世話になったプログラミングスクールであるRUNTEQです♪
こちらのリンクを経由すると1万円引きになります。
RUNTEQを通じて開発学習の末、受託開発企業をご紹介いただき、現在も双方とご縁があります。
もし、興味がありましたらお気軽にコメントか、TwitterのDMでお声掛けください。