Pythonの抽象ベースクラス
著者:Leonardo Giordani - 03/04/2016
はじめに
抽象ベースクラス(Abstract Base Class) の導入により、Pythonは再び非常に革新的で柔軟な言語であることを示しました。このような注目すべき機能がどのように純粋なPythonモジュールによって言語に導入されたかを見るのは興味深いことです。これは、Pythonが、委譲(Delegation) に基づく純粋な多態性(ポリモーフィズム: polymorphism) の基盤のおかげで、変更に対して非常にオープンな方法で構築されていることを示しています。
多くのPythonプログラマーはAbstract Base Class(abc) や Collections モジュールのクラスを見落としていましたが、これはこのコンセプトの最もシンプルで有用なアプリケーションの一つです。確かに、この機能は毎日使うものではありませんし、あなたのPythonプログラミングの方法を変えるものでもありません。しかし、それが言語に何をもたらすのか、そしてそれがあなたのためにどのような問題を解決できるのかを理解する前に、捨ててしまうべきものでもありません。
EAFP
Pythonは委譲に強く基づいた動的型付けオブジェクト指向言語であり、その問題へのアプローチは本質的にポリモーフィックです。これは、Pythonがオブジェクトの構造ではなく、主にオブジェクトの振る舞いを扱うことを意味します。よく知られているEAFPプロトコル(Easier to Ask Forgiveness than Permission)はこのアプローチに由来しています。
余談
プログラミングの方針に EAFP スタイルと LBYL スタイルという分類の仕方があります。それぞれ、"Easier to Ask for Forgiveness than Permission" と "Look Before You Leap" の頭文字に由来する言葉です。
概念的には、EAFP スタイルは問題が起こってから対処する方針で、反対に LBYL はあらかじめ問題が起こる要因を取り除いておく方針を表しています。
code: python
try:
except TypeError:
# object is not subscriptable
...
このコードでは、オブジェクトがリストか辞書か(どちらも [1] のような添字記法が可能です)ではなく、オブジェクトがインデックス(またはキー)でアクセスできるかどうかをチェックします。関数の中でパラメータを受け取るとき、Pythonは型を指定しません(型のヒントは置いておきます)。それは、与えられた型やその派生型の1つを受け取ることは重要ではないからで、あなたが興味があるのは、あなたが使用するメソッドを提供する何かを受け入れることです。
オブジェクト指向の環境では、振る舞い(behaviour) とはオブジェクトの実行時インターフェースのことです。これは、オブジェクトが提供するメソッドのコレクションである静的インターフェイスとは異なります。実行時インターフェイスとは、オブジェクトが使用されるときに表示される実際のインターフェイスのことで、クラスが提供するメソッドだけでなく、親クラスやメタクラス、__getattr__() が提供する他のエントリーポイントが提供するメソッドも含まれます。
複雑なチェック方法
しかし、時には、「リストのように動作する」というような複雑なチェックを行う必要があります。この条件をテストするにはどうすればよいでしょうか。入ってくるオブジェクトがいくつかの標準的なメソッドを持っているかどうかをテストすることもできますが、これは不完全なだけでなく、間違っています。たとえば、次のようなテストを書くことができます。
code: pythoon
try:
obj.append
obj.count
obj.extend
obj.index
obj.insert
except AttributeError:
これは、リストのようなオブジェクトが提供するメソッドを網羅しようとするものです。しかし、このテストでは、以下のような実際にはリストのようには動作しないオブジェクトを受け入れます。
code: python
class FakeList:
def fakemethod(self):
pass
def __getattr__(self, name):
return self.fakemethod
このようなクラスを書くことはまずないでしょうが、これは先ほどのテストの潜在的な落とし穴の一つを示しています。このテストは、動作をテストするのではなく構造に頼ろうとしているので間違っています。isinstance() に頼ろうとする誘惑は大きくなります。
code: python
if isinstance(someobj, list):
...
この方法は、可能かどうかは別にして、正確な型をテストするため以前よりも悪い方法です。isinstance() と issubclass()が親クラスの階層をたどるほど賢くても、リストのように振る舞うクラスが継承されていない場合は除外されます。
話を委譲に戻しましょう
この問題に対処するためにPEP 3119 で提案されたアイデアは非常にエレガントで、Pythonの性質、つまり委任に強く基づいているという性質を利用しています。Python 3 で実装され、Python 2.7 にバックポートされたこの解決策は、2つの isinstance() と issubclass() の組み込み関数の性質を変更しています。現在、 isinstance() が最初に行うことは、質問されたクラスの __instancecheck__() メソッドを呼び出すことで、基本的に標準のアルゴリズムとは異なるアルゴリズムで呼び出しに答える機会を与えています。issubclass()でも同じことが起こり、__subclasscheck__() となります。 code: python
issubclass(myclass, someclass)
このコードでは、someclass と myclass の関係を純粋に外部からチェックすることはなくなりました。issubclass()が最初に行うことべきことは以下の通りです。
code: python
someclass.__subclasscheck__(myclass)
これは非常に自然なことです。なぜなら、結局のところ、someclass はそれ自身のサブクラスであることを判断する最良のソースだからです。
サブクラスの新しいタイプ
委譲ベースのインスタンスチェックとサブクラスチェックの導入により、Pythonは新しいタイプのサブクラスを提供し、その結果、クラスを関連付ける新しい方法を提供しています。今、サブクラスは、継承を使用して取得された本当のサブクラスかもしれません。
code: python
class ChildClass(ParentClass):
pass
または、登録によって得られる仮想サブクラスとすることができます。
code: python
ParentClass.register(ChildClass)
本当のサブクラスと仮想サブクラスの違いは、非常にシンプルです。本当のサブクラスは、__bases__ 属性を通じて親クラスとの関係を知っているので、見つからないメソッドの解決を暗黙のうちに委任することができます。仮想サブクラスは、それを登録したクラスについて何も知らず、サブクラスのどこにも親クラスとの関係を示すものはありません。したがって、仮想親クラスは分類としてのみ有用です。
抽象ベースクラス
他のクラスを登録することができ、その結果、それらのクラスの仮想的な親となるクラスは、Pythonでは抽象ベースクラス(Abstract Base Class :ABC)と呼ばれています。
この新しい言語要素の名前は重要です。ABCはまず第一に、Pythonで作成できる他のクラスと同様にクラスであり、分類を作成するために通常の方法でサブクラス化することができます。ABCは基本クラス、つまり基本的な動作やカテゴリーを表すクラスであることも意味しています。最後に、これらは抽象クラスです。これはPythonでは非常に正確な意味を持ち、この記事の最後の部分の議題となります。
collectionsモジュールが提供するクラスは抽象基底クラスであり、同じモジュール内のいくつかの基底型の仮想的な親として設定されています。インストールされているPython 3の _collections_abc.py ファイル(例えば /usr/lib/python3.10/_collections_abc.py )を確認すると、以下のようなコードがあります。
code: python
Sequence.register(tuple)
Sequence.register(str)
Sequence.register(range)
MutableSequence.register(list)
Sequence とMutableSequence の抽象ベースクラスは、Pythonのいくつかの組み込み型を登録します。
クラスを登録することは、メソッドや属性に関する何らかのチェックを意味するものではないことを理解することが非常に重要です。登録は、登録されたクラスによって与えられた動作が提供されることを約束するだけです。
このことを示すために、コレクションクラスの1つを使った非常に簡単な例を示します。
code: python
>> import collections
>> class MyClass():
... pass
...
>> issubclass(MyClass, collections.Sequence)
False
>> collections.Sequence.register(MyClass)
<class '__main__.MyClass'>
>> issubclass(MyClass, collections.Sequence)
True
>>
ご覧のように、MyClassクラスは、最初は colors.Sequence のサブクラスとして認識されませんが、登録後は、たとえクラスがまだ空であっても、issubclass() が True を返します。
抽象ベースクラスの作成方法
公式ドキュメントで紹介されている例は、非常にシンプルでわかりやすいものです。
code: python
from abc import ABCMeta
class MyABC(metaclass=ABCMeta):
pass
MyABC.register(tuple)
assert issubclass(tuple, MyABC)
assert isinstance((), MyABC)
クラスを作成し、abcモジュールが提供するABCMetaメタクラスを使用するだけで、register() メソッドと、_subclasscheck__() および __instancecheck__() の適切な実装を持つクラスを得ることができます。_collections_abc.py ファイルを再度確認すると、これがまさにコレクションクラスの実装方法であることがわかります。
code: python
class Hashable(metaclass=ABCMeta):
...
class Iterable(metaclass=ABCMeta):
...
メタクラスの怖さを知っていますか?
メタクラスはPythonでは奇妙なテーマです。ほとんどの場合、初心者へのアドバイスは「使わないでください」であり、まるで言語のエラーであり、避けなければならないもののようです。
私はそうは思いません。実際のところ、多くの理由から私はこのような立場には絶対に賛成できません。
まず第一に、Pythonでプログラミングをするのであれば、Pythonが提供する全てのものを、良い部分も悪い部分も含めて理解した方が良いでしょう。プログラミング言語はツールであり、その長所と短所を知らなければなりません。ほとんどの場合、私たちが「制限」と呼んでいるものは、私たちがそれをわかっていないために制限となっている機能に過ぎません。例えば、C言語はオブジェクト指向ではありません。これは強みでしょうか、それとも制限でしょうか? Pythonは非常に強力な検査機構(inspection mechanism) を提供してくれます。これは強みでしょうか、それとも制限でしょうか? 他にも数え切れないほどの例を挙げることができます。
次に、強力な機能とは、あなたがよく知るべきものです。結局のところ、私たちはある言語をその言語が提供するユニークな機能のために使うのであって、他の言語と共有する機能のために使うのではありません。私がPythonを使うのは、その強力なポリモーフィズム実装のためであり、ループや継承のためではありません。例えば、JavaやC++でもそれらは提供されています。私がC言語でデバイスドライバを書くのは、機械語に近くて速いからであって、他の多くの言語で提供されているint型やfloat型のためではありません。つまり、強力な機能は、他の言語ではできないことを可能にするものなので、それらをマスターする必要があるのです。
3つ目は、ある言語の機能が設計上のエラーである場合、なぜエラーなのか、どうすればその機能を使わないで済むのかを理解する必要があります。ES6以前のJavaScriptでは、varキーワードの振る舞いに見られるように、スコープに関する問題がありました。このような制限を知らないでいると、あなたのソフトウェアはバグだらけになってしまいます。だから、JavaScriptのforループの勉強は数分で終わりましたが(結局C言語のようなforループです)、デバイス全体の危険なボタンであるvarの扱いには多くの時間を費やしました。
話をPythonに戻します。メタクラスは、お遊びで言語に組み込まれたギリギリの機能ではありません。オブジェクトと型の関係はとても美しいものですが、基本的に誰もそれについて語らないのは残念なことです。ですから、メタクラスに対して文句を言ったり、危険だとか複雑だとか言うのはやめてください。
メタクラスは言語の一部です。そして、それらは理解するのに複雑ではありません。
なぜABCにメタクラスなのか?
Pythonでプログラミングをしている人は、クラスとインスタンスにはある程度慣れているはずです。インスタンスを作るときにはクラスを使い(設計図のようなもの)、クラスはインスタンスに何かを入れることができることを知っています。例えば
code: python
# クラス定義
class Child():
def __init__(self):
self.answer = 42
# インスタンスの生成:インスタンスとクラスを結びつける
c = Child()
# インスタンスの使用
assert c.answer == 42
さて、クラスを作るときにはメタクラス(青写真のようなもの)を使いますが、メタクラスはクラスの中に物を入れることができます。
code: python
# メタクラスの定義
class NewType(type):
def __init__(self, name, bases, namespace):
self.answer = 42
# クラスの生成:クラスとメタクラスを結びつける
class Child(metaclass=NewType): pass
# クラスの使用
assert Child.answer == 42
複雑そうですか?私の意見では、全くそうではありません。2つの例を見てみると、まったく同じことを言っているのがわかります。1つ目はインスタンス・クラスの関係を、2つ目はクラス・メタクラスの関係を表しています。
これが、メタクラスを理解するために必要なすべてです。例えば、__getattribute__() や __new__() メソッドを入れる必要があります。これはメタクラスによって行われますが、メタクラスは通常すべてのクラスに対応しています。実際、クラスに __class__ 属性をチェックすると、まさに次のようになります。
code: python
>> int
<class 'int'>
>> int.__class__
<class 'type'>
>>
メタクラスとMROについて
少し高度なアノテーションですが、メタクラスがクラスにメソッドを入れると言うのは、全体を単純化しています。実際のところ、クラスが __class__ 属性や メソッド順序解決(MRO: Method Resolution Order) プロトコルを通じて実行時にインスタンスにメソッドを提供するように、メタクラスはクラスにメソッドを提供します。属性は代わりに、メタクラスの __new__() または __init__() メソッドによってクラスの内部に置かれます。
まず、インスタンスとクラスのMROメカニズムについて説明します。インスタンスのメソッドを呼び出すと、Pythonは自動的にそのメソッドを最初にインスタンスの中で探し、次に親クラス、そしてその階層のすべてのクラスの中で探します。
つまり、この例では
code: python
class GrandParent(): pass
class Parent(GrandParent): pass
class Child(Parent): pass
Childのインスタンスで get_name() メソッドを呼び出すと、まずChildクラスを探し、次にParent、GrandParentの順に探します。最後にオブジェクトを確認します。
この階層のクラスが別のメタクラスを定義している場合、MROはどうなるのでしょうか?例えば以下のようになります。
code: python
class NewType(type): pass
class GrandParent(): pass
class Parent(GrandParent): pass
class Child(Parent, metaclass=NewType): pass
この場合、すべてが通常通りに動作しますが、オブジェクトをチェックした後、MRO は NewType メタクラス(およびその先祖)もチェックします。
つまり、メタクラスはミックスインとして機能し、通常の MRO の最後にのみクエリされるのです。これは、NewTypeがParentやGrandParentを祖先に持たない標準的な親クラスであった場合、多重継承を使用するとまさにこのようになります。
しかし、MROは標準的な継承を扱うだけなので、メタクラスはMROの一部ではありません。Child クラスの MRO を確認すると、メタクラスが含まれていないことがわかります。
code: pytohn
>> Child.mro()
>> Child.__class__
<class '__main__.NewType'>
>>
抽象メソッド
なぜABCは抽象的と呼ばれるのですか?ABCはインスタンス化が可能なので、(例えばJavaのような)純粋なインターフェースではありません。
code: python
>> import abc
>> class MyABC(metaclass=abc.ABCMeta):
... pass
...
>> m = MyABC()
>>
しかし、abc.abstractmethod デコレーターを使用して、一部のメソッドを抽象的に定義することができます。これにより、そのメソッドが実装されていない場合は、クラスがインスタンス化されないようになります。簡単な例を見てみましょう。私は抽象ベースクラスを定義し、抽象メソッドを作成し、それをインスタンス化しようとします。
code: python
class MyABC(metaclass=abc.ABCMeta):
@abc.abstractmethod
def get(self):
pass
Pythonは次のようにTypeError例外を発行します。
code: python
>> m = MyABC()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class MyABC with abstract methods get
MyABCを継承してメソッドを実装した新しいクラスを作らなければなりません。
code: python
class Concrete(MyABC):
def get(self):
return 1
これで、クラスをインスタンス化することができます。
code: python
>> c = Concrete()
>>
振る舞いはどうする?
では、Pythonの「構造ではなく振る舞いをチェックする」という信条はどうなったのでしょうか? コレクションでは、EAFP プロトコルを廃止し、「Look Before You Leap」というアプローチに戻りました。私たちは言語の根底にある哲学に反しているのでしょうか?
Python言語の生みの親であるGuido van Rossum氏が PEP 3119 でこのことについて述べていることは非常に興味深いものです:呼び出し(Invocation) とは、メソッドを呼び出すことでオブジェクトと対話することです。通常、これはポリモーフィズムと組み合わされ、与えられたメソッドを呼び出すことで、オブジェクトのタイプに応じて異なるコードを実行することができます。検査(インスペクション:Inspection) とは、(オブジェクトのメソッド以外の)外部のコードが、そのオブジェクトの型やプロパティを調べ、その情報に基づいてそのオブジェクトをどのように扱うかを決定する能力のことである。[古典的なOOP理論では、呼び出しが好ましい使用パターンであり、インスペクションは、以前の手続き型プログラミングスタイルの遺物と考えられ、積極的に推奨されていません。しかし、実際にはこの考え方はあまりにも独断的で柔軟性に欠け、Pythonのような言語のダイナミックな性質とは非常に相反する一種のデザインの硬直性をもたらします。 つまり、純粋なポリモーフィックなアプローチを強制的に使用することは、時に複雑すぎる、あるいは正しくないソリューションをもたらす可能性があるということです。ここでのキーワードは、「動的性質」ではなく、「独断的」、「柔軟性がない」、「硬直性」であると私は考えています。私は、Python の、このような言語や作者の柔軟性をとても気に入っています。
if isinstance(obj, collections.Sequence) と書くことはEAFPではありませんし、あなたが書いたどんな条件付きテストもそうです。とはいえ、条件付きテストを純粋なEAFPアプローチに置き換える人はいないでしょう。これこそがPythonのCollectionやABCの目的であり、コードの一部をよりシンプルにすることです。
最後に
この記事を読んで、抽象ベースクラス、特に標準的なコレクションが便利で理解しやすいことを理解していただけたと思います。また、メタクラスを使うにはある程度のスキルが必要ですが、メタクラスはそれほど怖くも危険でもありません。
参考