类、继承、组合与 dataclass
本节目标
查询 Python 类、方法绑定、继承、MRO、组合和 dataclass 配置。
类把状态和操作组织为可实例化的类型;继承复用并扩展同一种抽象,组合则把协作对象放进实例。本文以 Python 3.14 类教程和 dataclasses 标准库文档为准,并接续可迭代对象、迭代器、生成器与推导式的对象协议。
类定义、类对象与命名空间
class 语句本身在运行时执行:先建立类体使用的命名空间并执行类体,正常结束后再由其中的绑定创建类对象。这个过程不是创建实例;调用类对象才会创建实例,并在定义了 __init__ 时初始化它。类体内的普通赋值成为类属性,函数定义成为类属性中的函数对象。
class Course:
level = "core"
assert Course.level == "core"
from dataclasses import FrozenInstanceError, dataclass, field
class NamedItem:
def __init__(self, name: str) -> None:
self.name = name
def label(self) -> str:
return self.name
class Outline:
def __init__(self, topics: list[str]) -> None:
self.topics = list(topics)
class Workshop(NamedItem):
kind = "workshop"
def __init__(self, name: str, outline: Outline) -> None:
super().__init__(name)
self.outline = outline
@classmethod
def from_topics(cls, name: str, topics: list[str]):
return cls(name, Outline(topics))
@dataclass
class DraftPlan:
topics: list[str] = field(default_factory=list)
@dataclass(frozen=True, slots=True)
class PublishedPlan:
title: str
course = Workshop.from_topics("Python Core", ["data-model", "iteration"])
first_draft = DraftPlan()
second_draft = DraftPlan()
first_draft.topics.append("classes")
published = PublishedPlan("Python Core")
try:
published.title = "Changed"
except FrozenInstanceError:
frozen = True
else:
frozen = False
print(f"course={course.name}")
print(f"kind={course.kind}")
print(f"bound-self={course.label.__self__ is course}")
print(f"mro={[item.__name__ for item in Workshop.__mro__]}")
print(f"outline={course.outline.topics}")
print(f"separate-lists={second_draft.topics == []}")
print(f"frozen={frozen}")
print(f"slots={not hasattr(published, '__dict__')}")
course=Python Core
kind=workshop
bound-self=True
mro=['Workshop', 'NamedItem', 'object']
outline=['data-model', 'iteration']
separate-lists=True
frozen=True
slots=True
类体内的副作用在定义类时就已发生;Course() 则在每次调用时创建并初始化新实例。把两个阶段分开,才能准确判断一段代码影响的是类对象还是实例状态。
实例属性与类属性
类属性属于类并可由所有实例经属性查找读取;self.name = ... 创建或更新的是该实例自己的属性。按照 Python 3.14 描述符指南的查找优先级,实例字典中的同名属性可以遮蔽普通类属性和非数据描述符;property 等数据描述符仍优先于实例字典。尤其不能把会被修改的 list、dict 或 set 当作无意共享的类属性,否则所有实例会看到同一个对象。
class Enrollment:
kind = "workshop"
def __init__(self, name: str) -> None:
self.name = name
Enrollment.tags = [] 只会创建一个由类持有的共享列表,并不是“每个实例一份”的默认值。独立状态应在 __init__ 中创建,dataclass 字段则使用 default_factory。
方法绑定与 self
类属性中的函数经实例访问时会变成绑定方法;绑定后的实例方法会自动把实例作为第一个实参。因此 course.label() 等价于 Workshop.label(course),但 Workshop.label 仍是需要显式传入实例的函数。self 只是清晰、通行的第一个形参名,并非保留字。
class Labelled:
def label(self) -> str:
return "Python"
assert Labelled().label() == "Python"
instance.method 已经绑定该实例,若再显式传入同一对象,调用就会多出一个参数。需要未绑定的函数对象时,应明确从 Class.method 取得。
实例方法、classmethod 与 staticmethod
实例方法接收实例,用于读取或维护对象状态。@classmethod 接收类作为第一个参数,惯例命名为 cls,适合可被子类继承的替代构造器;代表脚本的 from_topics 用 cls(...) 而非硬编码 Workshop(...)。@staticmethod 不自动接收实例或类,只是放在类命名空间中的普通工具函数。
class Parse:
@classmethod
def empty(cls):
return cls()
@staticmethod
def valid(name: str) -> bool:
return bool(name)
逻辑需要读写实例状态时,它就是实例方法而非静态方法。替代构造器若要保留子类类型,应通过 cls 构造,而不能写死具体类名。
初始化、替代构造器与对象不变量
实例化先创建实例,再调用 __init__ 建立可依赖的状态。构造器应在返回前满足对象不变量,例如名称非空、组合对象已就位;@classmethod 可把另一种输入形式转换后调用主构造器。__init__ 只负责初始化,必须返回 None,不能替换刚创建的实例。
class Ticket:
def __init__(self, number: int) -> None:
if number <= 0:
raise ValueError("number must be positive")
self.number = number
构造过程应先验证输入,或者按能保持不变量的顺序安排验证与赋值。这样初始化失败时,就不会让带有部分状态的对象进入正常使用路径。
继承、重写与 super
子类会先在自身及其基类中查找属性,并可重写继承的方法。重写时需要扩展父类行为,应调用协作式 super():它按当前类的 MRO 找到下一个实现,使更多层或多重继承中的各方都有机会参与。直接写 NamedItem.__init__(self, name) 把实现固定到一个基类,绕开了这个协作链。
class Base:
def label(self) -> str:
return "base"
class Child(Base):
def label(self) -> str:
return f"child-{super().label()}"
协作式 super() 要求 MRO 中的参与类都接受并转发兼容参数。一旦把直接点名基类的调用混入这条链,初始化就容易被遗漏或重复执行。
多重继承与 MRO
多重继承中的属性查找顺序由 MRO(方法解析顺序)决定,而不是只按源码中“看起来先写谁”来猜。Class.__mro__ 给出线性化后的实际顺序;它保持各个基类声明的相对顺序、每个父类最多访问一次,并让 super() 能协作地前进。代表脚本的单继承 MRO 明确为 Workshop、NamedItem、object。
class Left:
pass
class Right:
pass
class Both(Left, Right):
pass
菱形继承的真实查找顺序应通过 Both.__mro__ 检查,各层再用 super() 沿该顺序协作。手工分别调用两个父类并不能代替 MRO,也绕过了“每个类在解析顺序中只出现一次”的线性化规则。
组合、委托与继承选择
继承表达“是一个”(Workshop 是 NamedItem),组合表达“有一个”(Workshop 有一个 Outline)。代表脚本把主题列表复制进 Outline,再由 Workshop 持有并通过 course.outline.topics 访问。对象只需把恰当操作委托给其组成部分,不必为了复用实现而假装自己是该组成部分。
class Outline:
def __init__(self, topics: list[str]) -> None:
self.topics = list(topics)
class Course:
def __init__(self, outline: Outline) -> None:
self.outline = outline
替换性不成立时,继承会过度扩大接口并增加 MRO 耦合。组合在这种关系中更贴合语义,也便于独立替换或测试协作对象。
dataclass 生成方法与字段
@dataclass 根据带类型注解的字段生成常用方法。默认会生成 __init__、__repr__ 与值相等的 __eq__;字段的声明顺序也会成为这些方法处理字段的顺序。注解主要识别字段,装饰器并不把它们当作运行时类型检查。需要定制时可自己定义相应方法或用装饰器参数控制生成行为。
from dataclasses import dataclass
@dataclass
class Plan:
title: str
assert Plan("Python").title == "Python"
dataclass 适合以字段为中心的记录类型。若一个类以复杂资源生命周期、非字段式不变量或行为主导的接口为主,应先定义构造与方法合同,而不是只为省去样板代码而套用装饰器。
default_factory、frozen 与 slots 的边界
可变默认值必须用 field(default_factory=list) 等方式取得新对象:default_factory 会为每个需要默认值的实例调用一次零参数工厂。frozen=True 模拟只读约束,但不会让对象图递归地不可变;它阻止字段重新赋值,却不会自动冻结字段指向的可变列表。slots=True 返回一个带生成槽位的新类;在没有提供实例字典的基类时,实例通常没有 __dict__,也不能随意添加新属性。
from dataclasses import dataclass, field
@dataclass(frozen=True, slots=True)
class Published:
title: str
tags: list[str] = field(default_factory=list)
@dataclass 会拒绝 tags: list[str] = [] 这样的不可哈希可变默认值,应改由 default_factory 为各实例创建。frozen=True 不提供深度或传递性不可变;slots=True 也不只是内存优化,它还会改变类对象、属性存储和动态属性的可用性。下一章将把这些属性访问规则延伸到装饰器、描述符与 property。