💭

オブジェクト指向設計実践ガイド_単一責任(SOLID)について

2022/02/26に公開

はじめに

今回は「オブジェクト指向設計実践ガイド」 第2章の単一責任(SOLID)を読んでその内容をまとめてみました。

単一責任クラスの設計

オブジェクト指向設計のシステムの基礎は「メッセージ」です。
しかし、その組織(メッセージのやり取りをする組織?)の構造でもっとも目立つのは「クラス」です。「メッセージ」こそが設計の核にありますが、まずクラスに属するものをどのように決めるかについて取り扱います。

第一にやるべきことは 「いますぐに求められる動作をする」 かつ 「あとにも簡単に変更できる」 ことです。

1. クラスに属するものを決める

問題は技術的知識に関することではなく、構造に関することです。
「アプリケーションの仕様、コードの書き方はすべて知っているけれど、それをどこに置けばよいのか分からない。」そんな状況を考えてみましょう。

メソッドをグループ分けし、クラスにまとめる

Rubyのようなクラスベースのオブジェクト指向言語ではクラス内にメソッドを定義します。
どんなクラスを作るかによってアプリケーションに対する考え方が変わるため、メソッドを正しくグループ分けし、クラスにまとめることはとても重要です。

変更が簡単なコードを組成する

  • 「変更が簡単なコード」の定義

    • 変更は副作用をもたらさない
    • 要件の変更が小さければ、コードの変更も相応して小さい
    • 既存コードは簡単に再利用できる
    • 最も簡単な変更方法はコードの追加である(たたし追加するコートはそれ自体の変更が容易なものとする)
  • 上記の定義を満たすためのコードは次のようなコードを指します

    • 見通しが良い:変更がもたらす影響が明白である
    • 合理的:変更にかかるコストは「変更がもたらす利益にふさわしい」
    • 利用性が高い:新しい環境や予期せぬ環境でも再利用できる
    • 模範的:上記の品質を自然と保つようなコードになっている

2. 単一の責任を持つクラスをつくる

「見通しが良い、合理的、利用性が高い、模範的」なコードを書くための第一歩は
それぞれのクラスが明確に定義された単一の責任を持つことです。

アプリケーションの例(自転車とギア)

自転車のギアは1漕ぎあたりに車輪がどれだけ回転するかを変えられます。
その回転数 車輪回転数(ギア比)=ギアの歯数➗漕ぐ回数 を求める「ギア」クラスを
作成してみます。

  • ギアによる車輪回転数(ギア比)を求める「ギア」クラス
class Gear
  attr_reader :chainring, :cog
  def initialize(chainring, cog)
    # ギアの歯数
    @chainring = chainring
    # 漕ぐ回数
    @cog = cog
  end

  # 車輪の回転数(ギア比)
  def ratio
    chainring / cog.to_f
  end
end

puts Gear.new(52, 11).ratio # -> 4.72727272727273
puts Gear.new(30, 27).ratio # -> 1.11111111111111

上記クラスは回転数(ギア比)の計算のみで進む距離はわかりません。
車輪の大きい自転車の場合、車輪が1回転するたびに進む距離は車輪
の小さい自転車に比べて長くなります。

「車輪の直径とギア比」による自転車の進み具合を「ギアインチ」と言いますが、
「ギアインチ」を求める公式の振る舞いを追加で実装してみます。

# 公式
ギアインチ=車輪の直径×ギア比
ただし
車輪の直径=リムの直径+タイヤの厚みの2倍とする
  • ギアインチの振る舞いをGearクラスに追加
 class Gear
   attr_reader :chainring, :cog, :rim, :tire
   def initialize(chainring, cog, rim, tire)
     @chainring = chainring
     @cog = cog
     # リムの直径(サイズ単位はインチを想定している)
     @rim = rim
     # タイヤの厚み(サイズ単位はインチを想定している)
     @tire = tire
   end

   def ratio
     chainring / cog.to_f
   end

   def gear_inches
   # タイヤはリムの周りを囲むので、車輪の直径を計算するためには2倍する
     ratio * (rim + (tire * 2))
   end
 end

 puts Gear.new(52, 11, 26, 1.5).gear_inches
 # -> 137.090909090909

 puts Gear.new(52, 11, 24, 1.25).gear_inches
 # -> 125.272727272727
  • 「ギアインチ」追加により既存コードが動かなくなった。
puts Gear.new(52, 11).ratio # 「ギアインチ」を追加する前は動いてたコード
# ArgumentError: wrong number of arguments (2 for 4)
# from (irb):20:in 'initialize'
# from (irb):20:in 'new'
# from (irb):20

単一責任は重要

