引言
本章前四篇解决的是「单个类内部」的问题:类与实例、__init__ 初始化、方法与 self、封装与 property。但真实项目的类不是孤岛——几十上百个类之间存在着大量「相似又不同」的关系:狗是动物,柯基是狗;订单里有商品;图形有圆形有矩形。如果每个类都从零写起,相似代码会成片重复;如果复制粘贴,改一处 bug 要同步十几处。
继承(inheritance)就是为此而生:让一个类自动获得另一个类的全部成员,再按需微调。它是面向对象三大特征之一(另两个是第 4 篇的封装与下一篇的多态),也是本章承上启下的枢纽——本节的「子类可被当作父类使用」语义,正是第 6 篇多态与鸭子类型的基石。
本篇将讲清五件事:class Child(Parent) 的语法与 is-a 语义、方法覆盖(override)的规则、super() 调用父类实现的写法、方法解析顺序(MRO)与菱形问题(用真实例子实测 C3 线性化的结果),最后给出「优先组合」(composition over inheritance)的工程取舍。阅读前提:已掌握前四篇的类、__init__、方法与 property。本篇不涉及多态与鸭子类型(第 6 篇)、魔法方法(第 7 篇)。
概念与原理
继承的语义:is-a
class Child(Parent) 表示「Child 是一种 Parent」。这被称为 is-a 关系:狗是一种动物(Dog is an Animal),矩形是一种图形。继承自动带来三样东西:
- 复用:子类不写任何代码,就拥有父类的全部方法与实例属性(前提是父类
__init__被正确执行); - 扩展:子类可以定义自己的新属性、新方法;
- 覆盖(override):子类可以重写同名方法,让同样的调用在新类型上表现出新行为。
判断该不该用继承,只需问一句:「子类是一种父类吗?」 站得住才继承,站不住就用组合(详见后文)。这一问能过滤掉大量错误设计——比如「让程序员类继承铅笔类」(程序员不是铅笔),或「让订单类继承列表类」(订单不是列表,只是恰好包含若干商品)。
Python 里所有类最终都继承自内置的 object——它提供了 __str__、__eq__ 等默认实现的雏形(细节在第 7 篇)。也就是说,类继承的最底层总有 object 兜底,这保证任何对象都有最基础的通用行为。
方法与属性如何被找到
当你调用 d.speak() 时,Python 的查找顺序是:先看 d 实例自身的属性,再看它的类 Dog,类里没有就沿继承链向上找 Animal,直到 object。子类里定义的同名方法会「盖住」父类的——这就是覆盖。覆盖是继承最常用的手段:父类给出通用骨架,子类替换细节。
但覆盖常常不是「全盘重写」,而是「在父类实现的基础上加一点」。super() 就是干这个的:super().__init__(name) 调用父类的构造函数完成基础初始化,然后子类再补自己的字段;super().speak() 拿到父类方法的返回值,再拼接扩展。父类里写过的逻辑不用抄第二遍——不抄,才是覆盖的正确姿势。
MRO:方法查找的路线图
单一继承下,查找路径就是一条直线:Dog → Animal → object。但 Python 允许一个类有多个父类(多重继承,multiple inheritance):class D(B, C) 时,「B、C 谁先?」「B 和 C 共同继承 A 时,A 会出现几次?」就必须有明确的答案,否则方法查找会乱套,父类方法甚至可能被执行两次。
这个答案就是方法解析顺序(Method Resolution Order, MRO)——一个类到 object 的线性顺序,所有方法查找都严格沿它进行。Python 3 采用 C3 线性化算法计算 MRO,规则可以直观理解成三条:
- 子类永远排在父类之前(自己最优先);
- 每个类的父类保持声明顺序(
D(B, C)则 B 在 C 前); - 每个类在列表里只出现一次,共同祖先只出现一次、且排在最后面。
每个类都可以用 ClassName.__mro__ 直接查看自己的查找顺序,这是观察继承结构最直接的工具。若多个父类的声明顺序互相矛盾(A 说 X 在 Y 前,B 说 Y 在 X 前),C3 无法得到一致答案,Python 会在定义类时直接抛 TypeError——把矛盾消灭在萌芽期,而不是等运行时才出错。
菱形问题:真实的多重继承困境
最典型的麻烦是菱形问题(diamond problem):B 和 C 都继承自 A,D 同时继承 B 和 C。依赖关系画出来像一个菱形。若 A 的方法被 B、C 都调用了 super(),链式调用会不会把 A 执行两遍?C3 的答案是:不会——因为 MRO 是 D → B → C → A → object,B 里的 super() 指向的不是 A,而是 MRO 里的下一个类 C,于是调用链变成 D → B → C → A,A 恰好执行一次。这个顺序我们稍后用真实代码实测。
继承 vs 组合:优先组合
继承(is-a)与组合(has-a)都能复用代码。组合(composition)指的是:一个类持有另一个类的实例——汽车有发动机、订单有商品列表。业界普遍原则是优先组合,仅在 is-a 语义成立时才用继承。理由:继承把父类的一切(包括不该暴露的内部)都灌给子类,父类一改,所有子类跟着动,耦合刚性;组合只暴露「我需要的那个成员」,对象间通过明确的方法协作,改动影响面小得多。Python 标准库自身也大量使用组合。判断流程是:先问 is-a 成立吗?不成立 → 组合;成立但只是想偷几个方法 → 也优先考虑组合,继承留给「语义真的是一类」的场景。
操作与实现
最小继承:复用一个模板
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return "……"
class Dog(Animal):
def speak(self):
return "汪汪"
class Cat(Animal):
def speak(self):
return "喵喵"
d = Dog("旺财")
c = Cat("咪咪")
print(d.name, d.speak())
print(c.name, c.speak())
print(isinstance(d, Animal), isinstance(d, Dog))
旺财 汪汪
咪咪 喵喵
True True
d.name 来自 Animal——Dog 没有写任何初始化逻辑,__init__ 沿继承链找到父类的实现执行,这就是复用。speak 在子类里被覆盖,同一个方法名在不同子类上给出不同结果——这就是多态的雏形(详情见下一章第 6 篇)。最终 isinstance(d, Animal) 为 True,实证了 is-a 关系:狗实例在语义上「是」动物。
覆盖与 super():在父类基础上微调
子类构造函数通常要「先完成父类初始化,再补自己的字段」,super().__init__ 保证父类的 name 不重复书写:
class Animal:
def __init__(self, name):
self.name = name
print(f"Animal.__init__ 设置 name={name}")
def speak(self):
return "……"
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name) # 先让父类完成基础初始化
self.breed = breed
print(f"Dog.__init__ 补充 breed={breed}")
def speak(self):
return super().speak() + " 汪汪" # 复用父类实现再扩展
d = Dog("旺财", "柯基")
print(d.name, d.breed)
print(d.speak())
Animal.__init__ 设置 name=旺财
Dog.__init__ 补充 breed=柯基
旺财 柯基
…… 汪汪
执行顺序清楚地印在输出里:「先父后子」。super().speak() 拿到父类的 "……",拼上 " 汪汪"——父类逻辑只写一次,子类只做增量。
观察 mro
先用教科书级的多层继承观察查找顺序(全部实测,无需猜测):
class A:
pass
class B(A):
pass
class C(A):
pass
class D(B, C):
pass
for cls in D.__mro__:
print(cls.__name__)
D
B
C
A
object
D → B → C → A → object:深度优先(先 B 再到 C),父类顺序保持(B 在 C 前),共同祖先 A 只出现一次且位于 object 之前(来自 C3 的「消除重复」)。这五行顺序,就是 D 的实例做任何方法查找的铁律。
菱形问题现场:super() 的下一个是谁
把菱形画出来,让每个 who 都调用 super().who(),看打印顺序——这正是「B 里的 super() 到底指谁」的实测:
class A:
def who(self):
print("A")
class B(A):
def who(self):
print("B")
super().who()
class C(A):
def who(self):
print("C")
super().who()
class D(B, C):
def who(self):
print("D")
super().who()
D().who()
D
B
C
A
输出与 D.__mro__ 逐行吻合:D 里 super() 指向 MRO 的下一个类 B;B 里 super() 指向不是 A,而是 C;C 里的 super() 才轮到 A。于是菱形顶点的 A.who 恰好执行一次——C3 线性化天然化解了「菱形祖先被重复调用」的经典难题。这也解释了为什么「super() 等于 ‘调用当前父类’」的说法在多重继承下是错的:它是「沿 MRO 的下一站」。
组合示例:has-a
再感受组合。汽车「有」发动机——Car 内部持有一个 Engine 实例,通过方法协作:
class Engine:
def __init__(self, capacity):
self.capacity = capacity
def start(self):
return "发动机点火成功"
class Car:
def __init__(self, brand, capacity):
self.brand = brand
self.engine = Engine(capacity) # 组合:Car 持有 Engine
def start(self):
return f"{self.brand}:{self.engine.start()}"
my_car = Car("雷速", 2.0)
print(my_car.start())
print("排量:", my_car.engine.capacity, "L")
雷速:发动机点火成功
排量: 2.0 L
「汽车是发动机的一种」显然不成立,所以这里不用继承,而是组合:Car 把能力委托给持有的 Engine。日后给 Engine 换实现(比如换电机),Car 只需替换持有的对象,自己的代码几乎不动。
易错点与陷阱
易错点一:子类忘了调用 super().__init__。 覆盖了构造函数却没先让父类初始化,父类设置的属性就落不了地——访问时直接崩溃:
class Animal:
def __init__(self, name):
self.name = name
class Dog(Animal):
def __init__(self, name, breed):
self.breed = breed # 忘了调用 super().__init__(name)
d = Dog("旺财", "柯基")
try:
print(d.name)
except AttributeError as e:
print("拦截:", e)
拦截: 'Dog' object has no attribute 'name'
常见的处理习惯:子类也定义了 __init__ 时,第一句话就是 super().__init__(...)——把父类该办的先办完。
易错点二:以为 super() 一定指向「直接父类」。 单一继承下这么说没错;多重继承下 super() 实际指向 MRO 的下一站,可能跳过直接父类、调到旁支(上文菱形实测:B 的 super() 调到了 C)。因此多重继承里设计 super() 调用链,必须以 __mro__ 为唯一依据,不要凭类声明图脑补。
易错点三:父类顺序互相矛盾导致类定义直接报错。 C3 无法调和时,Python 在 class 语句执行时就抛 TypeError,错误信息形如 Cannot create a consistent method resolution order (MRO) for bases X, Y。遇到它别怀疑解释器,回头检查父类声明顺序是否自相矛盾(例如 A(X, Y) 与 B(Y, X) 再被同一子类同时继承)。
易错点四:is-a 不成立却硬用继承。 只想「借几个方法」而让 Order 继承 list,会让订单实例获得 append、sort 等一堆本该被隐藏的操作,还污染了 isinstance 语义(订单「是」列表?)。想借方法,优先组合:self.items: list 持有列表对象,需要的能力用普通方法暴露。继承是「是」的关系,组合是「有」的关系——拿不准时,默认选组合。
小结
class Child(Parent) 表达 is-a 语义,子类自动获得父类成员并可按需扩展或覆盖;覆盖时应通过 super() 复用父类实现,「先父后子」完成初始化增量。方法查找严格沿 MRO 进行:C3 线性化保证子类在前、父类顺序保持、共同祖先唯一且靠后,ClassName.__mro__ 可直接观察;菱形问题中 super() 指向 MRO 的下一站而非直接父类,因而祖先方法恰好执行一次。覆盖需先调 super().__init__,父类声明矛盾会让类定义直接失败;is-a 不成立时优先组合,别让继承成为偷懒的复制手段。
练习与思考题
- 定义
Person(name、greet()返回「你好,我是 {name}」),再定义Student(Person):构造函数接收name与school,用super()完成基础初始化;覆盖greet()返回「你好,我是 {name},就读于 {school}」。写出代码并运行,确认输出符合预期。 - 多层继承实测:定义
class X: pass、class Y(X): pass、class Z(Y): pass、class W(Z, Y): pass,运行print([c.__name__ for c in W.__mro__]),对照本节三条 C3 规则解释输出顺序(提示:Y在Z之后还是之前?为什么?)。 - 思考题:把菱形例子中的
class D(B, C)换成class D(C, B),预测who()链的输出顺序,再运行D.__mro__验证。这个改动说明了父类声明顺序的什么作用? - 设计判断题:以下两处设计,分别该用继承还是组合?a) 电商系统的「优惠券」与「限时折扣券」;b) 订单类想复用「打印金额字符串」的格式化工具。给出理由。