《Python AI 应用开发入门》第 4.3 节。
使用 dataclass 建立消息、用户和模型响应,并用可选、联合与容器类型表达数据结构。
本节目标
学完本节后,你应当能够:
- 3. 理解数据类自动生成的初始化、显示和比较行为。
- 4. 为字段设置普通默认值和
default_factory。 - 5. 使用
__post_init__ 完成简单运行时验证。 - 6. 使用
T | None、Optional[T] 和联合类型。
1. 为什么需要数据类
上一节使用普通类组织状态和行为。但有些类的任务很简单:只需要清楚地保存一组数据。
例如,一条消息包含角色和内容。使用普通类可以这样写:
class Message: def __init__(self, role, content): self.role = role self.content = content
这段代码没有问题。不过随着数据模型增多,我们会反复编写相似的初始化代码。为了方便调试和测试,通常还希望这些对象能够:
数据类就是为这种场景准备的:我们声明“有哪些字段”,Python 帮我们生成初始化、显示和比较等常用代码。
2. 第一个数据类
dataclass 来自 Python 标准库:
from dataclasses import dataclass@dataclassclass Message: role: str content: str
创建对象:
message = Message( role="user", content="什么是数据类?",)print(message.role)print(message.content)print(message)
输出类似:
user什么是数据类?Message(role='user', content='什么是数据类?')
类体中的两行代码分别声明了字段名和预期类型:
- •
role: str:role 字段预期保存字符串。 - •
content: str:content 字段预期保存字符串。
@dataclass 会读取这些字段声明,并为紧跟在下面的类生成常用方法。
@... 这种写法叫装饰器语法。当前先理解为“让 dataclass 帮这个类生成数据模型常用代码”,下一节会解释装饰器的基本原理。
3. @dataclass 帮我们生成了什么
默认情况下,@dataclass 会根据字段生成几个常用方法。
初始化方法
前面的 Message 大致获得了下面这个初始化方法:
def __init__(self, role: str, content: str): self.role = role self.content = content
因此我们不用自己编写 __init__()。创建对象时可以使用位置参数,也可以使用关键字参数;字段较多时,关键字参数通常更容易读懂。
可读的对象表示
普通对象直接打印时,通常只会看到类名和内存地址。数据类生成了更清楚的对象表示,因此 print(message) 会直接显示类名和各个字段,调试时更容易看出对象里保存了什么。
按字段比较
first = Message("user", "你好")second = Message("user", "你好")print(first == second) # Trueprint(first is second) # False
两个变量虽然指向不同对象,但字段值相同,所以 == 得到 True。is 判断的是“是否就是同一个对象”,因此得到 False。
数据类不会替你自动完成业务验证、JSON 转换或文件保存,这些职责仍需显式设计。
4. 字段默认值
字段也可以提供默认值。下面创建 ModelResponse 时,只需要传入 content,其余字段会使用默认值:
from dataclasses import dataclass@dataclassclass ModelResponse: content: str model: str = "demo-model" finish_reason: str | None = None
使用:
response = ModelResponse(content="这是一条模拟回答。")print(response.model) # demo-modelprint(response.finish_reason) # None
str | None 表示字段可以是字符串,也可以是 None。本节后面会详细解释。
和函数参数一样,没有默认值的字段要写在前面,有默认值的字段写在后面。否则 Python 无法生成合法的 __init__() 参数顺序。
5. 为什么列表字段要使用 default_factory
会话对象通常需要一个默认的空消息列表。最直观的写法似乎是直接把 [] 作为默认值:
from dataclasses import dataclass@dataclassclass ChatSession: title: str messages: list[Message] = []
但是运行到类定义时,Python 会直接报错:
ValueError: mutable default <class 'list'> for field messages is not allowed: use default_factory
此时还没有创建任何 ChatSession 对象。报错发生在 @dataclass 处理类、准备生成 __init__() 的时候。
为什么数据类要拒绝这个写法?因为 [] 会在执行类定义时创建一次,而不是每次创建 ChatSession 时都重新创建。如果允许它作为默认值,生成的 __init__() 就会反复使用同一个列表。结果会是:向第一个会话添加消息,第二个会话也能看到这条消息。
列表可以在创建后继续添加、删除或修改元素,因此属于可变对象。数据类会主动拒绝列表这类可变默认值,避免不同实例意外共享同一份数据。
我们真正需要的是:每创建一个会话,就创建一个新的空列表。这正是 default_factory 解决的问题。
使用 field(default_factory=list):
from dataclasses import dataclass, field@dataclassclass ChatSession: title: str messages: list[Message] = field(default_factory=list)
default_factory 可以理解为“默认值生成器”。这里传入的是 list 函数本身,而不是提前创建好的列表。每次创建 ChatSession 时,数据类都会调用一次 list(),所以每个会话都能得到自己的新列表:
first = ChatSession("Python")second = ChatSession("AI")first.messages.append(Message("user", "你好"))print(len(first.messages)) # 1print(len(second.messages)) # 0
注意这里写的是 list,没有括号。如果写成 list(),传入的就不再是“之后用来创建默认值的函数”,而是一个已经创建好的列表。
需要默认字典或集合时,同样把对应的创建函数交给 default_factory:
field(default_factory=dict)field(default_factory=set)
它与函数参数中使用 None 避免共享可变默认值解决的是同一类问题:每次创建对象时,都应该得到一份独立的数据。
6. post_init 与运行时验证
数据类替我们生成了 __init__(),但有时保存字段后还要做清理和验证。为此,可以定义 __post_init__():
from dataclasses import dataclass@dataclassclass Message: role: str content: str def __post_init__(self): self.role = self.role.strip().lower() self.content = self.content.strip() if self.role not in ("system", "user", "assistant"): raise ValueError(f"消息角色无效:{self.role}") if not self.content: raise ValueError("消息内容不能为空")
创建对象时的顺序是:先由生成的 __init__() 保存字段,再自动调用 __post_init__()。因此下面传入的多余空格和大写角色会被统一整理:
message = Message(" USER ", " 你好 ")print(message.role) # userprint(message.content) # 你好
如果角色无效或内容清理后为空,对象会在创建时立即报错:
# Message("unknown", "你好")# Message("user", " ")
__post_init__() 适合处理字段自身的简单规则,例如去掉空格、统一大小写和检查取值范围。读取文件、请求网络等复杂操作不适合藏在对象初始化过程中,否则一次看似普通的对象创建会产生难以预料的额外行为。
7. 类型注解是在说明预期,不是在检查数据
下面的字段写了 str:
@dataclassclass User: user_id: str display_name: str
这里的 str 主要是在告诉读者和开发工具“这个字段预期是字符串”。它可以提供:
但 Python 在运行代码时,默认不会逐项执行这些注解。即使传入整数,下面这行代码仍然可以创建对象:
user = User(user_id=123, display_name="小林")
这不代表整数是正确的数据,只代表类型注解本身不是验证器。如果数据来自 JSON、用户输入或外部 API,还要在运行时检查实际值:
def create_user(value): if not isinstance(value, dict): raise ValueError("用户数据必须是字典") user_id = value.get("user_id") display_name = value.get("display_name") if not isinstance(user_id, str) or not user_id.strip(): raise ValueError("user_id 必须是非空字符串") if not isinstance(display_name, str) or not display_name.strip(): raise ValueError("display_name 必须是非空字符串") return User(user_id.strip(), display_name.strip())
这里的 isinstance(user_id, str) 会在程序运行时判断 user_id 是否真的是字符串。
可以这样区分二者:
类型注解描述开发者的预期,运行时验证负责检查真实外部数据。
8. 值可能为空时:可选类型
有些字段并不是每次都有内容。例如模型调用成功时,没有错误消息;调用失败时,才需要保存错误原因:
@dataclassclass ModelResponse: content: str error_message: str | None = None
str | None 表示值可以是 str 或 None:
success = ModelResponse(content="回答成功")failure = ModelResponse( content="", error_message="模型暂时不可用",)
也可以使用 Optional:
from typing import Optionalerror_message: Optional[str] = None
在这里:
Optional[str]
与:
str | None
表达相同含义。本课程使用 Python 3.11 或更高版本时,优先采用更直接的 str | None。
注意,“可选类型”说的是值可以为 None,不是说创建对象时一定可以省略这个参数。参数能否省略,取决于字段是否提供了默认值。
9. 值可能属于多种类型时:联合类型
如果请求编号有时来自系统生成的整数,有时来自外部服务的字符串,可以把两种可能都写出来:
request_id: str | int
传统写法是:
from typing import Unionrequest_id: Union[str, int]
两者表达相同含义。
联合类型应该有明确原因。不要为了“什么值都能传”而不断追加类型;可能性越多,使用这个字段的代码就越难处理。
如果一个字段的不同类型代表完全不同状态,通常更适合拆成不同数据模型,或重新设计字段。
10. 说明容器里保存什么
只写 list 或 dict,读者仍然不知道容器里保存的是什么。类型注解可以继续说明内部元素:
messages: list[Message]metadata: dict[str, str]allowed_roles: tuple[str, ...]tags: set[str]
含义分别是:
- •
list[Message]:元素都是 Message 的列表。 - •
dict[str, str]:键和值都是字符串的字典。 - •
tuple[str, ...]:包含任意数量字符串的元组。
固定长度且每个位置类型不同的元组可以写成:
result: tuple[str, int]
它表示第一项是字符串,第二项是整数。
嵌套类型要以可读性为限:
history_by_user: dict[str, list[Message]]
如果类型长到很难读,考虑建立新的数据类,而不是继续堆叠容器。
11. 消息、用户与模型响应
from dataclasses import dataclass@dataclassclass User: user_id: str display_name: str@dataclassclass Message: role: str content: str def __post_init__(self): self.role = self.role.strip().lower() self.content = self.content.strip() if self.role not in ("system", "user", "assistant"): raise ValueError("消息角色无效") if not self.content: raise ValueError("消息内容不能为空")@dataclassclass ModelResponse: content: str model: str finish_reason: str | None = None usage_tokens: int | None = None
每个数据类只表达一个清楚的概念。和到处传递字典相比,调用者不必再凭记忆猜测有哪些键,编辑器也能根据字段提供提示。
12. 数据类与 JSON
JSON 模块处理的是字典、列表、字符串等基础数据,不能直接识别任意数据类。asdict() 可以先把数据类递归转换为字典:
from dataclasses import asdictmessage = Message("user", "解释数据类")message_data = asdict(message)print(message_data)# {'role': 'user', 'content': '解释数据类'}
转换后的字典可以交给 json.dumps():
import jsontext = json.dumps( message_data, ensure_ascii=False, indent=2,)print(text)
反过来,从 JSON 得到的只是外部字典,内容可能缺失或类型错误。应该先读取和检查字段,再创建数据类:
def message_from_dict(value) -> Message: if not isinstance(value, dict): raise ValueError("消息必须是字典") role = value.get("role") content = value.get("content") if not isinstance(role, str): raise ValueError("role 必须是字符串") if not isinstance(content, str): raise ValueError("content 必须是字符串") return Message(role=role, content=content)
message_from_dict() 先检查外层结构和字段类型,Message.__post_init__() 再检查角色范围和非空内容。两层检查各自负责一部分规则。
13. 数据类中的会话
from dataclasses import dataclass, field@dataclassclass ChatSession: title: str messages: list[Message] = field(default_factory=list) def __post_init__(self): self.title = self.title.strip() if not self.title: raise ValueError("会话标题不能为空") def add_message(self, message: Message) -> None: self.messages.append(message) def recent_messages(self, limit: int = 5) -> list[Message]: if limit <= 0: raise ValueError("limit 必须大于 0") return self.messages[-limit:].copy()
使用:
session = ChatSession("数据类练习")session.add_message(Message("user", "什么是 default_factory?"))print(session.recent_messages())
数据类并不等于“只能放字段、不能写方法”。ChatSession 的主体仍是一组明确的会话数据,add_message() 和 recent_messages() 又紧密围绕这些数据工作,因此使用数据类仍然合理。
14. 该选数据类、普通类还是字典
| |
|---|
| 数据结构暂时不固定,或刚从 JSON 读取、尚未验证 |
| |
| 主要任务是执行操作、协调其他对象,或初始化过程较复杂 |
可以先问自己:“这个对象最主要是在表示数据,还是在完成任务?”在本章项目中:
- •
Message、User、ModelResponse:数据类。 - •
ChatSession:数据类或普通类都可以,取决于封装要求。 - •
ModelClient、ChatService:普通类,因为主要职责是行为和协作。 - • JSON 刚解析出来的内容:先保留为字典,验证通过后再转换成数据类。
常见错误
把类型注解当成验证器
Python 默认不会仅靠 content: str 拒绝整数。外部数据仍需 isinstance() 或明确构造函数验证。
使用共享可变默认值
列表、字典和集合字段使用 field(default_factory=...),不要为所有实例共享同一个对象。
把所有类都改成数据类
模型客户端的核心是行为,不需要为了统一形式强行使用 @dataclass。
创建过于复杂的联合类型
如果调用者需要大量 isinstance() 才能判断字段含义,优先重新设计数据模型。
动手练习
练习 1:消息模型
定义 Message 数据类,包含 role 和 content。使用 __post_init__ 清理并验证数据,测试三个有效角色、空内容和无效角色。
练习 2:响应模型
定义 ModelResponse:
- •
finish_reason: str | None = None。 - •
usage_tokens: int | None = None。
创建成功与失败示例,并解释每个可选字段。
练习 3:会话与 JSON
定义使用 default_factory=list 的 ChatSession,加入两条 Message,使用 asdict() 和 json.dumps() 导出。再编写 message_from_dict() 恢复并验证消息。
随堂小测
- 2.
@dataclass 如何知道哪些名字是字段? - 3. 为什么列表字段要使用
default_factory? - 4.
__post_init__ 在什么时候执行? - 6.
Optional[str] 是否表示函数参数可以省略? - 7.
list[Message] 提供了什么信息?
参考答案
- 3. 保证每个实例调用工厂函数,获得独立的新列表。
- 4. 数据类生成的初始化方法保存字段之后自动执行。
- 7. 说明这是一个元素预期为
Message 的列表,帮助读者、编辑器和类型检查工具。 - 8. 类型注解不会自动保证运行时数据正确,外部数据可能缺字段、类型错误或违反业务规则。
- 9.
== 比较字段值,is 判断是否为同一个对象。