JEPA4Japan · 教程

数据类与枚举

1,878字 6分钟阅读 #Python

创建简洁的数据模型,并用有名称的选项替代含义模糊的魔法值。

课程进度 课程大纲 已发布 24/24 课

dataclass 就像一张打印好的记录卡

普通类可能需要很多行代码来接收字段、显示字段并比较字段。@dataclass 会替你生成这些常规的记录卡功能。

  1. 命名字段 标题和分钟数
  2. Dataclass 生成常规方法
  3. 记录 易读且可比较
dataclass 为一组字段赋予了有名称的结构。
from dataclasses import dataclass


@dataclass
class Reading:
    title: str
    minutes: int


first = Reading("Enums", 25)
second = Reading("Enums", 25)

print(first)
print(first == second)
print(first is second)
Reading(title='Enums', minutes=25)
True
False

默认情况下,这个装饰器会生成 __init__()、实用的 __repr__(),以及针对同一个类逐字段比较的 __eq__()。值相等的实例仍然是两个对象,因此 is 依然为假。字段注解也不会自动验证运行时参数;第 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——这样生成的初始化器就会为每个新实例调用它。

字段就位后检查卡片

生成的初始化器会在字段赋值后调用 __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} min)"


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

init=False 会让 label 不出现在构造函数参数中。不要在 __post_init__() 中进行文件访问或大型工作流程,以便让对象构造保持可预测。

@dataclass(frozen=True) 会阻止普通的字段重新赋值。可以使用 dataclasses.replace() 创建一份修改后的副本。但 frozen 只是一个浅层承诺:

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


pocket = FrozenPocket()
pocket.tags.append("still changes")
print(pocket.tags)
['still changes']

生成相等性比较时,冻结的 dataclass 通常也会获得哈希值;但如果参与比较的字段包含不可哈希的列表,哈希操作仍然会失败。需要深层稳定的值时,应使用字符串、数字、元组和冻结集合等不可变字段。unsafe_hash=True 并不是解决可变数据问题的魔法。

Enum 就像一份选项固定的菜单

Enum 用已知的成员对象替代随意使用的字符串。

  1. 菜单 LOW、NORMAL、HIGH
  2. 成员 Priority.HIGH
  3. 存储的值 "high"
枚举把封闭词汇表中的一个选项变成有名称的对象。
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。这个只使用标准库的程序结合了冻结的条目 dataclass、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("Tonight")
for title, minutes, raw_priority in [
    ("  Dataclasses  ", 25, "high"),
    ("Enum boundaries", 15, "normal"),
    ("Hashing notes", 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} min")
print(f"Total: {sum(item.minutes for item in plan)} min")
print(f"Unique items: {len(set(plan))}")
print(f"Fresh queue is empty: {ReadingQueue('Tomorrow').items == []}")

运行 python3 reading_queue.py 并检查:

Tonight:
1. [HIGH] Hashing notes - 10 min
2. [HIGH] Dataclasses - 25 min
Total: 35 min
Unique items: 2
Fresh queue is empty: True

冻结的条目可以哈希,因为所有参与比较的字段都可以哈希。再提醒一次边界问题:注解并不会强制要求使用 Priority;真正执行检查的是 __post_init__()。另外,负数限制不在这个小型队列的约定范围内,因此在接受用户输入之前应该对其进行验证。

三个小任务

  1. 证明工厂有效。 创建两个队列,向其中一个添加条目,并证明另一个队列的条目列表仍然为空。
  2. 冻结副本。 使用 dataclasses.replace() 创建一个条目的已完成版本,同时不修改原始条目。
  3. Enum 边界。 解析一个有效的优先级值和一个未知的优先级值,并为未知选项给出清晰的错误消息。

当你做到以下几点时,就可以学习第 16 章了……

  • 你知道默认的 @dataclass 会生成什么,也知道 is 仍然表示什么;
  • 你会把必填字段放在带默认值的字段之前;
  • 你会使用 default_factory 创建彼此独立的可变默认值;
  • 你会在 __post_init__() 中验证或派生少量值;
  • 你能解释为什么 frozen 是浅层的,以及为什么哈希取决于每一个字段;
  • 你能区分 Enum 成员、它的 name 和它的 value;
  • 你会按身份比较成员,并在边界处解析外部文本;
  • 你可以运行这个队列,并看到 Fresh queue is empty: True。

接下来,你将添加类型提示和协议,让静态工具能够看见这些数据和行为约定。