JEPA4Japan · チュートリアル

Pythonデータモデル

2,202文字 7分で読めます #Python

表現、比較、ハッシュ、コンテナ、反復を支える特殊メソッドを実装します。

コース進捗 コース目次 24レッスン中 24件を公開中

Pythonの構文はプロトコルをお願いする

特殊メソッドは魔法の飾りではありません。正確な契約を持つフックです。普通の構文がオブジェクトへ振る舞いを頼みます。

  1. len(box) __len__をお願い
  2. a == b __eq__で相談
  3. for item in box イテレーターをお願い
  4. box[0] __getitem__をお願い
公開された構文を使うと、Pythonが対応するプロトコルを呼び、結果を確認します。

ドメインが正直に守れるプロトコルだけを実装します。たとえば__len__は0以上の整数を返す契約であり、文字列の"2"は返せません。

値に2つの顔と公平な比較を与えよう

repr(value)は開発者向けの安定した診断用の顔、str(value)は利用者向けの読みやすい顔です。__str__がなければ、Pythonは__repr__を使います。

class ReadingItem:
    def __init__(self, title, minutes):
        self.title = title
        self.minutes = minutes

    def __repr__(self):
        return f"ReadingItem({self.title!r}, {self.minutes!r})"

    def __str__(self):
        return f"{self.title}({self.minutes}分)"

!rにより引用符の境界が見えます。reprには機密情報を入れません。表現はログやトレースバックへ届くからです。

等価性は相談です。対応しない型には、FalseでもNotImplementedError例外でもなく、シングルトンのNotImplementedを返します。

def __eq__(self, other):
    if type(other) is not type(self):
        return NotImplemented
    return (self.title, self.minutes) == (other.title, other.minutes)
  1. 左へ聞く この型を比較できる?
  2. NotImplemented Pythonが相手側にも聞く
  3. 最後の結果 等価はFalse、順序はエラーの場合
対応しない等価性は通常False、対応しない順序は通常TypeErrorになります。

ドメインに1つの意味ある順序がある場合だけ<を定義します。読書優先度を(minutes, title)とする規則は作れますが、電話番号の大小には普通意味がありません。場面ごとに順序が違うなら、呼び出し側が並べ替えキーを渡します。

通常の値では、等価性を反射的、対称的、推移的に保ちます。利用可能だからという理由ではなく、ドメイン契約として使うフィールドを選びます。

等価性の安定した状態だけをハッシュしよう

ハッシュコンテナには1つの法則があります。

a == bなら、hash(a) == hash(b)である。

等価性と同じ、安定したフィールドと型をハッシュします。

def __hash__(self):
    return hash((type(self), self.title, self.minutes))

辞書キーや集合要素である間、そのフィールドは変えられません。値の等価性を定義しながら変更可能なクラスは、ハッシュ不可にします。__eq__を定義して__hash__を定義しないと、Pythonは通常__hash__ = Noneにします。この安全な既定値を保ってください。フィールド等価性の横へ同一性ハッシュを戻すと、等しい物のハッシュが異なる可能性があります。

箱の中身を漏らさず質問に答えよう

  1. len / bool 大きさ、0ならFalse
  2. in 明確な包含の意味
  3. iter 毎回新しいイテレーター
  4. [0]と[1:] 要素と安全なスライス
コンテナは一般的な質問へ答えながら、変更可能な内部ストレージを守ります。

__bool__がなければ、Pythonは__len__を使い、0を偽、正の長さを真にします。__contains__はinを支えます。入れ子の走査位置が混ざらないよう、__iter__は毎回新しいイテレーターを返します。__getitem__は整数、負のインデックス、スライス、通常のIndexErrorを支えます。スライスはタプルや新しいコンテナにできますが、内部の変更可能なリストそのものは返しません。

Pythonらしい読書キューを作ろう

完全な標準ライブラリのプロジェクトをreading_queue.pyへ保存します。

