JEPA4Japan · チュートリアル

型ヒントとプロトコル

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

型注釈、ユニオン、ジェネリックなコレクション、構造的インターフェースで期待を表現します。

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

型ヒントは門番ではなく付箋

型ヒントは「この値は何であるべきか」を、読み手と静的ツールへ伝えます。通常は入口に立って、異なる実行時値を止める門番ではありません。

  1. 注釈 name: str
  2. 型チェッカー 実行前に読む
  3. Python 実際の物を使う
ヒントは静的な契約を説明し、実行時検証は別の仕事です。
def repeat(label: str, times: int) -> str:
    return label * times


print(repeat("go", 3))
print(repeat(2, 3))
gogogo
6

型チェッカーなら2つ目を警告するはずですが、Pythonは整数の掛け算を実行します。利用者、ファイル、ネットワークから来るデータは、自分で検証します。注釈は自動変換もしません。

箱の中身と「ないかも」を説明する

コレクションの型ヒントは、入れ物と中身の両方を表します。

def total_by_topic(sessions: list[tuple[str, int]]) -> dict[str, int]:
    totals: dict[str, int] = {}
    for topic, minutes in sessions:
        totals[topic] = totals.get(topic, 0) + minutes
    return totals


print(total_by_topic([("Python", 20), ("Python", 25)]))
{'Python': 45}

ヒントが実行時に全要素を検査するわけではありません。必要な能力だけを求めます。反復するだけならIterable[str]、位置を読むならSequence[str]、リスト固有の変更が必要ならlist[str]が候補です。

str | Noneは文字列またはNoneで、Optional[str]の現代的な書き方です。int | strはUnion[int, str]と同じユニオンの考え方です。

def author_label(author: str | None) -> str:
    if author is None:
        return "不明"
    return author.strip().title()

None分岐が戻った後、型チェッカーはauthorをstrへ絞り込みできます。Optionalは「引数を省略できる」という意味ではありません。author_label()だけでは引数不足です。省略も許すならauthor: str | None = Noneとデフォルトを付けます。

  1. ユニオン int | str
  2. 調べる isinstance(value, int)
  3. intの道 整数操作が安全
  4. 残りの道 文字列操作が安全
実行時の根拠が、Pythonとチェッカーの両方にユニオンを絞らせます。

型エイリアスは、繰り返す形へ分野の名前を付けます。

from typing import TypeAlias

Session: TypeAlias = tuple[str, int]

Python 3.12以降ならtype Session = tuple[str, int]とも書けます。エイリアスは語彙であり、新しい実行時クラスや検証器ではありません。フィールド名や規則が必要ならデータクラスを使います。

振る舞いと型の関係を説明する

Callable[[str], str]は、文字列1つで呼び出せて、文字列を返す物を表します。

from collections.abc import Callable


def clean_all(words: list[str], clean: Callable[[str], str]) -> list[str]:
    return [clean(word) for word in words]

この契約を守る関数、束縛メソッド、呼び出し可能オブジェクトを渡せます。

TypeVarは、具体的な型1つではなく関係を保ちます。

from collections.abc import Sequence
from typing import TypeVar

T = TypeVar("T")


def first_or_none(items: Sequence[T]) -> T | None:
    return items[0] if items else None

list[int]なら結果はint | None、list[str]ならstr | Noneです。object | Noneでは、この便利なつながりが消えます。本当の入出力関係があるときだけ型変数を加えます。

Protocolは能力を示すカード

Protocolは、利用側が必要とする小さな振る舞いに名前を付けます。クラスは、互換性のあるメンバーを持つことで構造的に満たします。継承は不要です。

  1. Protocol summary()が必要
  2. Article その能力がある
  3. Video こちらにもある
同じ家系図ではなく、互換性のある形がProtocolを満たします。
from typing import Protocol


class Summarizable(Protocol):
    def summary(self) -> str:
        ...


def show(item: Summarizable) -> None:
    print(item.summary())

Protocolは小さく保ちます。@runtime_checkableを付けると限定的なisinstance()検査ができますが、完全なメソッドシグネチャや注釈された値の型までは検証しません。

from typing import Protocol, runtime_checkable


@runtime_checkable
class Fetcher(Protocol):
    def fetch(self) -> list[str]:
        ...


