里氏转换原则示例

 

什么是里氏替换原则(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思想的精髓。它只做一件事:

  1. 获取碰撞体上的 PowerUpItem组件。 我不关心它具体是哪种道具,我只知道它肯定是一个 PowerUpItem
  2. 调用它的 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通过多态性,将这些重复劳动全部抽象掉,你只需要和抽象的父类接口打交道即可。

代码案例很好地说明了里氏替换原则带来的三大好处:

  1. 提高代码复用性 :所有公共属性和方法都在基类中实现,子类只需关注自身差异。
  2. 减少代码冗余 :使用多态调用(直接调用基类方法)消除了大量的条件判断语句。
  3. 增强程序的可扩展性 :未来如果要添加一个新的道具(例如 PowerUpLaser),只需要:
    • 创建一个继承自 PowerUpItem的新类。
    • 重写 ApplyEffectRemoveEffect方法。
    • 无需修改任何现有的使用道具的代码(如 ApplyPowerUp方法) 。新类的对象可以直接替换 PowerUpItem并被正确使用。