为什么要使用初始化方法而不是构造函数?

65

我刚进入一个新公司,很多代码库都使用初始化方法而不是构造函数。

struct MyFancyClass : theUberClass
{
    MyFancyClass();
    ~MyFancyClass();
    resultType initMyFancyClass(fancyArgument arg1, classyArgument arg2, 
                                redundantArgument arg3=TODO);
    // several fancy methods...
};

他们告诉我这与时间有关。一些事情必须在构造函数之后完成,否则会失败。但大多数构造函数都是空的,我真的看不到不使用构造函数的理由。

所以我向你们这些C++巫师求助:为什么要使用init方法而不是构造函数?


36
如果你自己的公司都无法解释它,我会觉得代码质量很差。 - Mike
似乎缺少一个 void - sellibitze
2
我同意Mike的观点。开始找工作吧。 - sbi
5
我是一名工程师。公司是一家工程公司。只要算法很棒,代码可能会有点臭。而且它们确实很棒!(如果我进一步询问,他们会解释得更详细。只是你们可以更好、更快地解释,所以我问了你们;-)) - bastibe
请查看维基上关于最新C++标准的链接,因为它解决了init函数的一个有效原因:http://en.wikipedia.org/wiki/C%2B%2B11#Object_construction_improvement - Kit10
显示剩余2条评论
12个回答

76

他们说"timing",我想是因为他们希望它们的init函数能够调用对象上的虚拟函数。这在构造函数中并不总是有效,因为在基类的构造函数中,派生类部分的对象“尚不存在”,特别是您无法访问派生类中定义的虚拟函数。相反,如果定义了函数,则调用函数的基类版本。如果未定义(暗示该函数是纯虚函数),则会出现未定义的行为。

另一个常见的原因是希望避免异常,但这是一种相当老式的编程风格(它是否是一个好主意是一个完全独立的争论)。这与构造函数无法返回错误值的事实有关,如果出现错误,构造函数不能返回错误值。因此,就你的同事给你的真正原因而言,我怀疑这不是这个原因。


2
这是您帖子的MSDN参考链接:http://msdn.microsoft.com/en-us/library/ms182331%28VS.80%29.aspx - Tarik
2
第一个确实是一个有效的理由。它也被称为“两阶段构造”。如果我需要这样的东西,我会将其隐藏在对象的内部。我永远不会向我的类的用户公开这个。 - sbi
至于第二个原因,构造函数仍然可以与辅助函数一起使用来管理资源,其中可能会出现异常(如果启用了异常)。 如果构造函数失败,则析构函数将不会在部分构造的对象上被调用。这就是为什么有时会看到手动初始化/清理代码的原因。 - brita_
@brita_: 是的,尽管可以说这是他们应该通过投入更多RAII来解决的代码问题。如果构造函数失败,则调用类的非静态数据成员的析构函数(仅调用已经初始化的那些,在其中一个初始化失败的情况下)。因此,如果您在构造函数中使用实例数据和/或变量来捕获需要清理的任何内容,则不需要为异常进行任何手动清理。但有时候说起来容易做起来难。 - Steve Jessop

36

是的,我可以想到几种方式,但通常这不是一个好主意。

大多数情况下,被提出的原因是你只能通过构造函数报告异常(这是正确的),而使用经典方法可以返回错误代码。

然而,在设计良好的面向对象代码中,构造函数负责建立类不变式。允许默认构造函数会创建一个空类,因此必须修改不变式以使其接受“null”类和“有意义”的类...并且每次使用类之前都必须确保对象已经被正确构建...这很困难。

那么现在,让我们解构一下这些“理由”:

  • 我需要使用虚方法:使用虚构造函数模式。
  • 有很多工作要做:那又怎样,无论如何都会完成这项工作,只需在构造函数中完成即可。
  • 安装可能失败:抛出异常
  • 我想保留部分初始化的对象:在构造函数中使用try / catch,并将错误原因设置为对象字段,不要忘记在每个公共方法的开头使用assert来确保在尝试使用对象之前该对象可用。
  • 我想重新初始化我的对象:从构造函数调用初始化方法,这样你就可以避免重复的代码,同时还有一个完全初始化的对象
  • 我想重新初始化我的对象(2):使用operator =(并使用复制和交换模式进行实现,如果编译器生成的版本不符合您的需求)。

总之,总体来说,这是一个不好的主意。如果你真的想要“void”构造函数,请将它们设为私有,并使用Builder方法。它和NRVO一样有效...并且你可以在构造失败的情况下返回boost :: optional<FancyObject>。


