【C++基础】第四十五课:[类]构造函数再探

构造函数初始值列表,委托构造函数,默认构造函数的作用,转换构造函数,隐式的类类型转换,explicit,聚合类,字面值常量类,constexpr构造函数

Posted by x-jeff on July 13, 2022

【C++基础】系列博客为参考《C++ Primer中文版(第5版)》C++11标准)一书,自己所做的读书笔记。
本文为原创文章,未经本人允许,禁止转载。转载请注明出处。

1.构造函数初始值列表

如果没有在构造函数的初始值列表中显示地初始化成员,例如:

1
Sales_data(const std::string &s, unsigned n, double p) : bookNo(s), units_sold(n), revenue(p*n) { }

则该成员将在构造函数体之前执行默认初始化。例如:

1
2
3
4
5
6
7
//Sales_data构造函数的一种写法,虽然合法但比较草率:没有使用构造函数初始值
Sales_data::Sales_data(const string &s, unsigned cnt, double price)
{
	bookNo = s;
	units_sold = cnt;
	revenue = cnt * price;
}

这两种方式的效果是相同的:当构造函数完成后,数据成员的值相同。区别是上面的版本初始化了它的数据成员,而下面的版本是对数据成员执行了赋值操作。这一区别到底会有什么深层次的影响完全依赖于数据成员的类型。

1.1.构造函数的初始值有时必不可少

有时我们可以忽略数据成员初始化和赋值之间的差异,但并非总能这样。如果成员是const或者是引用的话,必须将其初始化。类似的,当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员初始化。例如:

1
2
3
4
5
6
7
8
class ConstRef {
public:
    ConstRef(int ii);
private:
    int i;
    const int ci;//此处不初始化是可以的
    int &ri;//此处不初始化是可以的
};

和其他常量对象或者引用一样,成员ci和ri都必须被初始化。因此,如果我们没有为它们提供构造函数初始值的话将引发错误:

1
2
3
4
5
6
7
//错误:ci和ri必须被初始化
ConstRef::ConstRef(int ii)
{//赋值:
	i = ii; //正确
	ci = ii; //错误:不能给const赋值
	ri = i; //错误:ri没被初始化
}

随着构造函数体一开始执行,初始化就完成了。我们初始化const或者引用类型的数据成员的唯一机会就是通过构造函数初始值,因此该构造函数的正确形式应该是:

1
2
//正确:显式地初始化引用和const成员
ConstRef::ConstRef(int ii) : i(ii), ci(ii), ri(i) { }

如果成员是const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值。

在很多类中,初始化和赋值的区别事关底层效率问题:前者直接初始化数据成员,后者则先初始化再赋值。

1.2.成员初始化的顺序

显然,在构造函数初始值中每个成员只能出现一次。否则,给同一个成员赋两个不同的初始值有什么意义呢?

不过让人稍感意外的是,构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。

成员的初始化顺序与它们在类定义中的出现顺序一致:第一个成员先被初始化,然后第二个,以此类推。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。

一般来说,初始化的顺序没什么特别要求。不过如果一个成员是用另一个成员来初始化的,那么这两个成员的初始化顺序就很关键了。举个例子:

1
2
3
4
5
6
7
class X {
	int i;
	int j;
public:
	//未定义的:i在j之前被初始化
	X(int val) : j(val), i(j) { }
};

在此例中,从构造函数初始值的形式上来看仿佛是先用val初始化了j,然后再用j初始化i。实际上,i先被初始化,因此这个初始值的效果是试图使用未定义的值j初始化i!

最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其他成员。

1.3.默认实参和构造函数

1
2
3
4
5
6
class Sales_data {
public:
	//定义默认构造函数,令其与只接受一个string实参的构造函数功能相同
	Sales_data(std::string s = "") : bookNo(s) { }
	//其他构造函数与之前一致
};

2.委托构造函数

C++11新标准扩展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数(delegating constructor)。一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数。

和其他构造函数一样,一个委托构造函数也有一个成员初始值的列表和一个函数体。在委托构造函数内,成员初始值列表只有一个唯一的入口,就是类名本身。和其他成员初始值一样,类名后面紧跟圆括号括起来的参数列表,参数列表必须与类中另外一个构造函数匹配。

举个例子,我们使用委托构造函数重写Sales_data类,重写后的形式如下所示:

1
2
3
4
5
6
7
8
9
10
class Sales_data {
public:
	//非委托构造函数使用对应的实参初始化成员
	Sales_data(std::string s, unsigned cnt, double price) : bookNo(s), units_sold(cnt), revenue(cnt*price) { }
	//其余构造函数全都委托给另一个构造函数
	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); }
	//其他成员与之前的版本一致
};

在这个Sales_data类中,除了一个构造函数外其他的都委托了它们的工作。需要注意的是,接受istream&的构造函数也是委托构造函数,它委托给了默认构造函数,默认构造函数又接着委托给三参数构造函数。

当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行。在Sales_data类中,受委托的构造函数体恰好是空的。假如函数体包含有代码的话,将先执行这些代码,然后控制权才会交还给委托者的函数体。

3.默认构造函数的作用

