【问题标题】:What happens during initialization of a class?在类的初始化过程中会发生什么?
【发布时间】:2011-08-27 08:24:55
【问题描述】:

这是让我感到困惑的代码:

#include <iostream>
using namespace std;

class B {
public:
    B() {
        cout << "constructor\n";
    }
    B(const B& rhs) {
        cout << "copy ctor\n";
    }
    B & operator=(const B & rhs) {
        cout << "assignment\n";
    }
    ~B() {
        cout << "destructed\n";
    }
    B(int i) : data(i) {
        cout << "constructed by parameter " << data << endl;
    }

private:
    int data;
};

B play(B b)
{
    return b;
}

int main(int argc, char *argv[])
{
#if 1
    B t1;
    t1 =  play(5);
#endif

#if 0
    B t1 = play(5);
#endif

    return 0;
}

Fedora 15 上的环境是 g++ 4.6.0。 第一个代码片段输出如下:

constructor
constructed by parameter 5
copy ctor
assignment
destructed
destructed
destructed

而第二个片段代码输出为:

constructed by parameter 5
copy ctor
destructed
destructed

为什么第一个例子调用了三个析构函数,而第二个例子只有两个?

【问题讨论】:

  • 您希望看到多少个构造函数?如果还不够,请尝试使用-fno-elide-constructors 进行编译,gcc 不会消除它默认执行的一些构造函数调用。

标签: c++ constructor destructor


【解决方案1】:

第一个案例:

B t1;
t1 =  play(5);
  1. 通过调用B的默认构造函数创建一个对象t1
  2. 为了调用play(),使用B(int i)创建了一个B的临时对象。 5 作为一个对象传递,B 的对象被创建,play() 被调用。
  3. return b; 内部 play() 导致调用 copy constructor 以返回对象的副本。
  4. t1 = 调用 Assignemnt 运算符 将返回的对象副本分配给 t1
  5. 第一个析构函数,析构在#3 中创建的临时对象。
  6. 第二个析构函数在#2 中析构返回的临时对象。
  7. 第三个析构函数销毁对象t1

第二种情况:

B t1 = play(5);  
  1. B 的临时对象是通过调用B 的参数化构造函数创建的,该构造函数将int 作为参数。
  2. 此临时对象用于调用类B复制构造函数
  3. 第一个析构函数会破坏在#1 中创建的临时对象。
  4. 第二个析构函数会破坏对象t1

在第二种情况下,一个析构函数调用较少,因为在第二种情况下,编译器使用 Return value Optimization 并在从play() 返回时省略了创建附加临时对象的调用。相反,Base 对象是在分配临时对象的位置创建的。

【讨论】:

  • @Charles Bailey:grr..confusion 混乱,我希望我能正确编辑。
  • 我不同意你在最后一段中的推理。我看不出为什么函数调用在这两种情况下都会得到不同的优化。从副本的数量来看,NRVO 似乎在这两种情况下都被使用了。
  • @Charles Bailey:因为在第二种情况下,对象t1 是在同一个语句中构造的,其中play() 返回一个临时的B 对象。这使得复制构造成为可能。在第一种情况下,对象t1 是通过返回play() 分配给它的,它已经由默认构造函数调用构造,因此没有复制构造,只有赋值。
  • 倒带,我指的是 RVO,而不是 NRVO。 b 当然是参数名。
【解决方案2】:

首先,检查子表达式play(5)。这个表达式在两种情况下都是一样的。

在函数调用表达式中,每个参数都是从其参数复制初始化的 (ISO/IEC 14882:2003 5.2.2/4)。在这种情况下,这涉及将5 转换为B,方法是使用采用int 的非显式构造函数来创建临时B,然后使用复制构造函数初始化参数b。但是,允许实现通过在 12.8 中指定的规则下使用来自int 的转换构造函数直接初始化b 来消除临时性。

play(5) 的类型是 B 并且 - 作为返回非引用的函数 - 它是一个 右值

return 语句将返回表达式隐式转换为返回值的类型 (6.6.3),然后使用转换后的表达式复制初始化 (8.5/12) 返回对象。

在这种情况下,返回表达式已经是正确的类型,因此不需要转换,但仍然需要复制初始化。


除了返回值优化

命名返回值优化 (NRVO) 是指返回语句为 return x; 形式的情况,其中 x 是函数本地的自动对象。当发生时,允许实现在返回值的位置构造x,并消除return处的复制初始化。

虽然在标准中没有这样命名,但 NRVO 通常指的是 12.8/15 中描述的第一种情况。

这种特殊优化在play 中是不可能的,因为b 不是函数体的局部对象,它是在输入函数时已经构造的参数的名称。

