课程进度 课程大纲 已发布 24/24 课
Python 基础
数据与集合
构建可靠的程序
使用对象建模
专业 Python
高级 Python
dataclass 就像一张打印好的记录卡
普通类可能需要很多行代码来接收字段、显示字段并比较字段。@dataclass 会替你生成这些常规的记录卡功能。
- 命名字段 标题和分钟数
- 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 这样简单的不可变默认值没有问题。可变的列表、字典或集合则需要工厂:
- Aki 的卡片 需要一个标签口袋
- Mina 的卡片 需要另一个口袋
- 工厂 每次都创建一个新列表
- 不共享 每张卡片的标签各自独立
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 用已知的成员对象替代随意使用的字符串。
- 菜单 LOW、NORMAL、HIGH
-
成员
Priority.HIGH -
存储的值
"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__()。另外,负数限制不在这个小型队列的约定范围内,因此在接受用户输入之前应该对其进行验证。
三个小任务
- 证明工厂有效。 创建两个队列,向其中一个添加条目,并证明另一个队列的条目列表仍然为空。
- 冻结副本。 使用
dataclasses.replace()创建一个条目的已完成版本,同时不修改原始条目。 - Enum 边界。 解析一个有效的优先级值和一个未知的优先级值,并为未知选项给出清晰的错误消息。
当你做到以下几点时,就可以学习第 16 章了……
- 你知道默认的
@dataclass会生成什么,也知道is仍然表示什么; - 你会把必填字段放在带默认值的字段之前;
- 你会使用
default_factory创建彼此独立的可变默认值; - 你会在
__post_init__()中验证或派生少量值; - 你能解释为什么 frozen 是浅层的,以及为什么哈希取决于每一个字段;
- 你能区分 Enum 成员、它的
name和它的value; - 你会按身份比较成员,并在边界处解析外部文本;
- 你可以运行这个队列,并看到
Fresh queue is empty: True。
接下来,你将添加类型提示和协议,让静态工具能够看见这些数据和行为约定。