深入理解C++对象模型(二)
hahahanba Lv1

构造函数语义学

一、默认构造函数的构造操作

C++中对于默认构造函数的解释是:默认的构造函数会在需要的时候被编译器产生出来。如下述实例所示,正确的程序语意要求Foo有一个默认构造函数,可以将它的两个成员初始化为0。那上述实例会合成出一个默认构造函数吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Foo 
{
public:
int val;
Foo *pnext;
};

void foo_bar()
{
Foo bar;
if (bar.val || bar.pnext)
// ... do sth
// ..
}

其实并不会!默认构造函数的合成关键是在于谁需要?是程序的需要还是编译器的需要?如果是程序的需要,那初始化便是程序员的责任。而上述例子便是程序员的责任,因此程序并不会合成一个默认构造函数。那么,什么时候编译器才会合成一个默认构造函数呢?

  • 当编译器需要默认构造函数的时候才会合成出一个默认构造函数! 且被合成出来的构造函数只执行编译器所需的行动。这样合成的默认构造函数是notrivial的,并且这个产生操作只有在默认构造函数真正被调用时才会进行合成。
  • 如果编译器不需要,而程序员又没有提供,这时的默认构造函数就是trivial的。需要注意trivial构造函数只存在于概念上,编译器实际上根本不会去合成出来(此类构造函数不做任何有意义的事情,所以编译器不去合成它)。

1.1 什么时候下会合成默认构造函数?

通常编译器会在以下四种情况会合成notrivial的默认构造函数。

1.1.1 具有默认构造函数的成员类对象

  • 类没有任何构造函数,但它内含一个含有默认构造函数的成员类,那么这个类的implicit默认构造函数就是nontrivial, 编译器需要为该类合成出 一个默认构造函数。

考虑下面的例子:

1
2
3
4
5
6
7
8
class Foo { public: Foo(), Foo(int) ... };
class Bar { public: Foo foo; char *str; };

void foo_bar()
{
Bar bar; // Bar::foo must be initialized here
if (str) {} ...
}

被合成的Bar默认构造函数内含必要的代码,使其能够调用类 Foo 的默认构造函数来处理成员对象 Bar::bar,但它并不产生任何代码来初始化Bar::str。被合成的默认构造函数如下所示:

1
inline Bar::bar() { foo.Foo::Foo();}

一个有趣的问题:类声明头文件可以被许多源文件所包含,如何避免合成默认构造函数、拷贝构造函数、析构函数、 赋值拷贝操作符(4 大成员函数)时不引起函数的重定义?
解决方法是以 inline 的方式完成(一个inline函数有静态链接,不会被文件以外者看到),如果函数太复杂不适合 inline,就会合成一个 explicit non-inline static 实例 (Static 函数独立于编译单元)

  • 如果类 A 内含一个或一个以上的成员类对象,那么类 A 的每一个构造函数必须调用每一个成员类的默认构造函数。C++ 要求以成员对象在类中的声明顺序来调用各个构造函数。这一点由编译器完成,它为每一个构造函数安插程序代码,以“成员声明顺序”调用每一个成员所关联的默认构造函数。
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Dopey { public: Dopey; ... };
class Sneezy { public: Sneezy(int); Sneezy(); ... };
class Bashful { public: Bashful(); ... };

class Snow_White
{
public:
Dopey dopey;
Sneezy sneezy;
Bashful bashful;
// ...
private:
int mumble;
};

// 1. Snow_White未定义默认构造函数,就会有一个nontrivial构造函数
// 被合成出来,依次调用Dopey、Sneezy、Bashful的默认构造函数。
Snow_White::Snow_white()
{
dopey.Dopey::Dopey();
sneezy.Sneezy::Sneezy();
bashful.Bashful::Bashful();
}

// 2. 如果Snow_White定义了默认构造函数,就会有下述nontrivial构造函数被合成出来。
// 已定义构造函数
Snow_White::Snow_white() : sneezy(1024)
{
mumble = 2048;
}

// 代码扩张后
Snow_White::Snow_white() : sneezy(1024)
{
dopey.Dopey::Dopey();
sneezy.Sneezy::Sneezy(1024);
bashful.Bashful::Bashful();

mumble = 2048;
}

1.1.2 带有默认构造函数的基类

如果一个没有任何构造函数的类派生自一个带有默认构造函数的基类,那么这个派生类的默认构造函数会被视为 nontrivial ,并且这个默认构造函数会被按照基类的声明顺序调用基类的默认构造函数合成出来。

