Skip to content

接口隔离原则

接口隔离原则((Interface Segregation Principle, (ISP)),和单一职责、里氏替换原则类似,它强调,客户端不应该被强迫依赖它不使用的方法。

更通俗的说法是:一个类不应该被强制实现它用不到的接口,如果接口太大、太杂,就应该拆成多个“小而专”的接口,你可以把接口想成“协议”或“契约”,如果契约太大,签约对象可能会因为“条款太多”而苦不堪言

这条原则是专门用来避免“胖接口”的,比如你总不能让一只鸟去游泳吧

例子

先来看 C# 不太好的设计

csharp
public interface ICharacter
{
    void Move();
    void Attack();
    void CastSpell();
}

然后你有一个 Warrior 类:

csharp
public class Warrior : ICharacter
{
    public void Move() { Console.WriteLine("走位~"); }
    public void Attack() { Console.WriteLine("挥剑!"); }

    public void CastSpell()
    {
        throw new NotImplementedException("战士不会魔法啊喂!");
    }
}

这就很尴尬…… Warrior 明明不会法术,但被接口“强制要求”去实现 CastSpell(),只好硬塞一个 throw,这就是违反了 ISP

这种情况下,你可以拆分接口,尊重各自特长

csharp
public interface IMovable
{
    void Move();
}

public interface IAttackable
{
    void Attack();
}

public interface ISpellCaster
{
    void CastSpell();
}

这样就可以按需组合了

csharp
public class Warrior : IMovable, IAttackable
{
    public void Move() { /* ... */ }
    public void Attack() { /* ... */ }
}

public class Mage : IMovable, IAttackable, ISpellCaster
{
    public void Move() { /* ... */ }
    public void Attack() { /* ... */ }
    public void CastSpell() { /* ... */ }
}

对于GDScript它并不支持接口,所以像上面那种通过 interface 拆行为的方式是没法直接照搬的。

你需要做的是让一个类不要写的那么臃肿,为此,你可以:

  • 按行为拆基类(像前面我们聊过的 AttackableHealable

  • 或者使用组合(把技能行为抽成组件)

  • 或者通过鸭子类型 + has_method() 来判定

违反 ISP 的例子(GDScript 版)

gdscript
class_name Character

func move():
    pass

func attack():
    pass

func cast_spell():
    pass

然后你有个 Warrior.gd

gdscript
extends Character

func cast_spell():
    push_error("战士不会法术!")

这其实也就是“接口太胖”的问题在 GDScript 里的变体。

在Godot4.5版本使用抽象类和抽象方法的情况下也是一样

gdscript
@abstract class_name Character

@abstract func move()

@abstract func attack()

@abstract func cast_spell()

这时候如果你让一个战士继承它:

gdscript
class_name Warrior
extends CharacterActions

func move(): print("前进!")
func attack(): print("挥砍!")
func cast_spell(): push_error("战士不会法术!")

这种情况下,你要么不将cast_spell作为抽象方法,要么使用组合,将角色的行为拆分成不同的组件