class ReadingItem:
    __slots__ = ("_title", "_minutes", "_sealed")

    def __init__(self, title, minutes):
        title = title.strip()
        if not title:
            raise ValueError("title must not be empty")
        if isinstance(minutes, bool) or not isinstance(minutes, int) or minutes < 0:
            raise ValueError("minutes must be a non-negative integer")
        object.__setattr__(self, "_title", title)
        object.__setattr__(self, "_minutes", minutes)
        object.__setattr__(self, "_sealed", True)

    def __setattr__(self, name, value):
        if getattr(self, "_sealed", False):
            raise AttributeError("ReadingItem is immutable")
        object.__setattr__(self, name, value)

    @property
    def title(self):
        return self._title

    @property
    def minutes(self):
        return self._minutes

    def __repr__(self):
        return f"ReadingItem({self.title!r}, {self.minutes!r})"

    def __str__(self):
        return f"{self.title}({self.minutes}分)"

    def __eq__(self, other):
        if type(other) is not type(self):
            return NotImplemented
        return (self.title, self.minutes) == (other.title, other.minutes)

    def __lt__(self, other):
        if type(other) is not type(self):
            return NotImplemented
        return (self.minutes, self.title) < (other.minutes, other.title)

    def __hash__(self):
        return hash((type(self), self.title, self.minutes))


class ReadingQueue:
    __hash__ = None

    def __init__(self, items=()):
        self._items = []
        for item in items:
            self.add(item)

    def add(self, item):
        if not isinstance(item, ReadingItem):
            raise TypeError("item must be a ReadingItem")
        self._items.append(item)

    def __len__(self):
        return len(self._items)

    def __contains__(self, item):
        return item in self._items

    def __iter__(self):
        return iter(tuple(self._items))

    def __getitem__(self, index):
        if isinstance(index, slice):
            return tuple(self._items[index])
        return self._items[index]


def main():
    python = ReadingItem(" Python data model ", 30)
    same = ReadingItem("Python data model", 30)
    async_item = ReadingItem("Async I/O", 45)

    assert repr(python) == "ReadingItem('Python data model', 30)"
    assert python == same and hash(python) == hash(same)
    assert len({python, same}) == 1
    assert python != ("Python data model", 30)
    assert sorted([async_item, python]) == [python, async_item]

    queue = ReadingQueue([python, async_item])
    left, right = iter(queue), iter(queue)
    assert next(left) == next(right) == python
    assert len(queue) == 2 and bool(queue) and same in queue
    assert queue[-1] == async_item and queue[1:] == (async_item,)
    try:
        hash(queue)
    except TypeError:
        pass
    else:
        raise AssertionError("mutable queue was hashable")

    print("テストに合格しました。")
    print(f"キュー:{len(queue)}件")
    for item in queue:
        print(f"- {item}")


if __name__ == "__main__":
    main()

出力:

テストに合格しました。
キュー:2件
- Python data model(30分)
- Async I/O(45分)

キューは意図的にハッシュを持たず、反復は毎回新しいスナップショットイテレーターを使い、スライスは非公開リストではなくタプルを返します。

3つの小さなチャレンジと次への準備

  1. 2つの顔。 Bookmark(title, page)へ診断用reprと読者向けstrを実装し、引用符を含むタイトルで試します。
  2. 比較の握手。 Duration.__eq__と__lt__が整数へNotImplementedを返すようにします。Duration(5) == 5は偽、Duration(5) < 5はTypeErrorだと確認します。
  3. 独立した本棚。 非公開ストレージ上で長さ、包含、同時に動く2つのイテレーター、負インデックス、タプルのスライスに対応します。

注意点:特殊メソッドの直接呼び出しは公開意図を飛ばし、NotImplementedErrorは比較用センチネルではありません。作り物の順序はドメインを偽り、変更可能なハッシュ状態は検索を壊し、内部リストの返却は変更経路を漏らします。

  • 公開構文をプロトコル要求へ対応付けられる
  • 開発者向けreprと読者向けstrを分けられる
  • 対応しない比較型へNotImplementedを返せる
  • 本当の順序規則を持つドメインだけを並べられる
  • 等価性/ハッシュ法則を守るか、変更可能な値をハッシュ不可にできる
  • サイズ、真偽、包含、新しい反復、インデックス、スライスを実装できる
  • キューが全assertを通り、内部リストを公開しない
  • 3つの小さなチャレンジを終えた

次章では、並行性と並列性を分け、各仕事を安全に実行できるスレッドまたはプロセスを選びます。