如果设计者提供多个构造函数,但其中都没有默认构造函数的话,编译器会扩张现有的每一个构造函数,将用以调用所有必要的默认构造函数的程序代码加进去。(它不会合成 一个新的默认构造函数)。

1.1.3 带有虚函数的类

有两种情况需要合成出默认构造函数:

  • 类声明(或继承)一个虚函数。
  • 类派生自一个继承串链,其中有一个或者更多的虚基类。

不管哪一种情况,由于缺乏用户声明的构造函数,编译器会详细记录合成一个默认构造函数的必要信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Widget
{
public:
virtual void flip() = 0;
// ...
};

void flip(const Widget& widget) { widget.flip(); }

// 假设Bell和Whistle都派生自Widget
void foo()
{
Bell b;
Whistle w;

flip(b);
flip(w);
}

由于含有虚函数,其扩张行动会发生在编译期间:

  • 编译器会产生一个虚表vtbl,其内放置类的虚函数地址。
  • 每一个类对象中,编译器会额外的合成一个指针成员vptr指向与之相关的类虚表vtbl。

此外,widget.flip()的虚拟调用操作(virtual invocation)会被重新改写,以使用 widget 的 vptr 和 vtbl 中的 flip 条目:

1
2
3
4
5
// widget.flip()的虚拟调用操作(virtual invocation)
(*widget.vptr[1])(&widget)

// 1表示flip()在 virtual table 中的固定索引;
// &widget 代表要交给被调用的某个flip()实例的this指针

为了让这个机制生效,编译器必须为每一个Widget(或其派生类) 对象的 vptr 设定初值,放置适当的virtual table地址。

1.1.4 带有虚基类的类

虚基类的实现法在不同的编译器之间有极大的差异。然而,每一种
实现法的共同点在于必须使虚基类在其每一个派生类对象中的位置,能够于执行期准备妥当。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class X { public: int i; };
class A : public virtual X { public: int j; };
class B : public virtual X { public: double d; };
class C : public A, public B { public: int k; };

// 无法在编译期决定pa->X::i的位置
void foo(const A* pa) { pa->i = 1024; }

void main()
{
foo(new A);
foo(new C);
// ...
}

// 可能的编译器转变操作
void foo(const A* pa) { pa->__vbcX->i = 1024; }

编译器无法固定住 foo() 之中“经由 pa 而存取的 X::i 的实际偏移位置,因为 pa 的真正类型可以改变。编译器必须改变执行存取操作的那些代码,使 X::i 可以延迟至执行期才决定下来。

__ vbcX (或编译器所做出的某个什么东西) 是在类对象构造期间被完成的。对于类所定义的每一个构造函数,编译器会安插那些 “允许每一个虚基类的执行期存取操作” 的代码。如果类没有声明任何构造函数,编译器必须为它合成一个默认的构造函数。

被合成出来的构造函数只能满足编译器的需要,通过下属两种方法:

  • 通过调用成员对象或基类的默认构造函数;
  • 为每一个对象初始化其虚函数机制或虚基类机制。

C++新手常见的2个误区:

  • ERROR: 如果类没有定义默认构造函数就会被合成一个。首先定义了其它的构造函数就不会合成默认构造函数;再次即使没有定义任何构造函数也不一定会合成默认构造函数,可能仅仅是概念上有,但实际上不合成出来。

  • ERROR: 编译器合成出来的默认构造函数会显式设定类内每一个数据成员的默认值。明显不会,区分了全局对象,栈对象、堆对象 就非常明白了只有在全局上的对象会被清 0,其它的情况都不会保证被清 0。

二、拷贝构造函数的构造操作

拷贝构造函数和默认构造函数一样,只有在必须的时候才会被产生出来,对于大部分的类来说,拷贝构造函数仅仅需要按位拷贝就可以。当然,满足 bitwise copy semantics 的拷贝构造函数是 trivial 的,不会真正被合成出来,与默认构造函数一样,只有 nontrivial 的拷贝构造函数才会被真正合成出来。

2.1 什么时候一个类不展现bitwise copy semantics呢?

分为四种情况,前 2 种很明显,后 2 种是由于编译器必须保证正确设置虚机制而引起的。

  1. 当类内含一个成员对象而后者声明了(也可能由于 nontrivial 语意从而编译器真正合成出来的)一个拷贝构造函数时。
  2. 当类继承自一个基类,而后者存在一个拷贝构造函数时(不论是被显式声明或是被合成而得)。
  3. 当类声明了一个或多个虚函数时。
  4. 当类派生自一个继承串链,其中有一个或多个虚基类时。