2
我会选择另一种反驳方式,即“我希望重新初始化我的对象的可能性:实现一个复制并交换的operator =”。 - Steve Jessop
我思考了一下,然后意识到可能存在优化机会。例如,考虑vector对象上的assign方法:通过使用assign,您可以重复使用已分配的存储空间,因此不需要进行虚假的内存分配。但是正确地执行它很棘手,因此我同意在一般情况下应该使用复制和交换。 - Matthieu M.
同意,拥有重新初始化函数并没有错,只是我不会从这里开始。如果没有其他选择,对于“优化机会”,请阅读“基本异常保证”。 - Steve Jessop
@Steve:是的,我们同意,就像所说的“正确地做这件事很棘手” :-) 我想这是那些使用STL作为例子可能不是最明智的选择之一,因为它是由专业程序员编写的,而不是普通人。 - Matthieu M.
vector::assign是一个重新初始化方法,遵循您上面的建议。 - Roger Pate

17

其他人列举了很多可能的原因(并对为什么大多数这些不是一个好主意进行了适当的解释)。让我发表一个关于时间安排更或少是有效使用初始化方法的实例

在以前的项目中,我们有很多服务类和对象,每个都是层次结构的一部分,并以各种方式相互交叉引用。因此,通常情况下,要创建ServiceA,您需要父服务对象,而父服务对象又需要服务容器,后者已依赖于初始化时存在某些特定服务(可能包括ServiceA本身)。原因是在初始化期间,大多数服务都将自己注册为特定事件的监听器,并/或通知其他服务有关成功初始化事件的信息。如果其他服务在通知时不存在,则不会发生注册,因此该服务将在应用程序使用期间无法接收到重要的消息。为了打破循环依赖链,我们必须使用显式的初始化方法与构造函数分开,从而有效地使全局服务初始化成为一个两阶段过程。

因此,尽管通常情况下不应遵循此惯用法,但在我看来它具有一些有效的用途。但是,最好将其用法限制在最小范围内,尽可能使用构造函数。在我们的情况下,这是一个遗留项目,我们还没有完全理解它的架构。至少使用init方法的用法仅限于服务类 - 常规类是通过构造函数初始化的。我相信可能有一种重构该架构以消除需要服务init方法的方法,但是至少我没有看到如何做到(坦率地说,在我参与该项目时,我们有更紧迫的问题要处理)。


听起来你在使用单例模式。(不要这样做。) - Roger Pate
@Roger,不,这些不是单例。实际上,您可以同时打开多个所谓的“模型”,每个模型包含每种服务类型的单个专用实例。我非常清楚单例的问题,但还是谢谢 :-) - Péter Török
2
如果您认为侦听器的注册是初始化的一部分,那么它只是两个阶段的初始化。在一个动态的世界观中,服务会不断地出现和消失,侦听器的注册是系统生命周期的一部分。 - Matthieu M.
@Matthieu,这个系统并不是很动态-可能存在的服务集合及其关系都是严格编码的,而且大多数模型中必须存在。虽然我们可以尝试重构系统以开始朝着那个方向发展,但这将是一个巨大的努力,我们没有足够的资源来完成。 - Péter Török
1
啊,资源...我们都会遇到这个问题 :) - Matthieu M.
@Roger Pate 为什么不使用单例模式?实际上,在系统中可能存在许多作为单例的管理器对象,它们可能会像上面提到的情况一样相互依赖。 - Joey.Z

9

我能够想到的两个原因:

  • 假设创建一个对象需要大量繁琐且容易出错的工作。如果使用一个简短的构造函数来设置基本事项,然后让用户调用初始化方法来完成大量工作,即使大任务失败,你至少可以确保已经创建了一些对象。也许这个对象包含了有关初始化失败方式的详细信息,或者由于其他原因需要保留未成功初始化的对象。
  • 有时候你可能想要在创建对象很久之后重新初始化它。这种情况下,只需要再次调用初始化方法,而不需要销毁和重新创建对象。

+1 我考虑了你的第二个参数;实际上,我认为这在C++中仍然被认为是一种不好的做法,应该使用一些辅助对象来完成。 - mbq
你的第二个要点可能是我有时使用初始化函数的最常见原因。是的,构造函数可以调用init,但这只会使代码变得更不清晰。 - Jay
这些是看到init或cleanup函数的典型原因,但它不会成为类公共接口的一部分,而是一个内部辅助函数,以避免代码重复。其他帖子涵盖了系统性执行此操作的原因。 - brita_

5

当你的编译器不支持异常,或者你的目标应用程序无法使用堆(通常使用堆来创建和销毁异常),init()函数是一个很好的选择。

如果需要定义构造顺序,init()例程也很有用。也就是说,如果全局分配对象,构造函数调用的顺序是未定义的。例如:

[file1.cpp]
some_class instance1; //global instance

[file2.cpp]
other_class must_construct_before_instance1; //global instance

该标准没有保证在调用instance1的构造函数之前会调用must_construct_before_instance1的构造函数。当涉及到硬件时,初始化顺序可能非常重要。

