0%

C++对象模型(五)构造与析构

C++里类有4种特殊的成员函数:

  • 构造函数。
  • 析构函数。
  • 复制函数,包括复制构造函数和复制赋值函数。
  • 移动函数,包括移动构造函数和移动赋值函数。

这些函数的特点是:有些时候,编译器会帮你生成这些函数;有些时候,编译器又会拒绝生成这些函数;还有些时候,编译器还会往你自己写的特殊函数中添加操作。鉴于这些特殊函数的重要性,我们有必要好好了解一下它们背后的故事。

本文介绍的是前两类,构造函数和析构函数。

注1:本文环境为Ubuntu 16.04,gcc5.4.0,使用c++14标准。
注2:本文大量内容来自《深入探索C++对象模型》

构造函数

什么类没有构造函数

我们知道构造函数是一种非常重要的函数,也是C++诞生的一个主要原因。那么,第一个问题,每个类都有构造函数吗?

对于下面这个平凡类:

1
2
3
4
5
6
7
8
9
10
struct Trivial {
int64_t x;
int64_t y;
};

int main() {
Trivial t;
t.x = 1;
t.y = 2;
}

main函数对应的汇编指令为(未开任何优化):

1
2
3
4
5
6
7
8
9
10
00000000004006b6 <main>:
4006b6: 55 push %rbp
4006b7: 48 89 e5 mov %rsp,%rbp
4006ba: 48 c7 45 f0 01 00 00 movq $0x1,-0x10(%rbp)
4006c1: 00
4006c2: 48 c7 45 f8 02 00 00 movq $0x2,-0x8(%rbp)
4006c9: 00
4006ca: b8 00 00 00 00 mov $0x0,%eax
4006cf: 5d pop %rbp
4006d0: c3 retq

没有Trivial的构造函数的影子。整个binary中也找不到Trivial的构造函数。

事实上,平凡类就是没有构造函数的,或者说编译器会为它生成一个trivial的构造函数。而一个trivial的构造函数就类似于C中struct的初始化:什么都不做。因此编译器实际上不会为平凡类生成构造函数。而平凡类不允许有自定义的构造函数,结论就是平凡类就不可能有构造函数。

然而有一种情况下,编译器还真会给平凡类生成一个构造函数,那就是显式声明一个= default的构造函数:

1
2
3
4
5
6
struct Trivial {
int64_t x;
int64_t y;

Trivial() = default;
};
1
2
3
4
5
6
7
8
9
10
00000000004009e8 <_ZN7TrivialC1Ev>:
4009e8: 55 push %rbp
4009e9: 48 89 e5 mov %rsp,%rbp
4009ec: 48 89 7d f8 mov %rdi,-0x8(%rbp)
4009f0: 90 nop
4009f1: 5d pop %rbp
4009f2: c3 retq
4009f3: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
4009fa: 00 00 00
4009fd: 0f 1f 00 nopl (%rax)

虽然这个函数里明显什么事情都没做,但它确实存在了。

当然,加上“-O2”你会发现,它又没了。

实际上,所有没有自定义构造函数的,满足bitwise语义的类,都可能没有构造函数。这个范围会比平凡类大一些。

编译器生成默认构造函数的条件

第二个问题,编译器什么时候会为一个类生成一个默认构造函数?

编译器只会在必要的时候为一个类生成默认构造函数。所谓必要,指:

  • 这个类没有自定义的构造函数或声明默认构造函数为= default,且
  • 没有用= delete删除默认构造函数,且
  • 代码中调用到了这个类的默认构造函数。

第一个条件很好理解,C++的编译器是充分相信程序员的,如果一个程序员写了随便一个构造函数,编译器会尊重Ta,不再为其生成默认构造函数。

第二个条件是指,编译器不会在看到这个类的定义时就为其生成一个默认构造函数,而是会推迟这个生成时机,直到有代码真的调用了才生成。

然而,即使满足上面的条件,如果类中默认构造函数没有声明为= default,且编译器判断这个类可以bitwise构造,编译器仍然不会真的生成一个默认构造函数。

bitwise与memberwise

当我们说到构造函数时,一个不得不提的概念是bitwise与memberwise。实际上这两个概念更多的是用来描述拷贝,但在构造上也有着类似的效果。

一个类型,如果满足:

  • 是标量类型,或
  • 是自定义类型,且满足:
    • 没有虚函数。
    • 没有虚基类。
    • 没有不符合bitwise语义的非静态成员变量。
    • 没有不符合bitwise语义的基类。
    • 没有自定义的构造函数。

