LOADING

加载过慢请开启缓存 浏览器默认开启

面向对象设计原则系列(4):里氏替换原则(LSP)


一、LSP 定义与核心思想

“If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of that program (correctness, task performed, etc.).”

“如果 S 是 T 的子类型,那么程序中任何使用 T 的地方,都可以透明地使用 S 而不改变程序的正确性。”
——Barbara Liskov

  • 行为子类型(Behavioral Subtyping):子类不仅要匹配父类的接口签名,还要遵守父类的行为契约(pre-/post-conditions、invariants)。
  • 替换透明性:对客户端而言,使用子类实例时,系统应当无感知、不出现异常或逻辑错误。

二、为什么需要 LSP

  1. 避免运行时错误
    不遵守 LSP 的子类可能在某些调用中抛出异常或返回无效结果,导致系统崩溃。
  2. 保证多态可靠
    多态(Polymorphism)依赖于子类替换父类,若不满足 LSP,就无法安全地把子类当作父类来使用。
  3. 提升代码可维护性
    严格遵守 LSP 能让继承层次更加健壮,后续扩展或重构更有信心。
  4. 支撑接口与抽象设计
    LSP 与 依赖倒置(DIP)接口隔离(ISP) 配合,才能让高层模块真正依赖于稳定的抽象。

三、契约与不变式

  1. 前置条件(Pre-conditions)
    子类方法不得加强父类的前置条件 —— 不能要求更多、更严格的输入限制。
  2. 后置条件(Post-conditions)
    子类方法应至少满足父类的后置条件 —— 输出不能比父类更弱。
  3. 不变式(Invariants)
    父类在调用前后建立的类不变式,子类必须保持不变。

这些契约可以通过代码注释、单元测试或 C# 的 Code Contracts(.NET Framework)来明确和验证。


四、Unity 中的 LSP 违反示例

4.1 反例:Projectile 基类与子类重写

public class Projectile : MonoBehaviour {
    public float speed = 10f;
    public virtual void Launch(Vector3 direction) {
        // 默认行为:添加刚体初速度
        var rb = GetComponent<Rigidbody>();
        rb.velocity = direction.normalized * speed;
    }
}

// 错误的子类:IceProjectile 希望减速,但抛出了异常
public class IceProjectile : Projectile {
    public float slowFactor = 0.5f;
    public override void Launch(Vector3 direction) {
        if (slowFactor <= 0 || slowFactor > 1)
            throw new ArgumentException("slowFactor 范围错误");
        // 抛出的异常打破了父类契约:父类从不抛异常
        var rb = GetComponent<Rigidbody>();
        rb.velocity = direction.normalized * speed * slowFactor;
    }
}

问题

  • 客户端代码可能直接调用 Launch() 而不捕获异常,导致游戏崩溃;
  • IceProjectileslowFactor 的验证属于前置条件增强,违反 LSP。

五、LSP 友好型重构

5.1 将前置条件移出子类

  • 策略:在工厂或加载阶段就校验参数,并在构造/初始化时捕获,而非在行为方法中抛出。
public class IceProjectile : Projectile {
    [Range(0.01f, 1f)] public float slowFactor = 0.5f;

    public override void Launch(Vector3 direction) {
        // no exception, assume slowFactor 已在 Inspector 限制或初始化校验
        var rb = GetComponent<Rigidbody>();
        rb.velocity = direction.normalized * speed * slowFactor;
    }
}

5.2 提取接口与组合

  • 策略:将可变行为抽象到策略/组件中,避免子类重写父类方法时破坏契约。
public interface IProjectileBehavior {
    Vector3 ComputeVelocity(float baseSpeed, Vector3 dir);
}

[CreateAssetMenu("Projectile/DefaultBehavior")]
public class DefaultBehavior : ScriptableObject, IProjectileBehavior {
    public Vector3 ComputeVelocity(float baseSpeed, Vector3 dir)
        => dir.normalized * baseSpeed;
}

[CreateAssetMenu("Projectile/IceBehavior")]
public class IceBehavior : ScriptableObject, IProjectileBehavior {
    [Range(0.01f,1f)] public float slowFactor = 0.5f;
    public Vector3 ComputeVelocity(float baseSpeed, Vector3 dir)
        => dir.normalized * baseSpeed * slowFactor;
}

public class Projectile : MonoBehaviour {
    public float speed = 10f;
    public IProjectileBehavior behavior;  // 依赖抽象
    public void Launch(Vector3 direction) {
        var rb = GetComponent<Rigidbody>();
        rb.velocity = behavior.ComputeVelocity(speed, direction);
    }
}
  • 此时 Projectile 不再被继承,所有变体都通过“组合+策略”方式扩展,完美遵守 LSP。

六、协变与逆变

  • C# 中接口和委托支持 协变(返回类型能由子类替换)和 逆变(参数类型能由父类替换),可配合 LSP 使用:

    public interface IFactory<in TBase> { TBase Create(); }
    public class GoblinFactory : IFactory<Enemy> { public Enemy Create() => new Goblin(); }
    // GoblinFactory 也可当作 IFactory<MonoBehaviour> 使用,符合逆变
    
  • 使用场景:动态插件系统、泛型工厂注册。


七、工具

  1. 静态分析

    • NDepend:检测派生类方法中抛异常、前置条件增强等 LSP 违例;
    • ReSharper:提示派生类重写后方法契约变化。
  2. 单元测试

    • 为基类行为写测试用例,然后用所有子类实例重复执行,验证无异常、结果符合预期。
    [Test]
    public void AllProjectiles_CanLaunchWithoutException() {
        foreach (var proj in allProjectilePrefabs) {
            var inst = Instantiate(proj);
            Assert.DoesNotThrow(() => inst.Launch(Vector3.forward));
        }
    }
    

八、小结

  • 契约优先:在设计继承层次前,先列出每个方法的前置/后置条件与不变式;
  • 参数校验:把参数合法性检查放在工厂或属性赋值阶段,避免行为方法抛异常;
  • 组合优先继承:能用“组合+策略”解决的问题,就不要用继承;
  • 测试覆盖:基类与所有子类共用一组测试用例,自动验证 LSP 合规性。