好的还是坏的做法?在getter中初始化对象


167

似乎我有一个奇怪的习惯……至少根据我的同事说。我们一直在一起做一个小项目。我编写类的方式是(简化示例):

[Serializable()]
public class Foo
{
    public Foo()
    { }

    private Bar _bar;

    public Bar Bar
    {
        get
        {
            if (_bar == null)
                _bar = new Bar();

            return _bar;
        }
        set { _bar = value; }
    }
}

因此,基本上,我只在调用getter且该字段仍为null时才初始化任何字段。我认为这可以通过不初始化任何地方都没有使用过的属性来减少过载。

ETA:之所以这样做,是因为我的班级有几个属性,这些属性返回另一个班级的实例,而后者又具有包含更多班级的属性,依此类推。当并非总是需要所有顶级类时,调用顶级类的构造函数将随后调用所有这些类的所有构造函数。

除了个人喜好之外,还有其他反对这种做法的反对意见吗?

更新:关于这个问题,我已经考虑了许多不同的观点,我将支持我接受的答案。但是,我现在对这个概念有了更好的理解,我能够决定何时使用它,何时不使用它。

缺点:

  • 线程安全问题
  • 当传递的值为null时,不服从“ setter”请求
  • 微观优化
  • 异常处理应在构造函数中进行
  • 需要在类的代码中检查null

优点:

  • 微观优化
  • 属性永远不会返回null
  • 延迟或避免加载“重”对象

大多数缺点不适用于我当前的库,但是我必须进行测试以查看“微优化”是否真的在优化任何东西。

最后更新:

好吧,我改变了答案。我最初的问题是这是否是一个好习惯。我现在坚信事实并非如此。也许我仍会在当前代码的某些部分中使用它,但不是无条件的,而且肯定不是一直如此。因此,我将失去习惯,在使用它之前先考虑一下。感谢大家!


14
这是延迟加载模式,在这里并不能给您带来很多好处,但是恕我直言,这仍然是一件好事。
Machinarius

28
如果您对性能有可观的影响,或者这些成员很少被使用并占用大量内存,或者实例化它们的时间很长,并且只想按需进行,那么惰性实例化就很有意义。无论如何,请确保考虑到线程安全性问题(当前代码不是),并考虑使用提供的Lazy <T>类。
克里斯·辛克莱

10
我认为这个问题更适合codereview.stackexchange.com
Eduardo Brites

7
@PLB不是单例模式。
Colin Mackay

30
令我惊讶的是,没有人提到此代码有严重的错误。您有一个公共财产,我可以从外面设置。如果将其设置为NULL,您将始终创建一个新对象,并忽略我的setter访问。这可能是一个非常严重的错误。对于私有财产,这可能还可以。我个人不喜欢进行此类过早的优化。没有任何其他好处就增加了复杂性。
SolutionYogi

Answers:


170

您这里只是“天真”的“惰性初始化”实现。

简短答案:

无条件使用延迟初始化不是一个好主意。它有自己的位置,但必须考虑到此解决方案的影响。

背景与说明:

具体的实现:
让我们首先看一下您的具体示例以及为什么我认为它的实现很幼稚:

  1. 它违反了最小惊讶原则(POLS)。将值分配给属性后,预期会返回该值。在您的实现中,情况并非如此null

    foo.Bar = null;
    Assert.Null(foo.Bar); // This will fail
  2. 它引入了很多线程问题:foo.Bar不同线程上的两个调用者可能会得到两个不同的实例,Bar而其中一个将不与该Foo实例建立连接。对该Bar实例所做的任何更改都会自动丢失。
    这是违反POLS的另一种情况。当仅访问属性的存储值时,它应该是线程安全的。尽管您可能会认为该类根本不是线程安全的-包括您的属性的getter-但您必须正确地对此进行记录,因为这不是正常情况。此外,我们很快会看到,没有必要引入此问题。

总的来说:
现在应该大致上看一下惰性初始化:
惰性初始化通常用于延迟构造时间较长或一旦完全构造就占用大量内存的对象的构造。
这是使用延迟初始化的非常合理的理由。

但是,此类属性通常没有设置器,这消除了上面指出的第一个问题。
此外,将使用线程安全的实现(例如Lazy<T>)来避免第二个问题。

即使在实现惰性属性的实现时考虑这两点,以下几点也是该模式的一般问题:

  1. 对象的构造可能会失败,从而导致属性获取程序出现异常。这是对POLS的另一种违反,因此应避免。甚至“开发类库的设计准则”中有关属性部分也明确指出,属性获取器不应引发异常:

    避免从属性获取器抛出异常。

    属性获取器应该是没有任何先决条件的简单操作。如果getter可能引发异常,请考虑将属性重新设计为方法。

  2. 编译器的自动优化受到损害,即内联和分支预测。请参阅Bill K的答案以获取详细说明。

