第5章 数据类构建器
**数据类(Data Class)**是一种仅包含若干字段、几乎不含额外行为的简单类,其主要用于快速构建用于存储数据的结构。
传统手动编写的简单类存在需重复编写模板化的 __init__ 代码;缺乏实用的 __repr__(仅显示内存地址);缺乏有意义的 __eq__(仅比较对象 ID,而非内容);需手动逐字段比较才能判断逻辑相等问题。
Python 提供三种主流数据类构建器,均不依赖继承实现功能注入,而是通过元编程技术(元类或类装饰器)在类创建时自动添加方法。
典型具名元组
collections.namedtuple 是一个工厂函数,用于创建具有命名字段的 tuple 子类。其核心优势包括:保持与普通元组相同的内存效率(字段名存储在类中,而非每个实例);兼容所有元组操作(索引、解包、比较等);提供可读性强的 __repr__;可无缝替换标准库中返回普通元组的函数,完全向后兼容。
from collections import namedtuple
# 创建命名元组需要类名和字段名列表两个参数
# 字段名可以以字符串可迭代对象的形式提供,也可以以单个空格分隔的字符串形式提供
# 单个空格分隔的字符串形式
City = namedtuple('City', 'name country population coordinates')
# 列表形式:
# City = namedtuple('City', ['name', 'country', 'population', 'coordinates'])
tokyo = City('Tokyo', 'JP', 36.933, (35.689722, 139.691667))
print(tokyo) # City(name='Tokyo', country='JP', population=36.933, coordinates=(35.689722, 139.691667))
print(tokyo.population) # 36.933(通过字段名)
print(tokyo[1]) # 'JP'(通过索引)
collections.namedtuple继承了 tuple 的所有方法(如 __eq__, __lt__),可用于排序
属性与方法
# _fields 是一个包含该类所有字段名的元组。
print(City._fields)
# ('name', 'country', 'population', 'coordinates')
# _make(iterable):从可迭代对象构建实例(等价于 City(*data))
Coordinate = namedtuple('Coordinate', 'lat lon')
delhi_data = ('Delhi NCR', 'IN', 21.935, Coordinate(28.613889, 77.208889))
delhi = City._make(delhi_data)
# _asdict():返回字段名到值的字典
d = delhi._asdict()
# {'name': 'Delhi NCR', 'country': 'IN', 'population': 21.935,
# 'coordinates': Coordinate(lat=28.613889, lon=77.208889)}
# 序列化示例
import json
json_str = json.dumps(d) # 自动处理嵌套命名元组(转为列表)
默认值支持
通过 defaults 参数为最右侧 N 个字段设置默认值:
Coordinate = namedtuple('Coordinate', 'lat lon reference', defaults=['WGS84'])
c = Coordinate(0, 0)
print(c) # Coordinate(lat=0, lon=0, reference='WGS84')
print(Coordinate._field_defaults) # {'reference': 'WGS84'}
defaults 必须是可迭代对象,长度 ≤ 字段数,默认值按从右到左顺序分配。
方法注入
虽然 namedtuple 不支持类语句语法,但可通过动态赋值添加方法:
from collections import namedtuple
Card = namedtuple('Card', ['rank', 'suit'])
# 1. 添加类属性
Card.suit_values = {'spades': 3, 'hearts': 2, 'diamonds': 1, 'clubs': 0}
# 2. 定义函数(第一个参数将自动接收实例)
def spades_high(card):
rank_value = ['2', '3', ..., 'A'].index(card.rank) # 假设 ranks 已定义
suit_value = card.suit_values[card.suit]
return rank_value * len(card.suit_values) + suit_value
# 3. 将函数绑定为方法
Card.overall_rank = spades_high
# 使用
lowest = Card('2', 'clubs')
highest = Card('A', 'spades')
print(lowest.overall_rank()) # 0
print(highest.overall_rank()) # 51
带类型具名元组
typing.NamedTuple 本质是基于元类的类构建器,也返回 tuple 子类。其优势在于支持 PEP 526 变量注解语法;支持在类语句中定义方法、文档字符串;自动为字段添加类型注解;允许在类语句中声明字段类型和默认值。
import typing
Coordinate = typing.NamedTuple('Coordinate', [('lat', float), ('lon', float)])
issubclass(Coordinate, tuple) # True
typing.get_type_hints(Coordinate)
# {'lat': <class 'float'>, 'lon': <class 'float'>}
# 通过关键字参数的形式构造
Coordinate = typing.NamedTuple('Coordinate', lat=float, lon=float)
from typing import NamedTuple
class Coordinate(NamedTuple):
# 每个字段必须有类型注解
lat: float
lon: float
# 支持为字段提供默认值
reference: str = 'WGS84'
# 类语句风格
# 可重写或者添加新的方法
from typing import NamedTuple
class Coordinate(NamedTuple):
lat: float
lon: float
# 自定义了用于格式化显示的str方法
def __str__(self):
ns = 'N' if self.lat >= 0 else 'S'
we = 'E' if self.lon >= 0 else 'W'
return f'{abs(self.lat):.1f}°{ns}, {abs(self.lon):.1f}°{we}'
issubclass(Coordinate, NamedTuple) 为 False(NamedTuple 是构建工具,非基类)
issubclass(Coordinate, tuple) 为 True。
typing.NamedTuple 和collections.namedtuple两者生成的类功能等价,但 typing.NamedTuple 更符合现代 Python 风格。通过 typing.NamedTuple 创建的类,除了 collections.namedtuple 本身生成的方法(如 _fields、_make()、_asdict() 等)以及从 tuple 继承的方法(如 __eq__、索引访问等)之外,不包含其他额外方法。区别在于typing.NamedTuple 生成的类会包含 __annotations__ 类属性。
@dataclass
本质是类装饰器,不改变继承关系(默认继承 object)。优势在于支持 PEP 526 变量注解;默认生成可变实例(可通过 frozen=True 设为不可变);支持类语句语法,便于自定义方法。
from dataclasses import dataclass
# 与上一代码相同 区别只在于声明的方式
@dataclass(frozen=True)
class Coordinate:
lat: float
lon: float
def __str__(self):
ns = 'N' if self.lat >= 0 else 'S'
we = 'E' if self.lon >= 0 else 'W'
return f'{abs(self.lat):.1f}°{ns}, {abs(self.lon):.1f}°{we}'
typing.NamedTuple 和 @dataclass 均将字段类型存入类的 __annotations__ 属性。但不建议直接访问 __annotations__。
关键字参数
@dataclass 装饰器支持多个仅限关键字参数,用于控制自动生成的特殊方法
@dataclass(*, init=True, repr=True, eq=True, order=False,
unsafe_hash=False, frozen=False)
| 参数 | 默认值 | 功能说明 |
|---|---|---|
init | True | 自动生成 __init__;若用户已定义则忽略 |
repr | True | 自动生成 __repr__;若用户已定义则忽略 |
eq | True | 自动生成 __eq__;若用户已定义则忽略 |
order | False | 自动生成 __lt__、__le__、__gt__、__ge__ 方法;当 eq=False但order=True或者已经定义或者继承了任一比较方法时会抛出异常。 |
unsafe_hash | False | 强制生成 __hash__ |
frozen | False | 模拟不可变性,生成__setattr__/__delattr__,用户尝试设置或者删除字段时抛出 FrozenInstanceError |
eq=True且frozen=True→ 自动生成__hash__,可哈希eq=True但frozen=False→__hash__ = None,不可哈希eq=False→ 保留父类__hash__
字段选项
最基本的字段选项即在类型提示中提供(或不提供)默认值。Python 不允许带默认值的参数之后出现不带默认值的参数,一旦声明了一个带默认值的字段,其后所有字段也必须提供默认值。
可变默认值
在函数定义中,若某次调用修改了可变默认值,会影响后续调用的行为。@dataclass 会使用类型提示中的默认值为 __init__ 生成带默认值的参数。为防止 bug,@dataclass 会拒绝如下的类定义。
# 错误示例 会抛出 ValueError
@dataclass
class ClubMember:
name: str
# 可变默认值是不被允许的
guests: list = [] # ❌ mutable default not allowed
# 使用 default_factory 可以解决这个问题
from dataclasses import dataclass, field
@dataclass
class ClubMember:
name: str
# dfault_factory 参数允许提供一个函数、类或其他可调用对象,它会在每次创建数据类实例时被无参调用,从而为每个实例生成独立的默认值
# 每个实例独立列表
# 此处支持参数化泛型类型
guests: list[str] = field(default_factory=list)
注意:
@dataclass仅对list/dict/set等内置可变类型报错,其他可变对象需自行防范。
field() 函数参数
dataclasses.MISSING 是一个哨兵值,表示该选项未提供。
| 参数 | 默认值 | 说明 |
|---|---|---|
default | MISSING | 字段默认值(当需配合其他选项时使用) |
default_factory | MISSING | 无参可调用对象,用于生成默认值 |
init | True | 是否包含在 __init__ 参数中 |
repr | True | 是否包含在 __repr__ 输出中 |
compare | True | 是否用于 ==、< 等比较 |
hash | None | None 仅当 compare=True 时,该字段才会被用于 __hash__ 计算;False 表示排除在哈希外 |
metadata | None | 用户自定义数据(@dataclass 忽略) |
# 创建一个隐藏字段
@dataclass
class ClubMember:
name: str
guests: list = field(default_factory=list)
athlete: bool = field(default=False, repr=False) # 不显示在 repr 中
初始化后处理
@dataclass 生成的 __init__() 方法仅负责将参数(或默认值)赋给实例字段。若需在初始化后执行额外逻辑(如验证、计算派生字段等),可定义 __post_init__() 方法。 只要该方法存在,@dataclass 就会在其生成的 __init__() 末尾自动调用 self.__post_init__()。
初始化后处理主要用于字段验证(如唯一性检查)和基于其他字段计算新字段值。
预期行为
# 提供 handle(关键字参数)
>>> anna = HackerClubMember('Anna Ravenscroft', handle='AnnaRaven')
>>> anna
HackerClubMember(name='Anna Ravenscroft', guests=[], handle='AnnaRaven')
# 未提供 handle:自动设为 name 的第一部分
>>> leo = HackerClubMember('Leo Rochael')
>>> leo
HackerClubMember(name='Leo Rochael', guests=[], handle='Leo')
# handle 冲突:抛出 ValueError
>>> leo2 = HackerClubMember('Leo DaVinci')
Traceback (most recent call last):
...
ValueError: handle 'Leo' already exists.
# 显式指定唯一 handle 即可成功
>>> leo2 = HackerClubMember('Leo DaVinci', handle='Neo')
>>> leo2
HackerClubMember(name='Leo DaVinci', guests=[], handle='Neo')
具体实现
from dataclasses import dataclass
from club import ClubMember
@dataclass
class HackerClubMember(ClubMember):
all_handles = set() # 类属性:记录所有已注册的 handle
handle: str = '' # 实例字段,默认为空字符串(可选)
def __post_init__(self):
cls = self.__class__
# 若未提供 handle,设为 name 的第一部分
if self.handle == '':
self.handle = self.name.split()[0]
# 检查 handle 唯一性
if self.handle in cls.all_handles:
raise ValueError(f'handle {self.handle!r} already exists.')
# 注册新 handle
cls.all_handles.add(self.handle)
上述代码无法通过静态类型检查器(如 mypy),原因在于all_handles 是类属性,但未使用 typing.ClassVar 标注;类型检查器可能误认为 all_handles 是实例字段。
带类型的类属性
实例字段和类属性
在 Python 的 dataclasses(数据类)机制中,实例字段(instance field)是指带有类型注解、且未被标记为 ClassVar 或 InitVar 的类变量。 它会被 @dataclass 识别为“数据字段”,并用于自动生成 __init__、__repr__、__eq__ 等特殊方法。
-
实例字段必须有类型注解
name: str # ✅ 是实例字段 age: int = 30 # ✅ 是实例字段(带默认值) -
不是
ClassVar(否则是类属性)from typing import ClassVar count: ClassVar[int] = 0 # ❌ 不是实例字段,是类属性 -
不是
InitVar(否则是 init-only 伪字段)from dataclasses import InitVar config: InitVar[dict] # ❌ 不是实例字段 -
每个实例拥有独立的值
实例字段的值存储在实例的__dict__(或__slots__)中,不同实例互不影响。 -
参与数据类的自动生成逻辑
- 出现在
__init__()参数列表中(除非init=False) - 出现在
__repr__()输出中(除非repr=False) - 参与
__eq__()比较(除非compare=False) - 可能参与
__hash__()(取决于hash参数)
- 出现在
from dataclasses import dataclass, field
from typing import ClassVar
@dataclass
class Person:
name: str # 实例字段
age: int = 0 # 实例字段,带默认值
scores: list = field(default_factory=list) # 实例字段(可变默认值)
# 类属性(不是实例字段)
species: ClassVar[str] = "Homo sapiens"
-
name、age、scores都是实例字段。 -
创建两个实例:
p1 = Person("Alice") p2 = Person("Bob")p1.name和p2.name是独立的。p1.scores is p2.scores为False(因为用了default_factory)。
-
species是类属性,所有实例共享:p1.species is p2.species为True。
使用 dataclasses.fields()可以查看所有实例字段
from dataclasses import fields
print([f.name for f in fields(Person)])
# 输出: ['name', 'age', 'scores']
# 注意:不包含 ClassVar 或 InitVar
typing.ClassVar
为避免 @dataclass 将类属性误认为实例字段,需使用 typing.ClassVar:
from typing import ClassVar
@dataclass
class HackerClubMember(ClubMember):
all_handles: ClassVar[set[str]] = set() # ✅ 明确为类属性
handle: str = ''
仅初始化变量
当需要向 __init__ 传递非实例字段的参数(如数据库连接、配置对象),使用 InitVar:
from dataclasses import InitVar
@dataclass
class C:
i: int
j: int | None = None
# 不是实例字段 不会被dataclasses.fields列出
database: InitVar[DatabaseType | None] = None
def __post_init__(self, database):
if self.j is None and database is not None:
self.j = database.lookup('j')
database 是 __init__ 参数,但不会成为实例属性,但必须在 __post_init__ 签名中显式声明dataclasses.fields() 不会列出 InitVar 字段
完整示例
from dataclasses import dataclass, field
from typing import Optional, ClassVar
from enum import Enum, auto
from datetime import date
class ResourceType(Enum):
BOOK = auto()
EBOOK = auto()
VIDEO = auto()
@dataclass
class Resource:
"""媒体资源描述(基于 Dublin Core 标准)"""
identifier: str # 必填
title: str = '<untitled>' # 首个默认值字段
creators: list[str] = field(default_factory=list)
date: Optional[date] = None
type: ResourceType = ResourceType.BOOK
description: str = ''
language: str = ''
subjects: list[str] = field(default_factory=list)
def __repr__(self):
cls_name = self.__class__.__name__
indent = ' ' * 4
lines = [f'{cls_name}(']
for f in fields(self):
value = getattr(self, f.name)
lines.append(f'{indent}{f.name} = {value!r},')
lines.append(')')
return '\n'.join(lines)
输出效果:
Resource(
identifier = '978-0-13-475759-9',
title = 'Refactoring, 2nd Edition',
creators = ['Martin Fowler', 'Kent Beck'],
date = datetime.date(2018, 11, 19),
type = <ResourceType.BOOK: 1>,
...
)
代码异味
代码异味(Code Smell) 是系统中更深层次问题的表面征兆,代码异味是易识别的,比如方法过长就容易被发现。但是代码异味本身不等于问题,而是问题的指示器,需进一步判断是否存在真正缺陷。
数据类是一种典型的代码异味,其仅包含字段及对应的 getter/setter 方法,没有自身行为。被称为“哑的数据容器”,常被其他类过度细致地操作。这违背面向对象核心原则,数据与行为应封装在同一类中。数据类本身不是罪过,但若长期作为核心领域模型存在且无行为,则很可能暴露设计缺陷。关键在于判断其角色是否合理,并在适当时机通过重构将行为归还给数据所属的类。
模式匹配类实例
类模式(class patterns) 是 Python 模式匹配(match/case)的重要组成部分,用于根据类型及可选属性匹配任意类的实例(不仅限于数据类)。
简单类模式
语法类似构造函数调用,用于匹配特定类型的实例:
# 正确操作
match x:
# float() 匹配 x 是否为 float 实例。
case float():
do_something_with(x)
# 错误操作
match x:
# float 被视为变量名,会绑定任意 subject
case float:
do_something_with(x)
仅对以下九个内置类型,Type(var) 中的 var 会被绑定到整个匹配对象(而非属性)bytes、
dict、float、frozenset、int、list、set、str、tuple
case [str(name), _, _, (float(lat), float(lon))]:
str(name):name绑定到整个字符串对象(因为str属于九个内置类型)- 同理,
lat和lon分别绑定到两个float实例
若类不属于上述九个类型,则 Class(attr) 中的 attr 被解释为属性匹配,即尝试匹配该实例的 attr 属性。
关键字类模式
适用于任何具有公开实例属性的类,语法清晰、可读性强。
import typing
class City(typing.NamedTuple):
continent: str
name: str
country: str
cities = [
City('Asia', 'Tokyo', 'JP'),
City('Asia', 'Delhi', 'IN'),
City('North America', 'Mexico City', 'MX'),
City('North America', 'New York', 'US'),
City('South America', 'São Paulo', 'BR'),
]
# 匹配亚洲城市
def match_asian_cities():
results = []
for city in cities:
match city:
case City(continent='Asia'):
results.append(city)
return results
# 提取属性值
def match_asian_countries():
results = []
for city in cities:
match city:
case City(continent='Asia', country=cc):
results.append(cc)
return results
# 也可直接使用同名变量
case City(continent='Asia', country=country):
results.append(country)
位置类模式
要求类支持位置匹配,通过 __match_args__ 类属性定义属性顺序。
# 使用位置模式匹配
def match_asian_cities_pos():
results = []
for city in cities:
match city:
case City('Asia'):
results.append(city)
return results
# 提取多个位置属性
def match_asian_countries_pos():
results = []
for city in cities:
match city:
case City('Asia', _, country):
results.append(country)
return results
'Asia'匹配第一个属性(continent)_忽略第二个属性(name)country绑定到第三个属性(country)
__match_args__ 的作用
City 类(继承自 NamedTuple)自动获得:
City.__match_args__ # ('continent', 'name', 'country')
该元组定义了位置模式中各位置对应的属性名。
对于普通自定义类,需手动定义 __match_args__ 才能支持位置模式。
可在同一模式中结合两者:
case City('Asia', country=cc)
适用于部分属性顺序固定、部分需显式命名的场景。

被折叠的 条评论
为什么被折叠?



