3 クラス設計

3.1 クラス単体で正常に動作するように設計する

良いクラスの構成要素は下記。

3.2 成熟したクラスへ成長させる設計術

  1. コンストラクタで確実に正常値を設定する。
  2. 計算ロジックをデータ保持側に寄せる。
  3. インスタンス変数/メソッド引数/ローカル変数を不変にする。
  4. 変更したい場合は新しいインスタンスを作成する。
  5. 値の渡し間違いを型で防止する。それに伴い、プリミティブ側ではなく独自の型を利用する。
  6. 「金額同士の掛け算」などの現実の営みにはないメソッドを追加しない
class Money
  # 3. インスタンス変数/メソッド引数/ローカル変数を不変にする。
  # ※ Rubyだと不変にはできないが、可能な努力は行う。
  attr_reader :amount, :currency

  def initialize(amount, currency)
    # 1. コンストラクタで確実に正常値を設定する。
    raise ArgumentError.new("金額が0以上でありません") if amount < 0
    raise ArgumentError.new("通貨を指定してください") if currency.nil? || currency.empty?
    @amount = amount
    @currency = currency
  end

  # 2. 計算ロジックをデータ保持側に寄せる。
  def add(other)
    # 5. 値の渡し間違いを型で防止する。
    raise TypeError.new("引数がお金ではありません") unless other.instance_of?(Money)
    raise ArgumentError.new("通貨単位が異なります") unless currency == other.currency
    added_amount = amount + other.amount
    # 4. 変更したい場合は新しいインスタンスを作成する。
    Money.new(added_amount, currency)
  end
end

savings   = Money.new(10000, "")
allowance = Money.new( 1000, "")
new_savings = savings.add(allowance)

puts new_savings
# => #<Money:0x00007f9733823468>

puts new_savings.amount
# => 11000

4 不変を活用する -安定動作を構築する-

4.1 再代入

  • 1.再代入/破壊的代入は行わない。
# 1.再代入/破壊的代入は行わない。
def damage()
  basic_attack_power = ( member.power + member.weapon_attack ).floor
  final_attack_power = ( basic_attack_power * ( 1.0 + member.speed / 100.0 ) ).floor
  reduction = ( enemy.defence / 2 ).floor
  damage = [0, final_attack_power - reduction].max
  damage
end

4.2 可変がもたらす意図せぬ影響

  • 1.可変インスタンスを使いまわさない。(インスタンスを変更した際に使いまわした箇所全体が影響範囲になるため)
  • 2.クラスに副作用を与えるメソッドは保守が大変になることを理解し、避ける。

副作用について。メソッドには主作用と副作用が存在する。

  • 主作用:メソッドが引数を受け取り、値を返すこと。
  • 副作用:主作用以外に、下記のような状態変更をすること。

副作用のある関数を作らないために、関数が下記の項目を満たすことを前提に設計する。

  • a.データ(=状態)を引数で受け取る
  • b.状態を変更しない
  • c.値は関数の戻り値として返す
# 1.可変インスタンスを使いまわさない。
attack_power_a = AttackPower.new(20)
attack_power_b = AttackPower.new(20)

weapon_a = Weapon.new(attack_power_a)
weapon_b = Weapon.new(attack_power_b)

weapon_a.attack_power_a.value += 5
# 2.クラスに副作用を与えるメソッドは保守が大変になることを理解し、避ける。
class AttackPower
  attr_accessor :value

  MIN = 0.freeze
  private_constant :MIN

  def initialize(value)
    raise ArgumentError if value < MIN
    @value = value
  end

  # a.データ(=状態)を引数で受け取る
  def reinforce(increment)
    raise TypeError unless increment.instance_of?(AttackPower)

    # b.状態を変更しない
    # c.値は関数の戻り値として返す
    AttackPower.new(value + increment.value)
  end

  def disable
    AttackPower.new(MIN)
  end
end

attack_power = AttackPower.new(20)
reinforced_attack_power = attack_power.reinforce(AttackPower.new(15))
disabled_attack_power = attack_power.disable

puts "reinforced attack power : #{reinforced_attack_power.value}"
# => reinforced attack power : 35