这些要点的结论如下:
对于延迟实现的每个单个属性,您应该考虑这些要点。
这意味着,这是个案决定,不能作为一般的最佳实践。

这种模式有它的位置,但是在实现类时它不是一般的最佳实践。由于上述原因,不应无条件使用它


在本节中,我想讨论其他方面提出的一些要点,这些要点可作为无条件使用惰性初始化的参数:

  1. 序列化:
    EricJ在一条评论中指出:

    可以序列化的对象在反序列化时不会调用它的构造函数(取决于序列化器,但是许多常见的行为方式都如此)。将初始化代码放入构造函数中意味着您必须提供反序列化的其他支持。这种模式避免了特殊的编码。

    此参数有几个问题:

    1. 大多数对象永远不会序列化。在不需要它时添加某种支持会违反YAGNI
    2. 当一个类需要支持序列化时,可以使用一些方法来启用它,而无需任何解决方法,而这种方法乍一看与序列化没有任何关系。
  2. 微观优化:您的主要论点是,仅当有人实际访问对象时,才要构造对象。因此,您实际上是在谈论优化内存使用情况。
    由于以下原因,我不同意这种说法:

    1. 在大多数情况下,内存中的更多对象不会对任何事物产生任何影响。现代计算机拥有足够的内存。如果没有通过探查器确认实际问题的情况,则这是过早的优化,因此有充分的理由反对。
    2. 我承认有时这种优化是合理的。但是即使在这种情况下,惰性初始化似乎也不是正确的解决方案。反对它有两个原因:

      1. 延迟初始化可能会损害性能。也许只是微不足道,但正如比尔的答案所表明的那样,其影响大于人们乍看时所想的。因此,这种方法基本上是在性能与内存之间进行权衡。
      2. 如果您的设计中仅使用类的一部分是常见的用例,那么这暗示了设计本身存在问题:所讨论的类很可能承担多个责任。解决方案是将班级拆分为几个重点突出的班级。

4
@JohnWillemse:这是您的体系结构问题。您应该以更小,更专注的方式重构类。不要为5种不同的事物/任务创建一个类。而是创建5个类。
Daniel Hilgarth

26
@JohnWillemse也许将其视为过早优化的情况。除非您有一个衡量性能/内存的瓶颈,否则我建议不要这样做,因为它会增加复杂性并引入线程问题。
克里斯·辛克莱

2
+1,对于95%的班级来说,这不是一个好的设计选择。延迟初始化有其优点,但不应将其归纳为ALL属性。它增加了复杂性,阅读代码的难度,线程安全性问题……在99%的情况下都没有明显的优化。另外,正如SolutionYogi所说,OP的代码有错误,这证明该模式的实现并非易事,除非真正需要延迟初始化,否则应避免这种模式。
ken2k

2
@DanielHilgarth感谢您一路写下(几乎)无条件使用此模式的所有错误。很好!
亚历克斯

1
@DanielHilgarth好,是,不是。违规是这里的问题,是的。但是也要“不”,因为POLS严格来说是一个原则,您可能不会对代码感到惊讶。如果Foo没有暴露在您的程序之外,那么您是否可以冒险。在这种情况下,我几乎可以保证您最终会感到惊讶,因为您无法控制属性的访问方式。风险只是一个漏洞,您对null此案的争论变得更加强烈。:-)
atlaste

49

这是一个不错的设计选择。强烈建议用于库代码或核心类。

它被一些“惰性初始化”或“延迟初始化”调用,并且通常被所有人认为是一个不错的设计选择。

首先,如果在类级别变量或构造函数的声明中进行初始化,则在构造对象时,您将产生创建永远无法使用的资源的开销。

其次,仅在需要时才创建资源。

第三,避免垃圾收集未使用的对象。

最后,与在类级别变量或构造函数初始化期间发生的异常相比,更容易处理可能在属性中发生的初始化异常。

此规则有例外。

关于“ get”属性中用于初始化的附加检查的性能参数,这无关紧要。与简单的带跳转的空指针检查相比,初始化和处理对象的性能受到更大的影响。

设计准则类库开发http://msdn.microsoft.com/en-US/library/vstudio/ms229042.aspx

关于 Lazy<T>

通用Lazy<T>类是完全相同的海报想要什么创建,请参阅延迟初始化http://msdn.microsoft.com/en-us/library/dd997286(v=vs.100).aspx。如果您使用的是.NET的旧版本,则必须使用问题中说明的代码模式。这种代码模式已经变得如此普遍,以至于Microsoft认为适合在最新的.NET库中包含一个类,以使其更易于实现。另外,如果您的实现需要线程安全,则必须添加它。

原始数据类型和简单类

显然,您不会对原始数据类型使用lazy-initialization或像这样的简单类使用List<string>

