一、CRP 定义与核心思想
“Prefer composition over inheritance.”
优先使用组合,而非继承。
——Gang of Four(《设计模式》)
- 组合(Composition):在对象内部持有其他对象的引用,通过调用它们来扩展功能;
- 继承(Inheritance):子类自动获得父类的所有行为和接口。
CRP 的核心:把易变行为封装成独立组件,按需组合到宿主对象,而不是通过继承层层扩展。
二、为什么 CRP 重要
- 降低耦合
继承会让子类与父类绑定在一起:父类的任何改动都可能影响所有子类; - 提高内聚
组合让每个组件只关注自身职责,易于理解和测试; - 支持运行时动态扩展
通过组合,可在运行时替换或增加新组件,无需重新编译继承体系; - 避免多重继承陷阱
C# 不支持多重继承,组合能模拟多重行为的混入(mixin)效果。
三、继承 vs 组合:对比分析
| 特性 | 继承 | 组合 |
|---|---|---|
| 耦合度 | 高度耦合:父类改动影响子类 | 低耦合:组件改动只影响自身 |
| 重用方式 | 通过子类继承父类所有行为 | 通过引用组件,仅调用所需方法 |
| 运行时灵活性 | 静态:继承关系在编译期固定 | 动态:可在运行时增删组件 |
| 接口契约 | 子类自动继承所有父接口 | 宿主类只实现自己需要的接口 |
| 多重继承支持 | C# 不支持多继承 | 组合可同时持有多个组件 |
四、Unity 中的 CRP 反例
// 反例:继承地狱——多个层级的 Enemy 类型
public class Enemy : MonoBehaviour {
public virtual void Attack() { … }
public virtual void Die() { … }
}
public class RangedEnemy : Enemy {
public float range;
public override void Attack() { /* 远程射击 */ }
}
public class FlyingRangedEnemy : RangedEnemy {
public float flyHeight;
public override void Attack() {
// 飞行 + 远程射击
}
}
public class Boss : FlyingRangedEnemy {
public void SpecialAttack() { … }
}
问题
- 修改
Attack()的签名或行为,所有子类都需同步修改; - 想给
Boss添加一个“护盾”功能,只能在继承链上再插一层或修改Boss类; - 多维度行为(飞行、射击、护盾)无法灵活组合。
五、CRP 重构策略
5.1 将行为抽象为组件
- 攻击组件:
IAttackBehavior - 移动组件:
IMoveBehavior - 特效组件:
IShield、IFly
public interface IAttackBehavior {
void Attack(GameObject user);
}
public class MeleeAttack : IAttackBehavior {
public void Attack(GameObject user) {
// 近战逻辑
}
}
public class RangedAttack : IAttackBehavior {
public void Attack(GameObject user) {
// 远程射击逻辑
}
}
public interface IMoveBehavior {
void Move(GameObject user, Vector3 direction);
}
public class GroundMove : IMoveBehavior {
public void Move(GameObject user, Vector3 dir) { /* 地面移动 */ }
}
public class FlyMove : IMoveBehavior {
public void Move(GameObject user, Vector3 dir) { /* 飞行移动 */ }
}
5.2 在 Enemy 中组合行为
public class Enemy : MonoBehaviour {
[SerializeField] IAttackBehavior attackBehavior;
[SerializeField] IMoveBehavior moveBehavior;
[SerializeField] IShield shieldBehavior; // 可选
void Awake() {
// 也可通过工厂或 DI 容器注入
}
void Update() {
Vector3 dir = /* ... */;
moveBehavior.Move(gameObject, dir);
if (ShouldAttack()) attackBehavior.Attack(gameObject);
}
public void ActivateShield() {
shieldBehavior?.Enable(gameObject);
}
}
- 新增 Boss 护盾:只需提供
ShieldBehavior实现并赋值给Enemy上的字段,无需继承。 - 动态切换:可在运行时修改
attackBehavior或moveBehavior,实现状态模式、形态变换。
六、高级模式与扩展
6.1 装饰器(Decorator)+组合
public class PoisonDecorator : IAttackBehavior {
private IAttackBehavior wrappee;
public PoisonDecorator(IAttackBehavior baseAttack) {
wrappee = baseAttack;
}
public void Attack(GameObject user) {
wrappee.Attack(user);
// 附加中毒效果
}
}
// 运行时组合
IAttackBehavior atk = new RangedAttack();
if (hasPoison) atk = new PoisonDecorator(atk);
enemy.attackBehavior = atk;
6.2 ECS 思想下的纯组合
在 Unity ECS 中,所有行为都是 Component,系统(System)按组件组合来驱动:
struct AttackData : IComponentData { public float damage; }
struct RangedTag : IComponentData { public float range; }
// RangedAttackSystem 根据 Entities.ForEach 拥有 AttackData + RangedTag 的实体执行射击
七、工具
- 继承深度度量:
- NDepend 中查看 TypeInheritanceDepth,深度 > 3 警告继承链过长;
- 耦合度量:
- Afferent/Efferent Coupling 监测组件间依赖;
- 可视化:
- Visual Studio Class Diagram / Rider Architecture View 展示组合关系;
- 静态分析:
- SonarQube 建议使用组合替代继承的警告。
八、注意事项
- 过度组合:将所有行为拆成组件可能导致字段过多、管理复杂;
- 性能开销:组件调用频繁时,尽量在
Awake缓存引用,减少接口虚调用; - 生命周期管理:组合模式下,宿主需负责组件的启停,注意
null检查; - 接口粒度:避免“上帝接口”,保持接口职责单一,与 ISP 配合使用。
九、小结
- 识别变化维度:先梳理系统中哪些行为会扩展、新增或组合;
- 抽象行为接口:将可变的功能点定义为接口或数据驱动组件;
- 按需组合:在宿主类中持有接口引用,通过 Inspector、工厂或 DI 容器注入;
- 动态切换:利用装饰器、状态模式,支持运行时行为变化;
- 配合 ECS:在 DOTS/ECS 场景中,将所有行为组件化,系统驱动。