2.1.1 重新设定虚表的指针

对于每一个新产生的类对象,如果编译器不能成功而正确的设置好其 vptr 的初值,将会导致可怕的后果。因此当编译器导入一个 vptr 到类中时,该类就不再展现 bitwise 语意了。

考虑下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class ZooAnimal {
public:
ZooAnimal();
virtual ~ZooAnimal();

virtual void animate();
virtual void draw();
// ...
private:
// ZooAnimal的animate()和draw()
// 所需要的数据
};

class Bear : public ZooAnimal {
public:
Bear();
void animate(); // 虽未明写virtual,但它其实是virtual
void draw(); // 虽未明写virtual,但它其实是virtual
virtual void dance();
// ...
private:
// Bear的animate()、dance()和draw()
// 所需要的数据
}
  • ZooAnimal 类对象以另一个 ZooAnimal 类对象作为初值,或 Bear 类对象以另一个 Bear 类对象作为初值,都可以直接靠 bitwise copy semantics 完成。
1
2
Bear yogi;
Bear winnie = yogi;

image

yogi 会被默认的 Bear 构造函数初始化。yogivptr 被设定指向 Bear 的虚表。因此,把yogivptr 值拷贝给 winnievptr 是安全的。

  • 类对象以其派生类的对象内容做初始化操作时,其 vptr 复制操作也必须保证安全。frannyvptr 不可以被设定指向 Bear 的虚表。否则当下面程序片段中的 draw() 被调用而franny 被传进去时,就会出问题:
1
2
3
4
5
6
7
8
9
10
11
Bear yogi;
ZooAnimal franny = yogi; // 注意此处会发生切割行为

void draw(const ZooAnimal& zoey) { zoey.draw(); }
void foo() {
// franny的vptr指向ZooAnimal的虚表而非Bear的虚表
ZooAnimal franny = yogi;

draw(yogi); // 调用Bear::draw()
draw(franny); // 调用ZooAnimal::draw()
}

image

也就是说,合成出来的ZooAnimal 拷贝构造函数会显式设定对象的 vptr 指向 ZooAnimal 类的虚表而不是直接从右手边的类对象中将其 vptr 现值拷贝过来。

2.1.2 虚基类子对象

虚基类的存在需要特别处理。一个类对象如果以另一个对象作为初值,而后者有一个虚基类子对象,那么也会使 bitwise copy semantics 失效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Raccoon : public virtual ZooAnimal {
public:
Raccoon() {/*设定私有数据初值*/}
Raccoon( int val ) {/*设定私有数据初值*/}
private:
// 所有必要的数据
}

class RedPanda : public Raccoon {
public:
RedPanda() {/*设定私有数据初值*/}
RedPanda( int val ) {/*设定私有数据初值*/}
private:
// 所有必要的数据
}

// 简单的bitwise copy还不够,编译器必须显式地将
// little_critter的虚基类pointer/offset初始化
RedPanda little_red;
Raccoon little_critter = little_red;

在上述情况下,为了完成正确的 little_red 初值设定,编译器必须合成一个拷贝构造函数,安插一些代码以设定虚基类pointer/offset 初值,对每一个成员执行必要的 memberwise初始化操作,以及执行其他的内存相关工作。

在下面的情况,编译器无法知道 bitwise copy semantics 是否还保持着,因为它无法知道 Raccoon 指针是否指向一个真正的 Raccoon 对象或者指向一个派生类对象。

1
2
Raccoon *ptr;
Raccoon little_critter = *ptr;

三、程序转化语义学

本部分是讨论编译器调用拷贝构造函数时的策略(如何优化以提高效率),或者说是是关于编译器对于程序是如何进行有效转化或者说翻译,以实现C++的语法机制。主要有以下的几种语意:

3.1 显式初始化操作

可以考虑如下的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
X x0;

void foo_bar() {
X x1(x0);
X x2 = x0;
X x3 = X(x0);
// ...
}

//必要的程序转化需要两个阶段:
// 1. 重写每一个定义,但不会执行初始化操作。
// 2. 安插类的拷贝构造函数调用操作。
void foo_bar() {
X x1(x0);
X x2 = x0;
X x3 = X(x0);
// ...

// 编译器安插X的拷贝构造函数的调用操作
x1.X::X(x0);
x2.X::X(x0);
x3.X::X(x0);
}

3.2 参数的初始化