在评论懒惰之前

Lazy<T> 是在.NET 4.0中引入的,因此请不要再添加有关此类的其他注释。

在评论微观优化之前

在构建库时,必须考虑所有优化。例如,在.NET类中,您将在整个代码中看到用于布尔类变量的位数组,以减少内存消耗和内存碎片,这仅是两个“微优化”。

关于用户界面

您不会对用户界面直接使用的类使用延迟初始化。上周,我花了一天的大部分时间来消除对组合框视图模型中使用的八个集合的延迟加载。我有一个LookupManager可以处理任何用户界面元素所需的集合的延迟加载和缓存的处理程序。

“信使”

我从未对任何延迟加载的属性使用过set-property(“ setters”)。因此,您永远不会允许foo.Bar = null;。如果需要设置,Bar那么我将创建一个方法SetBar(Bar value),而不使用延迟初始化

馆藏

类集合属性在声明时始终会初始化,因为它们永远不能为null。

复杂类

让我以不同的方式重复一遍,您对复杂的类使用了惰性初始化。通常是设计不良的类。

最后

我从来没有说过在所有班级或所有情况下都这样做。这是个坏习惯。


6
如果您可以在不同的线程中多次调用foo.Bar而没有任何中间值设置,但获得不同的值,那么您的类就很糟糕。
Lie Ryan

25
我认为这是一个错误的经验法则,没有很多考虑。除非Bar是已知的资源消耗者,否则这是不必要的微观优化。在Bar占用大量资源的情况下,.net中内置了线程安全的Lazy <T>。
安德鲁·汉隆

10
“处理在属性中可能发生的初始化异常要比在类级别变量或构造函数初始化期间发生的异常容易。” -好的,这很愚蠢。如果由于某种原因而无法初始化对象,我想尽快知道。即当它被构造时。对于使用惰性初始化,有很多论据,但我认为普遍使用它不是一个好主意。
millimoose

20
我真的很担心其他开发人员会看到这个答案,并认为这确实是一个好习惯(哦,男孩)。如果您无条件使用它,这将是非常非常糟糕的做法。除了已经说过的话,您正在使每个人的生活变得更加艰难(对于客户开发人员和维护开发人员),却获得的收益却很小(如果有收益的话)。您应该从专业人士那里听到:唐纳德·克努斯(Donald Knuth)在“计算机编程的艺术”系列中著名地说道:“过早的优化是万恶之源。” 您正在做的不仅是邪恶的,而且是邪恶的!
亚历克斯

4
您选择错误的答案(以及错误的编程决定)的指标太多。在列表中,缺点多于优点。与之相比,支持它的人更多。在此问题中发布的该站点上经验更丰富的成员(@BillK和@DanielHilgarth)对此表示反对。您的同事已经告诉您这是错误的。说真的,这是错的!如果我发现我的团队的一名开发人员(我是团队负责人)这样做,他将花费5分钟的超时时间,然后接受讲解为什么他永远都不要这样做。
亚历克斯

17

您是否考虑使用实现这种模式Lazy<T>

除了轻松创建延迟加载的对象外,还可以在初始化对象时获得线程安全:

就像其他人所说的那样,如果对象确实很耗资源,或者在对象构建期间需要花费一些时间来加载它们,则可以延迟加载它们。


谢谢,我现在明白了,我一定会Lazy<T>现在研究,不要使用我以前的方式。
约翰·威廉姆斯

1
您没有获得魔术线程安全性……您仍然需要考虑一下。从MSDN:Making the Lazy<T> object thread safe does not protect the lazily initialized object. If multiple threads can access the lazily initialized object, you must make its properties and methods safe for multithreaded access.
Eric J.

@EricJ。当然可以 只有在初始化对象时才获得线程安全性,但是稍后您需要像处理其他任何对象一样处理同步。
马蒂亚斯Fidemraizer

9

我认为这取决于您要初始化的内容。我可能不会为列表做这些,因为构造成本很小,因此可以放入构造函数中。但是,如果它是一个预先填充的列表,那么直到第一次需要它时,我才会这么做。

基本上,如果构造成本超过对每个访问进行条件检查的成本,则可以懒创建它。如果没有,请在构造函数中执行。


谢谢!这就说得通了。
John Willemse

9

我可以看到的缺点是,如果您想询问Bars是否为null,那么它将永远不会为空,您将在此处创建列表。


我认为这不是缺点。
彼得·波菲

为什么会有不利之处?只需使用any代替null即可。if(!Foo.Bars.Any())
s.meijer 2013年

6
@PeterPorfy:它违反了POLS。您放进null去,但是别找回来。通常,您假定您获得的返回值与放入属性的值相同。
Daniel Hilgarth

@DanielHilgarth再次感谢。这是我以前从未考虑过的非常有效的论点。
约翰·威廉姆斯