当对象被默认初始化或值初始化时自动执行默认构造函数。默认初始化在以下情况下发生:

  • 当我们在块作用域内不使用任何初始值定义一个非静态变量或者数组时。
  • 当一个类本身含有类类型的成员且使用合成的默认构造函数时。
  • 当类类型的成员没有在构造函数初始值列表中显式地初始化时。

值初始化在以下情况下发生:

  • 在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时。
  • 当我们不使用初始值定义一个局部静态变量时。
  • 当我们通过书写形如T( )的表达式显式地请求值初始化时,其中T是类型名(vector的一个构造函数只接受一个实参用于说明vector大小,它就是使用一个这种形式的实参来对它的元素初始化器进行值初始化)。

类必须包含一个默认构造函数以便在上述情况下使用,其中的大多数情况非常容易判断。

不那么明显的一种情况是类的某些数据成员缺少默认构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
class NoDefault {
public: 
	NoDefault(const std::string&);
	//还有其他成员,但是没有其他构造函数了
};
struct A { //默认情况下my_mem是public的
	NoDefault my_mem;
};
A a; //错误:不能为A合成构造函数
struct B {
	B() { } //错误:b_member没有初始值
	NoDefault b_member;
};

在实际中,如果定义了其他构造函数,那么最好也提供一个默认构造函数。

3.1.使用默认构造函数

下面的obj的声明可以正常编译通过:

1
2
Sales_data obj(); //正确:定义了一个函数而非对象
if (obj.isbn() == Primer_5th_ed.isbn()) //错误:obj是一个函数

但当我们试图使用obj时,编译器将报错,提示我们不能对函数使用成员访问运算符。问题在于,尽管我们想声明一个默认初始化的对象,obj实际的含义却是一个不接受任何参数的函数并且其返回值是Sales_data类型的对象。

如果想定义一个使用默认构造函数进行初始化的对象,正确的方法是去掉对象名之后的空的括号对:

1
2
//正确:obj是个默认初始化的对象
Sales_data obj;

对于C++的新手程序员来说有一种常犯的错误,它们试图以如下的形式声明一个用默认构造函数初始化的对象:

1
2
3
4
Sales_data obj(); //错误:声明了一个函数而非对象
Sales_data obj2; //正确:obj2是一个对象而非函数

Sales_data a("S"); //正确,调用一个非默认的构造函数,a是一个对象

4.隐式的类类型转换

如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称作转换构造函数(converting constructor)

在Sales_data类中,接受string的构造函数和接受istream的构造函数分别定义了从这两种类型向Sales_data隐式转换的规则。也就是说,在需要使用Sales_data的地方,我们可以使用string或者istream作为替代:

1
2
3
4
string null_book = "9-999-99999-9";
//构造一个临时的Sales_data对象
//该对象的units_sold和revenue等于0,bookNo等于null_book
item.combine(null_book);

在这里我们用一个string实参调用了Sales_data的combine成员。该调用是合法的,编译器用给定的string自动创建了一个Sales_data对象。新生成的这个(临时)Sales_data对象被传递给combine。因为combine的参数是一个常量引用,所以我们可以给该参数传递一个临时量。

1
2
3
4
Sales_data item;
string a = "abc";
item.combine(a); //正确
cout<<a.bookNo<<endl; //错误

4.1.只允许一步类类型转换

‼️编译器只会自动地执行一步类型转换。例如,因为下面的代码隐式地使用了两种转换规则,所以它是错误的:

1
2
3
4
//错误:需要用户定义的两种转换:
//(1)把"9-999-99999-9"转换成string
//(2)再把这个(临时的)string转换成Sales_data
item.combine("9-999-99999-9");

如果我们想完成上述调用,可以显式地把字符串转换成string或者Sales_data对象:

1
2
3
4
//正确:显式地转换成string,隐式地转换成Sales_data
item.combine(string("9-999-99999-9"));
//正确:隐式地转换成string,显式地转换成Sales_data
item.combine(Sales_data("9-999-99999-9"));

4.2.类类型转换不是总有效

另一个是从istream到Sales_data的转换:

1
2
//使用istream构造函数创建一个函数传递给combine
item.combine(cin);

这段代码隐式地把cin转换成Sales_data,这个转换执行了接受一个istream的Sales_data构造函数。该构造函数通过读取标准输入创建了一个(临时的)Sales_data对象,随后将得到的对象传递给combine。

Sales_data对象是个临时量,一旦combine完成我们就不能再访问它了。实际上,我们构建了一个对象,先将它的值加到item中,随后将其丢弃。

4.3.抑制构造函数定义的隐式转换

在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为explicit加以阻止:

1
2
3
4
5
6
7
8
class Sales_data {
public:
	Sales_data() = default;
	Sales_data(const std::string &s, unsigned n, double p) : bookNo(s), units_sold(n), revenue(p*n) { }
	explicit Sales_data(const std::string &s) : bookNo(s) { }
	explicit Sales_data(std::istream&);
	//其他成员与之前的版本一致
};

此时,没有任何构造函数能用于隐式地创建Sales_data对象,之前的两种用法都无法通过编译:

1
2
item.combine(null_book); //错误:string构造函数是explicit的
item.combine(cin); //错误:istream构造函数是explicit的

⚠️关键字explicit只对一个实参的构造函数有效。需要多个实参的构造函数不能用于执行隐式转换,所以无须将这些构造函数指定为explicit的。只能在类内声明构造函数时使用explicit关键字,在类外部定义时不应重复:

1
2
3
4
5
//错误:explicit关键字只允许出现在类内的构造函数声明处
explicit Sales_data::Sales_data(istream& is)
{
	read(is, *this);
}

4.4.explicit构造函数只能用于直接初始化

👉直接初始化和拷贝初始化

1
2
3
4
5
6
7
8
9
10
11
12
//不加explicit
string a = "abc";
Sales_data item=a; //正确,拷贝初始化
Sales_data item1(a); //正确:直接初始化
cout<<item.bookNo<<endl; //abc
cout<<item1.bookNo<<endl; //abc

//加explicit
string a = "abc";
Sales_data item=a; //错误,不能将explicit构造函数用于拷贝形式的初始化过程
Sales_data item1(a); //正确:直接初始化
cout<<item1.bookNo<<endl; //abc

4.5.为转换显式地使用构造函数

尽管编译器不会将explicit的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显式地强制进行转换:

1
2
3
4
//正确:实参是一个显式构造的Sales_data对象
item.combine(Sales_data(null_book));
//正确:static_cast可以使用explicit的构造函数
item.combine(static_cast<Sales_data>(cin));

4.6.标准库中含有显式构造函数的类

我们用过的一些标准库中的类含有单参数的构造函数:

  • 接受一个单参数的const char*的string构造函数不是explicit的。
  • 接受一个容量参数的vector构造函数是explicit的。

5.聚合类

聚合类(aggregate class)使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的:

  • 所有成员都是public的。
  • 没有定义任何构造函数。
  • 没有类内初始值。
  • 没有基类,也没有virtual函数。

例如,下面的类是一个聚合类:

1
2
3
4
struct Data {
	int ival;
	string s;
};

我们可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员:

1
2
//val1.ival = 0; val1.s = string("Anna")
Data val1 = {0, "Anna"};

初始值的顺序必须与声明的顺序一致,也就是说,第一个成员的初始值要放在第一个,然后是第二个,以此类推。下面的例子是错误的:

1
2
//错误:不能使用"Anna"初始化ival,也不能使用1024初始化s
Data val2 = {"Anna", 1024};

与初始化数组元素的规则一样,如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化。初始值列表的元素个数绝对不能超过类的成员数量。

值得注意的是,显式地初始化类的对象的成员存在三个明显的缺点:

  • 要求类的所有成员都是public的。
  • 将正确初始化每个对象的每个成员的重任交给了类的用户(而非类的作者)。
  • 添加或删除一个成员之后,所有的初始化语句都需要更新。

6.字面值常量类

我们之前提到过constexpr函数的参数和返回值必须是字面值类型。除了算术类型、引用和指针外,某些类也是字面值类型。和其他类不同,字面值类型的类可能含有constexpr函数成员。这样的成员必须符合constexpr函数的所有要求,它们是隐式const的。

数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但它符合下述要求,则它也是一个字面值常量类:

  • 数据成员都必须是字面值类型
  • 类必须至少含有一个constexpr构造函数。
  • 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;或者如果成员属于某种类类型,则初始值必须使用成员自己的constexpr构造函数。
  • 类必须使用析构函数的默认定义,该成员负责销毁类的对象。

6.1.constexpr构造函数

尽管构造函数不能是const的,但是字面值常量类的构造函数可以是constexpr函数。事实上,一个字面值常量类必须至少提供一个constexpr构造函数。

constexpr构造函数可以声明成=default的形式(或者是删除函数的形式)。否则,constexpr构造函数就必须既符合构造函数的要求(意味着不能包含返回语句),又符合constexpr函数的要求(意味着它能拥有的唯一可执行语句就是返回语句)。综合这两点可知,constexpr构造函数体一般来说应该是空的。我们通过前置关键字constexpr就可以声明一个constexpr构造函数了:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Debug {
public:
	constexpr Debug(bool b = true) : hw(b), io(b), other(b) { }
	constexpr Debug(bool h, bool i, bool o) : hw(h), io(i), other(o) { }
	constexpr bool any() { return hw || io || other; }
	void set_io(bool b) { io = b; }
	void set_hw(bool b) { hw = b; }
	void set_other(bool b) { hw = b; }
private:
	bool hw; //硬件错误,而非IO错误
	bool io; //IO错误
	bool other; //其他错误
};

constexpr构造函数必须初始化所有数据成员,初始值或者使用constexpr构造函数,或者是一条常量表达式。

constexpr构造函数用于生成constexpr对象以及constexpr函数的参数或返回类型:

1
2
3
4
5
6
constexpr Debug io_sub(false, true, false); //调试IO
if (io_sub.any()) //等价于if(true)
	cerr << "print appropriate error messages" << endl;
constexpr Debug prod(false); //无调试
if (prod.any()) //等价于if(false)
	cerr << "print an error message" << endl;