初心者データサイエンティストの備忘録

調べたことは全部ここに書いて自分の辞書を作る

オブジェクト指向設計実践ガイド【第2章】まとめ

オブジェクト指向を使ってアプリケーションを作るために、オブジェクト指向の設計方法を学んでいます。 今回は、「オブジェクト指向設計実践ガイド」の第2章:単一責任のクラスを設計するを読んでまとめていきます。
なお、使用する言語はPythonです。

aisinkakura-datascientist.hatenablog.com

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

この節ではTRUEなコードを書くべきとの主張がされています。自分の理解を深めるために、TRUEでない状態も併せて考えました。

要件 意味 満たしていない状態
見通しが良い(Transparent) 変更するコードにおいても、そのコードに依存する別の場所のコードにおいても、変更がもたらす影響が明白である。 コードを変更する際に、どこを変更すべきかわからない。
合理的(Reasonable) どんな変更であっても、かかるコストは変更がもたらす利益にふさわしい。 例えば、1,000万の利益を得るための改修に2,000万かかってしまった。
利用性が高い(Usable) 新しい環境、予期していなかった環境でも再利用できる。 例えば、PythonからRに文法を変えたら全く動かなくなった。
模範的(Exemplary) コードに変更を加える人が、上記の品質を自然と保つようなコードになっている。 コードが変更に耐えられない状態になっており、変更が生じるたびにスパゲティ化していく。

このTRUEなコードを書くための最初の一歩が、それぞれのクラスが単一責任になるように設計することだと本書では述べられています。

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

本節以降では、自転車を例にしてコードの説明がされています。私の場合、自転車の各部品の用語が覚えられなかったので、まずは用語を説明します。

図1:自転車の各用語の説明

色々用語が出てきましたが、最終的には表1のような2つのクラスを作成することを目指します。

表1:各クラスの変数とメソッド

次に本節の要点を箇条書きでまとめておきます。

  • 設計を最初から完璧にすることはできない。
  • したがって、変更に強いコードを書かなければならない。
  • 変更に強いコードにするための工夫の一つとして、クラスやメソッドを単一責任にするべき。
  • クラスが単一責任であるかの確認方法は2つある。
    • 1つ目は、あたかもクラスに知覚があるかのように問うてみること。例えば、「Gearクラスさん、ratioを教えてください」のような違和感のない問いだけで構成されるクラスは単一責任である。一方で、「Gearクラスさん、rimを教えてください」のような違和感がある問いが混ざっていたら単一責任ではない。
    • 2つ目は、一文でクラスを表現するこできるか確かめること。今回の場合、「Gearクラスは、Gearに所属するcog, chainringから、ratioを計算すること」と説明できる。(gear_inchesは今回は実装するが、単一責任の観点からはGearクラスに本来入れるべきではないと考えられる。)なお、一文で説明できないとは文章中に「それと」や「または」が入ってしまうときを指している。
  • ただし、改めて繰り返すが、最初から完璧な設計はできない。したがって、単一責任ではないクラスを設計してしまうこともあるかもしれない。そのため、単一責任を完璧に果たせない場合でも、変更に強い設計変更に対応可能なコードを書くべき。

正直、このあたりは十分に理解できていません。まず、単一責任の定義が本書では曖昧で、人によってとらえ方が異なるのではないかと思います。また、なぜ、今回はGearクラスでgear_inchesを実装してしまうのかもよくわかりませんでした。本当に単一責任にするなら、Gear、Wheelクラスに加えて、Gear、Wheelクラスの間を取り持つクラスが必要になるのではと私は考えています。

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

本節では、変更に強いコードを書くテクニックが紹介されます。

インスタンス変数の隠蔽

インスタンス変数に変更が生じても、コード全体に変更が生じないコードを書く工夫です。
アンチパターン(良くない例)とデザインパターン(良い例)を示して説明します。

# アンチパターン
class AntiPattern:
    def __init__(self, a, b):
        self.a = a
        self.b = b
        
    def func1(self):
        return self.a+self.b
    
    def func2(self):
        return self.a-self.b
    
    def func3(self):
        return self.a*self.b
    
    def func4(self):
        return self.a/self.b

アンチパターンが悪い理由は、self.aとself.bに変更が生じたときに、func1~func4の中身に変更が必要になることです。例えば、下記のコードでは8か所変更しています。

# 要件がa→a+1、b→b-1に変更になってしまった場合、アンチパターンだと...
class AntiPatternChange:
    def __init__(self, a, b):
        self.a = a
        self.b = b
        
    def func1(self):
        return (self.a+1)+(self.b-1) # 変更した!
    
    def func2(self):
        return (self.a+1)-(self.b-1) # 変更した!
    
    def func3(self):
        return (self.a+1)*(self.b-1) # 変更した!
    
    def func4(self):
        return (self.a+1)/(self.b-1) # 変更した!

