为什么构造函数中的while(true)循环实际上不好?


47

尽管是一个普遍的问题,但我的范围是C#,因为我知道像C ++这样的语言在构造函数执行,内存管理,未定义的行为等方面具有不同的语义。

有人问我一个有趣的问题,对我来说不容易回答。

为什么(或者根本不?)让类的构造函数开始永无止境的循环(即游戏循环)被认为是不好的设计?

有一些与此有关的概念:

  • 像最小惊讶原则一样,用户并不希望构造函数具有这种行为。
  • 单元测试比较困难,因为您无法创建此类或将其注入,因为它永远不会退出循环。
  • 从概念上讲,循环的结束(游戏结束)是构造函数完成的时间,这也是奇怪的。
  • 从技术上讲,此类除了构造函数外没有其他公共成员,这使它很难理解(尤其是对于没有可用实现的语言)

然后是技术问题:

  • 构造函数实际上从未完成,所以GC在这里发生了什么?这个对象已经在Gen 0中了吗?
  • 由于基本构造函数从不返回,因此无法或至少非常复杂地从此类派生

这样的方法是否存在更明显的弊端或弊端?


61
为什么好呢?如果您仅将主循环移至某个方法(非常简单的重构),则用户可以编写类似以下代码:var g = new Game {...}; g.MainLoop();
Brandin 2013年

61
您的问题已经陈述了使用它的6个理由,我希望看到一个赞成它的理由。你也可以问,为什么这是一个坏主意,把一个while(true)循环的属性setter: new Game().RunNow = true
Groo

38
极端的类比:为什么我们说希特勒的所作所为是错误的?除了种族歧视,第二次世界大战爆发,数百万人丧生,暴力[等等,有50多种其他原因],他没有做错任何事情。当然,如果您删除关于某个错误原因的任意原因列表,那么您也可以得出结论,该问题对任何事情都具有好处。
巴库里

4
关于垃圾回收(GC)(您的技术问题),没有什么特别的。该实例在构造函数代码实际运行之前就存在。在这种情况下,该实例仍然可以访问,因此GC无法声明该实例。如果设置了GC,则该实例将继续存在,并且在每次设置GC时,将根据通常规则将对象提升为下一代。是否发生GC取决于是否(足够)个新对象被创建。
Jeppe Stig Nielsen

8
我会更进一步,说while(true)不好,无论使用在哪里。这意味着没有明确,干净的方法来停止循环。在您的示例中,退出游戏或失去焦点时,循环不应该停止吗?
乔恩·安德森

Answers:


250

构造函数的目的是什么?它返回一个新构造的对象。无限循环有什么作用?它永远不会回来。如果构造函数根本不返回,该构造函数如何返回该新构造的对象?不行

因此,一个无限循环破坏了构造函数的基本契约:构造某些东西。


11
也许他们的意思是将while(true)放在中间?冒险,但合法。
John Dvorak

2
我同意,这是正确的-至少。从学术角度来看。对于返回某些内容的每个函数都一样。
Doc Brown

61
想象一下创建该类的子类的可怜草皮……
Newtopian

5
@JanDvorak:嗯,这是一个不同的问题。OP明确指定“永无止境”并提到事件循环。
约尔格W¯¯米塔格

4
@JörgWMittag好吧,是的,不要把它放在构造函数中。
John Dvorak

45

这样的方法是否存在更明显的弊端或弊端?

当然是。它是不必要的,意外的,无用的,毫不含糊的。它违反了类设计的现代概念(内聚,耦合)。它破坏了方法协定(构造函数具有定义的任务,而不仅仅是一些随机方法)。它当然不能很好地维护,将来的程序员将花费大量时间试图了解正在发生的事情,并试图猜测这样做的原因。

就您的代码不起作用而言,这绝不是“错误”。但是从长远来看,由于使代码难以维护(例如,难以添加测试,难以重用,难以调试,难以扩展等),从长远来看,这可能会带来巨大的辅助成本(相对于最初编写代码的成本)。 )。

专门针对软件开发方法进行了许多/最现代的改进,以简化编写/测试/调试/维护软件的实际过程。所有这些都可以通过诸如此类的东西来规避,因为它们“有效”,所以代码被随机放置。

不幸的是,您经常会遇到完全不了解这一切的程序员。它起作用了,就是这样。

用类比结束(另一种编程语言,此处的问题是计算2+2):

$sum_a = `bash -c "echo $((2+2)) 2>/dev/null"`;   # calculate
chomp $sum_a;                         # remove trailing \n
$sum_a = $sum_a + 0;                  # force it to be a number in case some non-digit characters managed to sneak in

$sum_b = 2+2;

第一种方法有什么问题?在相当短的时间内返回4;它是正确的。反对意见(以及开发人员可能提出的所有通常理由):

  • 较难阅读(但是,嘿,一个好的程序员可以很容易地阅读它,我们一直都是这样做的;如果您愿意,我可以将其重构为方法!)
  • 速度较慢(但这不是在时间上至关重要的地方,因此我们可以忽略它,此外,最好仅在必要时进行优化!)
  • 使用更多资源(新进程,更多RAM等-但是dito,我们的服务器足够快)
  • 引入依赖关系bash(但是无论如何它永远不会在Windows,Mac或Android上运行)
  • 以及上述所有其他原因。

