我应该避免使用对象继承来开发游戏吗?


36

使用Unity开发游戏时,我更喜欢OOP功能。我通常创建一个基类(大多是抽象的),并使用对象继承将相同的功能共享给其他各种对象。

但是,我最近听到某人的消息,必须避免使用继承,而应该改用接口。所以我问为什么,他说:“对象继承当然很重要,但是如果有很多扩展对象,类之间的关系就深深地耦合了。

我使用的是一个抽象基类,像WeaponBase和创建如特定的武器类ShotgunAssaultRifleMachinegun类似的东西。有这么多优点,我真正喜欢的一种模式是多态。我可以将子类创建的对象当作基类一样对待,这样可以大大减少逻辑并使其更可重用。如果需要访问子类的字段或方法,则回退到子类。

我不想定义字段相同,在不同的类,常用的像AudioSource/ Rigidbody/ Animator和很多我这样定义成员字段的fireRatedamagerangespread等等。同样在某些情况下,某些方法可能会被覆盖。因此,在这种情况下,我使用的是虚拟方法,因此基本上可以使用父类中的方法来调用该方法,但是如果子级中的逻辑不同,则可以手动覆盖它们。

因此,是否应将所有这些东西当作坏习惯丢弃?我应该改用接口吗?


30
“有很多优点,而我真正喜欢的一种模式是多态。” 多态不是一种模式。从字面上看,这是OOP的重点。无需重复代码或继承即可轻松实现其他类似公共字段的内容。
立方'18

3
建议接口的人比继承要好吗?我很好奇。
Hawker65 '18

1
@Cubic我从未说过多态是一种模式。我说:“ ...我真正喜欢的一种模式是多态性”。这意味着我真正喜欢的模式是使用“多态性”,而不是多态性是一种模式。
现代化的

4
人们以任何形式的开发吹捧使用接口而不是继承。但是,这确实会增加很多开销,认知失调,导致类爆炸,经常增加可疑的价值并且通常在系统中无法很好地协同工作,除非项目中的每个人都遵循界面。因此,暂时不要放弃继承。但是,游戏具有一些独特的特征,Unity的体系结构要求您使用CES设计范例,这就是为什么您应该调整方法的原因。阅读Eric Lippert的《巫师与勇士》系列,其中描述了游戏类型的问题。
Dunk

2
我要说的是,接口是继承的一种特殊情况,其中基类不包含任何数据字段。我认为建议不要尝试建立一个必须对每个对象进行分类的大树,而应基于基于接口的许多小树,在这些树中可以利用多重继承。接口不会丢失多态性。
Emanuele Paolini,

Answers:


65

在实体和库存/项目系统中偏重于继承不是继承。当您可以(在运行时)组装复杂事物的方式导致多种不同组合时,此建议通常适用于游戏逻辑结构。那就是我们更喜欢构图的时候。

从UI构造到服务,从应用程序级逻辑支持继承而不是组成。例如,

Widget->Button->SpinnerButton 要么

ConnectionManager->TCPConnectionManagerVS ->UDPConnectionManager

...这里有一个明确定义的派生层次结构,而不是众多潜在派生结构,因此使用继承更容易。

底线:在可能的地方使用继承,但在必须的地方使用合成。PS我们赞成在实体系统中进行组合的另一个原因是,通常有许多实体,并且继承会导致访问每个对象上的成员的性能成本;参见vtables


10
继承并不意味着所有方法都是虚拟的。因此,它不会自动导致性能损失。VTable仅在调度虚拟调用中起作用,而不在访问数据成员中起作用。
罗斯兰

1
@Ruslan我添加了“可以”和“可以”一词来容纳您的评论。否则,为了使答案简洁明了,我没有赘述过多。谢谢。
工程师

7
P.S. The other reason we may favour composition in entity systems is ... performance:这是真的吗?查看@Linaith链接的WIkipedia页面,您将需要组成接口的对象。因此,当您引入了另一种间接访问级别时,您有(甚至更多)虚拟函数调用和更多的高速缓存未命中。
Flamefire '18

1
@Flamefire是的,的确如此;您可以通过多种方法来组织数据,从而使缓存效率达到最佳状态,无论哪种方式,并且与这些额外的间接访问方式相比,无疑要更为优化。这些不是继承而是组成,尽管从结构数组/数组结构的意义上讲(缓存线性排列的交错/非交错数据)意义更大,它们拆分成单独的数组,有时复制数据以匹配集合管道不同部分的操作 但是再说一次,这里将是一个非常重大的转移,为简洁起见,最好避免。
工程师

