Python 里「组合优于继承」是什么意思?什么时候用组合、什么时候用继承?
简化版
**「组合优于继承(composition over inheritance)」是一条面向对象设计原则——当你想「复用别的类的功能」时,优先考虑「让一个类『持有』另一个类的对象(组合)」,而不是「继承那个类」。**两种复用方式:① 继承(is-a)——class Dog(Animal),Dog「是一个」Animal,直接获得 Animal 的所有方法(子类和父类强耦合);② 组合(has-a)——class Car: def __init__(self): self.engine = Engine(),Car「有一个」Engine,通过持有对象来用它的功能(松耦合)。为什么组合更好:① 松耦合——组合只依赖对方的「公开接口」,继承依赖父类的「内部实现」(父类改动容易连累子类);② 更灵活——组合能运行时替换(换个 Engine)、能组合多个对象;继承的关系是编译期固定的;③ 避免继承的坑——深继承层次难懂、脆弱基类问题、菱形继承。什么时候用继承:确实是「is-a」关系(正方形是矩形)、要用多态(统一接口)、框架要求(继承某基类)。判断口诀:能说「A 是一个 B」用继承(is-a),只是「A 用到了 B 的功能」用组合(has-a)。核心记忆:组合(持有对象、has-a、松耦合、灵活)优于继承(is-a、强耦合),复用功能优先组合;真正的 is-a 关系 + 多态才用继承。
详细版
组合 vs 继承:
| 维度 | 继承(is-a) | 组合(has-a) |
|---|---|---|
| 关系 | 「是一个」 | 「有一个」 |
| 复用方式 | 直接获得父类方法 | 持有对象、调其方法 |
| 耦合 | 强(依赖父类实现) | 松(依赖公开接口) |
| 灵活性 | 低(编译期固定) | 高(运行时可替换) |
| 语法 | class B(A) | self.a = A() |
# 继承(is-a):Dog 是一个 Animal
class Animal:
def eat(self): print("吃")
def sleep(self): print("睡")
class Dog(Animal): # Dog 继承 Animal(是一个)
def bark(self): print("汪")
d = Dog()
d.eat() # 直接用继承的方法
# 组合(has-a):Car 有一个 Engine
class Engine:
def start(self): print("引擎启动")
def stop(self): print("引擎停止")
class Car:
def __init__(self):
self.engine = Engine() # Car 持有 Engine(有一个)
def drive(self):
self.engine.start() # 通过持有的对象用功能
print("行驶")
# 组合的灵活:运行时替换
class ElectricEngine:
def start(self): print("电机启动")
def stop(self): print("电机停止")
class Car2:
def __init__(self, engine):
self.engine = engine # 注入引擎(依赖注入)
def drive(self):
self.engine.start()
gas_car = Car2(Engine())
ev = Car2(ElectricEngine()) # 换个引擎(组合的灵活)
# 继承滥用的坑(不是真正的 is-a)
class Stack(list): # ✗ Stack 继承 list(不好)
def push(self, x): self.append(x)
# 问题:Stack 意外暴露了 list 的所有方法(insert、sort、[]...)
# 用户可以 stack.insert(0, x) 破坏栈的语义
class Stack2: # ✓ Stack 组合 list(好)
def __init__(self):
self._items = [] # 持有 list,不暴露它
def push(self, x): self._items.append(x)
def pop(self): return self._items.pop()
# 只暴露栈该有的接口(push/pop),不暴露 list 的其他方法
# 用组合实现"功能复用"(代替 Mixin/多继承)
class Logger:
def log(self, msg): print(f"[LOG] {msg}")
class Service:
def __init__(self):
self.logger = Logger() # 组合一个 logger,而非继承 LoggingMixin
def process(self):
self.logger.log("处理中")
⚠️ 核心洞察:继承是「最强的耦合关系」——子类不仅依赖父类的「公开接口」,还依赖父类的「内部实现细节」(父类改了内部逻辑,子类可能莫名其妙坏掉,这叫『脆弱基类问题』);而组合只依赖对方的「公开接口」(把对方当黑盒用),耦合松得多、更能抵御变化。所以设计原则是「优先组合」:需要「复用某个类的功能」时,先想「能不能『持有』它(组合)而不是『继承』它」。经典反例是「Stack 继承 list」——虽然 Stack 想复用 list 的 append/pop,但继承让 Stack「意外地是一个 list」,暴露了 list 的所有方法(
insert、sort、[]索引……),用户能用这些方法破坏栈的「后进先出」语义;用组合(Stack 持有一个 list、只暴露 push/pop)就干净——只暴露该暴露的接口。组合还有两个大优势:① 运行时灵活——Car(engine)可以在创建时注入不同的引擎(依赖注入),继承的父类是写死的;② 避免深继承和多继承的复杂——一层层继承的类难懂、菱形继承(多继承)的 MRO 微妙。但继承不是坏东西——当关系「真的是 is-a」(Circle是Shape)、需要「多态」(一组对象走统一接口)、或框架要求继承某基类时,继承是自然且正确的选择。判断标准:「A 真的『是一个』B 吗?」是就继承,只是「A 要用 B 的功能」就组合。
完整版教学
一、两种复用方式
先理解继承和组合两种复用:
面向对象里"复用别的类的功能"有两种方式:
继承(inheritance):is-a(是一个)
class Dog(Animal): ...
→ Dog "是一个" Animal,直接获得 Animal 的所有方法
→ 子类扩展/覆盖父类
→ 编译期确定的、强的关系
组合(composition):has-a(有一个)
class Car:
def __init__(self):
self.engine = Engine() # Car "有一个" Engine
→ Car 持有 Engine 的对象,通过它用功能
→ 运行时确定、松的关系
关系判断(关键):
"A 是一个 B 吗?"(A is a B)→ 继承
Dog 是一个 Animal ✓ → 继承
"A 有一个 B / A 用到 B 的功能吗?"(A has a B)→ 组合
Car 有一个 Engine ✓ → 组合
复用的本质:
继承:白盒复用(子类看得到父类内部、依赖实现)
组合:黑盒复用(只用对方公开接口、不看内部)
"组合优于继承":
设计原则——复用功能时优先组合、慎用继承
(不是"永远别用继承",而是"默认组合、有 is-a 才继承")
所以复用有继承(is-a、白盒、强耦合)和组合(has-a、黑盒、松耦合);优先组合
面向对象里「复用别的类的功能」有两种方式:继承(inheritance):is-a(是一个)(class Dog(Animal)、Dog「是一个」Animal 直接获得所有方法、子类扩展/覆盖父类、编译期确定的强关系);组合(composition):has-a(有一个)(class Car: self.engine = Engine()、Car 持有 Engine 对象通过它用功能、运行时确定的松关系)。关系判断(关键):「A 是一个 B 吗?」→ 继承(Dog 是一个 Animal)、「A 有一个 B / A 用到 B 的功能吗?」→ 组合(Car 有一个 Engine)。复用的本质:继承是白盒复用(子类看得到父类内部、依赖实现)、组合是黑盒复用(只用对方公开接口、不看内部)。「组合优于继承」:设计原则——复用功能时优先组合、慎用继承(不是永远别用继承、而是默认组合、有 is-a 才继承)。理解「复用有继承(is-a、白盒、强耦合、编译期)和组合(has-a、黑盒、松耦合、运行时);判断 A 是一个 B 用继承、A 有一个 B/用到 B 功能用组合;组合优于继承(默认组合、有 is-a 才继承)」,就理解了两种复用方式。
二、为什么组合更好
理解组合的优势:
组合优于继承的原因:
① 松耦合(最重要):
继承:子类依赖父类的"内部实现"(白盒)
父类改内部逻辑 → 子类可能坏(脆弱基类问题)
组合:只依赖对方的"公开接口"(黑盒)
对方改内部实现 → 只要接口不变、我不受影响
→ 组合的耦合松得多、更能抵御变化
② 更灵活(运行时可替换):
继承:父类是"写死的"(编译期固定)
组合:可以运行时注入/替换持有的对象
Car(GasEngine()) / Car(ElectricEngine())
→ 依赖注入、策略模式都靠组合
③ 避免继承的坑:
✗ 深继承层次:A→B→C→D,难懂、改一处影响一串
✗ 脆弱基类:父类改动连累所有子类
✗ 菱形继承(多继承):MRO 微妙、super 复杂
✗ 意外暴露:继承 list → 暴露 list 所有方法
→ 组合都能避免
④ 接口清晰:
组合只暴露"你想暴露的"(封装内部对象)
继承暴露父类的"所有 public 方法"(可能不想暴露)
⑤ 单一职责 + 可测试:
组合的各部分职责单一、可独立测试、可 mock
继承的父子逻辑纠缠、难单独测
一句话:
继承是"最强的耦合",组合是"松散的协作"
→ 松耦合的系统更好维护、更能变化
所以组合优于继承因:松耦合(依赖接口非实现)+灵活(运行时替换)+避坑(深继承/脆弱基类/意外暴露)
组合优于继承的原因:① 松耦合(最重要)——继承子类依赖父类的「内部实现」(白盒、父类改内部子类可能坏、脆弱基类问题)、组合只依赖对方的「公开接口」(黑盒、对方改内部只要接口不变我不受影响);② 更灵活(运行时可替换)——继承父类写死(编译期固定)、组合可运行时注入/替换(Car(GasEngine())/Car(ElectricEngine())、依赖注入/策略模式);③ 避免继承的坑——深继承层次难懂、脆弱基类、菱形继承 MRO 微妙、意外暴露(继承 list 暴露所有方法);④ 接口清晰——组合只暴露想暴露的、继承暴露父类所有 public 方法;⑤ 单一职责 + 可测试——组合各部分职责单一可独立测试可 mock。一句话:继承是「最强的耦合」、组合是「松散的协作」。理解「组合优于继承因:①松耦合(依赖接口非实现、避免脆弱基类)②灵活(运行时替换、依赖注入)③避坑(深继承/菱形/意外暴露)④接口清晰(只暴露想暴露的)⑤可测试;继承是最强耦合、组合是松散协作」,就理解了为什么组合更好。
三、继承的坑
理解继承容易出的问题:
继承的常见坑:
坑1:脆弱基类问题(fragile base class):
子类依赖父类的内部实现
父类改了内部逻辑(哪怕接口没变)→ 子类可能坏
例:父类的方法 A 内部调了方法 B,子类覆盖了 B
→ 父类改 A 不再调 B → 子类行为莫名变化
坑2:意外暴露父类接口:
class Stack(list): ... # 想复用 append/pop
→ Stack 暴露了 list 的全部方法(insert/sort/[]/extend...)
→ 用户 stack.insert(0, x) 破坏栈语义(后进先出没了)
→ 继承"是一个"太强,暴露了不该暴露的
坑3:深继承层次:
A → B → C → D → E
→ 一个方法在哪定义的?MRO 一层层找、难懂
→ 改 A 影响整条链、牵一发动全身
坑4:菱形继承(多继承):
A
/ \
B C
\ /
D
→ D 继承 B、C,B、C 都继承 A
→ MRO 复杂、super 调用顺序微妙、易出 bug
坑5:不是真正的 is-a:
为了"复用几个方法"就继承 → 关系不对
→ 复用是目的、继承是手段,但继承强加了 is-a 关系
坑6:违反 Liskov 替换原则:
子类不能完全替代父类用(如正方形继承矩形,
设置宽高会互相影响,破坏矩形的契约)
→ 这些坑组合基本都能避免
所以继承坑:脆弱基类/意外暴露接口/深继承/菱形继承/假 is-a/违反里氏替换
继承的常见坑:坑1:脆弱基类问题(子类依赖父类内部实现、父类改内部逻辑子类可能坏,如父类方法 A 内部调 B、子类覆盖了 B、父类改 A 不再调 B 则子类行为变);坑2:意外暴露父类接口(class Stack(list) 暴露了 list 全部方法、用户 stack.insert(0, x) 破坏栈语义);坑3:深继承层次(A→B→C→D→E、方法在哪定义难懂、改 A 影响整条链);坑4:菱形继承(多继承)(D 继承 B、C、B、C 都继承 A、MRO 复杂 super 微妙);坑5:不是真正的 is-a(为复用几个方法就继承、关系不对);坑6:违反 Liskov 替换原则(子类不能完全替代父类、如正方形继承矩形设宽高互相影响)。这些坑组合基本都能避免。理解「继承坑:①脆弱基类(父类改内部子类坏)②意外暴露接口(Stack(list)暴露 insert 破坏语义)③深继承难懂④菱形继承 MRO 复杂⑤假 is-a(为复用而继承)⑥违反里氏替换(正方形继承矩形);组合能避免」,就掌握了继承的坑。
四、什么时候用继承
理解继承合适的场景:
继承合适的场景(真正的 is-a + 多态):
① 真正的 is-a 关系:
Circle 是一个 Shape ✓
Dog 是一个 Animal ✓
SavingsAccount 是一个 Account ✓
→ 子类"确实是"父类的一种,语义正确
② 需要多态(统一接口):
class Shape:
def area(self): ...
shapes = [Circle(), Square(), Triangle()]
total = sum(s.area() for s in shapes) # 统一当 Shape 用
→ 一组对象走统一接口、可互换 → 继承+多态自然
③ 框架/库要求继承:
class MyView(django.views.View): ... # Django 要求
class MyModel(models.Model): ...
class MyException(Exception): ... # 自定义异常
→ 框架的扩展点是"继承某基类"
④ 抽象基类定义接口:
class Serializer(ABC):
@abstractmethod
def serialize(self): ...
→ 用继承 + 抽象方法强制接口
⑤ 复用 + 扩展(真的是特化):
子类"是父类的一种特化",加特有行为
class AdminUser(User): # 管理员是一种用户
def ban_user(self): ...
判断"该继承":
① 能说"A 是一个 B"(is-a,语义正确)
② 子类能完全替代父类用(里氏替换)
③ 需要多态、或框架要求
不该继承(改用组合):
① 只是想复用几个方法(不是 is-a)
② 关系是"A 有 B / A 用 B"(has-a)
③ 不想暴露父类的全部接口
所以继承用于:真 is-a+多态+框架要求+抽象接口;满足里氏替换、语义正确才继承
继承合适的场景(真正的 is-a + 多态):① 真正的 is-a 关系(Circle 是一个 Shape、Dog 是一个 Animal、子类确实是父类的一种语义正确);② 需要多态(统一接口)(shapes = [Circle(), Square()] 统一当 Shape 用、sum(s.area() for s in shapes)、一组对象走统一接口可互换);③ 框架/库要求继承(class MyView(django.views.View)、class MyException(Exception)、框架的扩展点是继承某基类);④ 抽象基类定义接口(class Serializer(ABC): @abstractmethod、用继承+抽象方法强制接口);⑤ 复用 + 扩展(真的是特化)(class AdminUser(User) 管理员是一种用户)。判断「该继承」:① 能说「A 是一个 B」(语义正确)、② 子类能完全替代父类用(里氏替换)、③ 需要多态或框架要求。不该继承(改用组合):只是想复用几个方法(不是 is-a)、关系是 has-a、不想暴露父类全部接口。理解「继承用于:①真 is-a(Circle 是 Shape)②多态(统一接口可互换)③框架要求(继承 View/Exception)④抽象接口(ABC)⑤特化(AdminUser 是 User);判断能说 A 是一个 B+满足里氏替换+需要多态才继承」,就掌握了继承的合适场景。
五、组合的实践模式
理解组合的常见用法:
组合的实践模式:
① 持有对象(最基本):
class Car:
def __init__(self):
self.engine = Engine() # 持有
def drive(self):
self.engine.start() # 委托给持有的对象
② 依赖注入(更灵活):
class Car:
def __init__(self, engine): # 从外面注入
self.engine = engine
Car(GasEngine()) # 运行时决定用哪个引擎
Car(ElectricEngine())
→ 解耦、可测试(注入 mock)
③ 委托/转发(组合 + 暴露部分接口):
class Stack:
def __init__(self):
self._items = []
def push(self, x): self._items.append(x) # 委托给 list
def pop(self): return self._items.pop()
# 只暴露 push/pop,不暴露 list 的其他方法
④ 策略模式(组合可替换的行为):
class Sorter:
def __init__(self, strategy):
self.strategy = strategy
def sort(self, data):
return self.strategy(data)
Sorter(quicksort) / Sorter(mergesort)
⑤ 用组合替代 Mixin/多继承:
# 不用 class Service(LoggingMixin, CacheMixin):
class Service:
def __init__(self):
self.logger = Logger() # 组合
self.cache = Cache()
→ 各功能是独立对象、松耦合、可替换
组合 + 继承结合(常见):
用继承表达 is-a(Shape → Circle)
用组合复用功能(Circle 组合一个 Renderer)
→ 两者不是对立、各司其职
所以组合模式:持有对象/依赖注入/委托转发/策略模式/替代 Mixin;各部分松耦合可替换
组合的实践模式:① 持有对象(最基本)(self.engine = Engine() + self.engine.start() 委托给持有的对象);② 依赖注入(更灵活)(def __init__(self, engine) 从外面注入、Car(GasEngine())/Car(ElectricEngine()) 运行时决定、解耦可测试注入 mock);③ 委托/转发(组合 + 暴露部分接口)(Stack 持有 _items list、只暴露 push/pop 不暴露 list 其他方法);④ 策略模式(组合可替换的行为)(Sorter(strategy) + Sorter(quicksort)/Sorter(mergesort));⑤ 用组合替代 Mixin/多继承(self.logger = Logger(); self.cache = Cache() 各功能独立对象松耦合可替换)。组合 + 继承结合(常见):用继承表达 is-a(Shape → Circle)、用组合复用功能(Circle 组合一个 Renderer)(两者不对立、各司其职)。理解「组合模式:①持有对象+委托②依赖注入(注入 engine、可测试)③委托转发(只暴露部分接口)④策略模式(可替换行为)⑤替代 Mixin(各功能独立对象);组合+继承结合(继承表 is-a、组合复用功能)」,就掌握了组合的实践模式。
六、总结与实践
总结组合与继承:
核心原则:组合优于继承
复用功能时优先组合、有真正 is-a 才继承
两种复用:
继承(is-a):class B(A),直接获得父类方法,强耦合
组合(has-a):self.a = A(),持有对象用功能,松耦合
组合更好的原因:
① 松耦合(依赖接口非实现,抗脆弱基类)
② 灵活(运行时替换、依赖注入)
③ 避坑(深继承/菱形/意外暴露)
④ 接口清晰、可测试
继承的坑:
脆弱基类、意外暴露接口、深继承、菱形继承、
假 is-a、违反里氏替换
什么时候用继承:
① 真正的 is-a(Circle 是 Shape)
② 需要多态(统一接口可互换)
③ 框架要求(继承 View/Exception/Model)
④ 抽象基类定义接口
判断口诀:
"A 是一个 B" → 继承(is-a)
"A 有一个 B / A 用 B 的功能" → 组合(has-a)
实践建议:
① 默认用组合,有 is-a + 多态才继承
② 别为"复用几个方法"就继承(用组合)
③ 继承要满足里氏替换(子类能替代父类)
④ 组合 + 依赖注入 → 松耦合、可测试
⑤ 组合和继承可结合(继承表 is-a、组合复用)
核心总结:
组合(持有对象、has-a、松耦合、灵活)优于继承(is-a、强耦合)
复用功能优先组合,真 is-a + 多态才继承
所以组合(has-a、松耦合、灵活)优于继承(is-a、强耦合),复用优先组合、真 is-a+多态才继承
核心原则:组合优于继承(复用功能时优先组合、有真正 is-a 才继承)。两种复用:继承(is-a、class B(A)、直接获得父类方法、强耦合)、组合(has-a、self.a = A()、持有对象用功能、松耦合)。组合更好的原因:松耦合、灵活、避坑、接口清晰可测试。继承的坑:脆弱基类、意外暴露接口、深继承、菱形继承、假 is-a、违反里氏替换。什么时候用继承:真正的 is-a、需要多态、框架要求、抽象基类定义接口。判断口诀:「A 是一个 B」→ 继承、「A 有一个 B/A 用 B 的功能」→ 组合。理解「组合优于继承;继承 is-a 强耦合、组合 has-a 松耦合灵活;组合好因松耦合+灵活+避坑+可测试;继承坑脆弱基类/意外暴露/菱形;继承用于真 is-a+多态+框架;A 是一个 B 用继承、A 用 B 功能用组合」,就掌握了总结与实践。
记忆钩子:「『组合优于继承(composition over inheritance)』是一条 OOP 设计原则:想『复用别的类的功能』时优先『让一个类持有另一个类的对象(组合)』而不是『继承那个类』;两种复用:①继承(is-a、是一个):class Dog(Animal),Dog 是一个 Animal 直接获得所有方法(子类和父类强耦合)②组合(has-a、有一个):class Car:self.engine=Engine(),Car 有一个 Engine 通过持有对象用功能(松耦合);★为什么组合更好:①松耦合(最重要)——组合只依赖对方的『公开接口』(黑盒),继承依赖父类的『内部实现』(白盒、父类改内部子类可能莫名坏掉=脆弱基类问题)②更灵活——组合能运行时替换(Car(GasEngine())/Car(ElectricEngine())依赖注入),继承的关系编译期写死③避坑——深继承层次难懂/菱形继承 MRO 微妙/意外暴露(class Stack(list)会暴露 list 所有方法、用户能 stack.insert(0,x)破坏栈的后进先出语义,用组合只暴露 push/pop 就干净);★什么时候用继承:确实是 is-a 关系(Circle 是 Shape)、需要多态(一组对象走统一接口 sum(s.area()for s in shapes))、框架要求(继承 View/Exception/Model)、抽象基类定义接口;★判断口诀:能说『A 是一个 B』用继承(is-a)、只是『A 用到了 B 的功能』用组合(has-a);继承还要满足里氏替换(子类能完全替代父类);组合和继承可结合(继承表 is-a、组合复用功能)」。
七、常见误区与追问
- 误区:「组合优于继承」意思是永远不要用继承。 不是——它是说「复用功能时『优先』考虑组合、慎用继承」,而不是「禁止继承」;继承在「真正的 is-a 关系 + 需要多态」的场景是自然且正确的(Circle 是 Shape、需要把一堆形状统一当 Shape 用求面积)、框架也常要求继承某基类;这条原则针对的是「滥用继承」——为了复用几个方法就继承、把 has-a 关系硬做成 is-a;判断标准是「A 真的是一个 B 吗」,是就继承,只是「A 要用 B 的功能」就组合。
- 误区:继承和组合只是语法不同、效果一样。 效果差别很大——继承是「最强的耦合」:子类不仅依赖父类的公开接口、还依赖父类的内部实现(父类改了内部逻辑,子类可能莫名其妙坏掉,即「脆弱基类问题」)、且子类会「意外继承并暴露父类的所有方法」;组合是「松散的协作」:只依赖对方的公开接口(把对方当黑盒)、能运行时替换持有的对象、只暴露你想暴露的接口;所以组合的系统更松耦合、更灵活、更能抵御变化,两者的设计影响完全不同。
- 误区:想复用一个类的几个方法,就应该继承它。 不应该——「只是想复用几个方法」不构成「is-a 关系」,为此继承会带来问题:① 强加了「是一个」的语义(明明只是想用功能);② 意外暴露父类的所有方法(可能破坏你的类的语义,如 Stack 继承 list 会暴露 insert/sort 等破坏栈语义的方法);③ 与父类强耦合(父类变动连累你);正确做法是用组合——持有那个类的对象、只调用你需要的方法、只暴露你想暴露的接口(如 Stack 持有一个 list、只提供 push/pop);这样干净、松耦合、语义清晰。
- 误区:正方形是矩形,所以 Square 应该继承 Rectangle。 数学上是 is-a、但代码继承会出问题(经典的 Liskov 替换违反案例)——如果 Rectangle 有
set_width和set_height(可独立设置),而 Square 继承它,Square 为了保持「正方形」不变式必须让 set_width 同时改 height(反之亦然),这就破坏了 Rectangle 的契约(「设置宽度不应影响高度」)——任何期望 Rectangle 行为的代码用 Square 替换就会出错,违反了里氏替换原则;这说明「继承要满足里氏替换(子类能完全替代父类用)」不只是「语义上是 is-a」;这类情况要么不用继承(组合、或都继承一个更抽象的 Shape)、要么让类不可变(避免 setter 的冲突)。 - 追问:为什么说「组合优于继承」,组合到底好在哪? 核心是「耦合度」和「灵活性」:① 松耦合——继承让子类依赖父类的「内部实现」(白盒复用,父类改内部逻辑子类可能坏,即脆弱基类问题),而组合只依赖对方的「公开接口」(黑盒复用,把对方当黑盒用,对方改内部只要接口不变我就不受影响),组合的耦合松得多、系统更能抵御变化;② 更灵活——组合持有的对象可以在运行时注入/替换(依赖注入,
Car(engine)能传不同引擎),而继承的父类是编译期写死的、无法运行时改变;③ 避免继承特有的坑——深继承层次难以理解、菱形(多重)继承的 MRO 和 super 调用微妙易错、继承会「意外暴露父类的全部方法」(可能破坏子类语义);④ 接口清晰、可测试——组合只暴露你想暴露的接口(封装内部对象)、各部分职责单一可独立测试和 mock;所以在需要「复用功能」时,优先用组合(持有对象)而非继承,能得到更松耦合、更灵活、更易维护的设计。 - 追问:什么时候应该用继承而不是组合? 当满足这些条件时用继承:① 真正的「is-a」关系——能自然地说「A 是一个 B」且语义正确(Circle 是一个 Shape、Dog 是一个 Animal、AdminUser 是一种 User),而不只是「A 想用 B 的功能」;② 满足里氏替换原则——子类能完全替代父类使用,任何期望父类的地方都能安全地传子类而不出错;③ 需要多态——你想把一组不同的对象「当作同一个基类」统一处理(
shapes = [Circle(), Square()]; sum(s.area() for s in shapes)),让它们走统一接口、可互换,这时继承 + 覆盖方法是最自然的实现;④ 框架/库要求——很多框架的扩展点就是「继承某个基类」(Django 的 View/Model、自定义 Exception 继承 Exception、实现 ABC 抽象基类);简单说:「真的是 is-a、需要多态、或框架要求」时用继承,「只是要复用功能、是 has-a 关系」时用组合;实际项目中两者常结合——用继承表达 is-a 的类型层次和多态,用组合复用具体的功能实现。 - 追问:Stack 继承 list 和 Stack 组合 list,有什么区别,哪个更好? 组合更好。
class Stack(list)(继承):Stack 复用了 list 的 append/pop,但同时「意外地是一个 list」、暴露了 list 的全部方法——用户可以调用stack.insert(0, x)、stack.sort()、stack[2]、stack.remove(x)等等,这些操作会破坏栈的「后进先出(LIFO)」语义(栈只应该从顶部 push/pop),Stack 无法阻止用户用这些方法搞乱它;class Stack: def __init__(self): self._items = [](组合):Stack 持有一个私有的 list_items、只对外暴露 push/pop(以及 peek、is_empty 等栈该有的接口),内部用_items.append/_items.pop实现,用户拿不到底层的 list、只能通过栈的接口操作,栈的语义被完好封装;这正是「组合优于继承」的经典例证——继承会「意外暴露不该暴露的接口」、破坏封装和语义,组合能「精确控制暴露什么」;所以实现一个有明确语义约束的容器(栈、队列、有序集合),应该用组合包一个内部容器、只暴露该暴露的接口,而不是继承内置容器。
八、加强记忆
「组合优于继承(composition over inheritance)」是一条 OOP 设计原则:想「复用别的类的功能」时优先「让一个类持有另一个类的对象(组合)」而不是「继承那个类」。两种复用:① 继承(is-a、是一个):class Dog(Animal),Dog「是一个」Animal 直接获得所有方法(子类和父类强耦合);② 组合(has-a、有一个):class Car: self.engine = Engine(),Car「有一个」Engine 通过持有对象用功能(松耦合)。为什么组合更好:① 松耦合(最重要)——组合只依赖对方的「公开接口」(黑盒),继承依赖父类的「内部实现」(白盒、父类改内部子类可能莫名坏掉 = 脆弱基类问题);② 更灵活——组合能运行时替换(Car(GasEngine())/Car(ElectricEngine()) 依赖注入),继承的关系编译期写死;③ 避坑——深继承层次难懂/菱形继承 MRO 微妙/意外暴露(class Stack(list) 会暴露 list 所有方法、用户能 stack.insert(0, x) 破坏栈的后进先出语义,用组合只暴露 push/pop 就干净)。什么时候用继承:确实是 is-a 关系(Circle 是 Shape)、需要多态(一组对象走统一接口 sum(s.area() for s in shapes))、框架要求(继承 View/Exception/Model)、抽象基类定义接口。判断口诀:能说「A 是一个 B」用继承(is-a)、只是「A 用到了 B 的功能」用组合(has-a);继承还要满足里氏替换(子类能完全替代父类)。一句话「组合(持有对象、has-a、松耦合、灵活、只依赖公开接口)优于继承(is-a、强耦合、依赖内部实现);复用功能优先组合、真正 is-a+多态+框架要求才继承;继承要满足里氏替换、别为复用几个方法就继承」。