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 并且该字段仍然为空时初始化任何字段。我认为这将通过不初始化任何未在任何地方使用的属性来减少过载。

ETA:我这样做的原因是我的类有几个属性,它们返回另一个类的实例,而后者又具有更多类的属性,等等。调用顶级类的构造函数随后会调用所有这些类的所有构造函数,但并不总是需要它们。

除了个人偏好之外,是否有反对这种做法的反对意见?

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

缺点:

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

优点:

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

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

最后更新:

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

4

9 回答 9

172

您在这里拥有的是“延迟初始化”的 - 天真 - 实现。

简短的回答:

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

背景及说明:

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

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

    foo.Bar = null;
    Assert.Null(foo.Bar); // This will fail
    
  2. 它引入了相当多的线程问题:foo.Bar不同线程上的两个调用者可能会获得两个不同的实例,Bar其中一个将没有与Foo实例的连接。对该Bar实例所做的任何更改都会静默丢失。
    这是违反 POLS 的又一案例。当仅访问属性的存储值时,它应该是线程安全的。虽然您可能会争辩说该类根本不是线程安全的 - 包括您的财产的吸气剂 - 您必须正确记录这一点,因为这不是正常情况。此外,我们很快就会看到,没有必要引入这个问题。

总而言之:
现在是时候看一下一般的延迟初始化了:
延迟初始化通常用于延迟构建需要很长时间才能构建的对象或完全构建后占用大量内存的对象。
这是使用延迟初始化的一个非常正当的理由。

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

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

  1. 对象的构造可能不成功,从而导致属性 getter 出现异常。这是又一次违反 POLS,因此应该避免。甚至“开发类库的设计指南”中关于属性的部分也明确指出属性获取器不应该抛出异常:

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

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

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

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

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


在本节中,我想讨论其他人提出的一些观点,作为无条件使用延迟初始化的论据:

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

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

    这个论点有几个问题:

    1. 大多数对象永远不会被序列化。在不需要时为其添加某种支持违反了YAGNI
    2. 当一个类需要支持序列化时,有一些方法可以在没有乍一看与序列化无关的解决方法的情况下启用它。
  2. 微优化:您的主要论点是您只想在有人实际访问它们时才构建对象。所以你实际上是在谈论优化内存使用。
    我不同意这个论点,原因如下:

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

      1. 延迟初始化可能会损害性能。也许只是微乎其微,但正如比尔的回答所表明的那样,影响比乍一看可能更大。因此,这种方法基本上是在性能与内存之间进行交易。
      2. 如果您的设计是只使用类的一部分的常见用例,这暗示了设计本身存在问题:所讨论的类很可能有多个职责。解决方案是将班级分成几个更集中的班级。
于 2013-02-08T13:50:50.810 回答
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,则必须使用问题中说明的代码模式。这种 Code Pattern 已经变得如此普遍,以至于 Microsoft 认为在最新的 .NET 库中包含一个类以使其更容易实现该模式是合适的。此外,如果您的实现需要线程安全,那么您必须添加它。

原始数据类型和简单类

Obvioulsy,您不会对原始数据类型或简单的类使用惰性初始化,例如List<string>.

在评论懒惰之前

Lazy<T>是在 .NET 4.0 中引入的,所以请不要再添加关于这个类的评论。

在评论微优化之前

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

关于用户界面

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

“二传手”

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

收藏品

类集合属性在声明时总是被初始化,因为它们永远不应该为空。

复杂类

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

最后

我从来没有说过要对所有课程或所有情况都这样做。这是一个坏习惯。

于 2013-02-08T14:16:29.993 回答
17

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

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

正如其他人所说,如果对象真的很耗费资源,或者在对象构建期间加载它们需要一些时间,那么您就可以延迟加载它们。

于 2013-02-08T14:08:12.670 回答
9

我认为这取决于你正在初始化什么。我可能不会做一个清单,因为建设成本很小,所以它可以放在构造函数中。但如果它是一个预先填充的列表,那么我可能不会,直到第一次需要它。

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

于 2013-02-08T13:51:22.353 回答
8

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

于 2013-02-08T14:26:27.633 回答
8

我只是想对丹尼尔的回答发表评论,但老实说,我认为这还不够。

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

对象的最大优点之一是它提供了一个安全、可信的环境。最好的情况是,如果您将尽可能多的字段设置为“Final”,并使用构造函数将它们全部填充。这使您的课程非常防弹。允许通过设置器更改字段的情况要少一些,但并不可怕。例如:

安全类
{
    字符串名称="";
    整数年龄=0;

    公共无效集合名称(字符串新名称)
    {
        断言(新名称!= null)
        名称=新名称;
    }// 遵循这个模式
    ...
    公共字符串 toString() {
        String s="安全类有名字:"+name+" 和年龄:"+age
    }
}

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

    如果(名称 == 空)
        throw new IllegalStateException("SafeClass 进入了非法状态!name 为 null")
    如果(年龄 == 空)
        throw new IllegalStateException("SafeClass 进入了非法状态!年龄为空")

    公共字符串 toString() {
        String s="安全类有名字:"+name+" 和年龄:"+age
    }

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

此外,您的类永远处于不确定状态——例如,如果您决定通过添加一些注释使该类成为休眠类,您会怎么做?

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

有关 brance 预测问题的示例(您反复出现,而不仅仅是一次),请参阅这个真棒问题的第一个答案:为什么处理排序数组比处理未排序数组更快?

于 2013-02-09T00:09:05.700 回答
8

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

于 2013-02-08T13:51:14.187 回答
4

让我在其他人提出的许多优点中再补充一点……

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

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

于 2013-02-28T00:44:08.840 回答
2

你确定 Foo 应该实例化任何东西吗?

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

然而,如果 Foo 存在的目的是创建 Bar 类型的实例,那么我认为懒惰地做这件事没有任何问题。

于 2013-02-09T11:57:54.953 回答