はじめに
最近設計やアーキテクチャについて悩むことが多いのですが、動的型付き言語であるPythonでも依存性逆転の原則について実現できるのかについて自分なりの意見を書いてみました。私の意見が絶対正しいというわけではないのですが、少しでも参考になれば幸いです。
※Pythonの解説記事ですがC++のコードも出てきますのでご了承ください(内容は難しくありません)
ポリモーフィズムとは?
まず、依存性逆転の原則を理解する前にポリモーフィズムについての理解が必要です。
説明をWikipediaから引用します。
ポリモーフィズム(英: Polymorphism)とは、プログラミング言語の型システムの性質を表すもので、プログラミング言語の各要素(定数、変数、式、オブジェクト、関数、メソッドなど)についてそれらが複数の型に属することを許すという性質を指す。ポリモルフィズム、多態性、多相性、多様性とも呼ばれる。
内容が分かりにくいので詳細に解説します。本質は「それらが複数の型に属することを許すという性質を指す」の部分です。Pythonなどの動的型付き言語の場合はあまり意識することはありませんが、静的型付き言語の場合変数の型が限定されるため基本的には複数の型に属することは許されません。
例えば以下の例を見てください。(C++のコードですが、内容は難しくないと思います)
# include <iostream>
using namespace std;
int main()
{
int a = 1;
string b = "hello";
a = b; // error
}
aという変数はintで定義しており、bという変数はstringで定義しています。「a = b」で「int型の変数aにstring型の変数bを代入」しようとしています。この操作でエラーになります。これは、変数aがint型として定義されており、複数の型に属することができないため、となります。
つまり、変数などは原則複数の型に属することはできません。しかし、ポリモーフィズムの場合は特別に複数の型に属することを許容する、という内容がWikipediaの引用部分の解説になります。
では、実際にポリモーフィズムの例を見てみましょう。(C++のコードですがコード自体を理解する必要はありません)
# include <iostream>
using namespace std;
class Human{
public:
string name;
int age;
Human(const string _name, const int _age){
name = _name;
age = _age;
};
virtual void print_gender(){};
void print_info(){
cout << "name: " << name << ", age: " << age << endl;
};
};
class Man: public Human{
public:
Man(const string _name, const int _age): Human(_name,_age){};
void print_gender() override {
cout << "I am man." << endl;
};
};
class Woman: public Human{
public:
Woman(const string _name, const int _age): Human(_name,_age){};
void print_gender() override {
cout << "I am woman." << endl;
};
};
int main()
{
Man* taro = new Man("taro", 12);
Woman* hanako = new Woman("hanako", 23);
Human* human = taro;
human->print_gender();
human->print_info();
}
重要な部分は下記になります。
Man* taro = new Man("taro", 12);
Woman* hanako = new Woman("hanako", 23);
Human* human = taro;
Humanクラスで定義しているhumanという変数にManクラスのtaroという変数を代入しています。上記の説明で変数は一つの型(クラス)にしか属することができないということを書きましたが、humanという変数はHumanというクラスとManというクラスの両方に属するように見えます。(厳密にはHumanのみに属します)
なぜこのようなことが可能になっているかというと、ManクラスはHumanクラスの派生クラスになっているからです。ManクラスとHumanクラスには関係があるので、関係があるものについては同じように扱うことを許可する、というイメージですかね。
ポリモーフィズムのメリットは、同じ性質をもったものを同じように扱うことができるため、きれいなコードを書くことができる、という部分かと思います。(具体例については解説しません。すみません。)
抽象と具象について
事前知識として「抽象」と「具象」についても少し理解しておく必要があります。単語について少し解説します。
抽象とは
抽象とはインターフェイスと言い換えることもできますが、要するに型定義のことです(正確には正しくないかもしれません)。
例えば、以下のような関数を考えます。
def add_number(a: int, b: int) -> int:
"""二つの引数を足した結果を返す"""
return a + b
add_numberという関数はintの引数を取り、intの結果を変えすというインターフェイスが定義されています。抽象の観点からすると実装の内部は考えないのでadd_numberという関数名だが内部で足し算ではなく掛け算が行われていても気にしません。あくまで、インプットとアウトプット(インターフェイス)のみしか考えません。
具象とは
具象では実装の内部まで考えます。なので、add_number関数の内部実装が足し算か掛け算か、ということも踏まえて考えることになります。
依存性逆転の原則とは?
前提知識の説明が長くなってしまいました。しかも、Pythonのコードもほとんど出てこないし、、、
まず、「依存」という言葉の意味がややこしいのでそこから解説します。
例えば、以下のコードを見てください。
def main():
taro: Man = Man('taro', 12)
taro.print_gender()
taro.print_info()
main関数内でどうやらManというクラスを使っているようです。この状態は「main関数がManクラスに依存している」ということができます。つまり、Manクラスの内容が変更されるとmain関数にも影響が及ぶ可能性がある、と言い換えることもできます。
では、ここで依存性逆転の法原則の説明をwikipediaから引用しましょう。
オブジェクト指向設計において、依存性逆転の原則、または依存関係逆転の原則[1](dependency inversion principle) とはソフトウエアモジュールを疎結合に保つための特定の形式を指す用語。この原則に従うとソフトウェアの振る舞いを定義する上位レベルのモジュールから下位レベルモジュールへの従来の依存関係は逆転し、結果として下位レベルモジュールの実装の詳細から上位レベルモジュールを独立に保つことができるようになる
A. 上位レベルのモジュールは下位レベルのモジュールに依存すべきではない。両方とも抽象(abstractions)に依存すべきである。
B. 抽象は詳細に依存してはならない。詳細が抽象に依存すべきである。
またまた難解な説明が。。。
まず、「上位」と「下位」という言葉ですが、先ほどの例でいうとmain関数が上位でManクラスが下位に当たります。つまり、「上位レベルのモジュールは下位レベルのモジュールに依存すべきではない。」は具体的に言うと、「main関数はManクラスに依存するべきではない」という意味になります。
つぎに、「両方とも抽象(abstractions)に依存すべき」という部分ですが、言い換えると内部の実装に依存するのではなくインターフェイス(型)に依存するべき、という意味になります。
先ほどのコードとほぼ同じですが、以下の例を見てください。
def main():
taro: Human = Man('taro', 12)
taro.print_gender()
taro.print_info()
違いは、main関数がManクラスへの依存からHumanクラスへの依存に代わっている、という点です。
では、HumanクラスとManクラスの実装を見てみましょう。(内容は抜粋です、全文は末尾に載せます)
@dataclass
class Human(metaclass=ABCMeta):
name: str
age: int
# 抽象メソッド
@abstractmethod
def print_gender(self) -> None:
pass
# 共通メソッド
def print_info(self) -> None:
print(f'name: {self.name}, age: {self.age}')
class Man(Human):
def print_gender(self):
print('I am man.')
Humanクラスはベースとなるクラスであり、複雑なビジネスロジックは含まれていません。つまり、抽象的なクラスということができます。逆にManクラスは内部実装が含まれるクラスのなので具象クラスとなります。HumanクラスとManクラスで何が違うかというとHumanクラスは変更される頻度が少なくManクラスは変更される頻度が多い、という部分です。理由は当然でHumanクラスはインターフェイスの定義のみ(正確には違いますが、、、)、Manクラスは内部実装、ビジネスロジックが含まれるからです。
ここまでくると、main関数がManクラス(具象)からHumanクラス(抽象)へ依存するように変えることのメリットが理解頂けると思います。Manクラスに依存する場合、main関数はManクラスの変更の影響を受けるわけですから、Manクラスの変更頻度が多いので多大な影響を受けます。逆にHumanクラスに依存する場合は、もちろんHumanクラスが変更された場合は影響を受けますが、Manクラスと比較すると変更頻度が少ないので影響が少ない、ということになります。
つまり、抽象に依存することでより変更に対して堅牢なコードを書くことができる、ということになります。
結局Pythonで依存性逆転の原則は実現できるのか?
できますが、完全ではありません。
具体的には抽象クラスabcとリント、型チェックツールを併用することで可能です。
ただし、Pythonの場合は型情報が間違っていてもプログラム自体は動きますので、他の静的型付き言語と同レベルのことを実現することはできません。
より高レベルを目指すのであれば、チームで開発環境をそろえる、プレコミットで型、リントのチェックを実施する、などを徹底すれば実現可能かと思います。
実用レベルで利用する分には問題ないレベルの機能が提供されていると思ってます。
最後に
少し急いで書いたのであまり分かりやすい内容ではないかと思います。この辺りは理解するのが難しい分野ですので、少しでも参考になれればなと。コメントで質問頂ければできる限り回答させて頂きます。間違い、アドバイスもご指摘頂けると助かります。
実装コード全文
from abc import ABCMeta, abstractmethod
from dataclasses import dataclass
@dataclass
class Human(metaclass=ABCMeta):
name: str
age: int
# 抽象メソッド
@abstractmethod
def print_gender(self) -> None:
pass
# 共通メソッド
def print_info(self) -> None:
print(f'name: {self.name}, age: {self.age}')
class Man(Human):
def print_gender(self):
print('I am man.')
class Woman(Human):
def print_gender(self):
print('I am woman.')
def main():
taro: Human = Man('taro', 12)
taro.print_gender()
taro.print_info()