C++标准(8.5节)说,把一个类对象当做参数传递给一个函数或者把它作为一个函数的返回值时,相当于以下形式的初始化操作:

1
2
3
4
5
6
7
8
9
// 其中xx是形式参数或者返回值,arg代表真正的参数值。
X xx = arg;

// 若已知下面的函数
void foo(X x0);

// 若采用下述的调用方式,将会使得局部实例x0以 memberwise 的形式以实际参数为初始值进行初始化
X xx;
foo(xx);

一般来说,编译器有两种做法:

  • 引入一个临时性对象。
1
2
3
4
5
6
7
8
// 1. 编译器生成一个临时性对象
X __temp0;
// 2. 编译器对拷贝构造函数的调用
__temp0.X::X(xx);
// 3. 重新改写函数调用操作;
foo(__temp0);
// 4. 修改参数调用方式为引用,否则如何工作又回到原点啦(临时性对象以类X的拷贝构造函数正确的设定初值,然后以bitwise的方式拷贝到x0这个局部实例中)
void foo(X &x0);
  • 采用“拷贝构建”的方式,将参数直接以拷贝构造函数建构到函数的堆栈上。

3.3 返回值的初始化

当返回值是对象时,这个对象是如何返回的呢?cfront 使用的是一个双阶段转化:

  • 首先加上一个额外的参数,是类对象的引用,这个参数将放置通过拷贝构建得来的返回值。
  • return 之前安插一个拷贝构建函数的调用操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
X bar()
{
X xx;
// 处理xx ...
return xx;
}

// bar()转换
//安插了临时引用参数__result
void bar(X & __result)
{
X xx;
// 默认构造函数调用
xx.X::X();

// 处理xx...

// 拷贝构造函数调用操作
__result.X::X(xx);

return;
}

四、成员初始化列表

当我们写下一个构造函数时,就有机会设定类成员的初值。要不是由成员初始化列表,就是在构造函数函数体之内。除了4种情况,你的任何选择其实都差不多。

4.1 初始化列表内部的真正操作是什么?

为了让你的程序能够顺利被编译,你必须使用成员列表初始化:

  • 当初始化一个 reference member 时;
  • 当初始化一个 const member 时;
  • 当调用一个基类的构造函数,而它拥有一组参数时;
  • 当调用一个成员类的构造函数,而它拥有一组参数时;

在这4种情况下,程序可以被正确编译并执行,但是效率不高。例如:

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
26
27
28
29
class Word {
String _name;
int _cnt;
public:
Word() {
_name = 0;
_cnt = 0;
}
};

// 在这里 Word 构造函数会产生一个临时性的 String 对象,然后将它初始化,之后以一个赋值运算符将临时性的对象指定给 _name,随后再摧毁那个临时性的对象。
// 构造函数内部扩张结果
// C++伪码
Word::Word(/* this pointer goes here*/)
{
// 调用 String 的默认构造函数
_name.String::String();

// 产生临时性对象
String temp = String(0);

// memberwise 地拷贝 _name
_name.String::operator=(temp);

// 摧毁临时性对象
temp.String::~String();

_cnt = 0;
}

一个更明显有效率的实现方法是:

1
2
3
4
5
6
7
8
9
10
11
// 较佳的方式
Word::Word : _name(0) { _cnt = 0; }

// 扩张后
// C++伪码
Word::Word(/* this pointer goes here*/)
{
// 调用 String(int) 构造函数
_name.String::String(0);
_cnt = 0;
}

4.1.1 成员列表初始化中会发生什么?

编译器会一一操作列表初始化,以适当顺序在构造函数之内安插初始化操作,并且在任何显式用户代码之前。且需要注意:列表中的项目顺序是由类中的成员声明顺序决定的,不是由初始化列表中的排列顺序决定的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 由于声明顺序的缘故,成员列表初始化中的 i(j) 其实比 j(val) 更早执行。但因为j一开始未有初值,所以i(j)的执行结果导致i无法预知其值。
class X {
int i;
int j;
public:
X(int val) : j(val), i(j) {}
...
};

// 比较受到喜爱的方式
X::X(int val)
:j(val)
{ i = j; }

// 另外一个常见的问题是,调用一个成员函数设定一个成员的初值
// X::xfoo()被调用,这样好吗?
X::X(int val)
: i(xfoo(val)), j(val)
{}
// 你并不知道xfoo()对X对象的依赖性有多高,如果xfoo()放到构造函数体内,那么对于“到底是哪一个成员在xfoo()执行时被设立初值”这件事,就可以确保不会发生模棱两可的情况。
 评论