Files
912-notes/c++ note/chp13/chp13.md

7.8 KiB

Constructor and Copy Control

目录

  • 构造函数
    • 初始化列表
    • 委派构造函数
    • 转换构造函数
  • 拷贝控制
    • 拷贝构造函数
    • 拷贝赋值运算符
    • 析构函数以及三者之间的联系

构造函数

初始化列表

初始化列表(Constructor Initializer List)其实是我比较常用的,但是我之前的理解有很多误区,这里对它进行一些深入的讨论。

初始化列表的功能与在构造函数体内直接赋值是没有任何区别的,比如说下面用初始化列表的代码:

Sales_data::Sales_data(const string &s, unsigned int cnt, double price): bookNo(s), units_sold(cnt), revenue(cnt * price){}

Sales_data::Sales_data(const string &s, unsigned int cnt, double price){
	bookNo = s;
	units_sold = cnt;
	revenue = price * cnt;
}

在功能上没有任何区别。我之前也是认为两者完全等价,甚至还更加偏向于第二种写法,因为感觉格式要好看一点。实际上,两者在性能上是不相同的。

构造函数的执行顺序,是首先执行初始化列表,继而执行构造函数体内的赋值语句。需要注意的是,即使初始化列表为空,编译器仍然会首先对各个成员变量进行默认的初始化操作,因此相对于第一种版本,第二种版本不仅没有剩下执行初始化列表的时间,还增加了函数体内的变量赋值的开销。如果成员变量是一个较大的类类型的话,两者的时间开销差别还是蛮大的。

因此,应该尽量使用初始化列表来对成员变量进行初始化操作,这样可以获得更优的性能。实际上,在一些情况下,必须使用初始化列表。

对于某一些类型的变量,比如常量变量(const),或者(左值)引用变量,只能进行初始化操作而不能进行赋值操作。如果一个类含有这些成员变量,则必须使用初始化列表对它们进行初始化。此外,如果一个成员变量是没有默认构造函数的类类型,也必须使用初始化列表对它进行初始化。从这里应该可以看出,尽管在多数情况下都可以忽略,但是初始化和赋值其实是两种不同的操作。

初始化列表的执行顺序问题。

初始化列表的执行顺序并非是按列表中变量的先后次序,而是按照变量在类定义的次序。因此,下面的代码是存在问题的:

class X {
private:
	int i;
	int j;
public:
	// undefined: i is initialized before j
	X(int val): j(val), i(j) { }
};

其中,初始化列表中i首先被初始化为j,然后再对j进行初始化,但是对i进行初始化时j还是未定义的,就有可能导致后续的错误。

基于上面的讨论,初始化列表的次序最好与成员变量的定义次序相同,并且最好不要用一个成员变量去初始化另一个成员变量,这样就可以避免可能的错误。比如说下面的代码就不存在初始化列表的执行次序问题了:

X(int val): j(val), i(val){}

委派构造函数

在我写代码的时候,也经常存在这样的需要,即一个构造函数是另一个更加详细的构造函数的子集,或者部分操作。这个时候我就不想全部重新写一遍,看起来也不好看,最好的办法就是在后者中调用前者,这就是委派构造函数(delegating constructor)——这个名字应该是指将一部分工作委派给另一个构造函数完成。

委派构造函数的语法也很简单,只需要在:后面,本来初始化列表的位置调用被委派的构造函数即可。被委派的构造函数(delegated constructor)的初始化列表和函数体都会相继被执行,之后再执行委派构造函数(delegating constructor)的剩余部分。下面的代码就是委派构造函数的一个例子:

class Sales_data {
public:
	// nondelegating constructor initializes members from corresponding arguments
	Sales_data(std::string s, unsigned cnt, double price): bookNo(s), units_sold(cnt), revenue(cnt*price) {}

	// remaining constructors all delegate to another constructor
	Sales_data(): Sales_data("", 0, 0) {}
	Sales_data(std::string s): Sales_data(s, 0,0) {}
	Sales_data(std::istream &is): Sales_data(){ read(is, *this); }
};

转化构造函数

转化构造函数是一类特殊的构造函数,它只含有一个参数,参数的类型可以是类本身(此时就是拷贝构造函数了,将在后面提到),也可以是其他类型。它的特殊性在于,转化构造函数定义了隐式的类型转化方法,即从参数的类型转化到当前的类类型。

比如说我可以定义一个Mystring类型,它基本沿用std::string的基本方法,但是增加一个构造函数,将输入的整型转化为对应的字符串,如下:

class Mystring{
public:
	string str;

	Mystring() = default;
	//converting constructor
	Mystring(int num): str(to_string(num)){}
}

这样,在任何期望一个Mystring类型变量的位置,都可以传入一个int类型,编译器会自动调用上面定义的转换构造函数,从该int类型构造出一个Mystring类对象。

例如存在一个外部函数,接收一个Mystring类型的对象作为参数,打印出其中的字符串信息:

void print(Mystring mystr){
	cout << mystr.str << endl;
}

print(2020);//this call is perfectly legal

完全可以对该函数传入一个整型的变量,此时编译器会自动调用转换构造函数,从该整型变量构造出一个Mystring类型变量,作为函数的参数。实际上,在Mystring对象初始化的时候,还可以采用下面的方式:

Mystring mystr = 2020;//copy initialization

这种初始化方式称为拷贝初始化(copy initialization),与直接初始化存在一些区别。在上面的语句中,编译器也会调用转化构造函数,因此该语句与

Mystring mystr(2020);//direct initialization

这样的直接初始化完全等效。与上面类似,甚至可以直接使用int型变量对Mystring对象进行赋值。

从上面也可以看出,转化构造函数是存在一些隐患的,比如说Mystring str = 2020;这样的语句看起来多少让人看起来有一些迷惑。为了避免这种歧义性,可以手动避免转化构造函数的隐式类型转化,只需要在转化构造函数的声明前面添加explicit关键字即可。

class Mystring{
public:
	string str;
	explicit Mystring(int num): str(to_string(num)){}
	//other functions

这样,就可以避免隐式类型转化了。如果需要将int转化为Mystring类型,就必须显式调用(explicit)转化构造函数才行。实际上,之前我已经多次遇到转化构造函数了,只是我当时并不知道而已。

例如std::string存在一个将const char*转化为string的构造函数,因此存在下面的string初始化与函数调用语句:

string str = "Study hard tomorrow.";  //legal
int num    = stoi("2020");            //legal, implicit convert "2020" to string.

同理,vector类中存在一个构造函数,可以指定vector的初始大小,传入的参数是一个int变量。但是并不可以这样初始化vector:

vector<int> iVec = 2020;              //illegal, this constructor is explicit
vector<int> iVec(2020);               //legal, explicit initialization

用一个整型变量给vector赋值,这看起来也太怪了,存在不少的歧义性,因此这个构造函数被标记为explicit了。

需要注意的是,explicit关键字只能出现在类构造函数声明的位置,如果该构造函数在类声明体外部被定义,则不能再次添加explicit关键字了。

//error: explicit allowed only on a constructor declaration in a class header
explicit Mystring::Mystring(int num){
	...
}