前言

继前两篇总结了C++对象模型及其内存布局后,我们继续来探索一下C++对象的默认构造函数。对于C++的初学者来说,有如下两个误解:

  • 任何class如果没有定义default constructor,就会被合成出来
  • 编译器合成出来的default constructor会显示设定“class内每一个data member的默认值“

如果读者对这两句话理解颇深,了解里面的陷阱,那么可以不必阅读下去;倘若你有一点点疑惑,那非常好,跟着我一起继续下去!

无用(trivial)的构造函数

一如前两篇的风格,先以一个例子来抛出一些疑惑,让读者带着问题来阅读,想来会事半功倍。如下面的程序代码,我定义了一个Cat类,里面包含两个参数,一个int型变量age和一个char型指针name。

class Cat{
public:
	int age;
	char* name;
};

然后,在运行如下的测试代码:

int main(){
	Cat cat;
	cout<<“age=”<<cat.age<<endl;
	cout<<"name="<<(long long*)cat.name<<endl;
}

上述测试代码输出如下:

age=4196864		
name=0x400800

我们来看看输出,age和name输出的都是一些随机值。在分析输出之前,先理解如下两个概念

  • ”程序员的需要“:程序员希望默认的构造函数能初始化类实例中所有的data members
  • “编译器的需要”:保证在创建类的实例的时候,存在一个构造函数,使得实例能创建,程序能正常运行。

如果按照程序员的需要,age和name会被初始化,可是编译器并没有那么智能,猜不透程序员的想法,它给定的输出就是一堆随机值。

类似于这种编译器并没有按照”程序员的需要“对每个成员变量进行初始化,而是仅仅满足了”编译器的需要“,而默认生成的构造函数就被称为trivial constructor。

那么,编译器会在什么时候能按照”程序员的需要“,对类里面的data members初始化呢?请继续往下看。

有用(non-trivial)的构造函数

所谓的non-trivial constructor就是:编译器在合成构造函数的时候会对类中的data mambers初始化,从而满足”程序员的需求”。还是那个疑问,什么时候编译器会自动合成一个这样的构造函数呢?下面分四种情况来一一说明。

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

如果一个类没有任何constructor,但它内含一个member object,后者还有一个default constructor,那么,编译器就会自动合成一个non-trivial constructor函数,不过这个合成操作只有在构造函数真正需要被调用的时候才合成。观察以下代码:

class Cat{
public:
	Cat():age(0){
		cout<<"Cat has been initialized"<<endl;
	}
    int age;
};
class Animal{
public:
	Cat cat;
};
int main(){
	Animal animal;
}

在Animal类中声明了一个Cat实例,Cat类中有自己的空构造函数。接着我们可以运行一下这段代码,输出如下:

Cat has been initialized //cat类对象被构造

从输出中可以看出,我们在main函数中声明了一个animal实例,然后animal类构造出了cat实例。可见,如果类的成员有其默认的构造函数,编译器就会合成一个non-trivial constructor,合成的空构造函数应该如下所示:

Animal(){
	Cat::Cat();
}

如果一个类内含一个或多个成员类对象,那么编译器为这个类合成的默认构造函数会一次调用成员类的空构造函数,其调用顺序是声明顺序决定的。那么,如果类自己定义了空的构造函数怎么办?很简单,编译器会将这些成员类的构造代码安插在空构造函数里面,其摆放顺序也是按照声明顺序。

如以下测试代码:

class Dog{
public:
	Dog(){
		cout<<"Dog has been initialized"<<endl;
	}
};
class Cat{
public:
	Cat():age(0){
		cout<<"Cat has been initialized"<<endl;
	}
    int age;
};
class Animal{
public:
	Animal(){weight=0;}//按照声明顺序,cat和dog的构造会在weight之前
	Cat cat;
	Dog dog;
	int weight;
};
int main(){
	Animal animal;
}

该测试代码的输出如下:

Cat has been initialized //先构造cat类对象,因为声明顺序在前
Dog has been initialized //再构造dog类对象

带有默认构造函数的基类

如果一个派生类没有任何自定义构造函数,但它的基类有默认的构造函数,那么派生类中默认合成的构造函数中会安插基类的构造函数代码(按照声明顺序)。

如下例所示:

class Animal{
public:
	Animal(){
		cout<<"Animal has been initialized"<<endl;
	}
};

class Cat : public Animal{
public:
	
};
int main(){
	Cat cat;
}

输出如下:

Animal has been initialized //父类构造函数的输出

输出结果显示子类合成的构造函数调用了父类的空构造函数。在继承关系中,我们知道构造顺序是先构造父类,如果有多个父类就按照声明的继承顺序依次构造,然后在构造子类。编译器在父类拥有空构造函数的情况下,会合成一个non-trivial constructor并在里面按顺序安插父类的空构造函数调用代码。

那如果子类声明了多个构造函数,唯独没有空构造函数怎么办呢?编译器会合成一个空的构造函数吗?答案是否定的,编译器不会为它合成空构造函数,而是将父类的构造函数或者类对象的空构造函数依次安插在每个声明的构造函数内。

上面一堆话可能比较绕口,举个小例子就很好理解了,请看下面的代码:

class Dog{
public:
	Dog(){
		cout<<"Dog has been initialized"<<endl;
	}
};

class Animal{
public:
	Animal(){
		cout<<"Animal has been initialized"<<endl;
	}
};

class Cat : public Animal{//Cat类继承于Animal类,并在类里面声明了dog类的实例
public:
	Dog dog;
	Cat(int a){//Cat类里面没有空构造函数
		age = a;
	}
private:
	int age;
};

int main(){
	//Cat cat;	//如果这样写的话,编译器会报错,因为编译器根本没有为Cat类合成空构造函数
	Cat cat(1);
}

上述代码的输出如下:

Animal has been initialized  //先构造基类
Dog has been initialized	//再构造类中声明的类对象

所以,我们可以理一下编译器安插的构造函数的顺序:

  • 如果有基类,且基类有空构造函数,就按照基类的继承顺序依次构造
  • 如果声明的成员变量有空构造函数,就按照声明顺序依次构造

带有一个虚函数的类

C++对象模型的那些事儿(一)中讲到,如果类含有虚函数,那么编译器会生成一个虚指针,虚指针指向含有虚函数地址的虚表。因此,如果一个类还有虚函数的话,那么编译器自然会生成一个空的构造函数,用来初始化虚指针。

于是,下面两个扩张行动就会在编译期间发生:

  • 一个虚函数表会被编译器产生出来,里面存放这虚函数的地址
  • 在每一个类对象中,一个额外的虚表指针会被编译器合成出来,其指向上述生成的虚表
typedef void(*Func)(void);
class Animal{
public:
	virtual void eat(){
		cout<<"Animal eat"<<endl;
	}
};

int main(){
	Animal animal;//调用空构造函数
	Fun pfun1 = (Fun)*((long long*)*(long long*)(&animal));
	pfun1();//执行函数,输出Animal eat
}

在上例中,main函数中定义一个Animal类的对象animal,调用了编译器合成的空构造函数,接下来我们通过强制类型转换,首先提取虚表指针的地址,然后提取虚表的地址,将虚表的第一个函数赋给一个函数指针,最后运行该函数,输出Animal eat。因此,编译器确实按照上述两个步骤合成了一个non-trivial的空构造函数。

带有一个虚基类的子类

虚基类的实现方法有点复杂,我们首先来看看下面这个例子:

class Animal{ 
public:
	int weight;
};

class Livestock : public virtual Animal{
public: 
	int color;
};

class Canidae : public virtual Animal{
public:
	int age;
};
class WatchDog :  public  Canidae , public  Livestock{
public:
	int breed;	
};

其中,Animal是一个基类,Livestock和Candiae类虚继承于Animal类,WatchDog类继承于Livestock和Candiae类。基于上述继承关系,我们执行一下测试代码;

void setWeight(const Animal* a){//运行时绑定。在编译期间无法决定a->Animal::weight的位置
	a->weight = 100;
}
int main(){
	setWeight(new Livestock);
	setWeight(new WatchDog);
}

针对上述测试代码,编译器无法固定setWeight()中的a->weight的实际偏移位置,因为a的真正类型都没有确定,所以必须是在确定传入的指针类型之后才能确定,也称为运行时绑定。

所有经由引用或者指针来存取一个虚基类的操作都可以通过指针来完成,那么,这个虚基类中weight变量的位置是如何确定的呢?想必你心里已有答案,没错

  • 对于类中所有定义的每一个构造函数,编译器会安插那些“允许每一个虚基类的执行器存取操作”(如上述的a->weight)的代码
  • 如果类没有任何构造函数,编译器必须为其合成一个空构造函数

总结&结束语

本文通过测试代码一一测试了编译器在什么时候会产生trivial和non-trivial构造函数。现做如下整理。

生成non-trivial构造函数的情况:

  • 带有默认构造函数的成员类对象
  • 带有默认构造函数的基类
  • 带由虚函数的类
  • 带有虚基类的类

除这四种情况外,并且没有声明任何构造函数的类,编译器会在“需要的时候”为其构造一个隐式的trivial构造函数,它们实际上并不会被合成出来。

这里的“需要的时候”只需要用到空构造函数的时候,如调用空构造函数构造一个类对象。如果没有用到空构造函数的时候,是不会被合成出来的

在合成的空构造函数中,只有基类子对象和成员类对象会被初始化。所有其他的nonstatic data member(如int,int*,int数组等)都不会被初始化。这些初始化虽然对程序而言或许有需要,但对编译器则非必要。

所以,请注意!如果你需要把某指针初始化为null这类操作,最好老老实实自己写构造函数来初始化,不要指向编译器能帮你干这些事!

现在!请回过头去看前言中的两句话,是不是突然就意识到没有一个是对的。

About Me

由于本人也是初学,在写作过程中,难免有错误的地方,读者如果发现,请在下面留言指出。

最后,如有疑惑或需要讨论的地方,可以联系我,联系方式见我的个人博客about页面,地址:About Me

另外,本人的第一本gitbook书已整理完,关于leetcode刷题题解的,点此进入One day One Leetcode

欢迎持续关注!Thx!