6
@AMissico:这不是一个虚构的概念。就像按下前门旁边的按钮会敲响门铃一样,看起来像属性的事物的行为也应该像属性。在脚下打开活板门是一种令人惊讶的行为,尤其是如果按钮没有这样标记的话。
Bryan Boettcher

8

延迟实例化/初始化是一种完全可行的模式。但是请记住,作为一般规则,API的使用者不要期望getter和setter从最终用户POV中花费可辨别的时间(否则会失败)。


1
我同意,并且我对问题做了一些编辑。我希望完整的基础构造函数链比仅在需要它们时实例化类花费更多的时间。
约翰·威廉姆斯

8

我只是对Daniel的回答发表评论,但老实说,我认为这还远远不够。

尽管这是在某些情况下(例如,从数据库初始化对象时)使用的非常好的模式,但是这是一种可怕的习惯。

关于对象的最好的事情之一就是它提供了一个安全,可信任的环境。最好的情况是,如果要创建尽可能多的“ Final”字段,并用构造函数填充它们。这使您的课程非常安全。允许通过setter更改字段的方法要少一些,但并不可怕。例如:

类SafeClass
{
    字符串名称=“”;
    整数年龄= 0;

    公共无效setName(String newName)
    {
        断言(newName!= null)
        name = newName;
    } //遵循这种年龄模式
    ...
    公共字符串toString(){
        字符串s =“ Safe Class具有名称:” + name +“和年龄:” + age
    }
}

使用您的模式,toString方法将如下所示:

    if(名称== null)
        抛出新的IllegalStateException(“ SafeClass进入非法状态!名称为null”)
    if(age == null)
        抛出新的IllegalStateException(“ SafeClass进入非法状态!年龄为null”)

    公共字符串toString(){
        字符串s =“ Safe Class具有名称:” + name +“和年龄:” + age
    }

不仅如此,而且在可能在类中使用该对象的任何地方都需要空检查(由于在getter中进行空检查,因此在类外部很安全,但您应该主要在类内部使用类成员)

另外,您的班级永远处于不确定状态-例如,如果您决定通过添加一些注释使该班级成为休眠类,您将如何做?

如果您是根据一些没有要求和测试的显微图像来做出任何决定,那几乎肯定是错误的决定。实际上,即使在最理想的情况下,您的模式也确实有可能确实在减慢系统速度,因为if语句可能会导致CPU上的分支预测失败,从而使事情变慢很多倍。只是在构造函数中分配一个值,除非要创建的对象相当复杂或来自远程数据源。

有关brance预测问题的示例(您反复出现,仅一次出现),请参见以下令人敬畏的问题的第一个答案:为什么处理排序数组比未排序数组更快?


感谢您的输入。就我而言,没有一个类具有任何可能需要检查null的方法,因此这不是问题。我会考虑您的其他异议。
John Willemse

我不太明白。这意味着您没有在存储成员的类中使用成员,而只是将类用作数据结构。如果是这种情况,您可能想阅读javaworld.com/javaworld/jw-01-2004/jw-0102-toolbox.html,其中很好地描述了如何通过避免从外部操作对象状态来改进代码。如果您在内部进行操作,该如何进行而不重复对所有内容进行null检查?
Bill K

这个答案部分是好的,但有些似乎是人为的。通常,使用此模式时,toString()会调用getName(),而不是name直接使用。
2013年

@BillK是的,这些类是一个巨大的数据结构。所有工作都在静态类中完成。我将在链接上查看该文章。谢谢!
约翰·威廉姆斯

1
@izkata实际上,在课堂上,无论您是否使用吸气剂,似乎都是一个折腾,我工作过的大多数地方都直接使用了该成员。除此之外,如果您始终使用吸气剂,则if()方法会更加有害,因为分支预测失败的发生频率会更高,并且由于分支,运行时可能会在吸气剂的获取方面遇到更多麻烦。但是,约翰一无所知,它们都是数据结构和静态类,这一切都是我最关心的事情。
Bill K

4

让我在其他人提出的许多优点上再加一点...

调试器将(默认情况下)在单步执行代码时评估属性,这可能Bar比仅执行代码更快地实例化实例。换句话说,调试只是改变程序的执行。

这可能是(也可能不是)问题(取决于副作用),但这是需要注意的事情。


2

您确定Foo应该完全实例化任何东西吗?

对我来说,让Foo实例化任何东西似乎很臭(尽管不一定是错误的)。除非Foo明确表示要成为工厂的目的,否则不应实例化其自己的协作者,应将其注入其构造函数中

但是,如果Foo的存在目的是创建Bar类型的实例,那么我懒洋洋地做就没什么错。


4
@BenjaminGruenbaum不,不是。而且,即使是这样,您也想提出什么观点?
KaptajnKold
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.