那它就满足bitwise构造的条件,即它在构造时没有任何特殊的操作(除了给它分配内存外),它的默认构造函数就是trivial的,实际上编译器不会真的生成这个函数。

而反过来,不满足这个条件的类,它就需要依次初始化每个成员,即是memberwise。

trivial的构造函数要比自定义的构造函数低很多(什么都不做),但它伤害到了正确性,即类的成员是没有一个可预期的初始值的的。从这个角度讲,即使是满足上面条件的类,我们也不应放任编译器选择trivial的默认构造函数,而应该自己定义一个正确初始化每个成员值的默认构造函数。当然,如果你要定义一个POD类型的话,除外。

构造函数的内容

一个构造函数有哪些内容?比如,对于一个有着两个基类A和B的有虚函数的类型C,它的构造函数需要完成以下工作:

  • 初始化基类A。
  • 初始化基类B。
  • 确保虚表指针指向正确的位置。
  • 初始化每个成员。
  • 依次调用构造函数体中的语句。

很显然,C要先完成A和B的构造,才能保证C自己的构造过程开始时,它已经“is a”A和B的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct A {
int32_t x;
A() {
x = 5;
F();
}
virtual ~A() {}
virtual void F() {}
};

struct B {
int32_t y;
B() {
y = 1;
}
virtual ~B() {}
};

struct C: public A, public B {
int32_t z;
C(): z(3) {}
void F() {
cout << z << endl;
}
};

这个例子中,在A::A中我们调用了一个虚函数F,假如整个过程发生在C的构造中,它调用的是哪个FA::F还是C::F

我们知道,在C去构造它的A的子对象时,它自己的成员都还没有初始化,那么此时去调用C::F显然是不合理的。因此我们有一个结论:构造函数中虚函数没有动态绑定,只有静态绑定。

《深入探索C++对象模型》中提到,当时的编译器在初始化基类子对象时,是通过将虚表指针指向基类的虚表,来实现一种伪的静态绑定。这么做的理由是不区别对待构造函数与其它函数。但这显然会伤害到效率。因此现代的编译器都会区别对待构造函数,真的静态绑定其中每个成员变量的调用。

通常我们会显式的把基类的构造写到派生类的初始化列表中。但即使不这么做,编译器也会将基类子对象的构造插到派生类的每个构造函数的开头,当然这要求基类有一个默认构造函数,或编译器能为其生成一个默认构造函数。

OK,在成功地构造完基类子对象后,C开始忙自己的构造了。

首先,如果C有虚表,那么要把虚表设置到正确地位置上。

之后开始按声明顺序依次构造C的每个非静态成员变量。

这里的“声明”要加粗,是因为如果忽略这一点,我们很可能会得到一个编译器的警告。

在这个阶段,每个非静态成员变量的初始值可能会来自三个地方:

  • 初始化列表中的表达式。
  • 成员初始化式(C++11新增)中的表达式。
  • 该成员的默认构造函数(如果非trivial)。

其优先级依次下降。其中最后一项不涉及顺序,而前两项都可能会涉及到不同成员变量间的构造顺序。

对于下面这个例子:

1
2
3
4
5
6
struct S {
int x;
int y = z + 2;
int z;
S(): z(0), x(y + 1) {}
};

当我们构造出一个S的对象时,会发现它的xy两个成员的值是未初始化的!这就是因为,x依赖了它后面声明的y,而y依赖了它后面声明的z,导致当它们进行初始化时,依赖的值都还没有初始化,自然会得到一个错误的值。

OK,初始化列表结束后,此时c已经是一个合法的,所有成员和函数都可用的C对象了。接下来要执行的就是构造函数体本身了。

这里有一个值得注意的点。执行到构造函数体前,类的所有成员变量都已经初始化过了,如果我们在构造函数体中再对其进行赋值,大概率浪费了前面的初始化:

1
2
3
4
5
6
struct S {
std::string name;
S(const std::string& s) {
name = s;
}
}

这个例子中,在整个构造过程中,name执行了一次默认构造函数,和一次赋值。而如果将这次赋值放到初始化列表中:

1
2
3
4
struct S {
std::string name;
S(const std::string& s): name(s) {}
}

name就只执行了一次复制构造函数。对于很多类型来说,后者的好处还是很明显的。

单参数构造函数要声明为explicit

