《Python编程入门》6.2 继承、组合与 dataclass

本节讲清继承语法与 super() 的含义——它不是父类而是 MRO 里的下一个类,并演示菱形问题。接着介绍 abc.ABC 的约束力与组合优于继承的判断标准,再拆解 dataclass 的 field、default_factory、frozen、order、slots 等参数,用选型表对比 dataclass、NamedTuple 与 TypedDict,并演示 PEP 695 泛型类。

本节目标:理解 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

三者都用来描述「一组字段」,但用途不同:

维度@dataclassNamedTupleTypedDict
底层类型普通类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 魔术方法与运算符重载 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时