class WrongShape:
    def fetch(self, required_argument):
        return 42


print(isinstance(WrongShape(), Fetcher))

fetch属性が存在するため、これでもTrueになり得ます。静的検査と実行時のデータ検証は引き続き必要です。

ミニ制作:型付き通知ダイジェスト

notification_digest.pyを作ります。from __future__ import annotationsにより、この現代的な注釈を古めの対応インタープリターでも実行できます。型エイリアス、Callable、ジェネリックヘルパー、Protocol、本物の実行時検証を組み合わせます。

from __future__ import annotations

from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass
from typing import Protocol, TypeVar

RawNotice = tuple[str, int]
T = TypeVar("T")


@dataclass(frozen=True)
class Notice:
    source: str
    message: str
    score: int

    def __post_init__(self) -> None:
        if not self.source.strip() or not self.message.strip():
            raise ValueError("text must not be blank")
        if isinstance(self.score, bool) or not isinstance(self.score, int):
            raise TypeError("score must be an integer")
        if not 0 <= self.score <= 100:
            raise ValueError("score must be from 0 to 100")


class NoticeSource(Protocol):
    def fetch(self) -> Iterable[Notice]:
        ...


class MemorySource:
    def __init__(self, name: str, records: list[RawNotice]) -> None:
        self.name = name
        self.records = records

    def fetch(self) -> Iterable[Notice]:
        for message, score in self.records:
            yield Notice(self.name, message, score)


def first_or_none(items: Sequence[T]) -> T | None:
    return items[0] if items else None


def collect(
    sources: Iterable[NoticeSource], keep: Callable[[Notice], bool]
) -> list[Notice]:
    notices = [
        notice
        for source in sources
        for notice in source.fetch()
        if keep(notice)
    ]
    return sorted(notices, key=lambda notice: (-notice.score, notice.source))


def format_digest(notices: Sequence[Notice]) -> str:
    if not notices:
        return "通知はありません。"
    lines = ["通知ダイジェスト"]
    lines.extend(
        f"- {notice.score:03d} | {notice.source}: {notice.message}"
        for notice in notices
    )
    top = first_or_none(notices)
    if top is not None:
        lines.append(f"最高得点の情報源:{top.source}")
    return "\n".join(lines)


sources: list[NoticeSource] = [
    MemorySource("Python", [("型ヒントのメモ", 88), ("スタイルガイド", 62)]),
    MemorySource("テスト", [("回帰テスト一式", 91)]),
]
notices = collect(sources, lambda notice: notice.score >= 70)
print(format_digest(notices))
print(f"空のヘルパー:{first_or_none([]) is None}")

python3 notification_digest.pyで実行し、次を確認します。

通知ダイジェスト
- 091 | テスト: 回帰テスト一式
- 088 | Python: 型ヒントのメモ
最高得点の情報源:テスト
空のヘルパー:True

MemorySourceはNoticeSourceを継承していません。互換性のあるfetch()があれば静的な構造的型付けを満たします。境界の注意:型ヒントは得点範囲を強制しないため、データクラスが実行時に検証します。不正なコールバックも、実行中に失敗する可能性があります。

3つの小さなミッション

  1. ユニオンを絞る。 int | strを受け取り、isinstance()の後で整数はゼロ埋め、空でない文字列は大文字にします。
  2. 型を保つ。 last_or_none(items: Sequence[T]) -> T | Noneを書き、整数とNoticeで試します。
  3. 情報源を追加。 NoticeSourceを継承せず、タプルに保存した通知をfetch()するクラスを作ります。

第17章へ進む準備

  • 型ヒントは通常、実行時値を強制しないと説明できる
  • 必要な能力に合わせてコレクションへ注釈を付けられる
  • Optional/Unionの考えを使い、実際の検査で絞り込める
  • 型エイリアスは形に名前を付けるだけで、検証しないと分かる
  • Callableでコールバックへ注釈を付けられる
  • TypeVarで入出力間の型関係を保てる
  • 小さなProtocolを定義し、構造的に満たせる
  • runtime_checkableは存在だけを調べ、完全なシグネチャを検証しないと分かる
  • ダイジェストを実行し、「空のヘルパー:True」を確認できる

次章では、イテレーターとジェネレーターが値を1つずつ作る仕組みを学びます。