Skip to content

里氏替换原则

里氏替换原则(Liskov Substitution Principle(LSP)),即所有引用基类的地方,必须能透明地使用其子类。

翻译成人话就是:

子类对象必须能够替换掉父类对象,而不会导致程序行为异常。”也就是说,继承不能乱继承,子类要保持和父类行为的一致性,该做的事你得做,不该做的事你也别乱搞!

强调子类的行为符合预期语义不会引发调用者逻辑异常,子类的行为不应该在逻辑或语义违背“角色设定”,也不应该导致调用者逻辑异常或误解

想像你开了一家冒险者公会,你雇了很多冒险者(基类),不管是战士、法师、弓箭手(子类),你只管发出一个命令:“攻击”Attack()命令,他们就各自出击,你不用管他们内部是砍、是射、是丢火球,但是有一天来了一位“冒险者”,结果按下 Attack() 后他却开始唱歌跳舞而不攻击敌人,这就破坏了冒险者“该战斗”的默认规则

  • 如果一个子类无法胜任父类的职责,它就不应该继承它!子类不能破坏父类的行为契约

  • 尽量使用接口,把“职责”拆开,谁该做什么就做什么,多用组合/接口,少用继承

  • 避免“假继承”,不要为了“共享代码”而强行继承,那样会出奇怪的 bug

例子

我们还是用RPG游戏举例,你设计了一个Character类,所有角色都能移动和攻击

gdscript
class_name Character

func move() -> void: 
    print("Character moves.")

func attack() -> void: 
    print("Character attacks.")
csharp
public class Character
{
    public virtual void Move() { Console.WriteLine("Character moves."); }
    public virtual void Attack() { Console.WriteLine("Character attacks."); }
}

然后你设计了两个子类:WarriorMage,都能替代 Character 正常使用

gdscript
class_name Warrior extends Character

func attack() -> void:
    print("Warrior slashes with sword!")
    
class_name Mage extends Character

func attack() -> void:
    print("Mage casts Fireball!")
csharp
public class Warrior : Character
{
    public override void Attack() { Console.WriteLine("Warrior slashes with sword!"); }
}

public class Mage : Character
{
    public override void Attack() { Console.WriteLine("Mage casts Fireball!"); }
}

你有一个游戏逻辑处理角色攻击:

gdscript
func battle(character: Character) -> void:
    character.move()
    character.attack()
csharp
void Battle(Character character)
{
    character.Move();
    character.Attack();
}

无论传进去的是 Mage 还是 Warrior,都能正常执行,不出问题~这就符合了里氏替换原则

不符合里氏替换原则的例子:

你突然搞了个 NPC 也继承 Character,但它不能攻击(比如是商人)!

gdscript
class_name NPC extends Character

func attack() -> void:
    push_error("啥?要我攻击?用商品砸吗?")
csharp
public class NPC : Character
{
    public override void Attack()
    {
        throw new NotSupportedException("NPCs can't attack!");
    }
}

然后你把它传进了 Battle() 里,逻辑就会出现问题

这就是违反了 LSP!子类不能破坏父类的行为契约!

你可能想说,Attack是一个虚函数,我不重写他用默认实现不就不违反LSP了吗?

但实际上,里氏替换原则强调的不是说“语法上”能不能,而是“语义上”能不能替换

显然,让不会攻击的商人去攻击,是违反了语义的

在传统的面向对象语言中,你可以拆分接口:

csharp
public interface IMovable { void Move(); }
public interface IAttackable { void Attack(); }

public class NPC : IMovable
{
    public void Move() { Console.WriteLine("NPC walks around."); }
}

战斗逻辑只接受 IAttackable,这样就从根源上避免了误传:

csharp
void Battle(IAttackable attacker)
{
    attacker.Attack();
}

这样你就不会不小心把一个小商人扔进战场了,他明明只想卖药水的!!

而 GDScript 不支持接口,但我们仍然可以通过其他方式来尽量遵循里氏替换原则(LSP)

就像在 C# 中我们用 IAttackableIMovable来拆分角色的行为一样, 在 GDScript 中你也可以写多个基类,比如:

gdscript
class_name Attackable
func attack() -> void:
    pass

class_name Moveable
func move() -> void:
    pass

然后角色想要“拥有攻击能力”就继承 Attackable,想要“拥有移动能力”就继承 Movable。 乍一看这和 C# 的接口系统有点像,好像也可以“按需继承”,看起来还挺香的

但是!重点来了!在 C# 中,你是可以同时实现多个接口的,比如:

csharp
public class Hero : IMoveable, IAttackbale 
{
    public void Attack() {}
    public void Move() {}
}
csharp
if (hero is IAttackable) {
    // OK! 说明它是能攻击的对象
}

但是 GDScript 不行哦!只能继承一个基类! 你没办法多继承两个“行为接口”,也不能像 csharp 一样做那种 is Attackable 的类型检查~ 这就让“这些类是不是属于同一种事物”变得模糊了

对于这种情况 ——

“它们本质上是同一类事物(比如:人物),但行为上不完全一致(有的不能攻击、有的不能移动)”

我们就不应该把这些行为强塞进继承层级里,而是要把行为抽出来作为组件组合进去! 这其实就是我们后面要讲到的:合成复用原则

用组合,你可以让角色拥有 AttackComponentMoveComponent,谁需要就谁挂,逻辑清晰又不混乱,角色还是角色,行为可以自由拼装,代码优雅又好维护!