5
“ GC可能在运行构造函数时处于某些异常的锁定状态”-为什么?分配器首先分配,然后作为普通方法启动构造函数。真的没有什么特别的,是吗?无论如何,分配器必须允许在构造函数中进行新分配(这可能会触发OOM情况)。
John Dvorak

1
@JanDvorak,只是想指出在构造函数中可能在“某处” 存在某些特殊行为,但是您是正确的,我选择的示例是不现实的。已移除。
AnoE

我真的很喜欢您的解释,并希望将这两个答复都标记为答案(至少要赞成)。这个比喻很好,您的思路和对次级成本的解释也是如此,但是我将它们视为代码的辅助要求(与我的观点相同)。当前的技术水平是测试代码,因此可测试性等是事实上的要求。但是@JörgWMittag的声明fundamental contract of a constructor: to construct something是正确的。
塞缪尔

这个比喻不是很好。如果看到此消息,我希望能在某处看到关于为什么使用Bash进行数学运算的评论,并且取决于您的原因,这可能完全没问题。对于OP来说,这是行不通的,因为在使用构造函数的任何地方,您基本上都必须在评论中道歉,“ //注意:构造此对象将启动一个永不返回的事件循环”。
Brandin

4
“不幸的是,您经常会遇到完全不了解所有这些内容的程序员。有效的就是这样。” 很伤心但如此真实。当我说职业生涯中唯一的重大负担就是那些人时,我想可以为我们所有人说话。
与莫妮卡(Monica)进行的轻度比赛

8

您在问题中给出了足够的理由将这种方法排除在外,但这里的实际问题是“这种方法是否存在明显更糟或更糟的东西?”

我的第一个想法是,这毫无意义。如果您的构造函数永远无法完成,则程序的其他部分都无法获得对所构造对象的引用,因此将其放入构造函数而不是常规方法的逻辑是什么。然后我想到,唯一的区别是您可以在循环中允许对循环进行部分构造的this转义引用。尽管这不严格限于这种情况,但可以保证,如果允许this转义,则这些引用将始终指向未完全构造的对象。

我不知道有关这种情况的语义是否在C#中得到了很好的定义,但我认为这并不重要,因为大多数开发人员都不希望尝试深入研究。


我假设构造函数是在对象创建之后运行的,因此以一种理智的语言泄漏,这应该是定义良好的行为。
mroman

@mroman:可以this在构造函数中泄漏-但仅当您在最派生类的构造函数中时才可以。如果您有两个类,A并且B使用B : A,如果的构造函数A将泄漏this,则的成员B仍将未初始化(并且虚拟方法的调用或强制转换B可能会因此而造成严重破坏)。
hoffmale

1
我想在C ++中可能是疯狂的,是的。在其他语言中,成员在为其分配实际值之前会收到一个默认值,因此,尽管实际上编写此类代码很愚蠢,但行为仍然得到很好的定义。如果调用使用这些成员的方法,则可能会遇到NullPointerExceptions或错误的计算(因为数字默认为0)或类似的东西,但是从技术上讲,确定发生了什么。
mroman

@mroman您能找到CLR的权威参考吗?
JimmyJames

好了,您可以在运行构造函数之前分配成员值,以便对象处于有效状态,并且成员分配有默认值,甚至在构造函数运行之前就已为其分配值。您不需要构造函数来初始化成员。让我看看是否可以在文档中找到它。
mroman

1

+1至少令人惊讶,但这对新开发人员来说是一个很难理解的概念。从实用的角度讲,很难调试构造函数中引发的异常,如果对象未能初始化,则将不存在该对象,以供您检查该构造函数的状态或从该外部进行记录。

如果您需要执行这种代码模式,请在类上使用静态方法。

当从类定义实例化对象时,存在构造函数来提供初始化逻辑。之所以构造此对象实例,是因为它是一个容器,它封装了希望从其余应用程序逻辑中调用的一组属性和功能。

如果您不打算使用要“构造”的对象,那么首先实例化该对象的意义是什么?在构造函数中有效地使用while(true)循环意味着您永远都不想完成它……

C#是一种非常丰富的面向对象语言,具有许多不同的构造和范例,供您探索,了解您的工具以及何时使用它们,底线是:

在C#中,不要在构造函数中执行扩展或永无止境的逻辑,因为...还有更好的选择


1
他似乎并没有提供任何实质性的过度点进行,而且在以往9个答案解释
蚊蚋

1
嘿,我不得不尝试一下:)我认为重要的是要强调一点,尽管没有禁止这样做的规则,但您不应该因为存在更简单的方法而被拒绝。其他大多数回应都集中在为什么不好的技术性上,但这很难卖给一个新开发者,他说“但是可以编译,所以我可以这样保留它”
Chris Schaller

