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

169

我好像有一个奇怪的习惯……至少我的同事是这样认为的。我们一起完成了一个小项目,我编写类的方式是(简化例子):

[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时才初始化任何字段。我认为这样可以通过不初始化任何未在任何地方使用的属性来减少负荷。

注:我这样做的原因是我的类有几个返回另一个类实例的属性,而该类实例还有更多的类属性,依此类推。调用顶层类的构造函数将随后调用所有这些类的构造函数,而并非始终都需要。

这种做法除了个人喜好之外,还有什么异议吗?

更新:我考虑了许多不同的意见,并坚持我的接受答案。但是,我现在对这个概念有了更好的理解,能够决定何时使用它以及何时不使用。

缺点:

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

优点:

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

大多数缺点不适用于我的当前库,但我需要测试以确定“微小优化”是否实际上正在优化任何内容。

最后更新:

好的,我改变了我的答案。我的最初问题是这是否是一个好习惯。我现在确信它不是。也许我仍然会在我的当前代码的某些部分使用它,但不是无条件地并且绝对不会一直使用它。所以我要放弃这个习惯,在使用它之前考虑一下。谢谢大家!


14
这是懒加载模式,它在这里并没有为您带来很大的好处,但在我看来它仍然是一件好事。 - Machinarius
28
如果懒惰实例化对性能有明显影响、那些成员很少被使用但又占用大量内存,或者实例化需要很长时间只想在需要时进行,那么懒惰实例化是有意义的。无论如何,请务必考虑线程安全问题(您当前的代码并没有考虑到)并且考虑使用提供的 Lazy<T> 类。 - Chris Sinclair
10
我认为这个问题更适合发布在http://codereview.stackexchange.com。 - Eduardo Brites
7
@PLB 这不是单例模式。 - Colin Mackay
31
我惊讶地发现没有人提到这段代码存在一个严重的 bug。你有一个公共属性,我可以从外部设置它。如果我将它设置为 NULL,你将始终创建一个新对象并忽略我的 setter 访问。这可能是一个非常严重的 bug。对于私有属性,这可能没问题。就我个人而言,我不喜欢做这样的过早优化。这会增加复杂性,但没有额外的好处。 - SolutionYogi
显示剩余24条评论
9个回答

173
你所看到的是一种“懒加载”的天真实现。
简短回答:
无条件地使用懒加载不是一个好主意。它有其适用场景,但必须考虑此解决方案的影响。
背景和解释:
具体的实现:
首先让我们看一下你的具体示例以及为什么我认为它的实现是天真的:
  1. It violates the Principle of Least Surprise (POLS). When a value is assigned to a property, it is expected that this value is returned. In your implementation this is not the case for null:

    foo.Bar = null;
    Assert.Null(foo.Bar); // This will fail
    
  2. It introduces quite some threading issues: Two callers of foo.Bar on different threads can potentially get two different instances of Bar and one of them will be without a connection to the Foo instance. Any changes made to that Bar instance are silently lost.
    This is another case of a violation of POLS. When only the stored value of a property is accessed it is expected to be thread-safe. While you could argue that the class simply isn't thread-safe - including the getter of your property - you would have to document this properly as that's not the normal case. Furthermore the introduction of this issue is unnecessary as we will see shortly.

总的来说:
现在是时候来看一下懒加载:
通常情况下,延迟构建对象被用于 需要很长时间或占用大量内存 的对象在完全构建之前。
这是使用懒加载的一个非常有效的理由。

然而,这类属性通常没有setter方法,从而解决了上面提到的第一个问题。
此外,应该使用线程安全的实现,例如 Lazy<T>,以避免第二个问题。

即使在考虑这两个点在懒加载属性的实现时,以下几点仍是这种模式的一般问题:

  1. 对象的构建可能不成功,从而导致属性getter抛出异常。这是对POLS的另一种违反,因此应该避免。即使“为开发类库设计的设计指南”中的有关属性的部分明确指出,属性getter不应该抛出异常:

    避免从属性getter抛出异常。

    属性getter应该是简单的操作,没有任何前提条件。如果getter可能抛出异常,请考虑将属性重新设计为方法。

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

这些要点的结论如下:
对于每个实现懒加载的单个属性,都应该考虑这些要点。
这意味着它是一个案件决定,并不能作为一般最佳实践来使用。

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


在本节中,我想讨论其他人提出的一些使用懒加载的论据:

  1. 序列化:
    EricJ在评论中提到:

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

    这个观点有几个问题:

    1. 大多数对象永远不会被序列化。在不需要时添加对它的某种形式的支持违反了YAGNI原则。
    2. 当一个类需要支持序列化时,存在一些方法可以启用它,而无需使用与序列化乍看起来无关的解决方案。
  2. 微优化: 您的主要论点是只有当有人实际访问它们时才想要构建对象。因此,您实际上正在谈论优化内存使用。

    我不同意这个观点,理由如下:

    1. 在大多数情况下,内存中多出一些对象根本不会对任何事情产生影响。现代计算机有足够的内存。在没有实际问题经过分析器确认的情况下,这是过早优化,而且有很多理由反对它。
    2. 我承认有时候这种优化是合理的。但即使在这些情况下,延迟初始化似乎也不是正确的解决方案。有两个原因反对它:

      1. 延迟初始化可能会影响性能。也许只是轻微的影响,但正如比尔的答案所示,影响比一开始想象的要大得多。因此,这种方法基本上是以性能换取内存。
      2. 如果您的设计中普遍使用仅使用类的某些部分,那么这暗示着设计本身存在问题:该类很可能具有多个职责。解决方案是将该类拆分为几个更专注的类。

4
这是你架构的一个问题。你应该以更小、更专注的方式重构你的类。不要为5个不同的事情/任务创建一个类,而是创建5个类。 - Daniel Hilgarth
27
也许你可以将这看作是过早进行优化的一个案例。除非你已经有了明确的性能/内存瓶颈,否则我建议不要这样做,因为它会增加复杂度并引入线程问题。 - Chris Sinclair
2
+1,对于95%的类来说,这不是一个好的设计选择。懒加载有其优点,但不应该为所有属性推广。它增加了复杂性,使代码难以阅读,存在线程安全问题...在99%的情况下没有可感知的优化。此外,正如SolutionYogi在评论中所说,OP的代码存在错误,这证明了除非实际需要懒加载,否则应避免使用此模式。 - ken2k
2
@DanielHilgarth 感谢您花费心思记录(几乎)所有使用此模式时的错误。做得好! - Alex
1
@DanielHilgarth 嗯,是和不是。问题在于违规,所以是的。但也“不是”,因为POLS严格来说是一个原则,即你可能不会对代码感到惊讶。如果Foo没有在程序外部公开,那么这是你可以选择冒险或不冒险的风险。在这种情况下,我几乎可以保证你最终会感到惊讶,因为你无法控制属性被访问的方式。风险变成了一个bug,你关于null情况的论点变得更加强有力。 :-) - atlaste
显示剩余10条评论

