前言写代码写到一定程度问题就不再是怎么写一个类而是这几个类之间到底该是什么关系。常见的关系有三类继承inheritance、组合composition、依赖dependency。名字都好懂难的是选哪个——很多初学者一看两个类有关系就想继承结果继承链越拉越长改一处父类就让一片子类出问题。这里要先纠正一个流传很广的说法继承是面向对象的核心能继承就继承。实际情况正相反设计原则里默认推荐组合继承只在确实满足is-a关系时才用。继承把子类和父类强耦合在一起父类的任何内部改动都可能波及子类组合只是持有另一个对象边界清晰得多。本文讲清这三类关系各自的含义、适用场景和判断标准示例基于 Python 3.8。一、继承is-a子类确实是父类的一种继承的语义是是一种is-a。判断标准很直接子类对象能不能在父类对象出现的任何地方替换使用而不违反使用者的预期。猫是一种动物所以Cat(Animal)合理员工是一种人所以Employee(Person)合理。# 适用于 Python 3.8class Animal:def __init__(self, name):self.name namedef eat(self):return f{self.name} 在吃东西class Cat(Animal): # Cat is-a Animaldef purr(self):return f{self.name} 在打呼噜c Cat(小白)print(c.eat()) # 从父类继承来的print(c.purr()) # 自己特有的继承的好处是复用父类实现并支持运行时多态调用方按Animal的接口写代码传进去的可以是任意子类。代价是耦合——父类改了eat的签名所有子类都得跟着看一遍。二、组合has-a优先考虑的方案组合的语义是有一个has-a一个类把另一个类的实例当作自己的属性通过它来完成工作。它不暴露对方的内部只借用对方的能力。# 适用于 Python 3.8class Engine:def __init__(self, power):self.power powerdef start(self):return f发动机启动功率 {self.power}class Car:def __init__(self, engine, brand):self.engine engine # Car has-a Engineself.brand branddef start(self):# 委托给内部对象而不是继承它的实现return f{self.brand} self.engine.start()car Car(Engine(150), 某品牌)print(car.start())组合的优势Car不需要知道Engine的内部结构只依赖它的公开接口想换一种发动机传入另一个实现了start()的对象即可不用改Car。这就是面向接口而非实现的落地方式也让单元测试可以塞进一个假的发动机。判断口诀如果两个类之间是它有一个它用组合如果它是一种它才考虑继承。拿不准时优先组合。三、依赖uses-a最松的一种关系依赖比组合还松一个类只是在某个方法里临时用到另一个类并不把它存在属性里。关系只存在于调用的那一瞬间A甚至不需要 importB就能接收它。# 适用于 Python 3.8class Report:def __init__(self, rows):self.rows rowsclass Printer: # 依赖只在方法参数里用一下def render(self, report):for row in report.rows:print(row)class Logger: # 组合长期持有def __init__(self, sink):self.sink sink # sink 是对象长期存在def log(self, msg):self.sink.write(msg \n)Printer().render(Report([1, 2, 3]))依赖是最弱的耦合Printer完全不知道Report从哪来、是什么子类只要能读rows就行。这也正是鸭子类型duck typing的体现——不关心类型只关心有没有那个属性或方法。四、把关系画成一张对照表关系别名语义耦合强度典型写法继承is-a子类是父类的一种强class B(A)组合has-a整体持有部分中self.part part聚合has-a弱持有但不独占生命周期中偏弱self.members members依赖uses-a只在方法里临时使用弱def f(self, other)关联knows-a长期知道对方存在中属性或引用其中聚合aggregation与组合composition的区别在生命周期组合里部分随整体一起创建销毁汽车与发动机聚合里部分可以独立存在班级与学生——班级没了学生还在。Python 不强制表达这个区别靠的是代码约定和文档。五、多重继承与 MROPython 允许一个类继承多个父类。查找方法时按方法解析顺序Method Resolution OrderMRO从左到右、广度优先地找可以用Cls.__mro__或Cls.mro()查看。# 适用于 Python 3.8class A:def tag(self):return Aclass B(A):def tag(self):return Bclass C(A):def tag(self):return Cclass D(B, C):def tag(self):return D super().tag()print([k.__name__ for k in D.__mro__]) # [D, B, C, A, object]print(D().tag()) # D Bsuper()在多重继承里指向的是 MRO 里的下一个类不是父类集合。所以协作式cooperative多重继承里每个类都应调用super()才能让链路走完。多重继承能少用就少用最常见的正当用途是混入类mixin——只提供一小段可复用行为不构成完整 is-a。常见坑点坑 1为了复用一段方法就继承。❌class Order(dict):只是想在订单里用字典的取值方式结果继承了一整套字典行为order[x]、order.get()到处可用语义越来越乱。✅ 用组合self.data {}只暴露你需要的方法。坑 2子类没有满足 is-a 却硬继承。❌class Stack(list):—— 栈不是列表的一种它还会意外暴露insert、sort等破坏只能从一端进出的约束。✅ 组合一个列表只提供push/pop/peek。坑 3父类__init__不调用。❌ 子类定义了__init__却忘了super().__init__(...)父类设置的属性全部缺失报AttributeError。✅ 子类__init__第一行调用super().__init__(...)。坑 4把依赖写成全局变量或 import 里的硬引用。❌ 在方法里直接import 某个具体类并new死它想换实现就得改这个类。✅ 通过参数把对象传进来依赖注入的朴素形式或者只依赖一个方法名。坑 5多重继承里只在一个分支写super()。❌ 在D.tag里只写B.tag(self)绕过 MROC的逻辑永不执行。✅ 统一用零参数super()让协作链走完整。坑 6混淆聚合与组合的生命周期。❌ 班级对象销毁时顺手把学生对象也丢弃以为整体没了部分也该没。✅ 想清楚部分是不是独立实体独立就用聚合只保存引用不负责销毁。坑 7用type(x) A判断继承关系。❌if type(obj) Animal:—— 子类实例判断为假。✅ 用isinstance(obj, Animal)。总结场景该选的关系子类能替换父类且语义成立继承is-a想复用一段行为的实现组合has-a只在方法里临时用一下别的对象依赖uses-a部分与整体生命周期一致组合部分可独立存在聚合需要横向复用一小段逻辑混入类mixin一句话总结类与类的关系里继承表达是什么组合表达有什么依赖表达用一下。默认优先组合与依赖只有在 is-a 成立、且确实需要多态替换时才用继承多重继承留给混入这一类明确场景。说明本文的super()、MRO、isinstance行为在 Python 3 中统一且稳定。Python 2 里旧式类不支持零参数super()只有新式类才行Python 2.7 已于 2020 年 1 月 1 日停止维护新代码只写 Python 3。