Pythonのclassで多重継承したときのsuper()

/ Python

Javaなどが単一クラスの継承しか許さないのに対してPythonはC++のように複数のクラスを継承する多重継承が許されている。これによって当然共通の基底クラスを持つような派生クラスを複数継承するといったことが可能になってくる。

多くの言語においてsuperキーワードは基底クラスにある同名メソッドを参照するために使われるが、Pythonにおいては組み込み関数super()でありその働きは全く持って他の言語のそれと異なるようだ。

Pythonにおける継承と多重継承

公式のチュートリアル、9.5. 継承 https://docs.python.org/ja/3/tutorial/classes.html#inheritanceが存在するものの中で最もわかりやすい説明となっている。継承に限らずクラスに関すること全部だ。いたって簡単な構文によって継承や多重継承を行うことが可能となっている。

次のようにダイヤモンド継承といわれる継承関係について実験を行いたい。

class A:
    def introduce(self):
        print('A')

class B(A):
    def introduce(self):
        print('B')
        super().introduce()

class C(A):
    def introduce(self):
        print('C')
        super().introduce()

class D(B,C):
    pass

いまクラスDのインスタンスからintroduce()を呼び出すとどのような結果が出力されるだろうか?

In [1]: D().introduce()
B
C
A

こういう具合だ。class D(B,C)の箇所におけるBとCの順番は本質的に重要になっていてこれが逆だと結果のBの位置とCの位置もまた逆になる。

Method Resolution Order

Method Resolution Orderという仕組みがある。D.mro()メソッドを呼び出してみることでこれが何なのかはすぐに確認できる。

In [3]: D.mro()
Out[3]: [__main__.D, __main__.B, __main__.C, __main__.A, object]

これはDに対して何かしらのメソッド呼び出しを行った場合にメソッドが検索される順番のことだ。複数の基底クラスを持っている場合は継承した順番にメソッドが検索され、共通の基底クラスがもしあれば必ず最後に1度だけ検索される。決してD->B->A->C(->A)などといったことにはならないようになっているのだ。

Method Resolution Orderの決め方について公式では簡明に説明されている。

  • 多重継承の順番を保持する。

  • 基底クラスを深さ優先で探索する。

  • 共通の基底クラスは後から出てきたものが優先する。

つまり深さ優先で検索したD->B->A->C->Aとなった場合にはあとから出てきた方のAが優先し、結果としてD->B->C->Aという望ましい順番になっている。公式によると「まとめると、これらの特徴のおかげで信頼性と拡張性のある多重継承したクラスを設計することができるのです。」とのことだ。もちろんこれらの一貫したアルゴリズムで解決できないような継承パターンを行った場合には例えば次のようなエラーが出る。

TypeError: Cannot create a consistent method resolution
order (MRO) for bases A, B, C

これはclass D(B,C):のところをclass D(A,B,C):の順番に変えてみたときに発生したエラーの内容だ。1つ目のルールによりAはBとCよりも先に出てこなければならないけが3つめのルールによりAはCよりも後でなければならないのでこれは不可だ。

このメカニズムによって先ほどの例におけるD.introduce()によって呼び出されたのはクラスBのintroduce()メソッドであることが理解できた。次になぜC.introduce()が呼ばれるのかだ。

組み込み関数super()

これについても公式https://docs.python.org/ja/3/library/functions.html#superにて説明されているものがわかりやすく必要十分な情報が乗っている。

Pythonにおけるsuperはキーワードではなく組み込み関数であり引数をとることもできる。引数無しでsuper()を呼び出すことが出来るのはクラス内のメソッドからだけだが逆にいえばクラス外からでも引数ありでsuper()組み込み関数を呼び出すことは可能だ。

あるクラスやそのインスタンスに関連付けられたMROがD->B->C->Aだった場合に、Dのsuper()はB->C->Aの順にメソッドを検索し、Bのsuper()はC->Aの順にメソッドを検索する。結果として先の例ではBにおけるsuper()経由のintroduce()メソッドの呼び出しでCのintroduce()が呼び出されている。

公式ではこのようなsuper()の挙動を「強調的な多重継承」と呼んでいる。この用途は動的な実行環境をもつPythonによって特に有効だと言われている。Bでのsuper()は問答無用でAのメソッドを検索しそうな気もするがそうではないのだ。具体的には自分が多重継承されているようなときがそうではない場合にあたる。super()はクラスやインスタンスのMROを考慮して呼び出すべきメソッドを決める。

もちろんBから問答無用でAのメソッドを検索させる方法も存在していて、その場合は単にA.introduce(self)と記述すればよい。極めて直観的である。super()に引数を与える中間的な呼び出し方も存在している。

class A:
    def introduce(self):
        print('A')

class B(A):
    def introduce(self):
        print('B')
        super().introduce()

class C(A):
    def introduce(self):
        print('C')
        super().introduce()

class D(B,C):
    def introduce(self):
        print('D')

class E(D):
    def main(self):
        super(D, self).introduce()

すこしわかりにくい。今DのサブクラスEを作って、そのクラスのメソッドからDからみたsuper()を経由でintroduce()を呼び出したいとする。このときはsuper(D, self).introduce()でその動作を達成することができる。実際に、

In [3]: E().main()
B
C
A

とDのintroduce()が呼ばれていないことがわかる。ちなみにクラスE内での引数無しsuper()経由の呼び出しはsuper(E, self)経由での呼び出しに他ならない。どちらも自分のクラスと自分なので省略した結果がそうなるのは納得ができる。

ちなみにクラスEは自分でDを継承しているのでMROにDが含まれていることがわかるためこういう書き方があり得るが、別にクラスEと直接は関係なさそうなBやCをsuper()の第1引数に渡しても何ら問題はない。MROが実際どうなっているかは実行時に動的に決まるのでコード書いているときに何が継承されているかとか、継承もとにintroduce()が存在するかとかは気にすることでないのだ。C++などののコンパイル言語と違ってその辺りは実行したときに不備があれば教えてくれる。こういう挙動が良くもあり悪くもあり、Javascriptに対するTypescriptみたいな視点だと悪い。

まとめ

多重継承とかコンパイル言語でも濫用すると何が起こっているのか他人が見てわからなくなるのにましてやそれが実行時までどうなるか分からんともなればかなり怖い。ともあれPythonはPythonの流儀に従って言語にクラスという仕組みを取り入れているのだからとやかく言うことではないね。

言語上のプライベートメソッドや変数が存在しないけど使う側で勝手にプライベートと解釈してね、という精神と同じように嫌なら自分たちでこれはJavaだと仮定して多重継承無し縛りをすればいいんじゃないかな。