第五章 数据类构建器

第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)FalseNamedTuple 是构建工具,非基类)
issubclass(Coordinate, tuple)True

typing.NamedTuplecollections.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)
参数默认值功能说明
initTrue自动生成 __init__;若用户已定义则忽略
reprTrue自动生成 __repr__;若用户已定义则忽略
eqTrue自动生成 __eq__;若用户已定义则忽略
orderFalse自动生成 __lt____le____gt____ge__ 方法;当 eq=Falseorder=True或者已经定义或者继承了任一比较方法时会抛出异常。
unsafe_hashFalse强制生成 __hash__
frozenFalse模拟不可变性,生成__setattr__/__delattr__,用户尝试设置或者删除字段时抛出 FrozenInstanceError
  • eq=Truefrozen=True → 自动生成 __hash__,可哈希
  • eq=Truefrozen=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 是一个哨兵值,表示该选项未提供。

参数默认值说明
defaultMISSING字段默认值(当需配合其他选项时使用)
default_factoryMISSING无参可调用对象,用于生成默认值
initTrue是否包含在 __init__ 参数中
reprTrue是否包含在 __repr__ 输出中
compareTrue是否用于 ==< 等比较
hashNoneNone 仅当 compare=True 时,该字段才会被用于 __hash__ 计算;False 表示排除在哈希外
metadataNone用户自定义数据(@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)是指带有类型注解、且未被标记为 ClassVarInitVar 的类变量。 它会被 @dataclass 识别为“数据字段”,并用于自动生成 __init____repr____eq__ 等特殊方法。

  1. 实例字段必须有类型注解

    name: str        # ✅ 是实例字段
    age: int = 30    # ✅ 是实例字段(带默认值)
    
  2. 不是 ClassVar(否则是类属性)

    from typing import ClassVar
    count: ClassVar[int] = 0  # ❌ 不是实例字段,是类属性
    
  3. 不是 InitVar(否则是 init-only 伪字段)

    from dataclasses import InitVar
    config: InitVar[dict]     # ❌ 不是实例字段
    
  4. 每个实例拥有独立的值
    实例字段的值存储在实例的 __dict__(或 __slots__)中,不同实例互不影响。

  5. 参与数据类的自动生成逻辑

    • 出现在 __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"
  • nameagescores 都是实例字段

  • 创建两个实例:

    p1 = Person("Alice")
    p2 = Person("Bob")
    
    • p1.namep2.name 是独立的。
    • p1.scores is p2.scoresFalse(因为用了 default_factory)。
  • species 是类属性,所有实例共享:p1.species is p2.speciesTrue

使用 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

dictfloatfrozensetintlistsetstrtuple

case [str(name), _, _, (float(lat), float(lon))]:
  • str(name)name 绑定到整个字符串对象(因为 str 属于九个内置类型)
  • 同理,latlon 分别绑定到两个 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)

适用于部分属性顺序固定、部分需显式命名的场景。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值