23
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Pythonの型ヒントでゆるくオブジェクト指向プログラミング - インターフェース・ポリモーフィズム・カプセル化

Posted at

オブジェクト指向をPythonで実現するための記法をまとめます。特に、型ヒント (と mypy) を利用して静的解析を行う方法にフォーカスを当てています。

ただし、動的型づけ言語だと厳密に実現するのは難しいようです。厳密さにはあまりこだわらずに、メリットを受けられる範囲で (変更に柔軟に対応できるような) 実用的な書き方をまとめられたらなと思います。

今回は、こちら(オブジェクト指向と10年戦ってわかったこと)の記事で取り上げられていたオブジェクト指向三大要素:

  • 継承 (インターフェース)
  • ポリモーフィズム
  • カプセル化

を取り扱います。

オブジェクト指向そのものについての説明は上記を参考にして下さい。(まともに理解していないので説明できません。誤りがあればご指摘いただけるとありがたいです。)

現在も変更が多そうなライブラリ (TensorFlow, PyTorch, Pandas, Web framework (Django, Flask, Starlette, FastAPIなど)) の実装方法を主観で整理した上でまとめたので、あまり突飛な実装方法は紹介していません。

今回使用したコードはすべてgithubにあります。IDEでとりあえず試したい場合などはこちらからクローンしてみて下さい

インターフェース

クラスの「規格」をまとめた抽象クラスの定義と、そのサブクラスが「規格」を準拠しているか検証する方法を整理します。(後述するポリモーフィズムと組み合わせると汎用性や変更のしやすさを向上させてくれます)

実装方法は、2パターンあります。

  1. abc (Abstract Base Class)
  2. NotImplementedError

abc (Abstract Base Class)

abc.ABCabc.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() メソッドを実際に呼び出すまで不足していることが分からない)

という点で、使い勝手が悪いです。

しかし、テストを最低限やっておけばインターフェースとして一応機能すると思います。

ライブラリでの使われ方

一般的に抽象クラス (または、単にベースクラス) 自体はよく使われています。

実装方法としては、主観ですが、NotImplementedErrorraise するものが圧倒的に多いように感じます。

abc を使う場合も、汎用的なメソッドが詰め込まれたベースクラスに abc メタクラスを継承させてメインのメソッドだけ abstractmethod にしていることが多いです。

例えば:

  • Tensorflow.keras: 至る所で Interface という表現と共に abc が使われています。例: Optimizer の基本機能 (抽象と具象の両方) が詰め込まれた abc メタクラスを定義し、それを継承して SGDAdam など様々な Optimizer layer を定義している。
  • Pytorch: ベースクラス (raise NotImplementedError) が多く使われています。例: torch.utils.data.Dataset はベースクラスになっています。ユーザーがこれを継承してカスタムデータセットを定義する際に、インターフェースを使っていることになります。
  • Pandas: 基本的にはベースクラスですが、abc 抽象クラスも要所要所で使われています。例: Series.str のメソッド群はそれぞれ共通の abc 抽象クラスをインターフェースとしています。
  • Web Framework全般: Django, Flask, Starlette, FastAPI などはすべてベースクラス (raise NotImplementedError) が使われている模様です。

ポリモーフィズム (多態性)

抽象クラス (インターフェース) に対してプログラミングします。そうすることで、抽象クラスを継承しているクラス(「規格」に準拠しているクラス)すべてが正常に動作することを期待できます。(各クラスのメソッドが期待通り正常に動作するという前提です)

具体的な処理の変更は具象クラスのメソッドの変更だけで閉じる可能性が高く、具体的な処理に依存しない処理 (抽象クラスで表現できる処理) の変更は個々の具象クラスについて気にする必要性が低くなります。変更の影響範囲が狭まれば狭まるだけ、より変更が容易になるので、ポリモーフィズムは重要そうです。

実装方法は、細かく分けると

  1. 抽象クラス (インターフェース) として型アノテーション
  2. 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 でベースクラスを継承しているか確認してから処理を行っています。細かく確認しているわけではなく、内部的な使用を目的とした関数やメソッドは何の確認もしていない場合が多いです。

カプセル化

いくつか見方があるようなので、整理すると:

  1. 互いに関連するデータの集合とそれらに対する操作を一つにまとめる
  2. 内部的に使用する属性/変数を外部から変更できないようにする
  3. クラスの役割をひとつにする。(無駄を省き洗練された分かりやすいものを作る)

互いに関連するデータの集合とそれらに対する操作を一つにまとめる

とりあえず、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) をつける方法があります。しかし、多少の制約はありますが、どちらも外部からアクセスできてしまいます。
あくまでもお作法を知っていて、良識な開発者はこれらの変数の変更を避けてくれるかもしれないという程度のものです。

参考

クラスの役割をひとつにする

これは、記法ではなく設計の話なので本記事では扱いません。

ライブラリでの使われ方

propertyの利用や、変数名にアンダーバーをつけているものは多数ありますが、それ以上に厳格な対策はしていなさそうです。

他の要素に比べると、カプセル化は妥協が必要だということだろうと思います。

まとめ

Pythonは動的型付け言語なので、あまり厳密にオブジェクト指向プログラミングができるわけではないとはいえ、オブジェクト指向的な設計思想は色々なライブラリに採用されており、Pythonでも十分有用なのではないでしょうか。

型アノテーションと mypy のおかげで、いくつかの重要な要素はラフに試せるようになってきていると感じました。クラス自体が型なのでアノテーションが簡単な上に、今回紹介した方法はほとんど余計な型アノテーションが要らないので、あまり型ヒントを使っていない人にもおすすめです。

オブジェクト指向とまともに戦ったことはないので、適宜取り入れていきたいです。

今回は特に有名な三要素のみ扱いましたが、オブジェクト指向はもっと奥が深いようなので、さらに掘り下げてみるとより便利なものがあるかもしれません。

参考

23
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?