跳到主要内容

类、继承、组合与 dataclass

本节目标

查询 Python 类、方法绑定、继承、MRO、组合和 dataclass 配置。

类把状态和操作组织为可实例化的类型;继承复用并扩展同一种抽象,组合则把协作对象放进实例。本文以 Python 3.14 类教程dataclasses 标准库文档为准,并接续可迭代对象、迭代器、生成器与推导式的对象协议。

类定义、类对象与命名空间

class 语句本身在运行时执行:先建立类体使用的命名空间并执行类体,正常结束后再由其中的绑定创建类对象。这个过程不是创建实例;调用类对象才会创建实例,并在定义了 __init__ 时初始化它。类体内的普通赋值成为类属性,函数定义成为类属性中的函数对象。

class Course:
level = "core"

assert Course.level == "core"
class_model_report.py
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 等数据描述符仍优先于实例字典。尤其不能把会被修改的 listdictset 当作无意共享的类属性,否则所有实例会看到同一个对象。

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 取得。

实例方法、classmethodstaticmethod

实例方法接收实例,用于读取或维护对象状态。@classmethod 接收类作为第一个参数,惯例命名为 cls,适合可被子类继承的替代构造器;代表脚本的 from_topicscls(...) 而非硬编码 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 明确为 WorkshopNamedItemobject

class Left:
pass

class Right:
pass

class Both(Left, Right):
pass

菱形继承的真实查找顺序应通过 Both.__mro__ 检查,各层再用 super() 沿该顺序协作。手工分别调用两个父类并不能代替 MRO,也绕过了“每个类在解析顺序中只出现一次”的线性化规则。

组合、委托与继承选择

继承表达“是一个”(WorkshopNamedItem),组合表达“有一个”(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_factoryfrozenslots 的边界

可变默认值必须用 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