深度探索C++对象模型 学习笔记 第二章 构造函数语意学
很多人抱怨说C++背着程序员做了太多事,如:
if (cin) { /* ... */ }
为了让cin能转换为真假值,为cin定义一个类型转换运算符operator int(),就可以完成以上工作了,但以下行为:
cin << intVal;
相当于左移一个int。可以用operator void *()取代operator int()来避免这种情况。而将类型转换运算符声明为explicit的也可以避免以上情况。
什么时候需要默认构造函数:
class Foo {
public:
int val;
Foo *pnext;
};
void foo_bar() {
Foo bar;
if (bar.val || bar.pnext) { // 程序要求bar的两个成员都为0
/* .... */
}
}
上例的foo_bar函数需要默认构造函数将两个成员都赋值为0,但此时并不是需要默认构造函数的时机,因为这是程序的需要,而非编译器的需要,此处应由程序员保证。
ISO-C++95中规定,当一个类没有用户定义的构造函数时,会生成一个默认构造函数。这个默认构造函数是用处不大的。
若一个类有一个类类型成员,且该成员有默认构造函数,则类的合成的默认构造函数有些用处,这个合成操作只有在构造函数真正需要被调用时才发生,那么在不同的文件中编译器如何避免合成出多个默认构造函数造成重复定义呢,解决方法是把默认构造函数、拷贝构造函数、析构函数、拷贝赋值运算符都以inline方式完成,一个inline函数有静态链接,不会被文件以外的人看到,如果函数太复杂,不适合做成inline的,就会合成一个非inline static实体。如以下程序:
class Foo {
public:
Foo();
Foo(int);
/* ... */
};
class Bar {
public:
Foo foo;
char *str;
/* ... */
};
void foo_bar() {
Bar bar; // Bar::foo在此初始化
/* ... */
}
Bar的合成的默认构造函数含必要的代码,能调用class Foo的默认构造函数来构造Bar::foo成员,但不产生代码来初始化Bar::str。Bar合成的默认构造函数类似于:
inline Bar::Bar() {
foo.Foo::Foo();
}
上例合成的默认构造函数只满足编译器的需要,而非程序的需要。
Bar类对象中的str成员的初始化是程序员的任务,用户定义的默认构造函数可能如下:
Bar::Bar() {
str = 0;
}
此时str被初始化了,但此时由于已经定义了一个构造函数,因此编译器不会合成默认构造函数,此时,对于类类型成员,编译器会调用其默认构造函数初始化它,如果有多个类类型成员需要默认构造函数初始化,C++会以他们的声明顺序调用各个类类型成员的默认构造函数,这些调用构造函数的代码会隐式地插入到用户写的代码前。
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;
};
上述代码中编译器会为Snow_White类合成一个默认构造函数,此默认构造函数依次调用Dopey、Sneezy、Bashful的默认构造函数,如果用户定义了如下的默认构造函数:
Snow_White::Snow_White() : sneezy(1024) {
mumble = 2048;
}
编译器会将其扩张为:
Snow_White::Snow_White() : sneezy(1024) {
dopey.Dopey::Dopey();
sneezy.Sneezy::Sneezy(1024);
bashful.Bashful::Bushful();
mumble = 2048;
}
如果一个定义了默认构造函数的基类派生出一个没有定义默认构造函数的派生类,派生类会合成一个默认构造函数,他将调用上一层基类的默认构造函数(如有多个基类,按基类声明的顺序调用)。派生类会在每个构造函数中隐式地扩张出基类的构造函数调用语句,基类的构造函数在派生类的成员被构造之前被执行。此时默认构造函数也是编译器的需要,此默认构造函数是有些用处的。
以下情况也是编译器需要合成默认构造函数,这时的默认构造函数是有些用处的:
1.声明或继承一个虚函数。
2.类派生自一个继承链,其中有一个或多个虚基类。
如以下程序:
class Widget {
public:
virtual void flip() = 0;
};
void flip(const Widget &widget) {
widget.flip();
}
void foo() {
Bell b; // Widget的派生类
Whistle w; // Widget的派生类
flip(b);
flip(w);
}
对以上程序编译器会扩张出以下操作:
1.编译器产生一个虚函数表,内放类的虚函数地址。
2.每个类对象中,会生成一个额外的指针成员(vptr),指向相关的类虚表地址。
此外,widget.flip();
会被改写,从而使用widget对象内含的vptr和虚函数表中的flip方法:
(*widget.vptr[1])(&widget); // 点运算符优先级高于解引用,因此含义为取flip方法的地址,且传给flip函数widget对象的地址
其中:
1.1表示flip方法在虚表中的固定索引。
2.&widget代表要交给某个被调用的flip方法的this指针。
为了让这个机制生效,编译器要为每个Widget和其派生类对象的vptr指针设定初值为适当的虚表地址。对类定义的每个构造函数,编译器都会安插一些代码这么做。对未声明任何构造函数的类,编译器会合成一个默认构造函数以正确初始化每个类对象的vptr指针,此时的默认构造函数是编译器需要的,是有作用的。
对于虚基类,实现多样,但共同点在于必须使虚基类在其每一个派生类对象中的位置能够在执行期(对应于编译期)准备好,如:
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;
};
void foo(const A *pa) {
pa->i = 1024; // 无法确定pa->X::i在对象中的实际偏移位置,因为pa的类型可以改变
}
int main() {
foo(new A);
foo(new C);
}
如上,编译器必须改变执行存取操作的代码,使X::i的位置可以延迟至执行期才决定下来,在cfront中的做法是在派生类对象的每个虚基类上安插一个指针,所有存取同一个虚基类的操作都用相关指针完成,以上foo函数可改写为:
void foo(const A *pa) {
pa->__vbcX->i = 1024; // 成员__vbcX指向虚基类X
}
__vbcX成员是类对象建构期间被完成的,对于类定义的每个构造函数,编译器会安插允许每一个虚基类可以执行期进行存取操作的代码。
以上四种情况下默认构造函数有用处,这些情况下默认构造函数被合成是因为编译器有需要而非程序有需要。其余情况下的默认构造函数是没什么用处的,因此实际上并不会被合成出来。
以一个对象内容作为另一个类对象的初值有三种情况,最明显的是对一个类做明确的初始化操作:
class X { ... };
X x1;
X x2 = x1;
另两种情况是当对象被当做参数交给某个函数时和对象被返回时:
extern void foo(X x);
void bar() {
X xx;
foo(xx); // 以xx作foo形参的初值
}
X foo_bar() {
X xx;
return xx; // 传回一个类对象时
}
如类设计者定义了一个拷贝构造函数:
X::X(const X &x);
Y::Y(const Y &y, int = 0);
那么,大部分情况下,当类对象以另一同类对象拷贝初始化时会调用以上函数。这可能会导致一个该类临时对象的产生或程序代码的转换(个人理解是函数调用,当拷贝构造函数定义在类外,即非inline函数时),或两者都有。
如果类没有显式定义一个拷贝构造函数,合成的拷贝构造函数将每个数据成员拷贝到左侧运算对象中,对成员类对象,不会拷贝,而是递归地对每一个成员初始化:
class String {
public:
// 没有定义拷贝构造函数
private:
char *str;
int len;
};
String noun("book");
String verb = noun;
以上verb初始化就像:
// 语意相等
verb.str = noun.str;
verb.len = noun.len;
如果一个String对象被声明为另一个类的成员:
class Word {
public:
// 没有显式的拷贝构造函数
private:
int _occurs;
String _word;
};
如上,如果此时发生赋值操作,此时word类默认会进行对每个成员初始化,这会拷贝其内置类型成员_occurs,对于String对象成员,会使用String的拷贝构造函数。
拷贝构造函数也是在必要的时候才会由编译器产生出来,必要的时候指不是简单地把右侧运算对象按每一位复制给左侧运算对象,而是需要通过调用拷贝构造函数将右边的运算对象传给左侧运算对象。
和默认构造函数一样,拷贝构造函数也分没什么用处的和有用处的,只有有用处的时候才会在程序中真正合成拷贝构造函数。
按位逐次拷贝指将一个对象中的每一位复制到另一个对象中,包括其中的内置成员和类类型成员,此时如果类类型成员有自己的资源,会出现两个对象共用一个资源的情况。
当一个类不展现出按位逐次拷贝语意时,拷贝构造函数是有作用的,此时会合成一个拷贝构造函数实体,如上例Word类的合成的拷贝构造函数用C++伪码表示如下:
inline Word::Word(const Word &wd) {
str.String::String(wd.str);
cnt = wd.cnt;
}
以下情况类不展现出按位逐次拷贝语意:
1.当一个类有类类型成员,且类类型成员有拷贝构造函数时(不管是显式声明的还是编译器合成的)。
2.当类继承自一个有拷贝构造函数的基类时(不管是显式声明的还是编译器合成的)。
3.但类中有虚函数时。
4.当类派生自一个继承串链,其中有虚基类时。
有如下继承关系:
做出以下操作时:
Bear yogi;
Bear winnie = yogi;
以上代码中,yogi使用默认构造函数完成初始化,yogi的vptr指向Bear类的虚表(靠编译器安插的码完成),因此,把yogi的vptr值拷贝给winnie的vptr是安全的:
而当基类用派生类对象初始化时:
ZooAnimal franny = yogi;
franny的vptr不能指向Bear类的虚表,而应该指向ZooAnimal类的虚表,即使franny是以yogi作为初值初始化的:
也就是说,ZooAnimal的拷贝构造函数会明确设定对象的vptr指向ZooAnimal的虚表,而非直接从右手边的类对象中将其vptr值拷贝过来。此时会使类的按位逐次拷贝语意失效。
当一个类对象以另一个有虚基类的类为初值时,也会使按位逐次拷贝语意失效。对于虚基类,每个含该虚基类的对象都应在执行期前就定下该虚基类在对象中的位置,而按位逐次拷贝语意可能会破坏这个位置。
如以下继承关系:
类Raccoon定义:
class Raccoon : public virtual ZooAnimal {
public:
Raccoon() { /* 设定private data初值 */ }
Raccoon(int val) { /* 设定private data初值 */ }
private:
// 所有必要数据
};
编译器会在Raccoon类的两个构造函数中插入代码调用ZooAnimal的默认构造函数并将Raccoon的vptr初始化,此代码在构造函数最开始的地方被插入。
以下是RedPanda类的声明:
class RedPanda : public Raccoon {
public:
RedPanda() { /* 设定private data初值 */ }
RedPanda(int val) { /* 设定private data初值 */ }
private:
// 所有必要数据
}
对于虚基类使按位逐次拷贝语意失效不发生在一个类对象以另一个同类对象作为初值时,而是发生在一个类对象以它的派生类对象作为初值时,如让Raccoon对象以一个ReaPanda对象作为初值时,此时编译器必须保证程序员企图存取ZooAnimal子对象时能正确执行,因此编译器必须合成一个拷贝构造函数,安插一些码来设定虚基类指针或偏移的初值。
以下情况编译器无法知道是否按位逐次拷贝语意是否够用:
Raccoon *ptr;
Raccoon little_critter = *ptr; // 不知道ptr实际指向的对象类型
C++标准说把一个类对象当做参数传给一个函数或作为一个函数的返回值,相当于以下形式的初始化操作:
X xx = arg;
编译器层面优化返回值,将以下代码:
X bar() {
X xx;
return xx; // 会调用X类的拷贝构造函数将值传回来
}
优化为:
void bar(X &__result) { // __result为返回值
__result.X::X();
// 处理__result
return; // 不用调用拷贝构造函数将值传回来
}
这样的编译器优化操作被称为NRV(Named Return Value)优化,这是C++编译器的一个义不容辞的优化,虽然它的需求超越了正式标准,但改善了效率,比如以下代码:
class test {
friend test foo(double);
public:
test() {
memset(array, 0, sizeof(double) * 100);
}
private:
double array[100];
};
test foo(double val) {
test local;
local.array[0] = val;
local.array[99] = val;
return local;
}
int main() {
for (int cnt = 0; cnt < 10000000; ++cnt) {
test t = foo(cnt);
}
return 0;
}
上述程序不能实施NRV优化,因为test类没有拷贝构造函数,只有增加了拷贝构造函数才能激活C++编译器的NRV优化(但现在有些编译器无论有没有拷贝构造函数都能激活NRV优化):
inline test::test(const test &t) {
memcpy(this, &t, sizeof(test));
}
NRV优化效率:
NRV提供了效率的提升,但还是饱受批评,因为优化是由编译器完成的,至于它是否真的完成,并不清楚,很少有编译器说明其实现程度或是否实现。并且当函数变复杂时,优化变得难以施行,如cfront中只有top level的return才会优化,嵌套的局部return语句不会实施NRV优化。
以下三个初始化在语意上相等:
X xx0(1024);
X xx1 = X(1024);
X xx2 = (X)1024;
但在第二行和第三行中,分别产生了一个临时对象,并且在赋值结束后,还调用了析构函数。
实现一个三维点类的拷贝构造函数的最简单方法:
Point3d::Point3d(const Point3d &rhs) {
_x = rhs._x;
_y = rhs._y;
_z = rhs._z;
}
使用C++库的memcpy函数更有效率:
Point3d::Point3d(const Point3d &rhs) {
memcpy(this, &rhs, sizeof(Point3d));
}
无论是用memset还是memcpy函数,都只有在不含任何由编译器产生的内部成员(如vptr指针)时才能有效运行,但如果类中有虚函数、虚基类,那么上述两个函数会直接复制这些内部成员值,如:
class Shape {
public:
Shape() {
memset(this, 0, sizeof(Shape));
}
virtual ~Shape();
}
实际编译器扩张后的源码为:
// C++伪码
Shape::Shape() {
__vptr__Shape = __vtbl__Shape; // 设置虚表指针
memset(this, 0, sizeof(Shape)); // 覆盖了虚表指针内容
}
以下情况必须使用构造函数初始值列表:
1.初始化一个引用成员。
2.初始化一个const成员。
3.调用一个基类构造函数,并且它有一组参数。
4.调用一个成员类的对象的构造函数,并且它有一组参数。
class X {
int i;
int j;
public:
X(int val);
int xfoo();
};
X::X(int val) : i(xfoo(val)), j(val) { }
在初始化列表中使用X的成员函数xfoo是可以的,但最好不要在初始化列表中使用另一成员初始化成员i,因为可能会由于初始化顺序而出错,而只有在我们知道调用xfoo(因为它可能改变其他数据成员)在逻辑上没错时才能这样做。
如果在派生类的构造函数中使用派生类的成员函数的返回值为参数在构造函数初始化列表中调用基类构造函数:
class FooBar : public X {
int _fval;
public:
int fval() {
return _fval;
}
FooBar(int val) : _fval(val), X(fval()) { }
};
我们知道派生类会先调用基类的构造函数,但这个基类构造函数会调用派生类的成员,但派生类此时还没创建。编译器会对初始化列表处理并可能重新排序,以完成上述代码功能。最好不这么做。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· winform 绘制太阳,地球,月球 运作规律
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)