5
一种使用这种初始化的方法是对象池。基本上,您只需从池中请求对象。池已经创建了一些N个空对象。现在调用者可以调用任何他/她想要设置成员的方法。一旦调用者完成对象的使用,它将告诉池销毁该对象。优点是,在对象被使用期间,内存将被保存,调用者可以使用自己适合的成员方法来初始化对象。一个对象可能服务于很多目的,但调用者可能不需要全部,也可能不需要初始化所有对象的成员。
通常考虑数据库连接。池可以有一堆连接对象,调用者可以填写用户名、密码等。

1

还有我想附上一个代码示例来回答问题 #1 --

因为MSDN也说:

当调用虚方法时,实际执行方法的类型直到运行时才会选择。当构造函数调用虚方法时,可能尚未执行调用该方法的实例的构造函数。

示例:以下示例演示违反此规则的影响。测试应用程序创建DerivedType的一个实例,导致其基类(BadlyConstructedType)构造函数执行。 BadlyConstructedType的构造函数不正确地调用了虚方法DoSomething。 正如输出所示,DerivedType.DoSomething()将执行,并且在DerivedType的构造函数执行之前执行。

using System;

namespace UsageLibrary
{
    public class BadlyConstructedType
    {
        protected  string initialized = "No";

        public BadlyConstructedType()
        {
            Console.WriteLine("Calling base ctor.");
            // Violates rule: DoNotCallOverridableMethodsInConstructors.
            DoSomething();
        }
        // This will be overridden in the derived type.
        public virtual void DoSomething()
        {
            Console.WriteLine ("Base DoSomething");
        }
    }

    public class DerivedType : BadlyConstructedType
    {
        public DerivedType ()
        {
            Console.WriteLine("Calling derived ctor.");
            initialized = "Yes";
        }
        public override void DoSomething()
        {
            Console.WriteLine("Derived DoSomething is called - initialized ? {0}", initialized);
        }
    }

    public class TestBadlyConstructedType
    {
        public static void Main()
        {
            DerivedType derivedInstance = new DerivedType();
        }
    }
}

输出:

调用基类构造函数。

派生类 DoSomething 被调用 - 初始化?否

调用派生类构造函数。


1

这对于进行资源管理非常有用。假设您有带析构函数的类,以便在对象生命周期结束时自动释放资源。假设您还有一个包含这些资源类的类,并且您在此上层类的构造函数中初始化它们。当您使用赋值运算符来初始化此更高级别的类时会发生什么?一旦内容被复制,旧的更高级别的类就变得无关紧要,并且所有资源类的析构函数都将被调用。如果这些资源类具有在赋值期间复制的指针,则所有这些指针现在都是无效指针。如果您改为在更高级别的类中的单独init函数中初始化资源类,则可以完全绕过资源类的析构函数,因为赋值运算符永远不必创建和删除这些类。我相信这就是所谓的“时间”要求的含义。


1
更多的是特殊情况:如果你创建一个监听器,你可能想让它在某个地方注册自己(比如一个单例或GUI)。如果你在构造函数期间这样做,它会泄漏一个指向自身的指针/引用,这是不安全的,因为构造函数尚未完成(甚至可能完全失败)。
假设收集所有侦听器并在事件发生时向它们发送事件的单例接收到一个事件,然后遍历其侦听器列表(其中一个是我们正在谈论的实例),以向每个侦听器发送消息。但是,该实例仍处于构造函数的中途,因此调用可能会以各种不良方式失败。在这种情况下,将注册过程放在一个单独的函数中是有意义的,显然不能从构造函数本身调用它(那样就失去了目的),而是从父对象中在构造完成后调用。
但这是一种特殊情况,而不是一般情况。

0

更多案例:

COOKING ARGS

构造函数不能调用另一个构造函数,但是初始化方法可以调用另一个初始化方法。

例如,我们有一个初始化器,它接受传统参数列表。我们有另一个初始化器,它接受名称=值对的字典。第二个初始化器可以查询字典以获取第一个初始化器接受的参数,并使用它们调用第一个初始化器。

当初始化器是初始化方法时,这很好,但当初始化器是构造函数时就不行了。

CHICKEN OR EGG

我们可能有一个汽车类,其初始化器必须具有指向发动机对象的指针,而发动机类初始化器必须具有指向其汽车对象的指针。这在构造函数中是不可能的,但在初始化方法中却很简单。

BREAKING UP ARG LIST

可能有大量的参数可以指定,但不需要指定(也许默认值足够,或者某些参数仅在其他参数的值取决于某些参数时才需要)。我们可能希望有几个初始化器而不是一个。

同样,将构造函数分解是根本不可能的,但将初始化器分解却很容易。


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