某种说法认为C++不是强类型语言,因为它允许类型间的隐式转换。C++中的隐式转换有一部分是因为要兼容C而背的包袱,导致整型的重载无比混乱。而另一部分隐式转换就是C++自己设计的问题了。

在某些场景下,C++的隐式转换是很有用的,但在很可能多得多的场景下,如果滥用隐式转换,就会带来潜在的问题。

1
2
3
4
5
6
7
8
9
10
11
12
struct S {
std::vector<std::string> v;
S(int x): v(x, "aaa") {}
};

void Func(const S& s) {
std::cout << "v.size:" << s.v.size() << std::endl;
}

int main() {
Func(100); // oops!
}

这个例子中,Func实际只接受const S&类型的参数,但我们搞错了,传进去了100。我们预期的结果当然是编译器报错,找不到Func(int),但实际呢?程序编译通过,成功运行,结果是:

1
v.size:100

发生了什么?隐式转换。编译器看到Func(100)时,它首先会去找Func,只找到了Func(const S&),没找到Func(int)。于是编译器会找有没有哪种隐式转换,允许将一个int转换为S,还真有,S正好有个构造函数是S(int)!于是编译器这里就执行了S的构造函数,构造出一个有着100个元素的对象。

怎么避免上面的场景发生?我们就要想办法禁止intS的隐式转换,而explicit就是这个作用。当它被用来修饰一个单参数的构造函数时,就会阻止编译器产生一种隐式转换的关系:

1
2
3
4
struct S {
std::vector<std::string> v;
explicit S(int x): v(x, "aaa") {}
};

然而当我们真的想将int转换为S时,该怎么办?两种方法:

  • 显式构造:Func(S(100))
  • static_cast:Func(static_cast<S>(100))

全局变量的初始化不要依赖其它编译单元的全局变量

这句话有两个前提:

  • 全局变量的初始化发生在main函数之前,串行进行。
  • 不同的实现文件(.cpp或.cc)属于不同的编译单元,而不同编译单元的全局变量的初始化顺序在链接时由链接器决定。

这就导致了一个类似于上面构造函数初始化列表的顺序问题,且它没有一个确定的顺序。

因此,如果一个全局变量在初始化时依赖了另一个编译单元的全局变量,很可能你会发现前者初始化时后者还没有初始化。

这里的全局变量也包括类的静态成员变量。

那么,如果真有这种全局变量的初始值依赖于其它变量,该怎么做呢:

  • 相同编译单元的全局变量的初始化顺序是确定的,可依赖的。

  • 如果必须跨编译单元依赖,那么把被依赖的变量放到一个函数里作为static变量。标准规定了函数中的static变量其初始化是在第一次调用时(运行到此行时),这是确定的,可依赖的:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // a.cpp
    TypeA gX(SomeFunc());
    // b.h
    const TypeB& SomeFunc();
    // b.cpp
    const TypeB& SomeFunc() {
    static TypeB b;
    return b;
    }

    别担心这里构造static变量没加锁,C++11后标准中规定了这种构造是线程安全的。

析构函数

编译器生成析构函数的条件

与默认构造函数类似,编译器为一个类生成析构函数的条件为:

  • 这个类没有自定义的析构函数或声明析构函数为= default,且
  • 没有用= delete删除析构函数,且
  • 代码中调用到了这个类的析构函数。

同样地,如果一个类符合bitwise析构的标准,编译器为它生成的析构函数就是trivial的,就是可以忽略的,此时这个类就没有析构函数了。

析构函数的内容

析构函数实际就是构造函数的逆过程。参考前面的类C,它的析构函数有以下内容:

  • 依次调用析构函数体中的语句。
  • 声明逆序调用每个成员变量的析构函数。
  • 声明逆序调用每个基类子对象的析构函数。

同样地,析构函数中也会遇到虚函数的绑定问题。与构造函数类型,所有出现在析构函数中的虚函数,都是静态绑定,因为在析构基类子对象时,派生类自己的成员已经都析构掉了,此时再调用派生类改写的方法大概率会出问题。

有虚函数的类也需要一个虚析构函数的定义

这里有两个值得注意的点。

第一个,一个类有虚函数,但析构函数不是虚函数,会有大问题的。我们为一个类增加虚函数时,一定是准备实现运行期多态的(否则声明虚函数干什么)。而我们知道运行期多态是要靠基类的指针和引用来触发的。对于下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct Base {
~Base() {
std::cout << "~Base" << std::endl;
}
virtual void F() {}
};

