违反LSP可以吗?


10

我正在跟进这个问题,但是我将重点从代码转换为原则。

根据我对Liskov替换原理(LSP)的理解,无论我的基类中有什么方法,它们都必须在我的子类中实现,并且根据页面,如果您在基类中重写了一个方法,则它什么也不做或抛出一个错误。例外,您违反了该原则。

现在,我的问题可以这样总结:我有一个abstract Weapon class,两个类,SwordReloadable。如果Reloadable包含一个特定的method,称为Reload(),我将不得不向下访问以访问该method,并且理想情况下,您希望避免这种情况。

然后,我想到了使用Strategy Pattern。这样,每把武器都只知道它能够执行的动作,因此,例如,一种Reloadable武器显然可以重新装弹,但是Sword不能,甚至不知道Reload class/method。正如我在Stack Overflow帖子中所说的那样,我不必沮丧,也可以维护List<Weapon>收藏集。

另一个论坛上,第一个答案建议让其Sword意识到Reload,只是什么也不要做。我在上面链接到的“堆栈溢出”页面上也给出了相同的答案。

我不完全明白为什么。为什么要违反该原则并让Sword知道Reload并保留为空?正如我在Stack Overflow帖子中所说,SP几乎解决了我的问题。

为什么它不是可行的解决方案?

public final Weapon{

    private final String name;
    private final int damage;
    private final List<AttackStrategy> validactions;
    private final List<Actions> standardActions;

    private Weapon(String name, int damage, List<AttackStrategy> standardActions, List<Actions> attacks)
    {
        this.name = name;
        this.damage = damage;
        standardActions = new ArrayList<Actions>(standardActions);
        validAttacks = new ArrayList<AttackStrategy>(validActions);
    }

    public void standardAction(String action){} // -- Can call reload or aim here.  

    public int attack(String action){} // - Call any actions that are attacks. 

    public static Weapon Sword(String name, damage, List<AttackStrategy> standardActions, List<Actions> attacks){
        return new Weapon(name, damage,standardActions, attacks) ;
    }

}

攻击接口和实现:

public interface AttackStrategy{
    void attack(Enemy enemy);
}

public class Shoot implements AttackStrategy {
    public void attack(Enemy enemy){
        //code to shoot
    }
}

public class Strike implements AttackStrategy {
    public void attack(Enemy enemy){
        //code to strike
    }
}

2
你可以的class Weapon { bool supportsReload(); void reload(); }。客户端将在重新加载之前测试是否受支持。reload根据合同定义抛出iff !supportsReload()。如果驱动类遵循我刚刚概述的协议,则遵循LSP。
usr

3
是否保留reload()空白或standardActions不包含重新加载操作只是一种不同的机制。没有根本的区别。两者都可以。=>您的解决方案可行的(这是您的问题)。如果Weapon包含空白的默认实现,Sword不需要了解有关重新加载的信息。
usr

27
我写了一系列文章,探讨了使用各种技术来解决此问题的各种问题。结论:不要试图在语言的类型系统中捕获游戏规则。在表示和执行游戏逻辑级别而不是类型系统级别的规则的对象中捕获游戏规则。没有理由相信您使用的任何类型的系统都足以表示您的游戏逻辑。ericlippert.com/2015/04/27/wizards-and-warriors-part-one
埃里克·利珀特

2
@EricLippert-感谢您的链接。我已经多次浏览过此博客,但是有些观点让我不太理解,但这不是你的错。我自己学习OOP,遇到过SOLID校长。第一次浏览您的博客时,我一点都不明白,但是我学到了更多知识,再次阅读了您的博客,然后慢慢开始理解其中所说的内容。有一天,我将完全理解该系列中的所有内容。我希望:D

6
@SR“如果它不执行任何操作或引发异常,则表示您违反了”-我认为您误读了该文章中的消息。问题不是直接因为setAltitude没有做任何事情,而是因为它没有满足后置条件“将在设置的高度绘制鸟”。如果将“重新加载”的后置条件定义为“如果有足够的弹药,武器可以再次攻击”,那么对于不使用弹药的武器,什么也不做是完全有效的实现。
塞巴斯蒂安·雷德尔

Answers:


16

LSP关注子类型化和多态性。并非所有代码实际上都使用这些功能,在这种情况下,LSP是无关紧要的。继承语言构造的两个常见用例不是子类型:

  • 继承用于继承基类的实现,但不继承其接口。在几乎所有情况下,都应首选成分。诸如Java之类的语言无法分离实现和接口的继承,但是例如C ++具有private继承。

  • 用于建模求和类型/联合的继承,例如:a BaseCaseACaseB。基本类型未声明任何相关接口。要使用其实例,必须将它们强制转换为正确的具体类型。铸造可以安全地完成,而不是问题。不幸的是,许多OOP语言无法将基类子类型仅限制为预期的子类型。如果外部代码可以创建一个CaseC,则假定a Base只能是CaseACaseB不正确的代码。Scala可以凭借其case class概念安全地做到这一点。在Java中,当Base是具有私有构造函数的抽象类,然后嵌套的静态类从基类继承时,可以对此进行建模。

