到MRO,掌握多继承与组合设计)
1. 继承到底在继承什么很多朋友学到类和对象这一步总有种“语法都认识但写代码时想不到用”的感觉。其实继承这个特性解决的是日常开发里最扎手的一件事当两个类高度相似时怎么避免没完没了地复制粘贴。说得更直白一点继承就是你站在一个已经写好的类肩膀上只增不减地去扩展新能力。以我自己的教学经验看一个合格的学习路径应该是先搞清楚“继承”解决什么问题再看代码怎么写最后踩几个坑理解它背后的运行逻辑。拿一个具体的例子来说。假设你在做一个模拟项目X里面需要描述人和公司两种对象这两个对象都有“名称”和“地址”。如果不用继承就需要在两个类里各写一遍__init__后续想统一加一个“邮编”字段就要改两处。这还只是两个类如果项目里有五个类有共同属性呢改起来就很痛苦了。继承的核心思路就是把这些公共的部分抽到一个父类基类里让其他类子类、派生类自动获得这些能力。用生活类比就是动物这个大类会呼吸、会移动狗和猫都继承自动物所以狗和猫天然就会呼吸和移动不需要各自再发明一次“呼吸”这个功能。你只需要专注描述“狗会叫”和“猫会喵”这种差异部分就好。class Animal: def __init__(self, name): self.name name def breathe(self): return f{self.name} 正在呼吸 class Dog(Animal): def bark(self): return f{self.name} 汪汪叫 dog Dog(旺财) print(dog.breathe()) # 旺财 正在呼吸 print(dog.bark()) # 旺财 汪汪叫注意看这段代码Dog类的定义里只是加了(Animal)并没有写__init__和breathe。但这只狗既会呼吸又能自己“汪汪叫”。这种子类自动拥有父类所有公开方法的能力就是继承最朴素的用法。1.1 继承背后的设计逻辑继承不是一个炫技语法它背后是一套非常实际的设计思路。第一个是DRY 原则也就是 Dont Repeat Yourself不要重复自己。你每写一遍重复代码都是在给未来的自己埋雷。比如有一天领导说所有动物的名字都要做合法性校验如果代码只有一处你改一处就完了如果代码重复了十份你就要改十处而且很容易漏掉其中一份最后线上出 Bug。第二个是is-a 关系。继承描述的是一种“是一种”的关系。狗是一种动物所以 Dog 继承 Animal 很合理。但如果哪天有人写出Dog(Company)说狗是一家公司这就违背了正常认知代码审核时基本会被打回。判定一个场景是否适合继承其实很简单如果 A 是 B 的一种那就可以考虑用继承如果 A 只是“拥有”B 的某些属性那就应该用组合而不是继承。这一点在文章后面我会单独展开讲很多人栽在这里。第三个是开放封闭原则。父类写好后尽量不去修改它避免影响所有依赖它的子类但代码要允许扩展让新子类加入。继承天然支持这种扩展方式给系统加一个新模块不需要动老代码只要写一个新类继承老类就行。这在大型项目里非常宝贵因为你永远不知道别的团队哪些代码正在依赖你的老类贸然改动很容易引发连锁故障。2. 子类如何跟父类打交道super 的来龙去脉继承让你省去了重复代码但也不是一劳永逸。你很快会遇到一个问题子类想拥有自己的__init__同时又需要父类的__init__帮忙初始化一些公共字段。比如上面的动物例子现在你要给猫加一个属性color猫和狗都要有name那Cat的__init__该怎么写class Cat(Animal): def __init__(self, name, color): # 让父类先去初始化 name super().__init__(name) self.color color cat Cat(咪咪, 橘色) print(cat.name) print(cat.color)这里用到了super()。很多人第一次看到super()会觉得很抽象以为它代表“父类的实例”其实不是。super()是一个代理对象它负责帮你把调用转发到继承链上的下一个类。在单继承场景下它确实等同于调用父类方法但在多继承场景下它的行为远比“父类”两个字复杂。先记着这点等到文章后面讲多继承和菱形继承问题时你就能深刻理解了。2.1 什么情况必须写 super().init()很多初学者的困惑是父类有__init__子类也写了__init__那到底要不要调用super().__init__答案取决于子类的__init__是否覆盖了父类的初始化逻辑。如果你在子类里写了__init__Python 就不会自动再去调用父类的__init__。也就是说父类的__init__里的代码不会被执行除非你显式调用super().__init__()。试着运行下面这段代码class Base: def __init__(self): print(Base 初始化) class Child(Base): def __init__(self): print(Child 初始化) c Child() # 输出结果只有Child 初始化看到了吗“Base 初始化”没有出现。这说明子类的__init__完全遮蔽了父类的__init__。如果父类__init__里有核心字段的赋值比如self.name name那这个操作就不会发生后续如果子类其他方法里用了self.name就会直接报AttributeError。所以一个非常实用的小经验子类重写__init__时如果不确定父类是否还有其他初始化代码要做就无脑加一行super().__init__()这不是多余的啰嗦而是负责。2.2 super() 的搜索顺序这里要引入MRO的概念全称是 Method Resolution Order方法解析顺序。Python 的每个类都有一个__mro__属性里面记录了一个类在查找方法或属性时会按什么顺序去检索。你可以打印出来看一下class A: pass class B(A): pass class C(B): pass print(C.__mro__) # (class C, class B, class A, class object)从输出可以看到在一个简单的单继承链上查找顺序就是从子类出发一层一层往上找直到object类。如果某一层找不到这个方法就继续往上走找到了就立即执行不再往上。这个顺序在单继承下很符合直觉。而super()做的事情其实就是沿着这个__mro__顺序从当前类的位置再往后找。所以一句话总结super()的真实作用它在 MRO 列表里继续向后搜索而不是死板地只找“父类”。这个理解是打通多继承的钥匙。3. 方法重写的正确姿势与常见误用继承关系里子类跟父类的方法名相同但内容不同这就叫方法重写。重写是面向对象里实现多态的基础。所谓多态就是“同一个方法名不同的类调用会有不同的行为表现”。比如前面例子里的动物每个动物都会叫但叫声不同class Animal: def speak(self): return 动物发出声音 class Dog(Animal): def speak(self): return 汪汪 class Cat(Animal): def speak(self): return 喵喵 animals [Dog(), Cat(), Animal()] for a in animals: print(a.speak())当循环运行到Dog()时调用的是Dog.speak()运行到Cat()时调用的是Cat.speak()。程序在运行时根据对象的真实类型去决定调用哪个方法而不是根据变量的声明类型。这就是 Python 中的运行时多态。这种能力在实际项目里非常有用。比如你在公司里的项目需要对接多种支付渠道每种渠道的接口参数各不相同你就可以让所有渠道都继承同一个基类然后分别重写pay()方法。上层业务代码只需要对着基类写一套逻辑完全不用关心具体的渠道是哪个。3.1 重写时如何保留父类能力很多新手会犯一个错误重写方法时把父类里那套逻辑完全丢掉从零开始写。比如下面的场景父类Bird有方法fly会打印“准备起飞”子类Penguin继承了Bird但企鹅不会飞于是重写了fly。这时候如果你把父类逻辑全扔了直接让企鹅走路没问题。但如果有一天你想在原有的“准备起飞”基础上额外加一个“检查天气”的动作呢class Bird: def fly(self): print(准备起飞) print(飞向南方) class WeatherBird(Bird): def fly(self): # 先调用父类原有逻辑 super().fly() # 再增加自己的逻辑 print(飞行前检查天气)这种方式叫“增强式重写”比完全覆盖式重写更稳妥。因为我们都知道写代码最大的成本在于维护。父类逻辑经过长期运行已经相对稳定重写时尽可能复用可以降低回归风险。所以在实际项目中我个人的习惯是能复用父类逻辑就复用除非父类的逻辑确实已经完全不再适用。3.2 重写str与repr除了普通方法Python 里还有一类特殊方法也叫魔法方法dunder方法。它们以双下划线开头和结尾。普通方法的重写大家比较容易理解但魔法方法的重写很多人就没意识到它也是普通意义上的“重写”。比如__str__方法它决定了print(对象)时打印的内容。默认情况下如果你不重写__str__打印一个对象显示的是乱七八糟的十六进制内存地址。class User: def __init__(self, name, age): self.name name self.age age def __str__(self): return f用户({self.name}, {self.age}岁) u User(张三, 25) print(u) # 用户(张三, 25岁)我见过不少朋友调试代码时为了看对象内部数据反复去 print 属性。其实只要你把__str__重写好了以后直接print(对象)就能看到关键信息调试效率会立刻提升一个档次。这算是重写魔法方法的一个典型收益。值得注意的是重写__str__和__repr__的区别。简单理解__str__是面向用户的给人看的打印到控制台时用__repr__是面向解释器的讲究还原对象的确定性描述。实践中如果只写一个建议优先写__repr__因为很多日志和调试框架默认调用的是__repr__而且当__str__没定义时Python 会退回去调用__repr__。3.3 方法重写时的参数风险重写方法的签名也就是参数列表是很多人忽略的坑。父类定义一个方法run(self, speed)子类重写的时候写成了run(self, distance)虽然听起来都差不多但这已经不是同一个方法了。当外部代码以统一的父类方法去调用时就会因为参数不匹配直接报错。我个人建议重写方法时参数保持一致最多增加可选参数。如果你确实需要让新方法接收完全不同的参数建议换一个方法名不要硬顶着重写。这看起来是个偏执的设计洁癖但确实能避免不少协作时的麻烦。因为你的代码不只是给自己看的同事也会用。类型签名不一致的方法重写很可能让调用方在运行时才爆出一个莫名其妙的神秘错误。4. 多继承钻石问题与 C3 线性化Python 支持一个类同时继承多个类这就是多继承。比如一个智能音箱它既是一个“音频播放器”又是一个“智能家居控制器”。用多继承可以同时获得两者的能力。class AudioPlayer: def play(self): print(播放音频) class SmartHomeController: def control(self): print(控制家电) class SmartSpeaker(AudioPlayer, SmartHomeController): pass speaker SmartSpeaker() speaker.play() # 播放音频 speaker.control() # 控制家电看起来很棒但多继承远没有单继承那么简单。最大的隐患就是“钻石问题”也叫“菱形继承问题”。当两个子类继承同一个基类而又有一个类同时继承这两个子类时就形成了一个菱形结构。class Base: def who_am_i(self): print(我是 Base) class Left(Base): pass class Right(Base): pass class Diamond(Left, Right): pass d Diamond() d.who_am_i() # 输出我是 Base你可能会想中间经手了Left和Right它们虽然没重写who_am_i但 Diamond 最终调用的还是 Base 的方法。这个例子太温和。我们把问题稍微改一改让Left和Right都重写who_am_iclass Base: def who_am_i(self): print(我是 Base) class Left(Base): def who_am_i(self): print(我是 Left) super().who_am_i() class Right(Base): def who_am_i(self): print(我是 Right) super().who_am_i() class Diamond(Left, Right): pass d Diamond() d.who_am_i()请你猜一下运行结果。如果你按照“Diamond 先找 LeftLeft 再找 Base”这个直觉去猜你会以为输出只有“我是 Left”和“我是 Base”。但实际的输出是我是 Left 我是 Right 我是 Base为什么这就是super()的强大之处。在 Diamond 类中MRO 的顺序是Diamond - Left - Right - Base - object。当Left.who_am_i里的super().who_am_i()被调用时它会沿着这个 MRO 顺序继续找下一个类也就是Right然后再找到Base。Left并没有死板地去找它的直系父类Base而是把接力棒交给了 MRO 上的下一个类。4.1 理解 Python 的 MRO 算法Python 的 MRO 是基于一个叫C3 线性化的算法计算出来的。它有三个硬性规则第一子类永远先于父类被检查第二多个父类按照它们在类定义时的顺序被检查第三如果一个类有多个父类那么父类之间的相对顺序要保留。我当初理解 C3 线性化时并没有死记硬背算法而是记住了它产生的结果在菱形继承中基类 Base 的方法只会在 MRO 的末尾出现一次而不会出现两次。如果是 C 那种传统的菱形处理方式Base 的实例数据可能会被复制两份产生二义性。Python 通过 MRO 的线性化把菱形结构展开成了一条直线这个设计是非常优雅的。为了验证 MRO你可以打印Diamond.__mro__print(Diamond.__mro__) # (class Diamond, class Left, class Right, class Base, class object)如果你写了多继承却不知道类的方法最终会跑向哪里第一件事就是应该打印__mro__。这是排查多继承一切诡异问题的起点。4.2 多继承中 super().init的协同配合多继承的问题在__init__阶段暴露得最明显。设想下面这个场景另一个班级项目里需要模拟一个“水陆两栖车”它既是“陆地车”又是“水面船”。陆地车的初始化需要设置轮子数量水面船的初始化需要设置排水量而两栖车两个都要设置。class LandVehicle: def __init__(self, wheels): self.wheels wheels print(f陆地车初始化轮子数量{wheels}) class WaterVehicle: def __init__(self, displacement): self.displacement displacement print(f水面船初始化排水量{displacement}) class AmphibiousVehicle(LandVehicle, WaterVehicle): def __init__(self, wheels, displacement): # 这里不能只调用一个父类的 __init__ super().__init__(wheels) # 想要调用第二个父类的 __init__怎么办很多人会想到用LandVehicle.__init__(self, wheels)和WaterVehicle.__init__(self, displacement)这种方式来显式调用。这样确实能跑通但问题很明显一旦继承结构改变或者父类改名这种硬编码的调用就会立刻失效。而且这种方式在多继承层级变深后特别容易引发同一个父类的__init__被重复调用两次导致数据被覆盖。更优雅的方案是让所有参与多继承的类都遵循一个“协作式继承”的约定每一层的__init__都使用super().__init__()去调用下一个类并且所有__init__都接受**kwargs参数来处理多余的参数。看下面的改动class LandVehicle: def __init__(self, wheelsNone, **kwargs): super().__init__(**kwargs) self.wheels wheels print(f陆地车初始化轮子数量{wheels}) class WaterVehicle: def __init__(self, displacementNone, **kwargs): super().__init__(**kwargs) self.displacement displacement print(f水面船初始化排水量{displacement}) class AmphibiousVehicle(LandVehicle, WaterVehicle): def __init__(self, wheelsNone, displacementNone, **kwargs): super().__init__(wheelswheels, displacementdisplacement, **kwargs) amphi AmphibiousVehicle(wheels4, displacement3.5)运行这段代码你会看到初始化顺序依次是WaterVehicle先初始化然后LandVehicle再初始化最后才是AmphibiousVehicle。这个顺序看起来奇怪但它是按照 MRO 的逆序来执行的目的就是保证每个__init__都只被调用一次而且参数能正确传递到需要的类上。我不建议初学者一上来掌握这种复杂的协作式初始化写法。我这里告诉你它的存在是想让你知道Python 官方推荐我们用super()去处理多继承但要让super()正常工作所有类都要遵守同一个“传递接力棒”的约定。如果团队成员没有统一规范多继承就会变成一个没人敢碰的雷区。这也是很多资深开发者更偏向组合而不是多继承的原因之一。5. 组合优于继承什么时候别硬用继承写到这里如果只给你一个结论我想说的是继承强大但千万不要依赖继承来处理所有代码复用。经验多了你就会发现真正优雅的面向对象设计里继承的使用频率没有想象中那么高更多时候用的是组合。组合的意思就是一个类持有另一个类的实例通过“我有”而不是“我是”来复用能力。比如一只狗需要能奔跑与其让狗继承一个Runner类不如给狗传一个Runner的实例class Runner: def run(self): return 跑得飞快 class Dog: def __init__(self, name, runner): self.name name self.runner runner def run(self): return self.runner.run() runner Runner() dog Dog(旺财, runner) print(dog.run())你会发现这样运行的效果和继承几乎一样但它有一个巨大优点Dog 类不再和 Runner 类绑定死在继承树上你想给狗换任何奔跑方式只要传入一个具有 run 方法的新对象即可。这就是组合的灵活性。继承是把能力“焊死”在类别层级里组合是把能力“注入”到实例里。真实项目中继承适合描述稳定的、长期的“是一种”关系而组合适合处理易变的、短期的“拥有”能力。我见过太多把“继承”用成“代码拼图”的情况A 类从 B 类继承只是因为 B 类里有一个用得到的方法。这种代码往往又脆又乱一旦需求变化牵一发而动全身而组合能做到局部替换、互不影响。5.1 抽象基类给继承设定规则如果你确认自己确实需要用到继承而且希望多个子类必须实现某些方法那建议使用抽象基类。Python 的标准库abc提供了这种机制。抽象基类本身不能直接实例化只能被继承而且要求子类必须实现指定的方法。from abc import ABC, abstractmethod class Shape(ABC): abstractmethod def area(self): 计算面积 class Circle(Shape): def __init__(self, radius): self.radius radius def area(self): return 3.14159 * self.radius ** 2 class Square(Shape): def __init__(self, side): self.side side def area(self): return self.side ** 2 shapes [Circle(2), Square(3)] for s in shapes: print(s.area())抽象基类的好处是你定义了一个“契约”每个子类都必须实现契约里的方法。如果某个子类忘了实现areaPython 在实例化时就会直接报错而不是等到运行调用area方法时才爆出NotImplementedError。这等于把错误提前到了最早的时间点防止后续逻辑带病运行。5.2 Mixin 模式多继承的最佳姿势前面提到了多继承的复杂性但有一种多继承的使用方式被广泛认为是安全的那就是 Mixin 模式。Mixin 是一个设计上专门用来“混入”其他功能的类它不代表一种独立的事物只用来提供一小块可复用的功能。例如加日志、加缓存、加序列化能力。class JsonMixin: def to_json(self): import json return json.dumps(self.__dict__) class User(JsonMixin): def __init__(self, name, age): self.name name self.age age user User(张三, 25) print(user.to_json()) # {name: 张三, age: 25}这里User只继承了JsonMixin就额外获得了序列化能力。Mixin 的设计要求是Mixin 类自身不能依赖其他类的特定属性也不应该单独实例化。它的职责就是提供一组高度内聚的方法像搭积木一样被其他类“混入”。Mixin 的命名通常以Mixin结尾方便团队理解它的用途。5.3 写完这段代码后我给初学者的最终建议不管你是刚接触类的概念还是已经用 Python 写过一段时间在动手规划继承结构前请先问自己三个问题第一“XX 是 YY 的一种吗”如果是才考虑继承如果不是优先用组合。第二“父类的方法和属性真的百分之百适合子类吗”如果只有一个方法适合那就用委托别硬继承。第三“如果父类明天改了实现我的子类会崩吗”如果会崩说明你的继承耦合过深需要调整设计。你在实际工作中遇到的多继承问题绝大多数都可以通过“组合 Mixin 抽象基类”的搭配来解决。继承不是不能用而是要谨慎地用、有章法地用。光为了少打几个字就去继承一个类后期付出的维护成本往往远超你省下的那点键盘输入。最后再分享一个调试心得。当你的类关系一复杂代码里出现“这个调用到底走的哪个类”的疑问时不要靠猜。用三个方法快速定位打印类.__mro__看搜索顺序用类.__bases__看直接父类如果还搞不清就在每个类的同名方法里加一行打印当前类名的print语句跑一遍就知道调用链了。这套组合拳我用了很多年几乎能解决所有继承相关的困惑。等你把这些机制都吃透了再回头看继承它会成为你手中非常强大的设计武器而不再是一个让人心生畏惧的黑魔法。