pythonのtyping_extensions.Protocolがなぜ嬉しいか(propertyの例)

mypyで使えるProtocolが便利という話の例。 structural subtypingをやる際にgoでもほしいと想うことが多かった例がProtocolでは大丈夫なので良いなーという例(今回はそのうちのひとつだけを紹介)。

Protocol?

Protocolをいつ使いたくなるかというと、大抵は複数ある何かを同一視したくなった場合。

例えば以下の様な例があげられる。

  • クラスの継承関係を無視して同様のふるまいを持つものを同一視したいとき
  • ある値を持つものを同一視したいとき
  • 関数とcallable objectを同一視したいとき
  • 同一視した表現を受け取って再帰的に同一視した表現を返したいとき (self-reference)

mypyでの型チェックはnominalなので、こういう構造を利用して同一視したい場合にはstructural subtypingが欲しくなる。これ用のinterfaceをProtocolと呼んでいる。

状況説明

例えばただただnameが欲しいだけの状況を考えてみる。こういう関数に渡されることをイメージした状態。

# <?????> の所に入る値は後々明らかになる
def get_name(o: <?????>) -> str:
    return o.name

nameが取得できれば同じと見做す。そのような状況のときに、外から眺めた見た目としては、以下の2つのクラスは同じふるまいをするように見える(属性アクセスでnameが取れるので)。

class Person:
    name: str

    def __init__(self, name: str) -> None:
        self.name = name

class Display:
    def __init__(self, typ: t.Type[t.Any]) -> None:
        self.typ = typ

    @property
    def name(self) -> str:
        return self.typ.__name__

例えば以下の様な形で使われる。

get_name(Person("foo"))  # => "foo"

get_name(Display(Person))  # => "Person"

duck-typing としてはとても自然。

Protocol を記述

そんなわけで上の様に動作することを期待するProtocolを記述したい。見た目だけで考えると以下のように書けると思うかもしれない。nameが見えれば良いので。

from typing_extensions as tx

class HasName(tx.Protocol):
    name: str

しかしこれはだめ。以下のようなエラーが出る。

$ mypy --strict 04protocol.py
...
04protocol.py:31: error: Argument 1 to "get_name" has incompatible type "Display"; expected "HasName"
04protocol.py:31: note: Protocol member HasName.name expected settable variable, got read-only attribute

エラーの意味はwriteableでもある属性を期待しているがread-onlyな属性が渡されているよということ。前者は後者を内包しているから一見通るのが正しいと感じもするけれど、確かに安全性の観点から考えるならread-onlyであることを強調したい(まじめに色々なことは考えていないけれど。意図しないミスを防ぎたいという観点で考えるとこの2つを混同して使っている状況に危うさを感じたりはしそう)。

そんなわけで以下の様なProtocolを書いてあげると大丈夫。

class HasName(tx.Protocol):
    @property
    def name(self) -> str:
        ...

というわけで先程まで秘密にしていたget_name()の定義は以下の様になる。

def get_name(o: HasName) -> str:
    return o.name

今度は以下が動く。

def main() -> None:
    get_name(Person("foo"))
    get_name(Display(Person))
    # get_name(object())

goでもこういうinterface(pythonでのProtocolは概ねgoのinterface)を定義したいのだよなー。例えば自動生成系のやつなどでstructに値を持つ形で済ませられれば最高なのだけれど、メソッドを強制されたりするので。

$ mypy --strict 05protoco.py

やりましたね。手元で動かしたい人の為のgist

refs