50

这是一个不错的设计选择,强烈推荐在库代码或核心类中使用。

有些人称之为“惰性初始化”或“延迟初始化”,而所有人普遍认为这是一个不错的设计选择。

首先,如果您在声明类级变量或构造函数中初始化,则在构建对象时,您必须创建可能永远不会使用的资源的开销。

其次,只有在需要时才会创建该资源。

第三,避免清除未使用的对象。

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

这个规则也有例外情况。

关于在“get”属性中进行初始化的附加检查的性能论据,它是微不足道的。与初始化和处理对象相比,空指针检查带有更显著的性能影响。

开发类库的设计准则,请参阅 http://msdn.microsoft.com/zh-cn/library/vstudio/ms229042.aspx

关于 Lazy<T>

通用的 Lazy<T> 类恰好是为该文章中的需求而创建的,请参阅 Lazy Initialization,网址为 http://msdn.microsoft.com/zh-cn/library/dd997286(v=vs.100).aspx。如果您使用的是较早版本的 .NET,则必须使用该问题中所述的代码模式。这种代码模式已经变得如此常见,以至于微软公司认为应在最新的 .NET 库中包括一个类来更轻松地实现该模式。此外,如果您的实现需要线程安全,则必须添加。

原始数据类型和简单类

显然,您不会对基本数据类型或像List<string>这样的简单类使用惰性初始化。

在评论惰性加载之前

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

在评论微观优化之前

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

关于用户界面

您不会对直接被用户界面使用的类使用惰性初始化。上周我花了大部分时间删除视图模型中用于组合框的八个集合的惰性加载。我有一个LookupManager,它处理所需任何用户界面元素的集合的惰性加载和缓存。