0

本质上没有什么不好的。但是,重要的是您为什么选择使用此构造的问题。通过这样做您得到了什么?

首先,我不会谈论while(true)在构造函数中使用。有绝对没有错,使用语法构造。但是,“在构造函数中启动游戏循环”这一概念可能会引起一些问题。

在这种情况下,让构造函数简单地构造对象并具有调用无限循环的函数是很常见的。这为您的代码用户提供了更大的灵活性。例如,您可能会限制使用对象。如果某人希望在其类中拥有一个成员对象,即“游戏对象”,则他们必须准备好在其构造函数中运行整个游戏循环,因为他们必须构造该对象。这可能导致折磨编码以解决此问题。在一般情况下,您无法预先分配游戏对象,然后再运行它。对于某些程序而言,这不是问题,但是有些程序类希望能够预先分配所有内容,然后调用游戏循环。

编程的经验法则之一是使用最简单的工具来完成这项工作。这不是一成不变的规则,但这是一个非常有用的规则。如果一个函数调用就足够了,为什么还要构造一个类对象呢?如果我看到使用的功能更强大的复杂工具,我认为开发人员有理由使用它,并且我将开始探索您可能正在做的奇怪技巧。

在某些情况下,这可能是合理的。在某些情况下,构造函数中有一个游戏循环对您而言是真正直观的。在这种情况下,您可以运行它!例如,您的游戏循环可能嵌入到一个更大的程序中,该程序构造类以体现一些数据。如果必须运行游戏循环来生成该数据,则在构造函数中运行游戏循环可能非常合理。只是将其视为一种特殊情况:这样做是有效的,因为总体程序使下一个开发人员可以直观地了解您的操作以及原因。


如果构造对象需要游戏循环,则至少应将其放入工厂方法中。GameTestResults testResults = runGameTests(tests, configuration);而不是GameTestResults testResults = new GameTestResults(tests, configuration);
user253751'9

@immibis是的,的确如此,除非那是不合理的情况。如果您正在使用基于反射的系统为您实例化对象,则可能无法选择工厂方法。
Cort Ammon

0

我的印象是,一些概念混淆了,并导致了一个措辞不太好的问题。

类的构造函数可能会启动一个新线程,该线程将“永远”结束(尽管我更喜欢使用它while(_KeepRunning)while(true)因为我可以在某个地方将布尔成员设置为false)。

您可以将创建和启动线程的方法提取到它自己的函数中,以便将对象构造和工作的实际开始分开-我更喜欢这种方式,因为我可以更好地控制对资源的访问。

您还可以查看“活动对象模式”。最后,我想问题是针对何时启动“活动对象”的线程的,而我的偏好是将构造分离并开始进行更好的控制。


0

您的对象使我想起了Tasks。任务对象有一个构造函数,但它也有一堆的静态工厂方法,如:Task.RunTask.StartTask.Factory.StartNew

这些与您要执行的操作非常相似,这很可能就是“惯例”。人们似乎遇到的主要问题是对构造函数的这种使用使他们感到惊讶。@JörgWMittag说它违反了一项基本合同,我想这意味着Jörg非常惊讶。我同意,仅此而已。

但是,我想建议OP尝试使用静态工厂方法。惊喜消失了,这里没有基本的合约在危急中。人们习惯于使用静态工厂方法来做特殊的事情,并且可以对它们进行相应的命名。

您可以提供允许进行细粒度控制的构造函数(如@Brandin所建议的,类似var g = new Game {...}; g.MainLoop();,可以容纳那些不想立即开始游戏并且可能想先通过游戏的用户。您可以编写类似var runningGame = Game.StartNew();开始游戏的内容。立即轻松。


这意味着约尔格从根本上感到惊讶,也就是说,这样的惊讶是从根本上的痛苦。
史蒂夫·杰索普

0

为什么构造函数中的while(true)循环实际上不好?

一点都不坏。

为什么(或者根本不?)让类的构造函数开始永无止境的循环(即游戏循环)被认为是不好的设计?

您为什么要这样做?说真的 该类具有一个功能:构造函数。如果您只需要一个函数,这就是我们拥有函数而不是构造函数的原因。

您提到了一些自己反对它的明显原因,但忽略了最大的原因:

有更好和更简单的选择来实现相同的目的。

如果有两个选择,一个更好,那么选择多少就无关紧要,您选择了更好的一个。顺便说一句,这就是为什么我们几乎从来没有使用goto。


-1

构造函数的目的是构造您的对象。在构造函数完成之前,原则上使用该对象的任何方法都是不安全的。当然,我们可以在构造函数中调用对象的方法,但是每次执行此操作时,我们都必须确保这样做对处于当前半休假状态的对象有效。

通过将所有逻辑间接放入构造函数中,您会给自己增加更多的负担。这表明您没有设计初始化之间的区别,并且使用得不够好。


1
这似乎并没有提供任何实质性的过度点进行,而且在以往8个回答解释
蚊蚋
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.