大ちゃんの駆け出し技術ブログ

RUNTEQ受講生のかわいいといわれるアウトプットブログ

【オブジェクト指向設計ガイド】単一責任

はじめに

おはようございます!大ちゃんの駆け出し技術ブログです!

綺麗なコードを書きたいということで、 Railsデザインパターンも学習していますが、下記書籍で綺麗な設計について学んでいます。

オブジェクト指向設計実践ガイド ~Rubyでわかる 進化しつづける柔軟なアプリケーションの育て方

内容は少しわかりづらくポートフォリオ作成段階では理解できなかったのですが、ポートフォリオが完成した今この本を読むと、「あのコードはこう書けばよかったんだな」と思いつつ読み進めることができています。ただ、それでも理解しづらい部分がたくさんあるため今回はこの本の第二章、「単一責任クラス」について要約したいと思います。

変更が簡単なコードとは

オブジェクト指向設計ガイドで「良い」とされている設計は、将来的に変更を加える際にその変更が非常にしやすい状態である設計を指していると思います。変更が簡単なコードにはTRUEの性質が伴うべきだと述べられています。

  • 見通しが良い(Transparent)・・・ 変更がもたらす影響・変更箇所が明確にわかっている
  • 合理性 (Reasonable)・・・どんな変更でもかかるコストは変更がもたらす利益の割にあっている
  • 利用性が高い(Usable)・・・ 新しい環境、予期していなかった環境でも再利用できる
  • 模範的(Exemplary)・・・ コードに変更を加える人が上記の品質を自然と保つようなコードになっている

このTRUE品質のコードにするための設計方法がこの本を通して学べることです。そしてTRUE品質のコードにするための最初の一歩とは、それぞれのクラスが単一の責任を持つように徹底することです。

単一責任クラスの見極め方

以下のクラスはこの本の説明で使われる自転車のギアのクラスです。

class Gear
  attr_reader :chainring, :cog
  def initialize(chainring, cog)
    @chainring = chainring
    @cog = cog
  end

  def ratio
    chainring / cog.to_f
  end
end

Gearクラスは自転車のギアのことを指しています。そしてクラスには3つのメソッドが定義されています。

chainring・・・チェーンリングの大きさ

cog・・・コグの大きさ

ratio・・・ギアの比率を求めるメソッド

Gear.new(52, 11).ratio
# => 4.7272727272

上記のGearクラスは現在比率を求めるメソッドとそれを求めるたに必要なメソッドのみが定義されており、極めて単一の責任がある状態と言えます。

しかし、ここで上記のクラスにギアインチを求めることができるようにして欲しいと設計変更が求められました。ここでいうギアインチは「車輪の直径(リムの直径 + (タイヤの厚み × 2)) × ギアの比率」で算出することができます。よって以下のようにクラスを変更しました。

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
    ratio * (rim + (tire * 2))
  end
end

追加されたメソッドとしては以下のとおりです。

rim・・・リムの直径

tire・・・タイヤの厚み

gear_inches・・・ギアインチを求めるメソッド

Gear.new(52, 11, 26, 1.5).gear_inches
# => 137.0909090909090

今現在クラスは正常に動作しています。特にバグらしいバグもないわけですが、今現在のクラスは果たして単一責任クラスなのでしょうか。それを見極める方法として2つの方法が説明されています。


クラス(メソッド)が単一責任かどうか見極める方法

あたかもそれに知覚があると仮定し質問する

「Gearクラスさん、あなたの比率を教えてくれませんか?」→ ○

「Gearクラスさん、あなたのタイヤのサイズを教えてくれませんか?」→ × (⇒ 自転車のギアは本来タイヤのサイズを知りません)

一文でクラスを説明してみる

「Gearクラスはギアの比率を教える責任がある」 → ○

「Gearクラスはギアの比率を教える責任がある。それと、タイヤのサイズを教える責任がある。」→ × (⇒「それと」や「または」が含まれていれば、それは単一責任から外れている)


①、②の方法で変更後のギアクラスでは本来知ることがないタイヤのサイズを知っていることが明らかです。つまり、現在のギアクラスは単一の責任ではなく複数の責任を持っていることがわかります。

複数責任から単一責任へ

複数の責任があると言うことは、その責任を別のクラスに渡すことが必要になります。それが既存のクラスに渡すのか、それとも新しいクラスを作成するのかは場合によりますが、今回はGearくらすしかない状態ですので後者の新しいクラスを作成して責任を移譲する方法をとります。

まず、変更前のクラスを確認します。

