nikkie-ftnextの日記

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

読書ログ | 『ロバストPython』5章「コレクション型」を読んで、コレクション(listやdictなどなど)への型ヒントの書き方や、振る舞いの拡張の仕方を完全に理解しました

はじめに

クソデカ天使様、なんて神々しい...、nikkieです😇

読み始めました、『ロバストPython』!1
将来の開発者に意図を伝えるPythonの書き方が指南された(議論のための)本です。
直近でコレクションについて少し調べていたこともあり2、「5章 コレクション型」に食指が動きました。

目次

5章 コレクション型

公開されている目次から引用します。

  • 5.1 コレクションの型アノテーション
  • 5.2 同種コレクションと異種コレクション
  • 5.3 TypedDict
  • 5.4 新しいコレクション型の作成
    • 5.4.1 ジェネリクス
    • 5.4.2 既存のデータ型の変更
    • 5.4.3 簡単に使えるABC
  • 5.5 まとめ

コレクション型とは、データの集まり、すなわち任意の数のデータを格納する型で、例を挙げると、リストや辞書などです。
これらへの型ヒントの書き方や、独自のコレクション型の作成が指南されます。

5章 コレクション型を読んでの3つの学び

5章の内容は学びが多くありました。

  1. 異種コレクションという概念により、型ヒントが書きづらいなと感じていた事象が言語化された!(5.2, 5.3)
  2. collectionsUserDictたちの使い所がようやく分かった!(5.4.2)
  3. ジェネリクス、完全に理解した(5.4.1)

小さい学び

  • コレクション型は要素の型も明示する(5.1)
    • 例:listlist[Any]なので、list[要素の型]とする
  • collections.abcについての指南(5.4.3)

以下のコードはPython 3.10.9、mypy 1.1.1で型チェックしています。

1. 型ヒントの書きづらさには異種コレクションが関係していた!

異種コレクション

5.2で導入される概念が同種コレクション・異種コレクション。
コレクションの要素のデータ型が同種か異種かという定義です。

  • 同種コレクションの例:リスト、辞書
    • 反復処理される3
  • 異種コレクションの例:タプル
    • 反復処理されない

