nikkie-ftnextの日記

イベントレポートや読書メモを発信

懺悔:Pythonでクラスの継承を書くとき、super(self.__class__, self)は決してオススメしません(無限に再帰しちゃうよ)

はじめに

オーイシおにーさん、ありがとうううううう!! vol.2おめでとおおおおおお!! nikkieです。

自作のSphinx拡張を例に、Pythonにおける継承について書いていきます。
やってはいけない事象の共有になります(📣俺みたいになるな!)

目次

自作Sphinx拡張、当初の実装

ドキュメンテーションツールSphinxの拡張を公開しています1
有効にすると、ビルドしたHTMLにある外部サイトに遷移するリンクが、ブラウザの別のタブで開くようになります。

実装を手短に言うと、HTMLを出力するTranslatorを以下に差し替えています2

# https://github.com/ftnext/sphinx-new-tab-link/blob/v0.2.1/sphinx_new_tab_link/__init__.py
class NewTabLinkHTMLTranslator(HTMLTranslator):
    def starttag(self, node, tagname, *args, **atts):
        # 外部サイトのリンクを別のタブで開くようにする処理

        return super().starttag(node, tagname, *args, **atts)

Pythonの組み込み関数 super()

https://docs.python.org/ja/3/library/functions.html#super

スーパークラスのメソッドを呼び出すために使います。
MRO(method resolution order)3により、親クラスまたは兄弟クラスのメソッドに解決されます。

公式ドキュメントの例はこちら:

class C(B):
    def method(self, arg):
        super().method(arg)

super().method(arg)Cの親クラスBmethod()を呼び出します。
これはsuper(C, self).method(arg)と同じです(コード中のコメントより)。

魔が差した:自作Translatorのメソッドを、他のTranslatorに代入できるのでは?

その時思い出していた例

ゼロから作るDeep Learning ❸』に以下のような実装があります。

# https://github.com/oreilly-japan/deep-learning-from-scratch-3/blob/af431eaa918283820ecbfe0bf8a9cdf49fc972ab/steps/step20.py#L161-L162
Variable.__add__ = add
Variable.__mul__ = mul

特殊メソッドに関数を代入しています4

これをTranslatorでもできるのではないか、と思ったのです。
継承してもいいんですが、代入するほうがかっこいい(超☆主観)し、使うのは自分だけだからやっちゃえ(※魔が差しています)

イデア実現のための試行錯誤

普通に代入して

class MyTranslator(HTML5Translator): ...


MyTranslator.starttag = NewTabLinkHTMLTranslator.starttag

MyTranslatorをTranslatorに指定すると、うまくいきません。

TypeError: super(type, obj): obj must be an instance or subtype of type

タイトル(h1)の出力にあたり

self.body.append(self.starttag(node, 'h1', '', CLASS='title'))

が呼び出されます。
MyTranslatorの(代入で用意した)starttag()メソッドが呼ばれるわけですが、その中の

return super().starttag(node, tagname, *args, **atts)

super()で上記のTypeErrorです。

これはMyTranslatorstarttag()メソッドとして、NewTabLinkHTMLTranslatorstarttag()メソッドを代入していることによるという理解です。

  • ここのsuper()super(NewTabLinkHTMLTranslator, self)と同じ
    • starttag()メソッドはNewTabLinkHTMLTranslatorにある
  • 実行時にselfMyTranslator
  • NewTabLinkHTMLTranslatorselfMyTranslatorには継承関係はない
    • NewTabLinkHTMLTranslatorインスタンスでも、サブタイプでもないため、上記のエラー

ここから更にハックしました(※罪を重ねました)

# https://github.com/ftnext/sphinx-new-tab-link/blob/v0.2.2/sphinx_new_tab_link/__init__.py
class NewTabLinkHTMLTranslator(HTMLTranslator):
    def starttag(self, node, tagname, *args, **atts):
        # 外部サイトのリンクを別のタブで開くようにする処理

        return super(self.__class__, self).starttag(
            node, tagname, *args, **atts
        )

🤔MyTranslatorはTranslatorを継承しているので、self.__class__と指定したら、親のTranslatorのstarttagメソッド5を呼ぶのではないか

https://docs.python.org/ja/3/library/stdtypes.html#instance.__class__

クラスインスタンスが属しているクラスです。

これでメソッド代入のケースは動作するようになります。

顛末:報告された無限の再帰

super(self.__class__, self)と書いているために無限の再帰エラーが報告されました。

pydata-sphinx-theme は実行時に動的に継承したTranslatorクラスを生成します。

この動きを sphinx-new-tab-link は想定できていなかったので、無限の再帰となるバグがありました

現在は修正済みです。

super(self.__class__, self)で無限の再帰が発生するのは、なぜ?

StackOverflowにわかりやすい解説がありました。

Bインスタンスは作れます

>>> # python -i bad_use_super.py でクラス定義を読み込んでから対話モードに入っています
>>> B(1, 2)
<class '__main__.B'>
<__main__.B object at 0x102723fd0>

Cインスタンスを作ろうとすると、無限の再帰が発生します

>>> C(2, 3)
<class '__main__.C'>
<class '__main__.C'>

RecursionError: maximum recursion depth exceeded while calling a Python object <class '__main__.C'>

この仕組みについての解説(の私の理解)

  • C().__init__()は、親クラスBに定義された__init__()を呼び出す
    • その中でself.__class__C
    • super(C, self).__init__(v, v2)、これは親クラスB__init__()の呼び出し
  • self.__class__Cであることは変わらないので、同じ呼び出しの繰り返し
    • RecursionError送出

解消方法(ただしハックなので持ち帰らないでください)

__init__()の例で解消

これを解消するには、self.__class__を使わないのが一番です。
ただ上記のメソッド代入の都合上、self.__class__は今の私には必要だったので、残そうとすると以下のコードとなりました。

class B(A):
    def __init__(self, v, v2):
        print(self.__class__)
-        super(self.__class__, self).__init__(v, v2)
+        if self.__class__ is B:
+            super(self.__class__, self).__init__(v, v2)
+        else:
+            super(B, self).__init__(v, v2)
>>> B(1, 2)
<class '__main__.B'>
<__main__.B object at 0x1024abfd0>
>>> C(2, 3)
<class '__main__.C'>
<__main__.C object at 0x1024abb80>

無限の再帰は発生しなくなりました。
C().__init__()B__init__()が呼び出されたとき、self.__class__Cなのでelse側が実行され、無限再帰となりません。
これは無理やりself.__class__を残しています

Translatorに近い例で解消

終わりに

自作Sphinx拡張を例に、super(self.__class__, self)って書いたらメソッド代入できるようになったけど、代償として無限の再帰の修正が必要になったことを書きました。
真似しないでくださいね。

メソッド代入でやろうとしたのが筋がすこぶる悪かったと認識しています。
Mixinを定義し、それを動的に継承してTranslatorクラスを作る実装をpydata-sphinx-themeに見つけている6ので、それをパクってメソッド代入自体を今後不要にしていきます!


  1. 応援ください
  2. 解説エントリ
  3. メソッド解決順序 https://docs.python.org/ja/3/glossary.html#term-method-resolution-order
  4. なんでこんなことをやっているのか、詳細は書籍をどうぞ
  5. starttagは親のTranslator(Sphinx)からさらに親(docutils)へとたどり、HTMLTranslatorに定義されています(0.17.1のソースを見ました)
  6. 心が震えた実装です