class Gear
  attr_reader :chainring, :cog
  def initialize(chainring, cog)
    @chainring = chainring
    @cog = cog
  end

  def ratio
    chainring / cog.to_f
  end
end

ここにはリムもタイヤの情報も書いてはいけません。複数の責任を負うことになるためです。しかし、ギアインチを求める機能はGearクラスには必要です。したがってリム及びタイヤは別クラスで定義し、Gearクラスでgear_inchesメソッドを定義します。

ではまず新しいクラスを定義します。新しいクラスにはリムとタイヤの情報があります。この情報がありそうなクラス名として正しそうなのはWheel(車輪)です。

class Wheel
  attr_reader :rim, :tire
  def initialize(rim)
    @rim = rim
    @tire = tire
  end
end

そして、gear_inchesメソッドで「車輪の直径(リムの直径 + (タイヤの厚み × 2))」が必要となるので、それを求めるdiameterメソッドを定義します。

class Wheel
  attr_reader :rim, :tire
  def initialize(rim)
    @rim = rim
    @tire = tire
  end

    def diameter
        rim + (tire * 2)
    end
end

そしてGearクラスにてgear_inchesメソッドを定義します。しかし、ここでGearクラスにはリムもタイアの情報もありません。どうするのかというと、WheelクラスのインスタンスであるwheelというメソッドをGearクラス内に定義します。

class Gear
  attr_reader :chainring, :cog, :wheel
  def initialize(chainring, cog)
    @chainring = chainring
    @cog = cog
        @wheel = wheel
  end

  def ratio
    chainring / cog.to_f
  end

    def gear_inches
        ratio * wheel.diameter
    end
end

Gearクラスのインスタンスを作成する場合、Wheelクラスのインスタンスの作成も必須になるということです。

Gear.new(52, 11, Wheel.new(26, 1.5))

これにより二つのクラスが各々に単一の責任をもつ状態になりました。gear_inchesメソッドも正常に動作します。

Gear.new(52, 11, Wheel.new(26, 1.5)).gear_inches

なぜ単一責任が重要なのか

上記のように単一責任にしたことでなぜTRUE品質になるのでしょうか。それには以下の理由があります。

別のクラスとの依存性がわずかしかないため、単独で変更した時に他のクラスへの影響がほとんどないため

ここでこの本を読む上で重要な依存性という言葉が出てきます。依存性とはつまり、あるクラスがあるクラスのことを知っている状態です。例えば、上記のGearクラスはwheelありきでインスタンスが作成されるため、wheelとなるオブジェクトがあることを知っています。(Wheelクラス自体の存在は知りません。あくまでwheelとなるオブジェクトがあることだけを知っています。ここの理解は少し難しいです。。。)

反対にWheelクラスは他のクラスについて何も知らない状態です。つまり、依存性がまったくないのクラスになります。よって、GearクラスもWheelクラスも互いに依存性がほとんどない状態と言えるでしょう。

これはコードの変更に対する別クラスへの影響がほとんどないことにつながります。例えば、Wheelクラスに新しくhogeの情報を加えたとします。

class Wheel
  attr_reader :rim, :tire, :hoge
  def initialize(rim)
    @rim = rim
    @tire = tire
        @hoge = hoge
  end

    def diameter
        rim + (tire * 2)
    end
end

しかし、この変更が起きたとしても既存のGearクラスに対してコードを変更する必要がありません。依存性がないことで、WheelクラスもGearクラスもコードを変更した時に互いに及ぼす影響がほとんどないのです。

単一責任はメソッドでも必要なことです。

単一の責任のメソッドからよく外れがちなのが繰り返し処理です。例えば、下記のメソッドを自分のポートフォリオで使用していた、「チャンネルの固有のIDと同じIDのチャンネルをレシーバーのチャンネル一覧から抽出する」メソッドです。

def get_same_id_channel(channels:, channel_id:)
  channel = channels.select { |channel| channel.dig("id") == channel_id }[0]
  channel
end

一見して説明は「そして」などはないですが、メソッドの中身を見ると、「繰り返し処理で各々のチャンネルのIDをdigメソッドで取り出す。そして、指定したIDのチャンネルと同じIDのチャンネルをレシーバーのチャンネル一覧から抽出する」処理をしています。よって上記の繰り返し処理は以下のように変更できます。

def get_same_id_channel(channels:, channel_id:)
  channel = channels.select { |channel| channel_id(channel) == channel_id }[0]
  channel
end

def channel_id(channel)
  channel.dig('id')
end

チャンネルIDを取り出す処理は別メソッドに置き換えることで責任を単一にすることができました。