オブジェクト指向の設計方法を学んでいます。 今回は、「オブジェクト指向設計実践ガイド」の「第3章:依存関係を管理する」を読んでまとめていきます。 なお、使用する言語はPythonです。
(過去記事) aisinkakura-datascientist.hatenablog.com aisinkakura-datascientist.hatenablog.com
3.1 依存関係を理解する
本章は、クラス間の依存関係についての解説です。テキストは、まず最初にいくつかの問題が含まれているスクリプトを例示し、その問題を解決する手法を説明していくという流れで進んでいきます。
テキスト記載のスクリプトをPythonに書き換えたものが下記です。
class Gear: def __init__(self, chainring, cog, rim, tire): self._chainring = chainring self._cog = cog self._rim = rim self._tire = tire @property def chainring(self): return self._chainring @property def cog(self): return self._com @property def rim(self): return self._rim @property def tire(self): return self._tire def ratio(self): return self.chainring/self.cog def gear_inches(self): return self.ratio()*Wheel(self.rim, self.tire).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
テキストによると、上記で「ここに問題あり!!」と記載した箇所、
def gear_inches(self): return self.ratio()*Wheel(self.rim, self.tire).diameter()
に含まれる問題は4つです。
- Gearは、Wheelという名前のクラスが存在することを予想している。
- Gearは、Wheelのインスタンスがdiameterに応答することを予想している
- Gearは、Wheelにrimとtireが必要なことを知っている
- Gearは、Wheelの最初の引数がrimで、2番目がtireである必要があることを知っている。
このうち、Pythonであれば4についてはあまり気にする必要がないかもしれません。Pythonの場合、引数の名前を指定すれば順番関係なく正しく動いてくれるからです。
なので、1~3に記載した問題をどのように解消していくのか見ていきます。
3.2 疎結合なコードを書く
「Gearは、Wheelという名前のクラスが存在することを予想している」を解消する方法
ここでは、元のスクリプトから離れて問題設定がより分かりやすいスクリプトを用いて勉強していきます。
まずは、こちらのスクリプトを見てください。
class BaseClass: def __init__(self, x): self._x = x @property def x(self): return self._x def func1(self): return ExampleClass(self.x).example_func()+1 def func2(self): return ExampleClass(self.x).example_func()+2 def func3(self): return ExampleClass(self.x).example_func()+3 class ExampleClass: def __init__(self, y): self._y = y @property def y(self): return self._y def example_func(self): return self.y
このスクリプトでは、func1~func3の中でExampleClassのインスタンスを作成しています。
これにより、BaseClassはExampleClassという名前のクラスを参照することを知っていなければならない状態になっています。
このとき、発生するトラブルは下記の2個です。
- ExampleClassの名前が変更されたときに、func1~func3まで全て書き換える必要がある
- ExampleClass以外のクラスのインスタンスを使ってfunc1~func3と同じような挙動をするメソッドを作る際に、func1~func3を再利用できない
実際に、どのようなトラブルが発生してしまうのか見てみます。
#ExampleClassの名前が変更されたときに、func1~func3まで全て書き換える必要がある # ExampleClassがExampleClass2になったとする class BaseClass: def __init__(self, x): self._x = x @property def x(self): return self._x def func1(self): return ExampleClass2(self.x).example_func()+1 # 変更した! def func2(self): return ExampleClass2(self.x).example_func()+2 # 変更した! def func3(self): return ExampleClass2(self.x).example_func()+3 # 変更した! class ExampleClass2: def __init__(self, y): self._y = y @property def y(self): return self._y def example_func(self): return self.y
上記のスクリプトを見るとわかるように、func1~func3の中身全てを書き換える必要が生じています。これは面倒ですね。
次に、「ExampleClass以外のクラスのインスタンスを使ってfunc1~func3と同じような挙動をするメソッドを作る際に、func1~func3を再利用できない」についてもスクリプトを見てみます。
# ExampleClass以外のクラスのインスタンスを使ってfunc1~func3と同じような挙動をするメソッドを作る際に、func1~func3を再利用できない # example_funcを持つ別のクラスにおいても、Baseクラスのfunc1、func2、func3と同様の挙動をするメソッドが必要になった class BaseClass: def __init__(self, x): self._x = x @property def x(self): return self._x def func1(self): return ExampleClass(self.x).example_func()+1 def func2(self): return ExampleClass(self.x).example_func()+2 def func3(self): return ExampleClass(self.x).example_func()+3 def func4(self): return ExampleClass3(self.x).example_func()+1 # 追加した! def func5(self): return ExampleClass3(self.x).example_func()+2 # 追加した! def func6(self): return ExampleClass3(self.x).example_func()+3 # 追加した! class ExampleClass: def __init__(self, y): self._y = y @property def y(self): return self._y def example_func(self): return self.y class ExampleClass3: def __init__(self, z): self._z = z @property def z(self): return self._z def example_func(self): return (self.z)**2
同じような挙動をするメソッドなのに、func1~func3を再利用できず、わざわざfunc4~func6まで追加しています。
これらのトラブルは、端的に言えばExampleClassをfunc1~func3の中でハードコーディングしているために発生しています。
したがって、ここではハードコーディングを避けた書き方をすればOKとなります。
修正したスクリプトがこちらです。
class DesignPattern: def __init__(self, x, exampleclass): self._x = x self._exampleclass = exampleclass # クラスの変数にインスタンスをとるようにする @property def x(self): return self._x @property def exampleclass(self): return self._exampleclass def func1(self): return self.exampleclass.example_func()+1 def func2(self): return self.exampleclass.example_func()+2 def func3(self): return self.exampleclass.example_func()+3 class ExampleClass: def __init__(self, y): self._y = y @property def y(self): return self._y def example_func(self): return self.y
ポイントは、クラスの変数としてインスタンスを与えることです。これによって、example_funcというメソッドを持つ全てのクラスに対応できるようになりました。
これにより、
- ExampleClassの名前が変更された
- ExampleClass以外のクラスのインスタンスを使ってfunc1~func3と同じような挙動をするメソッドを作る
に容易に対応できるようになりました。
# ExampleClassの名前がExampleClass2に変更された ec2 = ExampleClass2(z=1) dp2 = DesignPattern(x=1, exampleclass=ec2) # インスタンスをあらかじめ作っておいてDesignPatternクラスの引数に入れるだけ print(dp2.func1()) print(dp2.func2()) print(dp2.func3()) # ExampleClass以外のクラスのインスタンスを使ってfunc1~func3と同じような挙動をするメソッドを作る ec3 = ExampleClass3(z=1) dp3 = DesignPattern(x=1, exampleclass=ec3) # こちらも同様 print(dp3.func1()) print(dp3.func2()) print(dp3.func3())
上記のように、DesignPatternに入れるインスタンスを変更するだけなので、容易にスクリプトの修正、再利用ができました。
Gearは、Wheelのインスタンスがdiameterに応答することを予想している
こちらも問題がわかりやすいようにスクリプトを変えて説明します。AntiPatternクラスのdoMethodOfAisatsuメソッドがAisatsuクラスのfuncメソッドを使っている例です。
(実行結果もわかりやすいようにGistで貼り付けました)
このとき、Aisatsuクラスのfuncメソッドの名前をaisatsu_funcに変更する必要が生じたとします。
このとき、同様の挙動をするスクリプトを作成するには、doMethodOfAisatsuメソッドの中身を下記のように書き換える必要があります。
def doMethodOfAisatsu(self): print(self.aisatsu.aisatsu_func().format("日本語", "おはようございます")) # 変更が生じた! print(self.aisatsu.aisatsu_func().format("英語", "Hello")) # 変更が生じた! print(self.aisatsu.aisatsu_func().format("中国語", "你好")) # 変更が生じた! print(self.aisatsu.aisatsu_func().format("マレーシア語", "Selamat pagi")) # 変更が生じた!
今回は4か所変更する必要が生じてしまいました。これは面倒です。そこで、外部依存する箇所を一か所に閉じ込めるという工夫をします。
上記スクリプトでDesignPatternクラスの内部にfuncメソッドを追加しました。これにより、Aisatsuクラスのfuncメソッドの名前が変更されたとき、下記の変更だけで済みます。
# Aisatsuクラスのfuncメソッドがaisatsu_funcに変更されたとき def func(self): return self.aisatsu.aisatsu_func()
これにより、doMethodOfAisatsuメソッドの中身を変更せずに済みました。
「Gearは、Wheelにrimとtireが必要なことを知っている」を解消する方法
これは比較的簡単です。Pythonにが可変長引数が実装されているので、それを使います。 可変長引数については、下記を参照しました。
Pythonの可変長引数(*args, **kwargs)の使い方 | note.nkmk.me
可変長引数については、単純なので最初からデザインパターンを記載します。
「3.3 依存方向の管理」の解説
ここまでクラス間の依存度を下げる方法を学んできました。この後は、依存方向をついて学んでいきます。
なお、テキストは「依存関係の逆転→依存方向の選択」の順で説明されています、しかし、私は私自身の理解のしやすさから順番を逆にして説明しようと思います。
依存方向の選択
まず、「クラスAがクラスBに依存している」という言葉の意味を明確にします。
ここでは、「クラスAがクラスBに依存している」といったとき、「クラスAはクラスBのアトリビュート・メソッドを使っている」という意味で使います。スクリプトで書くと下記のような状態を「クラスAがクラスBに依存している」と言います。
class A: def __init__(self, ib): self._ib = B(b=1) def func_A(self): print(self._ib.func_B()) # クラスAでクラスBのメソッドを使っている class B: def __init__(self, b): self._b = b def func_B(self): return self._b # ここまで説明してきたアンチパターンをいくつも使っていますが、お許しください
このとき、依存されるBが満たすべき要件は下記の通りです。
- 要件が変わりにくい
- 依存されているクラスが少ない
どんなにクラス間の依存度を下げたとしても、要件が変わるたびに依存しているクラス(上記の例だとクラスA)は変更を強いられます。そのため、要件が変わりやすいクラスに依存すべきではないということが1の要件がある理由です。
また、依存されているクラスが多いと、そのクラスの変更がアプリケーション全体に及ぶ可能性が高まります。これが2の要件がある理由です。
これらの事柄を本書では、図1のようにまとめています。(p83から引用)
依存関係の逆転
本節では、クラスの依存関係を逆転する方法について解説します。 まずは、AがBに依存する場合です。
次にこれを機能を変えずに依存関係を逆転させ、BがAに依存するようにしたスクリプトがこちらです。
ポイントは、Aで定義したメソッドをBで呼び出している点です。
恐らく、どちらの方向にもクラスの依存関係を作ることができるのではないかと思います。したがって、本節前半の「依存方向の選択」に記載した要件に準じて依存関係を決め、それに合わせてスクリプトを書くことが重要なのだと私は理解しました。
まとめ
今回は「第3章:依存関係を管理する」について解説しました。
本章の前半では、クラス間の依存度を下げる方法について学ぶことができます。また、後半では依存関係の方向性を決める方法と依存関係を逆転する方法について学ぶことができました。