“Setters”

我从未为任何惰性加载的属性使用设置属性(“setters”)。因此,您永远不会允许foo.Bar = null;。如果需要设置Bar,则会创建一个名为SetBar(Bar value)的方法,并且不使用惰性初始化。

集合

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

复杂类

让我用不同的方式重申这一点,您使用惰性初始化来处理复杂类。这些通常是设计不良的类。

最后

我从未说过要在所有类或所有情况下都这样做。这是一个坏习惯。


7
如果你在不进行任何值设置的情况下,在不同的线程中多次调用foo.Bar方法,但得到不同的返回值,那么你可能使用的是一个质量差劣的类。 - Lie Ryan
26
我认为这是一个不考虑多方面情况的错误经验法则。除非Bar是已知的资源吞吐量大的程序,否则这是一种不必要的微观优化。而且,如果Bar确实是资源密集型的话,在.NET中还有线程安全的Lazy<T>可以使用。 - Andrew Hanlon
10
“在属性初始化时发生的异常比在类级别变量或构造函数初始化期间发生的异常更容易处理。” 这是可笑的。如果由于某种原因无法初始化对象,我希望尽快得知,即立即在构造时知道。使用延迟初始化有很好的理由,但我认为贯穿使用它并不是一个好主意。 - millimoose
20
我非常担心其他开发者会看到这个答案并认为这确实是一个好的实践方式(哦天哪)。如果你无条件地使用它,那么这是非常非常糟糕的实践方式。除了已经说过的内容之外,你让每个人的生活变得更加困难(对于客户端开发人员和维护开发人员),而收益却很小(如果有的话)。你应该听听专家们的意见:唐纳德·克努斯在《计算机程序设计艺术》系列中著名地说过“过度优化是万恶之源。”。你正在做的不仅仅是邪恶,而且是魔鬼般的! - Alex
4
有很多指标表明你选择了错误的答案(以及错误的编程决策)。你的缺点比优点多。支持反对它的人比支持它的人更多。在这个问题中发帖的经验更丰富的成员(@BillK和@DanielHilgarth)都反对它。你的同事已经告诉过你是错的。说真的,这是错误的!如果我发现我的团队中有一个开发人员这样做,他将会被暂停5分钟,然后会被告知为什么他永远不应该这样做。 - Alex
显示剩余19条评论

17

您是否考虑使用Lazy<T>实现此模式?

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

正如其他人所说,如果对象真的很耗资源或者在对象构造期间加载它们需要花费一定的时间,则可以懒惰地加载它们。


谢谢,我现在明白了,我一定会去了解Lazy<T>,并且不再使用我过去常用的方法。 - John Willemse
1
你不能通过魔法实现线程安全... 你仍然需要考虑它。来自 MSDN:使 Lazy<T> 对象线程安全并不能保护懒惰初始化的对象。如果多个线程可以访问懒惰初始化的对象,则必须使其属性和方法对多线程访问安全。 - Eric J.
@EricJ。当然,当然。你只有在初始化对象时才能获得线程安全性,但以后你需要像处理任何其他对象一样进行同步。 - Matías Fidemraizer

9

我认为这取决于你正在初始化的内容。对于一个列表,我可能不会这样做,因为构造成本相当小,所以它可以放在构造函数中。但如果是一个预填充的列表,我可能要等到第一次需要时再进行初始化。

基本上,如果构造成本超过了每次访问时执行条件检查的成本,则使用懒加载方式创建它。否则,应该在构造函数中创建。


8
我能看到的缺点是,如果你想询问Bars是否为空,它永远不会为空,并且你会在那里创建列表。

为什么会是缺点呢?只需使用任何一个不等于空的检查即可。if(!Foo.Bars.Any()) - s.meijer
6
它违反了POLS。你输入null,但却没有得到它的返回值。通常情况下,人们会假设在属性中输入什么值就会得到相同的值作为返回。 - Daniel Hilgarth
@DanielHilgarth 是的,我认同你的观点。我之前以为答案是说如果某个东西总是返回一个值而从不返回 null 那么就是错误的。但你的说法完全正确。 - Peter Porfy
@AMissico:如果您不知道POLS / POLA,您可能需要单击链接。这是设计用户界面(API也是)时非常重要的原则。 - Daniel Hilgarth
6
@AMissico: 这并不是一个虚构的概念。就像按门口旁边的按钮预期会响起门铃一样,看起来像属性的东西应该表现得像属性一样。突然在你脚下打开一个陷阱门是令人惊讶的行为,特别是如果按钮没有标注。 - Bryan Boettcher
显示剩余4条评论

