JEPA4Japan · チュートリアル

データクラスと列挙型

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

簡潔なデータモデルを作り、意味の曖昧な値を名前付きの選択肢に置き換えます。

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

データクラスは印刷済みの記録カード

普通のクラスでは、フィールドを受け取る処理、表示、比較だけでも何行も必要です。@dataclassは、その定型的な記録カードの仕組みを自動で作ります。

  1. 欄に名前 titleとminutes
  2. dataclass 定型メソッドを生成
  3. 記録 読みやすく比較できる
データクラスは、フィールドの集まりへ名前付きの形を与えます。
from dataclasses import dataclass


@dataclass
class Reading:
    title: str
    minutes: int


first = Reading("列挙型", 25)
second = Reading("列挙型", 25)

print(first)
print(first == second)
print(first is second)
Reading(title='列挙型', minutes=25)
True
False

既定では、__init__()、便利な__repr__()、同じクラスのフィールドを比べる__eq__()が生成されます。値が等しくても別オブジェクトなので、isはFalseです。フィールド注釈も実行時の引数を自動検証しません。この役割分担は第16章で扱います。

各カードに専用のポケットを付ける

生成される初期化メソッドはフィールド順に従うため、デフォルトなしのフィールドを、デフォルト付きフィールドより先に書きます。

@dataclass
class Article:
    title: str
    minutes: int
    completed: bool = False

completedの後へ必須フィールドを置くと、クラス作成時にTypeErrorです。Falseのような単純な不変値は直接のデフォルトにできます。変更可能なリスト、辞書、集合にはファクトリーを使います。

  1. Akiのカード タグ用ポケットが必要
  2. Minaのカード 別のポケットが必要
  3. ファクトリー 毎回新しいリストを作る
  4. 共有しない タグが混ざらない
default_factoryは、変更可能な状態の意図しない共有を防ぎます。
from dataclasses import dataclass, field


@dataclass
class Notebook:
    owner: str
    tags: list[str] = field(default_factory=list)


mine = Notebook("Aki")
yours = Notebook("Mina")
mine.tags.append("python")

print(mine.tags, yours.tags)
print(mine.tags is yours.tags)
['python'] []
False

渡すのはlist()の結果ではなく、括弧なしのファクトリーlistです。生成された初期化メソッドが、インスタンスごとに呼び出します。

欄がそろった後でカードを検査する

生成された初期化メソッドは、各フィールドを代入した後に__post_init__()を呼びます。小さな検証、正規化、派生フィールドの作成に使います。

from dataclasses import dataclass, field


@dataclass
class Session:
    topic: str
    minutes: int
    label: str = field(init=False)

    def __post_init__(self):
        self.topic = self.topic.strip()
        if not self.topic or self.minutes <= 0:
            raise ValueError("topic and minutes must be valid")
        self.label = f"{self.topic}({self.minutes}分)"


print(Session("  Python  ", 30).label)
Python(30分)

init=Falseならlabelはコンストラクター引数になりません。ファイルアクセスや大きな処理は入れず、生成を予測しやすく保ちます。

@dataclass(frozen=True)は通常のフィールド再代入を防ぎます。変更版が必要ならdataclasses.replace()で新しい値を作ります。ただし、frozenの保証は浅いものです。

@dataclass(frozen=True)
class FrozenPocket:
    tags: list[str] = field(default_factory=list)


pocket = FrozenPocket()
pocket.tags.append("まだ変わる")
print(pocket.tags)
['まだ変わる']

等価比較を生成したfrozenデータクラスには通常ハッシュも付きますが、比較対象フィールドにハッシュ不可能なリストがあれば、ハッシュ時に失敗します。深く安定させるなら文字列、数値、タプル、frozensetなどを選びます。unsafe_hash=Trueは、変わるデータを安全にする魔法ではありません。

Enumは選択肢が決まったメニュー

**列挙型(Enum)**は、自由な文字列を既知のメンバーオブジェクトへ置き換えます。

  1. メニュー LOW、NORMAL、HIGH
  2. メンバー Priority.HIGH
  3. 保存値 "high"
Enumは、閉じた語彙から選んだ1つを名前付きオブジェクトにします。
from enum import Enum


class Priority(Enum):
    LOW = "low"
    NORMAL = "normal"
    HIGH = "high"


