什么是里氏替换原则(LSP)?
Liskov Substitution Principle( LSP)是面向对象设计的五大原则(SOLID)中的“L”。它的核心思想是:
子类必须能够替换掉它们的父类,而不会破坏程序。
换句话说,程序中任何使用父类(基类) 对象的地方,都应该可以透明地替换为其任意子类(派生类) 的对象,并且程序的行为依然是正确的。
以某个道具类举例,
- 基类(PowerUpItem) :定义了所有道具的 公共接口(Contract) 。这个“合约”就是那些虚方法:
ApplyEffect
,RemoveEffect
,Awake
,Start
,Update
。 - 子类(PowerUpCircle, PowerUpGhost等) :每个子类都继承了这个“合约”,并通过
override
关键字 实现了自己特有的行为 。
道具基类为PowerUpItem,具体的道具实现要继承该类,
public class PowerUpItem : MonoBehaviour
{
[Header("初始信息(自动填充)")]
public string mItemName; // 道具名称
public float mItemTime; // 道具效果持续时间
public Sprite mItemSprite; // 道具图标
public int mItemWeight; // 道具权重,决定道具生成的概率
public int mItemLevel; // 道具等级,决定道具属性
public string mDescription; // 道具描述
protected virtual void Awake()
{
}
protected virtual void Start()
{
}
protected virtual void Update()
{
}
public virtual void ApplyEffect(Player player)
{
}
public virtual void RemoveEffect(Player player)
{
}
}
所有子类都 严格遵循了父类定义的接口 。它们没有修改父类方法的方法签名(名称、参数、返回类型),只是改变了内部的实现细节。这意味着,从外部看,任何一个子类对象都“是一个”PowerUpItem
,并且承诺能完成 PowerUpItem
声称能做的所有事情(比如调用 ApplyEffect
方法)。
以PowerUpCircle举例,重写父类的虚方法。
public class PowerUpCircle : PowerUpItem
{
public override void ApplyEffect(Player player)
{
base.ApplyEffect(player);
// 状态栏添加道具倒计时
Game.Instance.uiPowerUps.AddPowerUp(this, () => {
RemoveEffect(player);
});
player.mPowerUpCircle = true; // 设置道具效果
Destroy(gameObject);
}
public override void RemoveEffect(Player player)
{
base.RemoveEffect(player);
player.mPowerUpCircle = false; // 移除道具效果
}
protected override void Awake()
{
base.Awake();
}
protected override void Start()
{
base.Start();
}
protected override void Update()
{
base.Update();
}
}
第一个 ApplyPowerUp
方法虽然用了继承,但仍然通过 is
关键字检查具体类型,然后进行强制转换。这本质上是一种“伪多态”,其并没有信任父类的接口,而是手动处理了类型差异。这增加了冗余代码,并且每增加一个新道具,都必须来这里修改 if-else
分支,违反了 开闭原则 (对扩展开放,对修改关闭)。
private void ApplyPowerUp(Collider2D collision)
{
// 获得道具
var powerUpItem = collision.gameObject.GetComponent<PowerUpItem>();
// 隐身效果
if (powerUpItem is PowerUpGhost)
{
// 触发道具效果
powerUpItem.ApplyEffect(this); // 设置道具效果
}
// 扩散效果
else if (powerUpItem is PowerUpSpread)
{
// 触发道具效果
powerUpItem.ApplyEffect(this); // 设置道具效果
}
// 环射效果
else if (powerUpItem is PowerUpCircle)
{
// 触发道具效果
powerUpItem.ApplyEffect(this); // 设置道具效果
}
// 增加攻击频率效果
else if (powerUpItem is PowerUpFrequency)
{
// 触发道具效果
powerUpItem.ApplyEffect(this); // 设置道具效果
}
// 子弹在屏幕中循环效果
else if (powerUpItem is PowerUpRepeatScreen)
{
// 触发道具效果
powerUpItem.ApplyEffect(this); // 设置道具效果
}
// 子弹变大的效果
else if (powerUpItem is PowerUpBigBullet)
{
// 触发道具效果
powerUpItem.ApplyEffect(this); // 设置道具效果
}
// 伤害增加的效果
else if (powerUpItem is PowerUpDamageUp)
{
// 触发道具效果
powerUpItem.ApplyEffect(this); // 设置道具效果
}
}
简化后,
private void ApplyPowerUp(Collider2D collision)
{
collision.GetComponent<PowerUpItem>()?.ApplyEffect(this);
}
这行代码是LSP思想的精髓。它只做一件事:
- 获取碰撞体上的
PowerUpItem
组件。 我不关心它具体是哪种道具,我只知道它肯定是一个PowerUpItem
。 - 调用它的
ApplyEffect
方法。
至于这个方法具体执行的是环绕射击、隐身还是扩散效果, 这完全由运行时该对象的实际类型决定 。PowerUpCircle
的对象替换了 PowerUpItem
的位置,并且程序行为完全正确。这就是“子类替换父类”的完美体现。
如果不使用继承,具体道具每个都要重复很多代码,获取道具时甚至需要这样写,何等的无语:
private void ApplyPowerUp(Collider2D collision)
{
// 隐身效果
var ghost = collision.gameObject.GetComponent<PowerUpGhost>();
if (ghost)
{
// 触发道具效果
ghost.ApplyEffect(this); // 设置道具效果
}
// 扩散效果
var spread = collision.gameObject.GetComponent<PowerUpSpread>();
if (spread)
{
// 触发道具效果
spread.ApplyEffect(this); // 设置道具效果
}
// 环射效果
var circle = collision.gameObject.GetComponent<PowerUpCircle>();
if (circle)
{
// 触发道具效果
circle.ApplyEffect(this); // 设置道具效果
}
// 增加攻击频率效果
var shootFrequency = collision.gameObject.GetComponent<PowerUpFrequency>();
if (shootFrequency)
{
// 触发道具效果
shootFrequency.ApplyEffect(this); // 设置道具效果
}
// 子弹在屏幕中循环效果
var bulletRepeatScreen = collision.gameObject.GetComponent<PowerUpRepeatScreen>();
if (bulletRepeatScreen)
{
// 触发道具效果
bulletRepeatScreen.ApplyEffect(this); // 设置道具效果
}
// 子弹变大的效果
var bigBullet = collision.gameObject.GetComponent<PowerUpBigBullet>();
if (bigBullet)
{
// 触发道具效果
bigBullet.ApplyEffect(this); // 设置道具效果
}
// 伤害增加的效果
var damageUp = collision.gameObject.GetComponent<PowerUpDamageUp>();
if (damageUp)
{
// 触发道具效果
damageUp.ApplyEffect(this); // 设置道具效果
}
}
如果没有一个统一的基类和多态机制,你就必须为每一种道具类型都写一遍几乎完全相同的代码:
- 重复调用
GetComponent<具体类型>()
- 重复写
if
判断 - 重复调用
ApplyEffect
这会导致代码 极度冗余、难以维护、不易扩展 。而LSP通过多态性,将这些重复劳动全部抽象掉,你只需要和抽象的父类接口打交道即可。
代码案例很好地说明了里氏替换原则带来的三大好处:
- 提高代码复用性 :所有公共属性和方法都在基类中实现,子类只需关注自身差异。
- 减少代码冗余 :使用多态调用(直接调用基类方法)消除了大量的条件判断语句。
- 增强程序的可扩展性 :未来如果要添加一个新的道具(例如
PowerUpLaser
),只需要:- 创建一个继承自
PowerUpItem
的新类。 - 重写
ApplyEffect
和RemoveEffect
方法。 - 无需修改任何现有的使用道具的代码(如
ApplyPowerUp
方法) 。新类的对象可以直接替换PowerUpItem
并被正确使用。
- 创建一个继承自