本节目标:理解
super()在 MRO 里的真实语义,掌握多重继承、抽象基类与「组合优于继承」的判断标准,并能熟练用@dataclass的各个参数生成__init__/__repr__/__eq__,在 dataclass、NamedTuple、TypedDict 之间做出正确选型。
适用版本:Python 3.12+(实测 3.14.6)
6.2 继承、组合与 dataclass
上一节我们学会了怎么定义一个类。但类与类之间还有关系:有的类「是一种」另一个类,有的类「拥有」另一个类。选错关系会让代码越写越脆。本节先讲继承与 super(),再给出「什么时候不该继承」的判断标准,最后用 dataclass 把数据类样板代码压缩到几行。
6.2.1 继承的语法与 super() 的真实含义
继承的语法是 class 子类(基类):。最容易误解的是 super()——很多人以为它是「父类」,其实它是「MRO 中当前类之后的那个类」:
class Base:
def __init__(self, name):
self.name = name
print("Base.__init__", name)
class Mixin:
def __init__(self, name):
print("Mixin.__init__", name)
super().__init__(name)
class Person(Mixin, Base):
def __init__(self, name, age):
self.age = age
super().__init__(name)
print("Person.__init__", name, age)
print("Person mro:", [c.__name__ for c in Person.__mro__])
p = Person("Ada", 36)
print("p.name, p.age:", p.name, p.age)
输出:
Person mro: ['Person', 'Mixin', 'Base', 'object']
Mixin.__init__ Ada
Base.__init__ Ada
Person.__init__ Ada 36
p.name, p.age: Ada 36
注意 Person.__init__ 里调 super().__init__(name),跳到的不是 object,也不是直接父类,而是 MRO 里的下一站 Mixin;Mixin.__init__ 里再调一次 super().__init__,才轮到 Base。这就是「协作式继承」:每个类只管自己那段,把剩下的交给 super() 顺链传递。
6.2.2 多重继承与菱形问题
当两条继承链最终汇到同一个祖先,就形成「菱形」:
object
|
Root
/ \
Left Right
\ /
Leaf
如果 Left 和 Right 都调用 super().__init__(),Root.__init__ 会不会被跑两遍?不会——因为 MRO 是线性的,Root 只出现一次:
class Root:
def ping(self): print("Root.ping")
class Left(Root):
def ping(self):
print("Left.ping"); super().ping()
class Right(Root):
def ping(self):
print("Right.ping"); super().ping()
class Leaf(Left, Right):
def ping(self):
print("Leaf.ping"); super().ping()
print("Leaf mro:", [c.__name__ for c in Leaf.__mro__])
Leaf().ping()
输出:
Leaf mro: ['Leaf', 'Left', 'Right', 'Root', 'object']
Leaf.ping
Left.ping
Right.ping
Root.ping
Root.ping 只执行了一次。菱形问题之所以不再可怕,是因为 MRO 由 C3 线性化算成一条不重复的链,super() 沿着这条链走,天然避免了重复调用和顺序歧义。
6.2.3 抽象基类:abc.ABC 与 @abstractmethod
如果你希望某个基类「只定义接口、不许被实例化」,用 abc.ABC 和 @abstractmethod:
from abc import ABC, abstractmethod
import math
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
@abstractmethod
def perimeter(self) -> float: ...
def describe(self) -> str:
return f"{type(self).__name__}: area={self.area():.2f}"
class Circle(Shape):
def __init__(self, r): self.r = r
def area(self): return math.pi * self.r ** 2
def perimeter(self): return 2 * math.pi * self.r
print(Circle(2).describe())
输出:
Circle: area=12.57
约束力体现在两处。第一,抽象类本身不能实例化:
Shape()
# TypeError: Can't instantiate abstract class Shape without an implementation
# for abstract methods 'area', 'perimeter'
第二,子类只要漏实现任何一个抽象方法,实例化就报错:
class Bad(Shape):
def area(self): return 1.0
Bad()
# TypeError: Can't instantiate abstract class Bad without an implementation
# for abstract method 'perimeter'
这把「接口契约」从口头约定变成了运行时的硬约束,比只写文档可靠得多。
6.2.4 组合优于继承
继承表达「is-a」,组合表达「has-a」。判断标准很简单:子类在语义上真的「是一种」父类吗?替换后行为是否仍然正确(里氏替换)? 违反时,继承就成了负担:
class Bird:
def fly(self): return "flying"
class Penguin(Bird):
def fly(self): raise NotImplementedError("企鹅不会飞") # 反例
企鹅「是一种」鸟,但不会飞,于是子类不得不推翻父类行为——这正是继承用错的信号。改用组合:
class Swimmer:
def swim(self): return "swimming"
class Penguin2:
def __init__(self): self.swimmer = Swimmer() # has-a
def swim(self): return self.swimmer.swim()
p = Penguin2()
print("composition:", p.swim())
print("has fly?", hasattr(p, "fly"))
输出:
composition: swimming
has fly? False
判断清单:只是想复用几个方法 → 用组合或模块级函数;需要被当成父类替换、且语义成立 → 才用继承;只想统一接口 → 用 abc.ABC 或 typing.Protocol。
6.2.5 @dataclass 全解
写数据类时,__init__、__repr__、__eq__ 全是样板。@dataclass 自动生成它们:
from dataclasses import dataclass, field, asdict
@dataclass
class Point:
x: int
y: int = 0
p = Point(1, 2)
print("repr:", p)
print("eq:", Point(1, 2) == Point(1, 2))
print("asdict:", asdict(p))
输出:
repr: Point(x=1, y=2)
eq: True
asdict: {'x': 1, 'y': 2}
字段有默认值时不能把无默认值的字段排在后面,否则定义类时就报错:
TypeError: non-default argument 'y' follows default argument 'x'
可变默认值必须用 field(default_factory=...),绝不能写 = []:
@dataclass
class Bag:
items: list = field(default_factory=list)
tags: set = field(default_factory=set)
b1, b2 = Bag(), Bag()
b1.items.append("a")
print("b2.items:", b2.items) # [] —— 各自独立
其余常用参数一张表说清:
| 参数 | 作用 | 示例效果 |
|---|---|---|
field(default_factory=...) | 每个实例各建一份可变默认值 | 避免共享 []/{} |
frozen=True | 实例不可变,并自动可哈希 | 赋值抛 FrozenInstanceError |
order=True | 生成 <、<=、>、>= | 可直接 sorted() |
slots=True | 自动生成 __slots__ | 省内存,无 __dict__ |
kw_only=True | 所有字段只能按关键字传 | 强制 Conf(host="x") |
@dataclass(frozen=True)
class FrozenPoint:
x: int
y: int
fp = FrozenPoint(1, 2)
print("hashable:", hash(fp) == hash(FrozenPoint(1, 2))) # True
# fp.x = 9 → FrozenInstanceError: cannot assign to field 'x'
frozen=True 让对象可哈希,于是能放进 set、当 dict 的键——这是它最实用的副产品。order=True 则让版本号之类的对象能直接比较:
@dataclass(order=True)
class Version:
major: int
minor: int
patch: int = 0
print(sorted([Version(3, 14, 0), Version(3, 9, 1), Version(3, 14, 2)]))
# [Version(major=3, minor=9, patch=1), Version(major=3, minor=14, patch=0), Version(major=3, minor=14, patch=2)]
6.2.6 dataclass vs NamedTuple vs TypedDict
三者都用来描述「一组字段」,但用途不同:
| 维度 | @dataclass | NamedTuple | TypedDict |
|---|---|---|---|
| 底层类型 | 普通类 | tuple 子类 | dict 子类 |
| 可变 | 默认可变 | 不可变 | 可变 |
| 可哈希 | 仅 frozen=True | 天生可哈希 | 不可哈希 |
| 支持方法 | 是 | 是 | 否(纯结构) |
| 运行期开销 | 低 | 极低(无 __dict__) | 无额外对象 |
| 典型场景 | 领域模型、配置 | 轻量坐标/记录、返回值 | JSON / API 数据结构 |
经验法则:要行为用 dataclass,要不可变轻量记录用 NamedTuple,描述外部 JSON 结构用 TypedDict。
6.2.7 dataclass 与 PEP 695 泛型
3.12 起支持 PEP 695 泛型语法,可直接给 dataclass 加类型参数:
from dataclasses import dataclass
@dataclass
class Stack[T]:
items: list[T]
def push(self, item: T) -> None:
self.items.append(item)
def pop(self) -> T:
return self.items.pop()
s = Stack[int]([])
s.push(1); s.push(2)
print("Stack:", s, "pop:", s.pop())
print("__type_params__:", Stack.__type_params__)
输出:
Stack: Stack(items=[1]) pop: 2
__type_params__: (T,)
class Stack[T]: 把类型参数直接写在类名后,比旧式 TypeVar + Generic[T] 更简洁,Stack.__type_params__ 可以验证它确实带上了类型参数。运行期依然不做类型检查,类型参数只服务于静态分析。
小结
super()不是「父类」,而是「MRO 里的下一站」;协作式继承靠它顺链传递,每层只管自己。- 多重继承的菱形问题由 C3 线性化化解:MRO 不重复,
Root只执行一次。 abc.ABC+@abstractmethod把接口契约变成运行时硬约束:漏实现任一抽象方法就无法实例化。- 组合优于继承的判断标准是里氏替换;只是想复用方法就用组合或模块级函数,只想统一接口用
ABC/Protocol。 @dataclass自动生成__init__/__repr__/__eq__;可变默认值必须用field(default_factory=...)。frozen=True顺带带来可哈希,order=True带来可比较,slots=True省内存,kw_only=True强制关键字传参。- 选型口诀:要行为选
dataclass,要不可变轻量记录选NamedTuple,描述 JSON 结构选TypedDict。 - 3.12+ 可用 PEP 695 语法
class Stack[T]:直接定义泛型数据类。
我们已经能定义出结构良好、关系清晰的类了。但一个对象被 print、被放进 set、被 len()、被 + 运算时会发生什么,取决于一组「魔术方法」。下一节 魔术方法与运算符重载
会把它们一次讲清。
阅读导航:上一节:6.1 类、实例与属性查找(MRO) · 下一节:6.3 魔术方法与运算符重载 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。