これの改善案として、コンストラクタで変えてしまう手もあります。

# アンチパターンの改善案1
class AntiPatternBetterChange1:
    '''
    a, b:数値
    コンストラクタで対応する
    '''
    def __init__(self, a, b):
        self.a = a+1 # 変更した!
        self.b = b-1 # 変更した!
        
    def func1(self):
        return self.a+self.b
    
    def func2(self):
        return self.a-self.b
    
    def func3(self):
        return self.a*self.b
    
    def func4(self):
        return self.a/self.b

しかし、これはコンストラクタに変数の値を変更する機能を持たせることになります。単一責任の原則にしたがえば、コンストラクタは「インスタンス作成時に動く」という機能だけを持っているべきです。そこで、変数の値を変更する機能は分離します。

# アンチパターンの改善案2
class AntiPatternBetterChange2:
    '''
    a, b:数値
    '''
    def __init__(self, a, b):
        self._a = a
        self._b = b
        
    def a(self): 
        """
        a:数値
        aの変更を管理する
        """
        return self._a
    
    def b(self):
        """
        b:数値
        bの変更を管理する
        """
        return self._b
        
    def func1(self):
        return self.a()+self.b()
    
    def func2(self):
        return self.a()-self.b()
    
    def func3(self):
        return self.a()*self.b()
    
    def func4(self):
        return self.a()/self.b()

このようにすれば、self.a、self.bに変更が生じた際にも下記のように2か所の変更で済みます。

class AntiPatternBetterChange2:
    '''
    a, b:数値
    '''
    def __init__(self, a, b):
        self._a = a
        self._b = b
        
    def a(self): 
        """
        a:数値
        aの変更を管理する
        """
        return self._a+1 # 変更した!
    
    def b(self):
        """
        b:数値
        bの変更を管理する
        """
        return self._b-1 # 変更した!
        
    def func1(self):
        return self.a()+self.b()
    
    def func2(self):
        return self.a()-self.b()
    
    def func3(self):
        return self.a()*self.b()
    
    def func4(self):
        return self.a()/self.b()

これでほとんど完成なのですが、func1~func4の中では毎回self.a()、self.b()とカッコがついておりスマートではありません。そこで、@propertyを使います。
@propertyについては、こちらの過去記事を参照してください。

aisinkakura-datascientist.hatenablog.com

@propertyを使ったスクリプトはこちらです。

# デザインパターン
class DesignPattern:
    '''
    a, b:数値
    '''
    def __init__(self, a, b):
        self._a = a
        self._b = b
    
    @property
    def a(self): 
        """
        a:数値
        aの変更を管理する
        """
        return self._a
    
    @property
    def b(self):
        """
        b:数値
        bの変更を管理する
        """
        return self._b
        
    def func1(self):
        return self.a+self.b
    
    def func2(self):
        return self.a-self.b
    
    def func3(self):
        return self.a*self.b
    
    def func4(self):
        return self.a/self.b

これで、インスタンス変数の変更に強いスクリプトを作成することができました!

データ構造の隠蔽

これは簡単にいえば、データの構造と意味を分けましょうという話です。
まず、悪い例を挙げます。

# アンチパターン
import math
class ObscuringReferences:
    def __init__(self, data):
        self._data = data
        
    @property
    def data(self):
        return self._data
    
    def diameters(self):
        return self.data[0]+(self.data[1]*2)

    def area_of_innner_ring(self):
        return (self.data[0]/2)**2*math.pi

このようなコードの場合、dataの構造が変わるとself.data[インデックス]となっている箇所を全て変更しなければなりません。さすがにそれは面倒すぎるので、構造と意味を分けるメソッドを挟み込みます。

# デザインパターン
# 構造と意味を分離する!
import math
import pandas as pd
class DesignPatternOfObscuringReferences:
    def __init__(self, data):
        self._data = data
    
    @property
    def data(self):
        return self.wheelify(arg_data=self._data)
    
    # 構造と意味を分離するメソッド
    def wheelify(self, arg_data):        
        return pd.Series(arg_data, index=["rim", "tire"])

    def diameters(self):
        return self.data["rim"]+(self.data["tire"]*2)
    
    def area_of_innner_ring(self):
        return (self.data["rim"]/2)**2*math.pi

上記のようなwheelifyを使うことで、もしdataの構造に変更が生じても、diametersやarea_of_innner_ring中のself.dataを変更せずに済みます。

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

ここでは、ちょっと細かいですが各メソッドの責任を単一にしていくことを学びました。

繰り返し処理のパターン