puts "disabled attack power   : #{  disabled_attack_power.value}"
# => disabled attack power   : 0

4.3 不変と可変の取り扱い方針

  • デフォルトは不変にする。
    • パフォーマンスに影響を与える場合は可変にしてもOK。
    • 局所的にしか利用しないことが明らかなローカル変数は可変にしてもOK。
  • 可変にする場合は正しく状態変更されるようにする。
    • ex.「本来負数になってはいけないのに、負数になる」といったことは避ける。
  • コード外のやりとり(ex.ファイルやDBの読み書き)は局所化する。

低凝集 -バラバラになったモノたち-

5.1 staticメソッド(≒クラスメソッド)の誤用

※補足 Rubyにはstaticメソッドが無く、似た性質のクラスメソッドが存在する。 下記の記事でjavaのstaticメソッドとRubyのクラスメソッドを比較している。 https://qiita.com/1plus4/items/b37ec6ea90569ffdebfe

  • クラスメソッドの誤った使い方をすると、データとロジックが分離してしまう。
  • インスタンスメソッドのふりをしたクラスメソッドも同様にデータとロジックが分離してしまうため気を付ける。
    • 「そのクラスメソッドをインスタンスメソッドに変更しても問題ないか?」を考えてみる。
  • 凝集度に無関係なものはクラスメソッドとして設計する。
    • ex. ログ出力用メソッド, フォーマット変換用メソッド

5.2 初期化ロジックの分散

  • 初期化ロジックが分散することを避けるために、privateコンストラクタとファクトリメソッドで目的別初期化を行う。
  • ファクトリメソッドが増えすぎたら、ファクトリクラスを検討する。
class GiftPoint
  attr_reader :value

  MIN_POINT = 0.freeze
  STANDARD_MEMBERSHIP_POINT =  3000.freeze
  PREMIUM_MEMBERSHIP_POINT  = 10000.freeze
  private_constant :MIN_POINT, :STANDARD_MEMBERSHIP_POINT, :PREMIUM_MEMBERSHIP_POINT

  private_class_method :new

  def initialize(point)
    raise TypeError.new unless point.instance_of?(Integer)
    raise ArgumentError.new unless MIN_POINT < point

    @value = point
  end

  class << self
    def for_standard_membership
      send(:new, STANDARD_MEMBERSHIP_POINT)
    end

    def for_premium_membership
      send(:new, PREMIUM_MEMBERSHIP_POINT)
    end
  end
end

standard_membership_point = GiftPoint.for_standard_membership
puts standard_membership_point.value
# => 3000

premium_membership_point = GiftPoint.for_premium_membership
puts premium_membership_point.value
# => 10000

other_membership_point = GiftPoint.new(1000)
puts other_membership_point.value
# => private method `new' called for GiftPoint:Class (NoMethodError)

5.3 共通処理クラス(Common・Util)

問題点

[問題点]

  • 共通処理クラスの共通処理はstaticメソッドとして実装されがち。 それにより、低凝集構造を生み出してしまう。
  • CommonやUtilといった名前から、共通処理クラスには様々なロジックが雑多に置かれがち。

[解決策]

class AmmountIncludingTax
  attr_reader :value

  def initialize(ammount_excluding_tax, tax_rate)
    raise TypeError.new unless ammount_excluding_tax.instance_of?(Integer)
    raise TypeError.new unless tax_rate.instance_of?(Float)

    raise ArgumentError.new if ammount_excluding_tax < 0
    raise ArgumentError.new if tax_rate < 0

    @value = ammount_excluding_tax * (1 + tax_rate)
  end
end

横断的関心事

下記のような、ユースケースに広く横断する事柄を、横断的関心事と呼ぶ。

  • ログ出力
  • エラー検出
  • デバッグ
  • 例外処理
  • キャッシュ
  • 同期処理
  • 分散処理

横断的関心ごとに関する処理は共通処理としてまとめてOK。

begin
  shopping_cart.add(product)
rescue
  Logger.report("例外が発生しました")
end

5.4 出力引数を使わない

[問題点]

  • 引数が入力なのか出力なのかを、ロジックを読んで確認しなければいけない。