Gearクラスは「進化する」アプリケーションの一部です。変更が簡単なアプリケーションは再利用可能なクラスで構成されます。2つ以上の責任を持つクラスは、簡単に再利用できません。

クラスが単一責任かどうか見極める

現状、Gearクラスは「ギア比、ギアインチ、タイヤサイズ、リムサイズ」を持っています。
ギア比はGearクラスからすれば理にかなっていますが、その他はしっくりきません。
Gearクラスが複数の責任を持つのは確かです。

3. 変更を歓迎するコードを書く

データではなく、振る舞いに依存する

  • インスタンス変数の隠蔽
    インスタンス変数をアクセサで包む
class Gear
  attr_reader :chainring, :cog # <-------インスタンス変数の隠蔽
  def initialize(chainring, cog)
   @chainring = chainring
   @cog = cog
  end

  def ratio
   chainring / cog.to_f # <-------インスタンス変数を使わず、アクセサを使う
  end
end
  • データ構造の隠蔽
    仮に「車輪の直径」計算の責任を持つクラスを作成したとします。
    そのクラスの中でRubyのStructクラスを使ってデータ構造を包み隠します。
class RevealingReferences
  attr_reader :wheels
  def initialize(data)
   @wheels = wheelify(data)
  end

  def diameters
   wheels.collect {|wheel|
   wheel.rim + (wheel.tire * 2)}
  end
  
  Wheel = Struct.new(:rim, :tire)
  def wheelify(data)
    data.collect {|cell|
    Wheel.new(cell[0], cell[1])}
  end
end

あらゆる箇所を単一責任にする

  • メソッドから余計な責任を抽出する
    上記 class RevealingReferencesdiametersはwheelsの繰り返し処理とそれぞれのwheelを計算しているので、2つの責任を持っています。
    以下のように2つに分割し、単純化できます。

     # 配列を繰り返し処理する
     def diameters
       wheels.collect {|wheel| diameter(wheel)}
     end
    
     # 「1つ」の車輪の直径を計算する
     def diameter(wheel)
       wheel.rim + (wheel.tire * 2))
     end
    

    では、Gearクラスの gear_inches メソッドはどうでしょうか。
    確かにgear_inchesはGearクラスの責任ですが、
    gear_inchesの中には「車輪の直径」の計算が隠されています。

     def gear_inches
       # タイヤはリムの周りを囲むので、車輪の直径を計算するためには2倍する
       ratio * (rim + (tire * 2))
     end
    

    なので、以下のように責任を分割できます。

     # ギアインチの計算
     def gear_inches
       ratio * (rim + (tire * 2))
     end
     
     # 車輪の直径を計算
     def diameter
       rim + (tire * 2)
     end
    
  • クラス内の余計な責任を隔離する
    Gearクラスに含まれいる車輪の振る舞いはGearクラスの責務でしょうか?

    Gearクラスを単一責任にするには車輪の直径を取り除く必要がありそうです。
    そのため、車輪直径計算の責任を持つWheel Structを作成します。

    class Gear
      attr_reader :chainring, :cog, :wheel
      def initialize(chainring, cog, rim, tire)
        @chainring = chainring
        @cog = cog
        @wheel = Wheel.new(rim, tire)
      end
    
      def ratio
        chainring / cog.to_f
      end
    
      def gear_inches
        ratio * wheel.diameter
      end
    
      Wheel = Struct.new(:rim, :tire) do
        def diameter
          rim + (tire * 2)
        end
      end
    end
    

4. Wheel Structをクラス化し、ついに完成

当初の要件に加え、車輪の円周計算もほしいという追加要望があったとします。
車輪の円周は円周率PI と直径を掛け合わせたもので、Wheelは直径の計算ができます。

円周計算メソッドの追加で要望対応は可能ですが、
車輪円周はGearクラスの責任ではないため、WheelをGearから分割、クラス化し、
Wheelクラスに円周計算メソッドを追加します。
これでGearとWheelの責任分割は完了です。

class Gear
  attr_reader :chainring, :cog, :wheel
  def initialize(chainring, cog, wheel=nil)
    @chainring = chainring
    @cog = cog
    @wheel = wheel
  end
  
  def ratio
    chainring / cog.to_f
  end

  def gear_inches
    ratio * wheel.diameter
  end
end

class Wheel
  attr_reader :rim, :tire
  def initialize(rim, tire)
    @rim = rim
    @tire = tire
  end
  
  def diameter
    rim + (tire * 2)
  end
  
  def circumference
    diameter * Math::PI
  end
end

@wheel = Wheel.new(26, 1.5)
puts @wheel.circumference
# -> 91.106186954104
puts Gear.new(52, 11, @wheel).gear_inches
# -> 137.090909090909
puts Gear.new(52, 11).ratio
# -> 4.72727272727273

Discussion