頑張るときはいつも今

自称エンジニアのゴリラです。日々精進。

【Ruby】オブジェクト思考設計実践ガイドを読み始めた

オブジェクト思考設計ガイドを読み始めた

記事に書くこと

こちらの本を2章を読んだことの忘備録になります。

オブジェクト思考設計ガイド

読み始めた背景

設計て大事ですよね。

なるべく早くアプリケーション開発するのは大事ですが、

設計がボロボロだった時に拡張ができないとか問題が発生します。

RubyとかRailsを少しは書けるようになってきましたが、

実装以外で迷うところはやはり設計です。

どこにコードを書くのか?、保守性が高いコードを書くためにはどのようなModelにするべきか、かなり迷います。

そんなこんなで、設計力を高めていきたいなと思いこちらの本を購入しました。

第2章 単一責任のクラスを設計する

先にまとめておくと

  • クラスは今すぐに求められる動作を使い、後にも簡単に変更できるようにモデル化する
    • そのためにメソッドを正しくグループ分けしてクラスにまとめることが必要
  • 単一責任のクラスを作る
    • クラスを変更する理由は2つ以上あってはならない
    • クラスを1文で説明して、andとかorがついたら複数の機能を持っている
  • データを外部から隠蔽する
    • データとしてではなく、アクセサメソッドを介して、外部から参照できるようにする

変更が簡単なコード

変更が簡単なコードって難しいですよね。

そもそも変更が簡単なコードって何だよと私もすごい思います。

書籍では変更が簡単について以下のように定義されていました。

  • 変更が副作用をもたらさない
  • 要件の変更範囲とコードの変更規模は比例する
    • 少しだけ変更するだけだったら、コードを変更する箇所も少しだよね的な
  • 既存のコードは簡単に利用できる
    • 何か追加の機能を開発する時に既存のコードを流用できるとか
    • Railsだとscopeとかが該当するのではないかと
  • 最も簡単な変更方法はコードの追加である。ただし追加するコード自体は変更が容易なものとする
    • 機能改修が多く、私もコードを追加することが多いのでとても染みた

じゃあ、上の定義を実現するためにどんなコードを書かないといけないのか?

下のTRUE(頭文字をとって)がコードがあるべき姿になります。

  • TransParent(見通しがいい)
    • 変更がもたらす影響が明白であること
    • コードが依存している場所、依存されている場所は明白であること
  • Reasonable(合理的)
    • どんな変更であっても、かかるコストは変更がもたらす利益に等しい
  • Usable(利用性が高い)
    • 新しい環境、予期していなかった環境でも利用できる
  • Exemplary(模範的)
    • コードに変更を加える人が、上記の品質を自然と保つようなコードになっている
    • ある意味コードが、コーティング規約になっているみたいな感じ

TRUEなコードを書くための最初の1歩が単一責任を実現することになります。

単一責任って何?

オブジェクト思考とかで、よく言われる単一責任の原則とかってありますよね。

単一責任とは、

1つのクラスに1つの役割(機能)

と言うものになります。

1つのクラスに1つの役割って解釈がとても難しいですよね。

システムAとシステムBがあり、そこをつなげるクラスを作成していたとします。

作ったクラスが下のような、機能を持っている時これは単一責任でしょうか?

  • システムAから何かしらの命令を受けてそれをシステムBが処理できる形に変換する

  • 変換した命令をシステムBへ通知する

システムAとシステムBの橋渡し(機能)と考えた場合って、単一責任ですよね。

ただ、オブジェクト思考的には、これは単一責任とは言えません。

作成したクラスは、システムAから命令を受けて変換する機能システムBに対して通知する機能

2つの機能が混在しているためです。

じゃあ、どうやってクラスが単一責任かチェックするのか。

作成したクラスを変更する理由が2つ以上存在してはならない

と言う意味に置き換えるてクラスの妥当性をチェックしていきます。

この意味で考えると、上のクラスが変更する理由としては、下のようなものです。

  • システムAからの命令プロトコルが変更されるかもしれない

  • システムBへの通知プロトコルが変更されるかもしれない

つまり、システムAから命令を受けて変換する機能システムBに対して通知する機能

それぞれ別クラス(もしくはインタフェースを作成するなど)に分離する必要があります。

役割=クラスを変更する理由と考えることで、単一責任になっているかを考えていくことができます。

なぜ単一責任が必要になるのか?

単一責任は、再利用が簡単なコードを書くための原則です。

2つ以上の責任を持っている場合、簡単に再利用ができなくなります。

(ある一方の機能のみ欲しいんだけど。。とりあえず別クラスに複製してみるかみたいな感じになりそうですよね)