诸如现实世界对象的概念层次结构之类的一些概念很难映射到面向对象的模型中。诸如“枪是武器,而剑是武器,因此我将拥有一个继承并继承的Weapon基类”之类的想法具有误导性:实词is-a关系在我们的模型中并不表示这种关系。一个相关的问题是,对象可能在运行时属于多个概念层次结构,或者可能更改其层次结构从属关系,大多数语言无法建模,因为继承通常是按类而不是按对象,并且在设计时而不是运行时进行定义。GunSword

在设计OOP模型时,我们不应该考虑层次结构,也不应该考虑一个类如何“扩展”另一个类。基类不是排除多个类的公共部分的地方。相反,请考虑如何使用您的对象,即这些对象的用户需要什么样的行为。

在这里,用户可能需要attack()携带武器,也许还有reload()。如果我们要创建一个类型层次结构,那么这两种方法都必须是基本类型,尽管不可重装武器可能会忽略该方法,并且在调用时什么也不做。因此,基类不包含公共部分,而是所有子类的组合接口。子类的接口没有不同,只是该接口的实现不同。

不必创建层次结构。这两种类型的GunSword可以是完全不相关的。而Gunfire()reload()一个Sword只可能strike()。如果需要多态管理这些对象,则可以使用适配器模式来捕获相关方面。在Java 8中,使用功能接口和lambda /方法引用可以很方便地做到这一点。例如,您可能有一个Attack可以提供myGun::fire或供应的策略() -> mySword.strike()

最后,有时明智的做法是完全避免使用任何子类,而是通过单个类型对所有对象建模。这在游戏中特别重要,因为许多游戏对象不能很好地适应任何层次结构,并且可能具有许多不同的功能。例如,一个角色扮演游戏可能拥有一项既是任务项,又装备了+2力量的属性,使您的统计数据增强,有20%的机率忽略受到的任何伤害,并提供近战攻击。或者,也许是可重装的剑,因为它是*魔术*。谁知道这个故事需要什么。

与其尝试找出一个混乱的类层次结构,不如让一个类为各种功能提供插槽。这些插槽可以在运行时更改。每个广告位都会是一个策略/回调,例如OnDamageReceivedAttack。随着你的武器,我们可以有MeleeAttackRangedAttackReload插槽。这些插槽可能是空的,在这种情况下,对象不提供此功能。然后有条件地调用这些插槽:if (item.attack != null) item.attack.perform()


在某种程度上类似于SP。为什么插槽必须为空?如果字典不包含操作,则什么也不做

@SR插槽是否为空或不存在并不重要,取决于用于实现这些插槽的机制。我以相当静态的语言的假设编写了这个答案,其中插槽是实例字段,并且始终存在(即Java中的常规类设计)。如果选择更具动态性的模型,其中插槽是字典中的条目(例如使用Java中的HashMap或普通的Python对象),则插槽不必存在。注意,更多的动态方法会放弃很多类型安全性,通常这是不希望的。
阿蒙

我同意现实世界中的对象不能很好地建模。如果我了解您的帖子,您说我可以使用策略模式?

2
@SR是的,某种形式的策略模式可能是明智的方法。还比较相关的类型对象模式:gameprogrammingpatterns.com/type-object.html
amon

3

因为制定策略attack还不足以满足您的需求。当然,它可以让您抽象出物品可以执行的动作,但是当您需要了解武器的射程时会发生什么呢?还是弹药容量?还是需要哪种弹药?您将回到沮丧的状态。拥有这种灵活性将使UI的实现更加困难,因为它需要具有类似的策略模式来处理所有功能。

综上所述,我对您其他问题的答案并不完全同意。具有sword从继承weapon是可怕,幼稚OO这总是导致无操作方法或类型的检查撒布的代码。

但在问题的根源,既不解决方案是错误的。您可以使用两种解决方案来制作一款有趣的功能性游戏。每个解决方案都有其自己的权衡,就像您选择的任何解决方案一样。


我认为这很完美。我可以使用SP,但它们是折衷方案,只需要了解它们即可。关于我的想法,请参见我的编辑。

1
Fwiw:一把剑有无限的弹药:您可以一直使用它,而无需永远阅读。reload不会做任何事情,因为开始时您会无限使用;一系列的近战武器:这是近战武器。不可能以对近战和远程都有效的方式来考虑所有的统计数据/动作。不过,随着年龄的增长,我越来越少地使用继承来支持接口,竞争以及使用Weapon剑和枪实例使用单个类的名称。
CAD97