まず、アンチパターンのメソッドを書きます。

# アンチパターン
def diameters(wheels):
    return [wheel["rim"]+wheel["tire"]*2 for wheel in wheels]

ここに記載したdiametersというメソッドは下記の2つの機能を持っています。

  • 各タイヤの直径を計算する
  • 各タイヤを繰り返し計算する

これにより、このメソッドは単一責任になっていません。
これを下記の2つのメソッドに分解します。

# デザインパターン
# 各責任に分ける

# 各タイヤの直径を計算する
def diameter(wheel):
    return wheel["rim"]+wheel["tire"]*2

# 各タイヤを繰り返し処理する
def diameters(wheels):
    return [diameter(wheel) for wheel in wheels]

これにより、単一責任なメソッドを作成できました。

意味でメソッドを分ける

一つ上のパターンでは機能でメソッドを分けました。しかし、ここでは意味でメソッドを分けていきます。 まずは、アンチパターンです。

# アンチパターン
def gear_inches(ratio, rim, tire):
    return ratio*(rim+(tire*2))

このgear_inchesメソッドは、まず最初に直径=rim+tire×2を計算しています。そして、計算された直径に対し、ratioを掛け算することで最終的にギアインチ=ratio×直径を計算しています。
このように、意味を2つ持っているメソッドも分解して単一責任にしていきます。それによりできるメソッドはこちらです。

# デザインパターン
def diameter(rim, tire):
    return rim+(tire*2)


def gear_inches(ratio, rim, tire):
    return ratio*diameter(rim, tire)

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

最後に当初の設計が不十分で責任を十分に分離できないときから、後々責任を分離しやすくする方法を記載しておきます。
方法は単純で、クラスの中に分離できそうな箇所を分離しておくです。
ここでは、最初化からデザインパターンスクリプトを書いておきます。

# Gearの要素として、chainring, cog, ratio, gear_inchesを持ちたい
# しかし、そのためには、wheelの要素を持っている必要がある
# 一旦は、Gearクラスの中にwheelも実装してしまう
# ただし、wheelに関する部分はGearの中でも隔離させておく

class Gear:
    def __init__(self, chainring, cog, rim, tire):
        self._chainring = chainring
        self._cog = cog
        self._wheel = self.Wheel(rim, tire)
    
    @property
    def chainring(self):
        return self._chainring
    
    @property
    def cog(self):
        return self._cog  
    
    def ratio(self):
        return self.chainring/self.cog
    
    def gear_inches(self):
        return self.ratio()*self._wheel.diameter()
    
    # wheelに関するスクリプト(なんらかの理由で別クラスを作りたくない場合にクラスの中で単一責任を持ったクラスを作成する)
    class Wheel:
        def __init__(self, rim, tire):
            self._rim = rim
            self._tire = tire
        
        def diameter(self):
            return self._rim + (self._tire*2)

これにより、Wheelクラスを分離したいときにそのままコピペして外に出すだけで済みます。
実際にWheelクラスを分離し、新たにcircumferenceメソッドを実装したときのスクリプトがこちらです。

# GearクラスとWheelクラスを独立させ、単一責任にする
# Whhelクラスにはcircumferenceメソッドを追加する

class Gear:
    def __init__(self, chainring, cog, wheel):
        self._chainring = chainring
        self._cog = cog
        self._wheel = wheel
        
    @property
    def chainring(self):
        return self._chainring
    
    @property
    def cog(self):
        return self._cog
    
    @property
    def wheel(self):
        return self._wheel
    
    def ratio(self):
        return self.chainring/self.cog
    
    def gear_inches(self):
        return self.ratio()*self.wheel.diameter()
    
class Wheel:
    def __init__(self, rim, tire):
        self._rim = rim
        self._tire = tire
        
    @property
    def rim(self):
        return self._rim
    
    @property
    def tire(self):
        return self._tire
    
    def diameter(self):
        return self.rim + (self.tire*2)
    
    def circumference(self):
        from math import pi
        return self.diameter()*pi

実際に手を動かすとわかりやすのですが、本当に手早くWheelクラスを新たに実装することができました。

まとめ

本章の前半では、コードの設計はコードを書く最初の時点で完璧に決めることはできない要件の仕様変更は顧客の都合などによりいくらでも起こりうるという現場の事情を踏まえて、いつでも変更しやすいコードを書くべきとしつこく説明しています。
また、それを実現するための手段の一つとして単一責任の考え方が導入されました。
本章の後半では、単一責任なコードを書くためのテクニックが紹介されました。

私としては、単一責任を十分に理解できたとは言えません。しかし、一旦は機能と意味を分けていくという意識とここで学んだテクニックを活かして実装していきたいと思います。