オブジェクト指向をPythonで実現するための記法をまとめます。特に、型ヒント (と mypy
) を利用して静的解析を行う方法にフォーカスを当てています。
ただし、動的型づけ言語だと厳密に実現するのは難しいようです。厳密さにはあまりこだわらずに、メリットを受けられる範囲で (変更に柔軟に対応できるような) 実用的な書き方をまとめられたらなと思います。
今回は、こちら(オブジェクト指向と10年戦ってわかったこと)の記事で取り上げられていたオブジェクト指向三大要素:
- 継承 (インターフェース)
- ポリモーフィズム
- カプセル化
を取り扱います。
オブジェクト指向そのものについての説明は上記を参考にして下さい。(まともに理解していないので説明できません。誤りがあればご指摘いただけるとありがたいです。)
現在も変更が多そうなライブラリ (TensorFlow
, PyTorch
, Pandas
, Web framework (Django
, Flask
, Starlette
, FastAPI
など)) の実装方法を主観で整理した上でまとめたので、あまり突飛な実装方法は紹介していません。
今回使用したコードはすべてgithubにあります。IDEでとりあえず試したい場合などはこちらからクローンしてみて下さい
インターフェース
クラスの「規格」をまとめた抽象クラスの定義と、そのサブクラスが「規格」を準拠しているか検証する方法を整理します。(後述するポリモーフィズムと組み合わせると汎用性や変更のしやすさを向上させてくれます)
実装方法は、2パターンあります。
- abc (Abstract Base Class)
- NotImplementedError
abc (Abstract Base Class)
abc.ABC
や abc.abstractmethod
デコレータを使用するとインターフェース (抽象クラス) が定義できます。また、この実装方法は、mypy
と相性が良いです。
以下の様に Car インターフェース (抽象クラス) を定義できます:
from abc import ABC, abstractmethod
class Car(ABC):
@abstractmethod
def move(self) -> bool:
pass
@abstractmethod
def stop(self) -> bool:
pass
これを継承したクラスは、少なくとも Car 抽象クラスで定義されている抽象属性と抽象メソッドを定義してあげる必要があります。(abstractmethod
デコレータがついていない属性やメソッドは、通常通り"継承"されます。abstractmethod
デコレータがついているメソッドも.super()
でアクセスできます。)。つまり、抽象クラスで定義された「規格」に準拠しているか検証する機能が備わっています。
定義されていないものがある場合、以下の様にインスタンス生成時にエラーをはきます:
class Tractor(Car):
def move(self) -> bool:
pass # do something
def stop(self) -> bool:
pass # do anything
tractor = Tractor() # => OK
class Kar(Car):
def move(self) -> bool:
pass # do something
kar = Kar() # => TypeError
mypy
で静的に解析することも可能です。(型アノテーションがなくても mypy
で検出できます)
ただし、これはあくまでも未定義なメソッドを検知しているだけなので、メソッドの処理内容が「規格」に沿っているかテストするのを忘れてはいけないです。
参考
NotImplementedError
以下の様に定義されたクラスをインターフェースとして使うこともできます:
class BaseCar:
def move(self) -> bool:
raise NotImplementedError
def stop(self) -> bool:
raise NotImplementedError
このクラスを継承した以下のクラスは、.stop()
メソッドの実行時にエラーをはきます:
class Truck(BaseCar):
def move(self) -> bool:
pass # do something
truck = Truck().stop() # => NotImplementedError
abc
を使う方法に比べると、
- どのクラスをインターフェースとして使う想定なのか分かりにくい
-
mypy
で、インターフェースで規定されているメソッドの定義不足を検出できない。(.stop()
メソッドを実際に呼び出すまで不足していることが分からない)
という点で、使い勝手が悪いです。
しかし、テストを最低限やっておけばインターフェースとして一応機能すると思います。
ライブラリでの使われ方
一般的に抽象クラス (または、単にベースクラス) 自体はよく使われています。
実装方法としては、主観ですが、NotImplementedError
を raise
するものが圧倒的に多いように感じます。
abc
を使う場合も、汎用的なメソッドが詰め込まれたベースクラスに abc
メタクラスを継承させてメインのメソッドだけ abstractmethod
にしていることが多いです。
例えば:
-
Tensorflow.keras
: 至る所で Interface という表現と共にabc
が使われています。例: Optimizer の基本機能 (抽象と具象の両方) が詰め込まれたabc
メタクラスを定義し、それを継承してSGD
やAdam
など様々な Optimizer layer を定義している。 -
Pytorch
: ベースクラス (raise NotImplementedError
) が多く使われています。例:torch.utils.data.Dataset
はベースクラスになっています。ユーザーがこれを継承してカスタムデータセットを定義する際に、インターフェースを使っていることになります。 -
Pandas
: 基本的にはベースクラスですが、abc
抽象クラスも要所要所で使われています。例:Series.str
のメソッド群はそれぞれ共通のabc
抽象クラスをインターフェースとしています。 - Web Framework全般:
Django
,Flask
,Starlette
,FastAPI
などはすべてベースクラス (raise NotImplementedError
) が使われている模様です。
ポリモーフィズム (多態性)
抽象クラス (インターフェース) に対してプログラミングします。そうすることで、抽象クラスを継承しているクラス(「規格」に準拠しているクラス)すべてが正常に動作することを期待できます。(各クラスのメソッドが期待通り正常に動作するという前提です)
具体的な処理の変更は具象クラスのメソッドの変更だけで閉じる可能性が高く、具体的な処理に依存しない処理 (抽象クラスで表現できる処理) の変更は個々の具象クラスについて気にする必要性が低くなります。変更の影響範囲が狭まれば狭まるだけ、より変更が容易になるので、ポリモーフィズムは重要そうです。
実装方法は、細かく分けると
- 抽象クラス (インターフェース) として型アノテーション
-
isinstance
で抽象クラス (インターフェース) と比較する
です。いずれにしても、mypy
を利用します。
また、abc
抽象クラスとベースクラス (raise NotImplementedError
) のどちらでインターフェースを定義しても同様に動作します。
抽象クラスを型アノテーション
抽象クラス (インターフェース) を型ヒントに使うと、実際にはそれを継承したクラスを代入していても、mypy
使用時には抽象クラスとして静的解析が行われます。なので、具体クラス特有のメソッドや属性の利用、インターフェースを使用していないクラスの誤った受け渡しを検知してくれます。
from abc import ABC, abstractmethod
class Car(ABC):
@abstractmethod
def move(self) -> None:
pass
class Taxi(Car):
def move(self) -> None:
pass
def stop(self) -> None:
pass
def stop_car(car: Car) -> None:
# 抽象クラスで型ヒントをつける
car.stop() # => (mypy) error: "Car" has no attribute "stop"
stop_car(Taxi())
また、同一インターフェースを継承したクラス達を動的に切り替える可能性がある場合 (関数内で具体クラスのインスタンス生成をする必要があるとき) は、次の様に出力を抽象クラスでアノテーションした関数を用意しておくと、mypy
が抽象クラスとして扱ってくれます:
class Truck(Car):
def move(self) -> None:
pass
def load(self) -> None:
pass
def get_car(model: str) -> Car:
if model == "taxi":
return Taxi()
else:
return Truck()
def stop_car(model: str) -> None:
# 出力が抽象クラスでアノテーションされている関数を用いる。
car = get_car(model)
car.stop() # => (mypy) error: "Car" has no attribute "stop"
# or 変数を抽象クラスでアノテーション
car_ex: Car = get_car(model)
car_ex.stop() # => (mypy) error: "Car" has no attribute "stop"
isinstance
で抽象クラスと比較する
また、型ヒントを使わなくても、isinstance
で抽象クラスを継承しているか確認すれば、mypy
が抽象クラスとして解析してくれます。(実行時は、Carを継承したクラスのインスタンスを渡せばisinstance
はTrueになるので問題なく関数が使えます。)
def stop_car(car) -> None:
if not isinstance(car, Car):
raise TypeError
car.stop() # => (mypy) error: "Car" has no attribute "stop"
ライブラリでの使われ方
これは、型アノテーションがどの程度採用されているか次第です。型ヒントのついているライブラリだと、素直にベースクラスをアノテーションしてポリモーフィズムを実現しています。
型ヒントがついていないライブラリだと、mypy
は使っていないと思いますが、要所要所で isinstance
でベースクラスを継承しているか確認してから処理を行っています。細かく確認しているわけではなく、内部的な使用を目的とした関数やメソッドは何の確認もしていない場合が多いです。
カプセル化
いくつか見方があるようなので、整理すると:
- 互いに関連するデータの集合とそれらに対する操作を一つにまとめる
- 内部的に使用する属性/変数を外部から変更できないようにする
- クラスの役割をひとつにする。(無駄を省き洗練された分かりやすいものを作る)
互いに関連するデータの集合とそれらに対する操作を一つにまとめる
とりあえず、class
を使っておけばこれはクリアされていると思います。
属性/変数を外部から変更できないようにする
こちらは話が簡単です。Pythonはconstやprivateのようなものが言語仕様上ないので、Pythonで厳密に実現するのは不可能です。妥協しましょう。
ですが、property
デコレータと abc.abstractmethod
を使えば mypy
で (ポリモーフィズムに則って、抽象クラスに対してプログラミングできている場合に限り)、以下の様に外部からの変更を静的解析できます:
class Car(ABC):
@property
@abstractmethod
def is_active(self) -> bool:
pass
def assign(car: Car) -> None:
# read-onlyな属性の変更を検知
car.is_active = False # => (mypy) error: Property "is_active" defined in "Car" is read-only
単に property
デコレータを使って定義した場合は変更不可能な属性になります。しかし、この抽象クラスを継承したクラスでsetterを新たに定義すれば (これは禁止できません) 外部からの変更を許容してしまいます。ただし、ポリモーフィズムを利用して、関数内で抽象クラスとして扱えば、mypy
の静的解析で、外部からの変更箇所を自動的に特定できます。
逆に、変更を許容したい場合は、setterの abc.abstractmethod
を抽象クラスで定義しておけばいいです。
もう一つ、お作法として変数名の先頭に _ (アンダーバー), __ (dunder) をつける方法があります。しかし、多少の制約はありますが、どちらも外部からアクセスできてしまいます。
あくまでもお作法を知っていて、良識な開発者はこれらの変数の変更を避けてくれるかもしれないという程度のものです。
参考
- stackoverflow: What is the meaning of single and double underscore before an object name?
- 初学者のためのPython講座 オブジェクト指向編7 カプセル化
クラスの役割をひとつにする
これは、記法ではなく設計の話なので本記事では扱いません。
ライブラリでの使われ方
property
の利用や、変数名にアンダーバーをつけているものは多数ありますが、それ以上に厳格な対策はしていなさそうです。
他の要素に比べると、カプセル化は妥協が必要だということだろうと思います。
まとめ
Pythonは動的型付け言語なので、あまり厳密にオブジェクト指向プログラミングができるわけではないとはいえ、オブジェクト指向的な設計思想は色々なライブラリに採用されており、Pythonでも十分有用なのではないでしょうか。
型アノテーションと mypy
のおかげで、いくつかの重要な要素はラフに試せるようになってきていると感じました。クラス自体が型なのでアノテーションが簡単な上に、今回紹介した方法はほとんど余計な型アノテーションが要らないので、あまり型ヒントを使っていない人にもおすすめです。
オブジェクト指向とまともに戦ったことはないので、適宜取り入れていきたいです。
今回は特に有名な三要素のみ扱いましたが、オブジェクト指向はもっと奥が深いようなので、さらに掘り下げてみるとより便利なものがあるかもしれません。