異種コレクションのタプルの型ヒントの例です。
例:料理の本。書籍名(str)とページ数(int

エイリアス4を定義して、型ヒントしています

Cookbook = tuple[str, int]

the_book: Cookbook = ("レシピボン", 45)

タプルは添字(インデックス)アクセスとなりますが(the_book[0])、「フィールドの名前でアクセスしたい!」と辞書が導入されます。

異種コレクションの辞書に起因する、型ヒントの書きづらさ

同種コレクションとして想定されている辞書を異種コレクションとして使うから型ヒントが書きづらいのか!という気付きがありました。

タプルの例を辞書で書き換えていきます。

from typing import Union

Cookbook = dict[str, Union[str, int]]

the_book: Cookbook = {"name": "レシピボン", "page_count": 45}

料理の本を表す辞書の型ヒントは以下のようになりました

  • キーは常にstr"name""page_count"
  • 値はstr(書籍名)またはint(ページ数)
    • 👉 Unionを使って表す

この辞書にフィールドが追加され、それがfloatやはたまた別のコレクションだったらどうでしょうか?
辞書の値の型ヒント(Unionを使った型ヒント)は(型エイリアスを使ったとしても)どんどん煩雑になっていきます。
これを見て、辞書を異種コレクションに使うと型ヒントが書きづらいと言語化ができました。

これらの問題は、すべて同種コレクションに異種データを格納するから生じたものだ。
この場合、コレクションの利用者に負担を押しつけるか、型アノテーションを全く放棄するかになる。(5.2)

異種コレクションとして辞書を使う場合の型ヒントにはTypedDictを使う

Python 3.8で導入されたTypedDictは、辞書にどうしても異種データを格納しなければならない場合のために作られたものだ。(5.3)

https://docs.python.org/ja/3/library/typing.html#typing.TypedDict

料理の本に適用してみます。

from typing import TypedDict


class Cookbook(TypedDict):
    name: str
    page_count: int


cookbook: Cookbook = {"name": "レシピボン", "page_count": 45}

TypedDictを使うとUnionが不要になってかなり分かりやすくなった感覚があります! すごい
TypedDictはあくまで型チェッカのためのもので、実行時にバリデーションされるわけではないそうです。

辞書を異種コレクションで使うケースですが、APIから返ってくるJSONや、YAMLなど種々のファイル読み込みが挙がっていました。
そんなときはTypedDictを使うことで、明確な型ヒントが書けそうです。

サンプルコードの中に、参考にしたい例(APIの返り値が複雑な例)がありました。

TypedDictを教えてくれてありがとう、『ロバストPython

2. collectionsUserDictたちの使い所がようやく分かった!

標準ライブラリcollectionsにUserDictUserListUserStringがあります。
「これらの使い所がはっきりしないな〜」とずっと感じていたのですが、5.4.2を読んで分かりました。
既存のコレクション型の一部のメソッドの動作を変えるときです。
__getitem__メソッドの動きを変えた辞書を作る例が紹介されました。

書籍には、なぜ組み込みのdictを継承して__getitem__をオーバーライド5してもうまくいかないかも解説されています。
dictの継承でうまくいかないからUserDictの出番なのですね!

3. ジェネリクス、完全に理解した

型ヒントのジェネリクスって、なんだかよくわからない不気味さがあったのですが、『ロバストPython』5.4.1の解説で完全に理解できました!6

ジェネリック型は、どのデータ型を使っても構わないことを示すためのもの (5.4.1)

紹介されたもの

typing.TypeVar

TypeVarの例は、リスト(要素はなんでもよい)を逆順にして返すような関数の型ヒントです。
引数と返り値でリストの要素の型は共通です。
これがTypeVarで表せます

from typing import TypeVar

T = TypeVar("T")


def reverse(coll: list[T]) -> list[T]:  # type: ignore[empty-body]
    # collを逆順にして返す処理
    ...


numbers: list[int] = reverse([1, 2, 3])
characters: list[str] = reverse(list("デパプリ"))

またコラムでは、ジェネリクスを使ってAPIのエラー処理も単純化できる例も示されました7

typing.Generic

グラフの例で解説されます。

class Graph(Generic[Node, Edge]):
    def __init__(self):
        self.edges: dict[Node, list[Edge]] = defaultdict(list)

    def add_relation(self, node: Node, to: Edge):
        self.edges[node].append(to)

    def get_relations(self, node: Node) -> list[Edge]:
        return self.edges[node]
  • edges属性はキーをNode型、値をlist[Edge]型とする辞書(同種コレクション)
  • add_relationメソッドは、Node型の引数nodeEdge型の引数toを受け取る
  • get_relationsメソッドはNode型の引数nodeを受け取り、list[Edge]型の値を返す

ジェネリクスを使うことで

あらゆる要素型のグラフを定義でき、かつそれらの型チェックを成功させられる。(5.4.1)

サンプルコードでは3つのグラフを定義していました。

  • Graph[Restaurant, Restaurant]
  • Graph[Recipe, Recipe]
  • Graph[Restaurant, Recipe]

言ってみれば、型ヒントで使う型を変数のようにしておくことで、要素の型を変えても定義できるわけですね。
なるほど、これがジェネリクス

サンプルコードで使っているRestaurantRecipetyping.NewTypeで定義していました。
https://docs.python.org/ja/3/library/typing.html#newtype
Restaurant = NewType('Restaurant', str)という実装なので、型チェックでは第2引数strのサブクラスとして扱われるみたいです。

終わりに

ロバストPython』5章からの自分にとっての学びをアウトプットしました。

この中ではTypedDictを知られたのが大きいですね。
型ヒントが書けないな〜と思っていたコードにTypedDictで立ち向かえそうです。

ジェネリクスもいままで避けてましたが、今回知ったのでもうちょっと素振りするなどして練度を上げたいな〜と思います。

非常に学びの深い章でした。
コレクションに関する型ヒントの難しさがスルッと解消した感がありますので、同じようなお悩みを抱えている方は読んでみると発見が多いのではないかと思います。
そして、やっぱり読書会があるとよさそうに思います〜(どなたかご一緒しませんか〜?📣)

型チェック自分用メモ

  • 型を変数に代入すると型エイリアス
  • TypeVarで型変数を代入できる
  • NewTypeでサブクラスの型を作れる

P.S. 思い出した記事たち

5章を読んでいて思い出した記事を貼っておきます。

異種コレクションの辞書にUnionと型エイリアスで型ヒントする例はPython Monthly Topicsで知っていました。

改めて見ると、この記事ではTypedDictも紹介されていたのですね!

もう一つ、メルカリさんのこちらの記事は、型エイリアスやTypedDictの代わりに、データ構造を定義して型ヒントするアプローチです(typing.NamedTuple@dataclasses.dataclass)。