mizzsugar’s blog

日々感じていることや学んだことを書きます。エンジニアリング以外にも書くかもしれません。

【Python】【TypeHint】ImmutableなオブジェクトにProtocolを使った時にmypyがエラーとなった

普段mypyを使って開発をしているのですが、Immutableなオブジェクトにtyping.Protocolを使ったらmypyがエラーを出して困ったので備忘録として記事を書きます。

Protocolとは

Wikipediaには「複数の者が対象となる事項を確実に実行するための手順について定めたもの」と書かれいています。

情報工学の文脈については、「情報工学分野でマシンやソフトウェア同士のやりとりに関する取り決め(通信規約)を指すためにも用いられるようになった」と書かれています。

https://ja.wikipedia.org/wiki/%E3%83%97%E3%83%AD%E3%83%88%E3%82%B3%E3%83%AB

異なるもの同士がやりとりをするための取り決めが「プロトコル」と言えそうです。

では、PyhtonのProtocolは何でしょう。

PythonのProtocolはPEP544で定義されました。PEP544を読んでみると

Currently, PEP 484 and the typing module [typing] define abstract base classes for several common Python protocols such as Iterable and Sized. 

collections.abc --- コレクションの抽象基底クラス — Python 3.8.2 ドキュメント

(みずき訳)
現在、PEP484とタイピングモジュールではIterableやSizedなどのPythonのプロトコルの抽象基底クラスが定義されています。

IterableやSizedはプロトコルだそうです。

Iterableだと、__iter__メソッドを持っているのが規約、Sizedは__len__メソッドを持っているのが規約のプロトコルです。

これらの規約を満たしているオブジェクトに対してiter()やlen()が使用できます。

逆に、満たしていないオブジェクトには使用できないので、__iter____len__を実装して規約を満たす必要があります。

PythonはIterableやSizedなどのプロトコルを用意してくれていますが、それらでは足りない独自の規約を作りたい時があります。

そんな時に、typing.Protocolを継承したクラスを作って独自の規約を作ります。

PEP 544 -- Protocols: Structural subtyping (static duck typing) | Python.org

typing.Protocolの使用例

https://www.python.org/dev/peps/pep-0544/ を少し改変しました。

from typing import Protocol, Iterator


class Template(Protocol):
    name: str        # プロトコルの要素
    value: int = 0   # プロトコルの要素(デフォルト値つき)

    def method(self) -> None:  # プロトコルの要素
        pass


class Concrete:
    def __init__(self, name: str, value: int, used: bool) -> None:
        self.name = name
        self.value = value
        # この要素があってもname, value, method()があるので
        # Templateクラスのプロトコルを満たしていると言える
        self.used = used

    def method(self) -> None:
        return

# ConcreteクラスのオブジェクトをTemplateクラスと型推論しても
# プロトコルに則っているのでエラーにならない
sample: Template = Concrete(name='aaaa', value=1, used=True)


# Iteratorのプロトコルに則っていないのでエラーになる
# Incompatible types in assignment (expression has type "Concrete", variable has type "Iterator[int]")
sample: Iterator[int] = Concrete(name='aaaa', value=1, used=True)

エラーになった例

ImmutableなオブジェクトにProtocolを使用しようとした時にmypyがエラーを出しました。

import dataclasses
import datetime
from typing import (
    Iterable,
    List,
    Protocol,
)


class Item(Protocol):
    name: str
    release_date: datetime.date


@dataclasses.dataclass(frozen=True)
class Book:
    name: str
    release_date: datetime.date
    author: str


@dataclasses.dataclass(frozen=True)
class Movie:
    name: str
    release_date: datetime.date
    director: str
    casts: List[str]


def aggregate_items(items: Iterable[Item]):
    pass


book_1 = Book(name='本1', release_date=datetime.date(2020, 1, 1), author='著者名')
book_2 = Book(name='本2', release_date=datetime.date(2019, 1, 1), author='著者名')
movie_1 = Movie(name='映画1', release_date=datetime.date(2010, 2, 1), director='監督名', casts=['女優', '俳優'])

items = aggregate_items([book_1, book_1, book_2, movie_1])

エラー内容

error_example.py:38: error: List item 0 has incompatible type "Book"; expected "Item"
error_example.py:38: note: Protocol member Item.name expected settable variable, got read-only attribute
error_example.py:38: note: Protocol member Item.release_date expected settable variable, got read-only attribute
error_example.py:38: error: List item 1 has incompatible type "Book"; expected "Item"
error_example.py:38: error: List item 2 has incompatible type "Book"; expected "Item"
error_example.py:38: error: List item 3 has incompatible type "Movie"; expected "Item"

なお、frozen=Trueを外したり、他のMutableな型で試した結果、エラーにはならなくりました。 このことから、ImmutableなオブジェクトにProtocolクラスを参照させようとするとエラーになるとわかりました。

解決策

Protocolを継承したクラスのアトリビュートに対して@propertyを使用することで、Immutableなオブジェクトがアトリビュートになった場合でもエラーにならなくなります。

propertyは、例えばアトリビュートnameの場合はget_nameのようなものです。nameという名前のアトリビュートの値が返されます。

https://github.com/python/typing/issues/622

class Item(Protocol):
    @property
    def name(self) -> str:
        pass

    @property
    def release_date(self) -> datetime.date:
        pass

なぜ、name: strではいけなくてpropertyでないといけないのでしょう。調べていたところ、この記事に出会いました。

pythonのtyping_extensions.Protocolがなぜ嬉しいか(propertyの例) - podhmo's diary

先程のエラーにerror_example.py:38: note: Protocol member Item.name expected settable variable, got read-only attributeというメッセージがありました。

Protocolクラスの属性はwriteableな変数を期待しているが、read-onlyな属性渡されていると言っています。

name: strだとread-onlyなアトリビュートとみなされます。

そのため、@propertyにして書き込み可能なアトリビュートに宣言してあげます。

ただ、Protocolクラスではwritableとなっているだけで、BookクラスやMovieクラスで属性の値を再代入したらエラーとなるので、安全です。