8

延迟初始化是一个完全可行的模式。但请记住,一般来说,您的API使用者不期望getter和setter从最终用户角度花费可感知的时间(或失败)。


1
我同意,并且我稍微编辑了我的问题。我预计完整的底层构造函数链需要比仅在需要时实例化类更多的时间。 - John Willemse

8
我本来只是想在Daniel的答案上留个评论,但我真的认为他没有深入到足够程度。
尽管在某些情况下(例如,当对象从数据库初始化时)这是一个非常好的模式,但这是一个可怕的习惯。
对象最好的一点是它提供了一个安全、可信赖的环境。最好的情况是将尽可能多的字段设置为“Final”,并在构造函数中填充所有字段。这使得您的类非常坚固。允许通过setter更改字段会略微降低安全性,但不是很糟糕。例如:
class SafeClass
{
    String name="";
    Integer age=0;
public void setName(String newName) { assert(newName != null) name=newName; }// follow this pattern for age ... public String toString() { String s="Safe Class has name:"+name+" and age:"+age } }
按照您的模式,toString方法将如下所示:
如果(name == null)
    抛出非法状态异常(“SafeClass处于非法状态!name为null”)
如果(age == null)
    抛出非法状态异常(“SafeClass处于非法状态!age为null”)

public String toString() {
    String s = “Safe Class具有名称:”+ name +“和年龄:”+ age
}
不仅如此,您需要在可能使用该对象的类中进行空值检查(由于getter中的空值检查,外部类是安全的,但您应该主要在类内部使用类成员)
此外,您的类始终处于不确定状态--例如,如果您决定通过添加几个注释将该类变为Hibernate类,该怎么做?
如果您基于某些微观优化做出任何决策而没有要求和测试,那几乎肯定是错误的决定。事实上,除非您正在创建的对象相当复杂或来自远程数据源,否则if语句可能会导致CPU上的分支预测失败,从而使系统放慢速度,这比仅在构造函数中分配值要慢得多。

关于分支预测问题(这是一个您会反复遇到的问题,而非仅一次),可以参考这个很棒的问题的第一个答案:为什么处理排序数组比处理未排序数组更快?


感谢您的意见。在我的情况下,没有任何类需要检查 null 的方法,所以这不是问题。我会考虑您提出的其他反对意见。 - John Willemse
我真的不太理解。这意味着您没有在存储它们的类中使用成员,而只是将类用作数据结构。如果是这种情况,您可能需要阅读http://www.javaworld.com/javaworld/jw-01-2004/jw-0102-toolbox.html,其中有一个很好的描述,说明如何通过避免外部操作对象状态来改进代码。如果您正在内部操作它们,那么如何在不重复检查所有内容的情况下进行操作? - Bill K
这个答案的一部分很好,但是有些地方似乎是刻意为之。通常在使用这个模式时,toString()应该调用getName()而不是直接使用name - Izkata
@BillK 是的,这些类是一个庞大的数据结构。所有的工作都在静态类中完成。我会查看链接中的文章。谢谢! - John Willemse
1
@izkata 实际上,在类内部似乎使用getter还是直接使用成员变量并没有明确的选择,我工作过的大多数地方都直接使用成员变量。除此之外,如果你总是使用getter,if()方法会更加有害,因为分支预测失败的次数会更多,并且由于分支,运行时可能更难将getter内联。然而,这一切都无关紧要,因为约翰揭示了它们是数据结构和静态类,这才是我最关心的事情。 - Bill K

5

让我补充其他人提出的许多好观点...

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

这可能是一个问题(取决于副作用),但需要注意。


2
你确定Foo根本不应该实例化任何东西吗?
在我看来,让Foo实例化任何东西似乎有点可疑(尽管不一定是错误的)。除非Foo的明确目的是成为一个工厂,否则它不应该实例化自己的协作者,而是应该在构造函数中获取它们。
但是,如果Foo的目的是创建类型为Bar的实例,那么懒加载也没什么问题。

4
不是真的。即使是真的,您想表达什么意思呢?尊重地说。 - KaptajnKold

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接