一、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
- 避免运行时错误
不遵守 LSP 的子类可能在某些调用中抛出异常或返回无效结果,导致系统崩溃。 - 保证多态可靠
多态(Polymorphism)依赖于子类替换父类,若不满足 LSP,就无法安全地把子类当作父类来使用。 - 提升代码可维护性
严格遵守 LSP 能让继承层次更加健壮,后续扩展或重构更有信心。 - 支撑接口与抽象设计
LSP 与 依赖倒置(DIP)、接口隔离(ISP) 配合,才能让高层模块真正依赖于稳定的抽象。
三、契约与不变式
- 前置条件(Pre-conditions)
子类方法不得加强父类的前置条件 —— 不能要求更多、更严格的输入限制。 - 后置条件(Post-conditions)
子类方法应至少满足父类的后置条件 —— 输出不能比父类更弱。 - 不变式(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()而不捕获异常,导致游戏崩溃; IceProjectile对slowFactor的验证属于前置条件增强,违反 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> 使用,符合逆变使用场景:动态插件系统、泛型工厂注册。
七、工具
静态分析
- NDepend:检测派生类方法中抛异常、前置条件增强等 LSP 违例;
- ReSharper:提示派生类重写后方法契约变化。
单元测试
- 为基类行为写测试用例,然后用所有子类实例重复执行,验证无异常、结果符合预期。
[Test] public void AllProjectiles_CanLaunchWithoutException() { foreach (var proj in allProjectilePrefabs) { var inst = Instantiate(proj); Assert.DoesNotThrow(() => inst.Launch(Vector3.forward)); } }
八、小结
- 契约优先:在设计继承层次前,先列出每个方法的前置/后置条件与不变式;
- 参数校验:把参数合法性检查放在工厂或属性赋值阶段,避免行为方法抛异常;
- 组合优先继承:能用“组合+策略”解决的问题,就不要用继承;
- 测试覆盖:基类与所有子类共用一组测试用例,自动验证 LSP 合规性。