choice = Priority("high")
print(choice)
print(choice.name)
print(choice.value)
print(choice is Priority.HIGH)
print(choice == "high")
Priority.HIGH
HIGH
high
True
False

メンバーはEnum内のシングルトンなので、isによる同一性比較が一般的です。Priority("high")は値、Priority["HIGH"]は名前で検索します。未知の入力はValueErrorまたはKeyErrorです。通常のEnumは、値が比較できてもメンバー同士を<で並べられません。明示したキーや順位を使います。

ミニ制作:優先度付き読書キュー

reading_queue.pyを作ります。標準ライブラリだけで動く完成プログラムです。frozen項目データクラス、Enum、検証、default_factoryで専用リストを持つキューを組み合わせます。

from dataclasses import dataclass, field
from enum import Enum


class Priority(Enum):
    LOW = "low"
    NORMAL = "normal"
    HIGH = "high"


RANK = {Priority.LOW: 1, Priority.NORMAL: 2, Priority.HIGH: 3}


@dataclass(frozen=True)
class ReadingItem:
    title: str
    minutes: int
    priority: Priority = Priority.NORMAL

    def __post_init__(self):
        clean = self.title.strip()
        if not clean:
            raise ValueError("title must not be blank")
        if (
            isinstance(self.minutes, bool)
            or not isinstance(self.minutes, int)
            or self.minutes <= 0
        ):
            raise ValueError("minutes must be a positive integer")
        if not isinstance(self.priority, Priority):
            raise TypeError("priority must be a Priority")
        object.__setattr__(self, "title", clean)


@dataclass
class ReadingQueue:
    name: str
    minute_limit: int = 45
    items: list[ReadingItem] = field(default_factory=list)

    def add(self, item):
        self.items.append(item)

    def plan(self):
        ordered = sorted(
            self.items,
            key=lambda item: (-RANK[item.priority], item.minutes, item.title),
        )
        chosen = []
        used = 0
        for item in ordered:
            if used + item.minutes <= self.minute_limit:
                chosen.append(item)
                used += item.minutes
        return chosen


queue = ReadingQueue("今夜")
for title, minutes, raw_priority in [
    ("  データクラス  ", 25, "high"),
    ("Enumの境界", 15, "normal"),
    ("ハッシュのメモ", 10, "high"),
]:
    queue.add(ReadingItem(title, minutes, Priority(raw_priority)))

plan = queue.plan()
print(f"{queue.name}:")
for number, item in enumerate(plan, start=1):
    print(f"{number}. [{item.priority.name}] {item.title} - {item.minutes}分")
print(f"合計:{sum(item.minutes for item in plan)}分")
print(f"一意な項目:{len(set(plan))}")
print(f"新しいキューは空:{ReadingQueue('明日').items == []}")

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

今夜:
1. [HIGH] ハッシュのメモ - 10分
2. [HIGH] データクラス - 25分
合計:35分
一意な項目:2
新しいキューは空:True

frozen項目は、比較される全フィールドがハッシュ可能なので集合へ入れられます。境界の注意:注釈だけではPriorityを強制しないため、__post_init__()で確認しています。また、負の制限時間はこの小さなキューの契約外なので、利用者入力を受ける前に検証が必要です。

3つの小さなミッション

  1. ファクトリーを証明。 2つのキューを作り、片方だけに追加して、もう片方のリストが空だと確かめます。
  2. frozenのコピー。 dataclasses.replace()で、元を変えずに完了済みの項目を作ります。
  3. Enumの境界。 正しい優先度値と未知の値を1つずつ解析し、未知の選択肢へ明確なエラーを出します。

第16章へ進む準備

  • 既定の@dataclassが何を生成し、isが何を意味するか分かる
  • 必須フィールドをデフォルト付きフィールドより前に置ける
  • 独立した変更可能デフォルトにdefault_factoryを使える
  • __post_init__()で小さな検証や派生値を作れる
  • frozenが浅く、ハッシュ可能性は全フィールド次第だと説明できる
  • Enumメンバー、そのname、valueを区別できる
  • メンバーを同一性で比較し、外部文字列を境界で解析できる
  • キューを実行し、「新しいキューは空:True」を確認できる

次章では、静的ツールにもデータと振る舞いの契約が見えるよう、型ヒントとプロトコルを加えます。