必要な機能(振る舞い)のみで構成されているクラスは、クラス全体を複製することができるのです。

既存のコードが単一責任であるかどうかについて見極めるもう1つの方法としては、

コードの役割を1文で説明できるかと言うことを意識してもいいようです。

説明に、それとが含まれている場合は、2つ以上の責任を持っています。

またはがつく場合はそこまで関連していない2つ以上の責任を負っていると言うことになります。

データではなく振る舞いに依存させる

データ=クラスがもつ変数振る舞い=メソッドです。

単一責任のクラスを作れば、どんな些細な振る舞いの変更も、

ただ1箇所のみに存在するようになります。

これがDRYの概念とも被ります。

DRYなコードは振る舞いに変更があったとしても、変更する箇所はただ1箇所になります。

メソッドもデータにアクセスする必要があります。

データにアクセスするための方法は、

があります。

業務要件として、データに直接アクセスする必要がある場合もありますが、

大抵の場合は、データをオブジェクトとして扱い、外部に対しては隠蔽し、

外部からはアクセサメソッド経由でアクセスする方が望ましいものとなります。

class Gear
    # Rubyのattr_readerでインスタンスパラメータをカプセル化(読み取り専用)
    attr_reader :chainring, :cog

    def initialize(chainring, cog)
        @chainring = chainring
        @cog       = cog
    end

    def ratio
        chainring / cog.to_f
    end
end

# attr_readerは下のメソッドを定義したと同じ
def cog
    @cog
end

# 外部からはデータを直接書き換えることはできない
gear = Gear.new(10, 10)
gear.cog = 20 # NG

Structを使ったデータの隠蔽

下のようなクラスとメソッドがあるとします。

Gearは外部から2次元配列をもらってインスタンス化されます。

class Gear
    
    attr_reader :data

    def initialize(data)
        @data = data
    end

    def diameters
        # 配列のindexを通してデータを処理
        data.collect {|cell| cell[0] + (cell[1] * 2)}
    end
    # ここからも先のメソッドも配列のindexを通してデータを処理する
    # ....
end

data = [ [10, 20], [20, 30], [30, 40] ]

gear = Gear.new(data)

dataはattr_readerで確かに隠蔽されていますが、

data自体が複雑な構成になっていて、diametersメソッドは、

dataの構造に依存した処理になっています。

配列の構造が変わった瞬間に壊れる可能性があるコードです。

配列の構造を知っている場所をただ1箇所に抑え込む必要があります。

そんな時に構造体Structを使ってみます。

class Gear
    
    attr_reader :data

    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(data[0], data[1])}
    end
end

配列の構造を知っている場所が、wheelifyだけになりました。

他のメソッドからWheelオブジェクトを介して、振る舞いが実装されています。

配列の構造が変わったとしても、wheelifyのみ変更すればいいコードになっています。

メソッドも単一責任にする

クラスと同様にメソッドも単一責任にすることで、

再利用しやすいものになります。

先ほどのコードのdiametersメソッドに着眼します。

def diameters
    @wheels.collect {|wheel| wheel.rim + (wheel.tire * 2)}
end

diametersメソッドは、配列で持っているタイヤを順番に直径計算して、

直径の配列を返すメソッドになります。

直径の配列を返す直径を計算する複数の機能を持っています。

直径計算を別なメソッドに切り出して、diametersは下のようにリファクタリングされます。

def diameters
    @wheels.collect {|wheel| diamter(wheel.rim, wheel.tire)}
end

def diameter(rim, tire)
    rim + (tire * 2)
end

クラス内の余計な責任を隔離する

先ほどStructを使って、Wheelを実現していました。

ただ、このモデル設計は違和感があります。

Gearクラスを一文で説明した際に、

外部からデータをもらってWheelを生成する。それと、wheelの直径を計算して・・・

Gearの目的とは離れた役割が出てきました。

wheelは外部にクラスとして分離していきます。

最終的には下のような構成にすることで、単一責任を実現しています。

class Gear
    
    attr_reader :chainring, :cog, :data

    def initialize(chainring, cog, wheel = nil)
        @chainring = chainring
        @cog       = cog
        @wheels    = 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(10, 1.5)

@gear  = Gear.new(52, 11, @diameter)

所感

今回のケースは、メッセージを取り合うクラスが2つしか存在しないので、

とてもシンプルな例だったと思います。

実際のアプリはもっと多くのクラスと依存関係をもちメッセージパッシングするので、

いつの間にか複数の役割を持っていたりします。

3章はその依存関係について記載されているみたいです。

(ここら辺の読書記録は2日に1回ペースであげたいけどなかなかできていない)