Python で構造的部分型(Structural Subtyping)の判定を isinstance で行う

Python で「クラスがあるメソッドを持つ」という条件を isinstance で判定できたらちょっと幸せになれるんじゃないか、などと何となく思ったので色々やってみました。
あ、ちなみに構造的部分型は Scala では「皿うどん」と呼ばれています。
(皿うどん)Structural Subtyping(構造的部分型)アレコレ - ( ꒪⌓꒪) ゆるよろ日記

__instancecheck__

下記のように isinstance 関数がクラスに対して呼ばれると、メタクラスの __instancecheck__ メソッドが呼ばれます。

class Test(object):
    pass

isinstance(Test() Test) #=> type.__instancecheck__(Test()) が呼ばれる

なので、この __instancecheck__ をオーバライドしてゴニョゴニョしてあげれば今回の目的が達成できます。
このような「継承ツリーとは関係なく、サブクラス判定をする」という機能は abc モジュールの ABCMeta で使われているようです。
abc — Abstract Base Classes — Python 3.7.3 documentation
PEP 3119 -- Introducing Abstract Base Classes | Python.org

metaclass

type.__instancecheck__ をオーバライドするには、みんな大好きメタクラスを使えば OK です。
今回は、 Iterable であるとか Iterator のようなオブジェクトを判定するために、「あるメソッドを持っているか」ということを識別します。
手始めに __iter__ を持っている、という Iterator クラスを作ってみます。

  • __instancecheck__ をオーバライドしたメタスラスを用意する
  • オーバライドした __instancecheck__ メソッド内で __iter__ を持つかどうかを判定して bool 値を返す
  • メタスラスを適用した Iterable クラスを作る

これだけです。

class IterableMeta(type):
    '''
    __iter__ メソッドがあるかどうかを判定するためのメタクラス
    '''

    def __instancecheck__(cls, instance):
        return hasattr(instance, '__iter__')


class Iterable(object):
    '''
    メタスラスを適用したクラス
    実際に isinstance で使うのはこっち
    '''
    __metaclass__ = IterableMeta


assert isinstance([], Iterable) #=> True!

これで「あるオブジェクトが Iterable である」ということが明示できるようになりました。
とってもわかりやすいですねー

一般化

とりあえずある属性を持つクラスであることを識別するということを一般化するとこうなります。

class AttrCheck(type):

    def __instancecheck__(cls, instance):
        return all(hasattr(instance, x) for x in cls.ATTRIBUTES)



def has_attrs(*attributes):

    class Checker(object):
        __metaclass__ = AttrCheck
        ATTRIBUTES = attributes

    return Checker



def test():

    Iterable = has_attrs('__iter__')
    Iterator = has_attrs('__iter__', 'next')
    ContextManager = has_attrs('__enter__', '__exit__')

    assert isinstance([], Iterable)
    assert isinstance(iter([]), Iterator)
    assert isinstance(open('/tmp/test.txt', 'w'), ContextManager)


if __name__ == '__main__':
    test()

これを使うと Iterable や Iterator, with 文で使える ContextManager であるかどうかなど、「あるメソッドを持つ」ということを条件に判定するだけであればいくらでもできようになりました。

まとめ

isinstance で判定する通常の継承ツリーとは違うことを全く同じ文脈でできるのは人によって善し悪しあるかもしれません。
abc.ABCMeta では、メタクラスを適用したクラスの __subclasshook__ というクラスメソッドを呼んで判定を行わせることができるようです。