2
请记住,要对民事请求进行评论以进行澄清或批评,并辩论思想的内容,而不是陈述思想的的性格或经验。
乔什(Josh

18

您已经得到了一些不错的答案,但是您问题中房间里的那头巨大的大象就是这个:

有人听说必须避免使用继承,我们应该改用接口

根据经验,当有人给你一个经验法则时,请忽略它。这不仅适用于“有人告诉你一些事情”,还适用于阅读互联网上的内容。除非您知道为什么(并且真的可以支持它),否则此类建议毫无价值,而且通常非常有害。

以我的经验,OOP中最重要和最有用的概念是“低耦合”和“高内聚”(类/对象彼此之间的了解尽可能少,并且每个单元负责尽可能少的事情)。

低耦合

这意味着代码中的任何“一堆东西”都应尽可能少地依赖于其周围环境。这同样适用于类(类设计),而且对象(实际执行),一般的“文件”(即数#include每个单独的S .cpp档,数量import每单个.java文件等)。

两个实体已耦合的迹象是,当另一个实体以任何方式更改时,其中一个实体将损坏(或需要更改)。

显然,继承会增加耦合。更改基类将更改所有子类。

接口可减少耦合:通过定义一个清晰的,基于方法的合同,只要不更改合同,就可以自由更改接口两侧的任何内容。(请注意,“接口”是一个通用概念,Java interface或C ++抽象类只是实现细节)。

高凝聚力

这意味着让每个类,对象,文件等都尽可能少地与之相关或负责。即,避免使用做很多事情的大类。在您的示例中,如果您的武器具有完全独立的方面(弹药,射击行为,图形表示,库存表示等),那么您可以使用不同的类来精确地代表其中的一种。然后,主要武器类别将转变为这些细节的“持有人”。那么,武器对象不过是指向这些细节的一些指针而已。

在此示例中,您将确保代表“射击行为”的班级对主要武器类的了解尽可能少。理想情况下,什么都没有。例如,这意味着您只需轻轻一按,就可以对世界上的任何物体(炮塔,火山,NPC等)进行“射击行为” 。如果您想在某个时间点更改武器在清单中的表示方式,那么您可以简单地做到这一点-只有您的清单类对此有所了解。

一个实体不具有内聚力的迹象是,它会越来越大,同时在多个方向上分支。

正如您所描述的那样,继承会降低凝聚力-归根结底,您的武器类别很多,可以处理武器的各种不同的,无关的方面。

界面通过在界面的两侧之间明确划分职责来间接增加内聚力。

现在做什么

仍然没有硬性规定,所有这些都只是指导原则。总的来说,正如用户TKK在回答中提到的那样,在学校和书籍中对继承的知识很多。这是关于OOP的幻想。接口可能更无聊,而且(如果您忽略了一些琐碎的示例)也更难一点,从而打开了依赖注入的领域,这种注入不像继承那么明确。

归根结底,基于继承的方案仍然比根本没有清晰的OOP设计要好。因此,请随时坚持。如果愿意,您可以对低耦合,高内聚性进行反思/谷歌搜索,看看是否希望将这种想法添加到您的武器库中。如果以后愿意,您可以随时重构以尝试一下。或在下一个更大的新代码模块中尝试基于接口的方法。


感谢您的详细解释。了解我的缺失对您真的很有帮助。
现代化的

13

必须避免继承的想法是完全错误的。

存在一种编码原则,称为“ 继承之上的构成”。它说您可以通过合成来实现相同的目的,这是可取的,因为您可以重用一些代码。请参见 为什么我应该更喜欢合成而不是继承?

我必须说我喜欢您的武器课程,并且会以同样的方式进行。但是我现在还没有做过游戏...

正如詹姆斯·特罗特(James Trotter)指出的那样,合成可能具有一些优势,尤其是在运行时更改武器工作方式的灵活性方面。继承可以做到这一点,但要困难得多。


14
但是,与其拥有武器类,不如拥有一个单一的“武器”类,以及用于处理射击工作,其附件及其功能,所拥有的“弹药存储”的组件,您可以建立许多配置,其中一些可以是“ shot弹枪”或“突击步枪”,但也可以在运行时进行更改例如用于切换附件和更改弹匣的容量等。或者可以创建以前没有的全新枪支配置甚至都没有想到 是的,您可以通过继承实现类似的功能,但并不容易。
Trotski94 '18年

3
@JamesTrotter在这一点上,我想到了《无主之地》及其枪支,并想知道仅凭继承这将是多么的怪诞……
Baldrickk

1
@JamesTrotter我希望那是答案,而不是评论。
法拉普

6

问题在于继承会导致耦合-您的对象需要更多地了解彼此。这就是为什么规则是“始终偏重于继承而不是继承”。这并不意味着永远不要使用继承,而是要在完全合适的地方使用它,但是如果您坐在那里,想着“我可以同时做到这两种方式,并且两者都有意义”,那就直接进行组合。

继承也可以是一种限制。您有一个“ WeaponBase”,它可以是一个AssultRifle棒极了。当您拥有一把双管shot弹枪并想让枪管独立发射时会发生什么事—有点困难但可行,您只有一个单管和双管枪管,但不能仅安装2个单管用同一把枪可以吗?您可以将一个桶装成3个桶吗,或者您需要一个新的桶?

在下面有一个GrenadeLauncher的AssultRifle枪怎么样-嗯,要坚固一点。您可以用手电筒代替GrenadeLauncher进行夜间狩猎吗?

最后,您如何允许您的用户通过编辑配置文件来制造上述枪支?这很困难,因为您已经对关系进行了硬编码,而这些关系可能最好由数据组成和配置。

多重继承可以在某种程度上解决这些琐碎的示例,但它增加了它自己的一系列问题。

在出现诸如“优先选择组成优先于继承”之类的俗语之前,我是通过编写一个过于复杂的继承树(这真是太好了,而且运行得很好)发现了这一点,然后发现后来真的很难修改和维护。俗话说的是,您有另一种紧凑的方式来学习和记住这样的概念,而不必遍历整个经验。但是,如果您希望大量使用继承,我建议您保持开放的胸怀,并评估它的工作原理对您来说-如果您大量使用继承就不会发生任何可怕的事情,在最坏的情况下这可能是一个很好的学习经历(充其量对于您来说可能会很好用)


2
“您如何允许您的用户通过编辑配置文件来制造上述枪支?” ->我通常要做的模式是为每种武器创建最终课程。例如,像Glock17。首先创建WeaponBase类。此类是抽象的,定义了常见的值,例如射击模式,射击率,弹匣大小,最大弹药,弹丸。它还处理用户输入,例如mouse1 / mouse2 / reload并调用对应方法。它们几乎是虚拟的,或者分为更多功能,以便开发人员可以在需要时覆盖基本功能。然后创建另一个类,扩展WeaponBase,
modernator

2
像SlideStoppableWeapon之类的东西。大多数手枪都有此功能,并且可以处理其他行为,例如滑行停止。最后创建从SlideStoppableWeapon扩展的Glock17类。Glock17是最后一门课,如果枪支只有它具备的独特能力,请最终在这里写下。当喷枪具有特殊的开火机构或重装机构时,我通常使用此模式。将这种独特的行为实施到班级结束时,尽可能多地结合父母的共同逻辑。
现代化家

2
@modernator正如我说的,这只是一个准则-如果您不信任它,可以随时忽略它,随时间推移对结果保持睁大眼睛,并经常评估收益与痛苦。我试着记住我I脚的地方,并帮助其他人避免脚趾受伤,但是对我有用的对您可能不起作用。
比尔K,

7
@modernator在Minecraft中,它们是Monster的子类WalkingEntity。然后他们添加了史莱姆。您认为效果如何?
user253751 '18

1
@immibis您是否有该问题的参考或轶事链接?谷歌搜索我的世界的关键字淹没了我的视频链接;-)
彼得-恢复莫妮卡

3

与其他答案相反,这与继承与组合无关。继承与组合是您决定如何实现类的决定。接口与类是在此之前做出的决定。

接口是任何OOP语言的一等公民。类是辅助实现细节。在接口之前介绍了类和继承的老师和书籍严重扭曲了新程序员的思维。

关键原则是,只要有可能,所有方法参数和返回值的类型都应该是接口,而不是类。这使您的API更加灵活和强大。在绝大多数情况下,您的方法及其调用者应该没有理由知道或关心它们正在处理的对象的实际类。如果您的API不了解实现细节,则可以在组成和继承之间自由切换而不会破坏任何内容。


如果接口是任何OOP语言的第一流公民,我想知道Python,Ruby等不是OOP语言。
BlackJack

@BlackJack:在Ruby中,该接口是隐式的(如果可以的话,是鸭式输入),并且通过使用合成而不是继承来表示(而且,就我而言,它具有介于两者之间的Mixins)...
AnoE

@BlackJack“接口”(概念)不需要interface(语言关键字)。
卡雷斯(Caleth)'18

2
@Caleth但称呼他们为头等舱公民确实恕我直言。当语言描述声称某物是一等公民时,通常意味着它是该语言中的一种真实事物,具有某种类型和价值并且可以传递。像类一样,类,函数,方法和模块也是Python中的一等公民。如果我们在谈论这个概念,那么接口也是所有非OOP语言的一部分。
BlackJack

2

每当有人告诉您一种特定的方法对所有情况都是最好的方法时,就等于告诉您一种且相同的药物可以治愈所有疾病。

继承与构成是一个“是”还是“有”的问题。接口是另一种(第三种)方式,适用于某些情况。

继承或“是”逻辑:您可以在新类要表现出来时使用它,并且如果新类要向公众公开,则可以完全(外部)使用您继承它的旧类旧类具有的所有功能...然后您继承。

组合或具有逻辑:如果新类只需要在内部使用旧类,而又不向公众公开旧类的功能,则可以使用组合-也就是说,将旧类的实例作为成员属性或新属性的变量。(这可以是私有财产,也可以是受保护的财产,或其他,取决于使用情况)。这里的关键点在于,这是一个用例,您不想将原始的类功能公开并使用,仅在内部使用,而在继承的情况下,您可以通过以下方式将它们公开。新课。有时您需要一种方法,有时需要另一种方法。

接口:接口还用于另一种用例-当您希望新类部分而不完全实现并向公众公开旧类的功能时。这样一来,您就可以拥有一个新类,该类与旧类的层次结构完全不同,并且仅在某些方面像旧类一样。

假设您有各种由类表示的生物,而它们具有由功能表示的功能。例如,一只鸟将具有Talk(),Fly()和Poop()。Duck类将从Bird类继承而来,实现所有功能。

福克斯级显然不能飞行。因此,如果将祖先定义为具有所有特征,则无法正确派生后代。

但是,如果将功能分为几组,用一个接口表示每个呼叫组,例如IFly,包含Takeoff(),FlapWings()和Land(),则可以为Fox类实现ITalk和IPoop的功能。但不是。然后,您将定义变量和参数以接受实现特定接口的对象,然后与它们一起工作的代码将知道它可以调用的内容,并且如果需要查看其他功能是否也可以始终查询其他接口。为当前对象实现。

这些方法中的每一种都有最好的用例,没有一种方法是所有情况下的绝对最佳解决方案。


1

对于游戏,尤其是使用与实体组件体系结构一起使用的Unity而言,您应该更偏重于组成而非游戏组件的继承。它更加灵活,并且避免陷入希望游戏实体“成为”继承树不同叶子上的两件事的情况。

例如,假设您在继承树的一个分支上有一个TalkingAI,而在另一个单独的分支上有VolumetricCloud,并且您想要一个对话云。对于深的继承树,这很难。使用实体组件,您只需创建一个具有TalkingAI和Cloud组件的实体,就可以了。

但这并不意味着在您的卷云实施中,您不应使用继承。这样做的代码可能很长,并且由几个类组成,您可以根据需要使用OOP。但这全都构成一个游戏组成部分。

作为附带说明,我对此有一些疑问:

我通常创建一个基类(大多是抽象的),并使用对象继承将相同的功能共享给其他各种对象。

继承不用于代码重用。这是为了建立is-a关系。很多时候,这些并存,但是您必须要小心。仅仅因为两个模块可能需要使用相同的代码,并不意味着一个模块与另一个模块具有相同的类型。

例如,如果您想使用List<IWeapon>带有不同类型武器的武器,则可以使用界面。这样,您可以从IWeapon接口和所有武器的MonoBehaviour子类继承,并避免缺少多重继承的任何问题。


-3

您似乎不明白什么是接口或接口。我花了10多年的时间来考虑OO,并问了一些非常聪明的人问题,才使我能够在界面上玩得开心。希望这可以帮助。

最好是结合使用它们。在我的面向对象的世界中,人们和人们的建模举了一个深刻的例子。灵魂通过思想与身体交界。头脑是灵魂(某些人认为是智力)与其发出的对身体的命令之间的接口。从功能上讲,人与人之间的思想界面是相同的。

在与世界交互的人(身体和灵魂)之间可以使用相同的类比。身体是思想与世界之间的接口。每个人都以相同的方式与世界交互,唯一的独特之处在于我们如何与世界交互,这就是我们如何使用思维来决定我们如何与世界交互/交互的方式。

接口只是将您的OO结构与另一种不同的OO结构相关联的一种机制,每一侧具有不同的属性,这些属性必须相互协商/映射才能在不同的介质/环境中生成某些功能输出。

因此,要想调用开放式函数,必须使用THAT实现接口Neuro_command_system,该接口具有自己的API,并带有在调用开放式函数之前如何实现所有必要要求的说明。

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.