struct Derived: public Base {
~Derived() {
std::cout << "~Derived" << std::endl;
}
std::string name;
};

int main() {
Base* p = new Derived;
delete p; // oops!
}
1
~Base

没有调用真正类型的析构函数,是个大问题!尤其是,Derived::name也没有被析构,出现了内存泄漏!

因此,第一个结论:有虚函数的类,一定要有虚的析构函数。

第二个值得注意的点,纯虚基类,析构函数也要有定义,不能是纯虚函数:

1
2
3
4
struct Base {
virtual ~Base() = 0;
virtual void F() = 0;
}

它的问题在于,它的所有派生类在析构时都会调用到Base的析构函数,然而发现这个函数没有定义!编译会因此失败。

因此,第二个结论:不能有纯虚的析构函数,也不能禁止生成析构函数(通过声明为= delete或声明为private却不给定义),一定要给析构函数一个定义(或等待编译器为你生成一个)。

变量的析构时间

标准规定了一个local变量的析构时间是在它出scope时,这个规则很简单,但有些特殊场景还是要单独说一下:

  • 全局变量、静态变量的构造时间是在main函数以前,而析构时间则是在main函数以后。同样地,不同编译单元间全局/静态变量的析构函数也是不确定的。

  • 临时变量的析构时间为代码中其所在的最外层表达式执行完成后,即:

    1
    2
    const char* s = getStringObject().c_str();
    // temp obj destructs here and s becomes danling!

    这里getStringObject()会返回一个临时的std::string对象,这个对象的生命期会直到完成s的赋值后,下一行调用开始前。

  • 但被赋值给const引用的临时变量,其生命期会与这个const引用保持一致,直到这个引用出scope才析构:

    1
    2
    3
    4
    {
    const std::string& s = getStringObject();
    //...
    } // temp obj destructs here
  • 如果goto跳回到函数前面,则这段代码中定义的变量都会被析构:

    1
    2
    3
    4
    RETRY:
    std::string name;
    std::vector<int> v;
    goto RETRY; // name and v will be destructed.
  • 存在短路逻辑的表达式中,编译器需要插入一些代码才能确定临时对象的析构时间:

    1
    2
    3
    if ((s + t) || (u + v)) {
    // ...
    }

    这里面s + t会产生临时对象,但u + v只能说可能产生临时对象,因为如果表达式被短路,根本走不到后半截,就不会产生这个临时对象,那么这个临时对象也就不需要被析构。编译期怎么会知道这个表达式会不会被短路呢?因此编译器需要插入一些代码来产生不同分支。这也稍稍增加了些运行期的开销。

当一个函数有着很多出口时,想决定一个local变量的scope就变困难了,编译器需要在每个可能return的地方都加上一些用于析构已经存在的变量,这也会增大binary的体积。

不要手动调用local变量的析构函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Base {
int j;
virtual void f();
};

struct Derived: public Base {
void f() {}
};

void func() {
Base b;
b.f();
b.~Base();
new(&b) Derived;
b.f(); // which f?
} // which dtor?

上面是一个很tricky的例子,我们手动调用了b的析构函数,又在其原地构造了一个派生类对象。此时再调用f,调用的会是哪个版本?出scope时,调用的是谁的析构函数?

实际这都是未定义的问题,编译器很可能不会按我们的想法去实现。因此结论就是不要这么用。

异常场景

当有了异常之后,构造函数和析构函数就更复杂了:

  • 构造函数抛了异常后,已经构造完的基类子对象和成员变量要析构,但未构造的成员不要析构,因此编译器需要插入大量代码。
  • 构造函数如果在进入函数体之前抛异常,此时对象本身还不完整(有成员未构造完),那么就不能执行析构函数。
  • 异常会导致函数栈unwind,期间每个还存活的对象都要析构,同样需要插入大量代码。
  • 异常还可能会被catch住,此时unwind停止,不再析构存活对象,又要做一些判断。
  • 当异常未被catch住时,如果unwind导致的析构抛了异常,同时存在两个异常会导致程序crash。

因此异常是一种比较昂贵的特性,想实现好异常安全也不那么容易,比如STL容器为了保证修改时的异常安全,做了非常多的事情。

但辩证的看,异常本身还是一种很有用的特性,至少我是支持使用异常的,只要知道上面这些开销,尽量避免错误的使用就好了。