[解決策]

  • データとデータを操作するロジックを同じクラスに凝集する。
class Location
  attr_reader :x, :y

  def initialize(x, y)
    @x = x
    @y = y
  end

  def shift(shift_x, shift_y)
    new_x = x + shift_x
    new_y = y + shift_y
    return Location.new(new_x, new_y)
  end
end

location = Location.new(1, 2)
new_location = location.shift(2, 3)

puts "Old location: #{location.x}, #{location.y}"
# => Old location: 1, 2

puts "New location: #{new_location.x}, #{new_location.y}"
# => New location: 3, 5

5.5 多すぎる引数

  • 引数が多くなりすぎた場合には、意味ある単位ごとにクラス化し、引数ではなくインスタン変数として表現する。

5.6 メソッドチェイン

  • 他のオブジェクトの内部状態を尋ねたり、その状態に応じて呼び出しが側が判断するのは、あらゆる箇所からのアクセスを引き起こすためNG。
  • 呼び出し側はただメソッドで命ずるだけで、命令された側で適切に判断や制御を行うように設計するべき。
class Equipments
  attr_accessor :can_change, :head, :armor, :arm

  def initialize(can_change, head, armor, arm)
    raise TypeError unless can_change.is_a?(TrueClass) || can_change.is_a?(FalseClass)
    raise TypeError unless head.is_a?(Equipment)
    raise TypeError unless armor.is_a?(Equipment)
    raise TypeError unless arm.is_a?(Equipment)
    @can_change = can_change
    @head = head
    @armor = armor
    @arm = arm
  end

  def equip_armor(new_armor)
    raise TypeError unless new_armor.is_a?(Equipment)
    armor = new_armor if @can_change
  end

  def deactivate_all
    head  = Equipment::EMPTY
    armor = Equipment::EMPTY
    arm   = Equipment::EMPTY
  end
end

6 条件分岐

6.1 条件分岐のネストによる可読性低下

[問題点]

下記はロジックの見通しが悪くなりがち。

  • 条件分岐のネスト
  • else句

[解決策]

  • 早期returnを利用する。
  • また、条件ロジックと実行ロジックの分離ができるという利点もある。

6.2 switch文の重複

[問題点]

  • 同じ条件式のswitch文が複数書かれていく。
  • そうすると、仕様変更時の修正漏れが発生する。

[解決策]

  • 条件分岐を一箇所にまとめる。
class Magic

  def initialize(magic_type, member)
    raise TypeError unless magic_type.is_a?(MagicType)
    raise TypeError unless member.is_a?(Member)

    case magic_type
    when "fire"
      @name = "ファイア"
      @cost_magic_point = 2
      @attack_power = 20 + (member.level * 0.5).to_i
      @cost_technical_point = 0
    when shiden
      @name = "紫電"
      @cost_magic_point = 3
      @attack_power = 30 + (member.level * 0.5).to_i
      @cost_technical_point = 0
    when "hell_fire"
      @name = "ヘルファイア"
      @cost_magic_point = 5
      @attack_power = 50 + (member.level * 0.5).to_i
      @cost_technical_point = 0
    else
      raise AugumentError
    end
  end
end
  • interfaceクラスを利用する。 ※Rubyにはinterfaceがないので、interfaceを意識した実装を行う。
class Fire
  attr_reader :member

  def initialize(member)
    @member = member
  end

  def name
    "ファイア"
  end

  def cost_magic_point
    return MagicPoint.new(20)
  end

  def attack_power
    return AttackPower.new(50)
  end

  def cost_technical_point
    return TechnicalPoint.new(0)
  end
end

class Shiden
  attr_reader :member

  def initialize(member)
    @member = member
  end

  def name
    "紫電"
  end

  def cost_magic_point
    return MagicPoint.new(10)
  end

  def attack_power
    return AttackPower.new(30)
  end

  def cost_technical_point
    return TechnicalPoint.new(10)
  end
end

magics = {}
magics[:fire] = Fire.new(member)
magics[:shiden] = Shiden.new(member)

def magic_attack(magic_type)
  using_magic = magics[magic_type]

  # 処理
end