(未命名的)返回值优化(RVO)在它所指的内容上的一致性更差,但通常用于指返回表达式不是命名对象而是转换为返回类型的表达式的情况和返回对象的copy-initialization可以结合起来,这样返回对象就可以直接从消除一个临时对象的转换结果中进行初始化。

RVO 不适用于play,因为b 已经是B 类型,所以copy-initialization 等同于direct-initialization 和不需要临时对象。


在这两种情况下,play(5) 都需要使用B(int) 作为参数构造B,并将B 复制初始化到返回对象。它也可能在参数的初始化中使用第二个副本,但许多编译器会消除此副本,即使未明确请求优化也是如此。这两个(或所有)对象都是临时对象。

在表达式语句t1 = play(5);中会调用复制赋值操作符,将play的返回值复制到t1,并销毁两个临时变量(play的参数和返回值) .自然,t1 必须在此语句之前构造,其析构函数将在其生命周期结束时被调用。

在声明语句B t1 = play(5);中,逻辑上t1被初始化为play的返回值,并且将使用与表达式语句t1 = play(5);完全相同数量的临时对象。但是,这是 12.8/15 中涵盖的第二种情况,其中允许实现消除用于 play 的返回值的临时值,而是允许返回对象别名为 t1play 函数的操作方式完全相同,但是因为它的返回对象只是 t1 的别名,所以它的 return 语句有效地直接初始化 t1,并且没有单独的临时对象用于需要销毁的返回值.

【讨论】:

    【解决方案3】:

    第一个片段构造了三个对象:

    • B t1
    • B(5)
    • 返回 b;或 B(b)

    这是我的猜测,虽然它看起来效率低。

    【讨论】:

    • 但是第二个的解释是什么?
    【解决方案4】:

    请参阅 Als 发布的第一个场景的详细介绍。

    我认为(编辑:错误;见下文)与第二种情况的区别在于编译器足够聪明,可以使用 NRVO(命名返回值优化)并省略中间副本:而不是在返回时创建临时副本(来自 play),编译器使用 play 函数内部的实际“b”作为 t1 的复制构造函数的右值。

    Dave Abrahams 在复制省略时有一个article,这是return value optimization 上的维基百科。

    编辑:实际上,Als 也添加了第二个场景的逐个播放。 :)

    进一步编辑:实际上,我在上面是不正确的。在这两种情况下都没有使用 NRVO,因为根据接受的答案for this question,标准禁止直接从函数参数(b in play)省略复制到函数的返回值位置(至少没有内联)。

    即使允许 NRVO,我们也可以说它至少没有在第一种情况下使用:如果是,第一种情况不会涉及任何复制构造函数。第一种情况的拷贝构造函数来自于从命名值b(在play函数中)到隐藏返回值位置的隐藏拷贝,用于play。第一种情况不涉及显式复制构造,因此它是唯一可能出现的地方。

    实际情况是这样的:NRVO 在这两种情况下都没有发生,并且在返回时正在创建隐藏副本...但在第二种情况下,编译器能够直接在 t1 的位置构造隐藏的返回副本.所以,从 b 到返回值的复制没有被删除,但是从返回值到 t1 的复制被删除了。然而,编译器在第一个已经构造了 t1 的情况下很难进行优化(阅读:它没有这样做;))。如果 t1 已经在与返回值的位置不兼容的地址处构造,则编译器无法直接将 t1 的地址用于隐藏的返回值副本。

    【讨论】:

      【解决方案5】:

      在您的第一个示例中,您调用了三个构造函数:

      • 声明B t1; 时的B() 构造函数,如果B() 是公共的,这也是一个定义。换句话说,编译器将尝试将任何已声明的对象初始化为某种基本有效状态,并将B() 视为将B 大小的内存块转换为所述基本有效状态的方法,以便在@ 上调用的方法987654326@ 不会破坏程序。

      • B(int) 构造函数,用作隐式转换; play() 接受 B 但被赋予 int,但 B(int) 被认为是一种将 int 转换为 B 的方法。

      • 1234563 /p>

      在作用域退出时,上述每个构造函数都必须与析构函数相匹配。

      然而,在您的第二个示例中,您正在使用 play() 的结果显式初始化 t1 的值,因此编译器不需要浪费周期为 t1 分配基本状态,然后再分配将play() 的结果复制到新变量。所以你只打电话

      • B(int) 获取play(B) 的有用参数

      • B(const B&amp; rhs) 以便 t1 将被初始化为(无论您的复制构造函数决定是什么)play() 的结果的正确副本。

      在这种情况下,您看不到第三个构造函数,因为编译器将play() 的返回值“省略”到t1;也就是说,它在play()返回之前就知道t1不存在有效状态,所以它只是将返回值直接写入为t1预留的内存中。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 2017-12-09
        • 1970-01-01
        • 2019-12-12
        • 2016-12-12
        • 1970-01-01
        • 2011-03-30
        相关资源
        最近更新 更多