命运2中的Fwiw出于某种原因使用了弹药!

@ CAD97-这是我所看到的有关此问题的类型。拥有无限弹药的剑,因此无需重新加载。这只是解决问题或将其隐藏。如果我引入手榴弹怎么办?手榴弹没有弹药或射击,也不应该知道这种方法。

1
我对此有CAD97。并且将WeaponBuilder通过组合战略武器来创造可以制造剑和枪的武器。
克里斯·沃勒特

3

当然,这是一个可行的解决方案。这是一个非常糟糕的主意。

问题不在于您是否具有将重载放在基类上的单个实例。问题是您还需要放置“挥杆”,“射击”,“招架”,“敲打”,“抛光”,“拆卸”,“尖锐”和“替换球杆尖头的指甲”。基类上的方法。

LSP的要点是您的顶级算法需要工作并且有意义。所以,如果我有这样的代码:

if (isEquipped(weapon)) {
   reload();
}

现在,如果抛出未实现的异常并使程序崩溃,那么这是一个非常糟糕的主意。

如果您的代码看起来像这样,

if (canReload(weapon)) {
   reload();
}
else if (canSharpen(weapon)) {
  sharpen();
}
else if (canPollish(weapon)) {
  polish();
}

那么您的代码可能会变得非常混乱,因为它们具有非常具体的属性,而这些属性与抽象的“武器”概念无关。

但是,如果您实施的是第一人称射击游戏,并且您的所有武器都可以射击/重装,除了那把刀,然后(在您的特定情况下),非常有意义的是让您的刀重装任何东西,因为这是例外和可能性。使基类杂乱无章的特定属性的可能性很低。

更新:请 尝试考虑抽象用例/术语。例如,也许每种武器都有一个“准备”动作,即为枪支装填武器,为剑装甲。


假设我有一个内部武器字典,其中包含武器的动作,并且当用户传递“重新加载”时,它会检查字典,例如,如果是武器动作.containsKey(action),则获取字典,抓取与其关联的对象,然后执行它。而不是具有多个if语句的武器类

请参阅上面的编辑。这就是我使用SP时所想到的

0

显然,如果不创建一个替代基类实例的子类就可以,但是如果您使用基类作为方便的功能存储库来创建子类,则可以。

现在,是否是一个好主意尚有待商but,但是如果您从未用子类代替基类,那么它不起作用的事实就没问题了。您可能有问题,但是在这种情况下LSP不是问题。


0

LSP之所以不错,是因为它使调用代码不必担心类的工作方式。

例如。我可以在我的BattleMech上安装的所有武器上调用Weapon.Attack(),而不必担心其中一些武器会引发异常并使我的游戏崩溃。

现在,根据您的情况,您想使用新功能扩展基本类型。Attack()并不是问题,因为Gun类可以跟踪其弹药并在其用尽时停止发射。但是Reload()是新事物,并不是武器的一部分。

简单的解决方案是向下转换,我认为您不必担心性能过高,您不必在每一帧都这样做。

或者,您可以重新评估您的体系结构,并认为抽象上所有武器都是可重装的,而某些武器则永远都不需要重装。

这样一来,您就不必再扩展枪支类了,也不会违反LSP。

但这是一个长期的问题,因为您必然会想出更多特殊情况,例如Gun.SafteyOn(),Sword.WipeOffBlood()等,如果将它们全部放在Weapon中,那么您将拥有一个超级复杂的通用基类必须改变。

编辑:为什么策略模式不好(tm)

不是,但是要考虑设置,性能和总体代码。

我必须在某处进行一些配置,告诉我枪可以重新装弹。当我实例化武器时,我必须阅读该配置并动态添加所有方法,检查是否有重复的名称,等等。

当我调用一个方法时,我必须遍历该动作列表并进行字符串匹配以查看要调用的方法。

当我编译代码并调用Weapon.Do(“ atack”)而不是“ attack”时,编译时不会出错。

它可能是某些问题的合适解决方案,比如说您拥有数百种武器,而且每种武器都具有不同的随机方法组合,但是却失去了许多面向对象和强类型输入的好处。向下转换并不能真正为您节省任何费用


我认为,SP可以处理所有的(见上文编辑),枪必须SafteyOn()Sword必须wipeOffBlood()。每个武器都不知道其他方法(也不应该知道)

SP很好,但是它相当于没有类型安全性的向下转换。我想我在回答一个不同的问题,让我更新
Ewan

2
策略模式本身并不意味着在列表或字典中动态查找策略。即weapon.do("attack"),类型安全weapon.attack.perform()和策略安全都是示例。尽管使用反射同样具有类型安全性,但是仅当从配置文件配置对象时,才需要按名称查找策略。
阿蒙

在这种情况下无法正常工作,因为有两个不同的攻击行动和重装,这需要结合一些用户输入
伊万
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.