本文目录导读:

这是一个非常经典且核心的面向对象设计问题,简短的回答是:在大多数情况下,合成复用原则(Composite Reuse Principle, CRP)确实优于继承,但这并不意味着继承是“坏的”或应该被完全抛弃。
两者各有适用的场景,理解其背后的权衡比单纯记忆“谁更好”更重要。
下面我们来详细拆解一下。
核心思想
- 合成/聚合复用原则(CRP):尽量使用对象组合(has-a关系),而不是类继承(is-a关系)来达到复用的目的。
- 继承:通过建立父子类关系,子类自动获得父类的属性和方法(is-a关系)。
为什么CRP通常优于继承?
继承虽然有复用方便的优点,但它破坏封装性、增加耦合度,并且可能带来意想不到的风险。
| 对比维度 | 合成复用(CRP) | 继承 |
|---|---|---|
| 耦合度 | 低(黑箱复用),你只依赖被组合对象的公开接口,不关心其内部实现。 | 高(白箱复用),子类严重依赖父类的内部实现细节(包括protected成员),父类实现一变,子类可能崩溃。 |
| 封装性 | 好,将功能封装在独立的类中,彼此通过接口交互。 | 差,子类可以访问和覆盖父类的方法,破坏了父类的封装。 |
| 灵活性/运行时行为 | 高,可以在运行时动态替换组合对象的行为(策略模式的核心)。 | 低,继承关系在编译时就确定了,无法在运行时改变。 |
| 层次结构复杂性 | 简单,通过组合不同的“零件”类,形成清晰的“具有”关系。 | 复杂,容易形成深层次、脆弱的继承树(“脆弱的基类问题”)。 |
| 复用颗粒度 | 更细,可以复用单个类的功能,并按需组装。 | 较粗,继承会带走父类所有非私有方法,可能包含不需要的功能(“透传”或“胖接口”问题)。 |
举个经典例子:模拟鸭子游戏
假设我们要设计一个模拟鸭子游戏,有各种鸭子:绿头鸭、红头鸭、橡皮鸭、木鸭。
使用继承的糟糕设计
- 基类
Duck:有quack()(呱呱叫)和fly()(飞)方法。 - 子类
RubberDuck(橡皮鸭):它不会飞,也不会呱呱叫(会吱吱叫)。- 问题:必须重写
fly()(覆盖为空)和quack()(改为squeak()),这违反了LSP(里氏替换原则)。
- 问题:必须重写
- 维护噩梦:新增一种“诱饵鸭”(Decoy Duck),不会飞、不会叫,又得重写,如果以后要给所有能飞的鸭子添加新的飞行行为,所有不能飞的鸭子类都得检查一遍。
使用CRP的良好设计
-
行为接口:
FlyBehavior接口(interface FlyBehavior { public function fly(); })QuackBehavior接口
-
具体行为类:
FlyWithWings实现FlyBehaviorFlyNoWay实现FlyBehaviorQuack实现QuackBehaviorSqueak实现QuackBehaviorMuteQuack实现QuackBehavior
-
基类
Duck:- 持有
FlyBehavior和QuackBehavior的对象引用(这就是组合)。 - 提供
performFly()和performQuack()方法,委托给行为对象。
- 持有
-
具体鸭子类:
class RubberDuck extends Duck { public function __construct() { $this->flyBehavior = new FlyNoWay(); // 不会飞 $this->quackBehavior = new Squeak(); // 吱吱叫 } } class MallardDuck extends Duck { public function __construct() { $this->flyBehavior = new FlyWithWings(); // 会飞 $this->quackBehavior = new Quack(); // 呱呱叫 } }
优势:
- 零修改:想改变行为,只需创建新的行为类或修改当前对象的引用(运行时切换)。
- 超灵活:可以复用
FlyNoWay给橡皮鸭、木鸭、诱饵鸭,不用为每个子类重写方法。 - 代码更少:去掉了大量重复的覆盖代码。
什么时候应该使用继承?
说了这么多CRP的好处,但继承在特定情况下依然是更好的选择:
- 真正的“is-a”关系,且行为高度稳定:
class Car extends Vehicle。Vehicle的startEngine()方法在可见的未来不会变,而且所有子类几乎都以相同的方式启动引擎,继承是简单直接的。 - 子类需要访问或修改父类的具体实现细节:比如模板方法模式(Template Method Pattern),父类定义了算法的骨架(包含抽象方法或钩子方法),让子类实现具体步骤,这严格来说是为了代码复用,但耦合度可控。
- 代码复用限于同一种类的内部实现:框架中的基类,你扩展它,通常是为了添加特定的业务逻辑,而不是为了改变其行为(
class MyController extends Controller)。 - 多态性的典型应用:比如在集合中存放不同类型的子类,通过父类指针或引用统一调用它们的方法(如
$animals[] = new Dog(); $animals[] = new Cat(); foreach ($animals as $animal) { echo $animal->makeSound(); })。注意:这里是用继承实现多态,但行为通常是稳定的。
核心原则
- “优先使用对象组合,而不是类继承。”
- 继承是一种高度耦合的复用手段,容易导致系统脆弱。
- 组合提供了更低的耦合度、更高的灵活性和更好的封装性。
在PHP开发中的现实指导:
- 如果犹豫不决,选组合(CRP)。 特别是当你使用依赖注入容器时,组合几乎总是更自然。
- 继承仅用于实现“类型层次结构”和“稳定的行为扩展”(比如框架基类),尽量避免纯粹为了代码复用而创建深层继承树。
- 接口(Interface) + 组合(CRP) 是现代PHP开发的黄金搭档,接口定义了契约,组合提供了实现,两者结合(接口隔离原则 + CRP)可以构建出非常健壮、可扩展的系统。
回到问题:PHP合成复用原则优于继承吗?
答:是的,在绝大多数情况下,为了达到“高内聚、低耦合”的设计目标,应该优先选择合成复用原则,继承是有其用途的工具,但应该谨慎使用,只在真正需要“是一种”关系且行为稳定时才使用。