【C++】类(五):构造函数再探
7.5 构造函数再探
7.5.1 构造函数初始值列表
当我们定义变量时,习惯立即对其进行初始化,而非先定义,再赋值。尽管现代 C++ 和其它现代语言(比如 Golang)一样会为大部分内置的类型赋予初值,但显式地初始化新的变量仍然是好习惯,特别是针对 C/C++ 当中的指针对象。
针对对象的数据成员而言,初始化和赋值有类似的区别。如果没有在构造函数的初始值列表当中显式地初始化成员,在该成员将在构造函数体之前执行默认初始化,如:
Sales_data::Sales_data(const string &s, unsigned cnt, double price) {
bookNo = s;
units_sold = cnt;
revenue = cnt * price;
}
上述代码与直接使用函数初始值列表的效果看起来是类似的,使用函数初始值列表对成员进行初始化的方法如下:
Sales_data::Sales_data(const std::string &s, unsigned n, double p): bookNo(s), units_sold(n), revenue(p * n) {}
上述两段代码在构造函数执行完毕后的效果是相同的。区别在于使用初始值列表的版本初始化了它的数据成员,而第一个版本是对数据成员执行了赋值操作。这一区别到底会有什么深层次的影响完全依赖于数据成员的类型。
构造函数的初始值有时必不可少
有时我们可以忽略数据成员初始化和赋值之间的差异,但并非总能这样。如果成员是 const 或引用的话,必须将其初始化(不能对常量进行赋值,因此必须使用初始值列表的方式给常量初始化一个值。同样地,由于引用是某个对象的别名,它本身没有地址,因此不能给引用类型赋值,应该使用初始化列表的方式对引用进行初始化)。类似的,当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员初始化(没有默认构造函数的类类型必须显式初始化)。例如:
class ConstRef {
public:
ConstRef(int ii);
private:
int i;
const int ci;
int &ri;
};
和其它常量或引用一样,成员 ci 和 ri 都必须被初始化。因此,如果我们没有为它们提供构造函数初始值的话,将引发错误:
ConstRef::ConstRef(int ii) {
// 定义构造函数
// 使用赋值的方式:
i = ii; // 正确👌
ci = ii; // 错误❌
ri = i; // 错误❌
}
随着构造函数体的执行,初始化就完成了。因此,我们初始化常量成员或引用类型数据成员的唯一机会就是通过构造函数初始值列表。因此,上述构造函数的正确形式应该是:
ConstRef::ConstRef(int ii): i(ii), ci(ii), ri(i) {}
如果成员是 const、引用,或者是属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初始值。
来自 C++ Primer 第五版的建议——使用构造函数初始值。
成员初始化的顺序
让人感到意外的是,构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。
但是需要注意的是,构造函数初始值列表中初始值的前后位置关系不影响实际初始化的顺序。实际初始化的顺序按照它们在类定义中出现的顺序来完成。
一般来说,初始化的顺序没有特殊要求,除非我们想要使用一个成员来初始化另一个成员,那么此时两个成员的初始化顺序就很关键了。
最佳实践:最好令构造函数初始值的顺序与成员声明的顺序保持一致。
默认实参和构造函数
Sales_data 默认构造函数的行为与只接受一个 string 实参的构造函数差不多。唯一的区别在于,接受 string 实参的构造函数使用这个实参初始化 bookNo,而默认构造函数(隐式地)使用 string 的默认构造函数初始化 bookNo。
我们可以把它们重写成使用默认实参的构造函数:
class Sales_data {
public:
Sales_data(std::string s = ""): bookNo(s) { }
Sales_data(std::string s, unsigned cnt, double rev): bookNo(s), units_sold(cnt), revenue(rev * cnt) { }
Sales_data(std::istream &is) { read(is, *this); }
};
当没有给定实参的时候,或者给定了一个 string 类型的实参的时候,两个版本的类创建相同的对象。由于不提供实参也可以调用上述的构造函数(原因在于第一个构造函数指定了默认实参),所以该构造函数实际上为我们的类提供了一个默认构造函数(正如我们所设想的那样,如果形参列表当中所有的参数都有默认实参,那么这个构造函数实际上等价为一个默认的构造函数,因为当我们调用构造函数时,不传入任何实参即可调用这个构造函数)。
如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。
7.5.2 委托构造函数
C++ 11 标准拓展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数(delegating constructor)。一个委托构造函数使用它所属类的其它构造函数执行它自己的初始化过程,或者说它把它自己的一些职责委托给了其它构造函数。
委托构造函数与其它构造函数一样,包含一个成员初始值列表和函数体。在委托构造函数内,成员初始值列表的唯一入口是类名本身。一个使用委托构造函数重写 Sales_data 类的例子如下:
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); }
};
当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行。
7.5.3 默认构造函数的作用
当对象被默认初始化或值初始化时自动执行默认构造函数。默认初始化通常发生在以下情况:
- 当我们在块作用域内不适用任何初始值定义一个非静态变量或数组时;
- 当一个类本身含有类类型成员且该类类型成员使用合成的默认构造函数时;
- 当类类型成员没有在构造函数初始值列表当中被显式初始化时;
值初始化在以下情况发生:
- 数组初始化的过程中我们提供的初始值数量少于数组的大小时;
- 当我们不适用初始值定义一个局部静态变量时;
- 当我们通过书写形如
T( )
的表达式显式地请求值初始化时。
最佳实践:在实际中,如果定义了其它构造函数,最好也提供一个默认构造函数。
使用默认构造函数
一个错误的例子如下:
Sales_data obj(); // 正确: 但实际的行为是定义了一个名为 obj 的函数, 而非定义了一个名为 obj 的对象
if(obj.isbn() == Primer_5th_ed.isbn()) { // 错误, 因为 obj 是一个函数. ❌
/* ... */
}
想要正确地使用默认构造函数来对类对象进行初始化,正确的方法如下:
Sales_data obj; // 正确👌
7.5.4 隐式的类类型转换
在 Sales_data 类中,接受 string 的构造函数和接受 istream 的构造函数分别定义了从这两种类型向 Sales_data 隐式转换的规则。即:在需要使用 Sales_data 的地方,我们可以使用 string 或 istream 作为替代:
string null_book = "9-999-99999-9";
// 构造一个临时的 Sales_data 对象
// 该对象的 units_sold 和 revenue 等于 0, bookNo 等于 null_book
item.combine(null_book);
此处 item 对象对 combine 的调用是合法的。编译器用给定的 string 自动创建了一个 Sales_data 对象。
为什么编译器能够将 string 对象隐式地转换为 Sales_data 对象?原因在于,Sales_data 当中定义了只有一个实参调用的构造函数,它定义了一条从构造函数的参数类型向类类型隐式转换的规则。(这部分应该是在说,正是因为类对象的众多构造函数当中恰好有一个构造函数的形参列表与需要隐式转换的类型相同,编译器才能够通过调用相应的构造函数隐式地建立起一个临时的类对象,从而完成隐式的类类型转换)
只允许一步类类型转换
编译器只会自动地执行一步类型转换,故:
item.combine("9-999-99999-9"); // 错误❌, 首先需要将字符串转为 string, 此时已经进行了一步转换, 不会进行第二步
item.combine(string("9-999-99999-9")); // 正确👌
类类型转换不是总有效
抑制构造函数定义的隐式转换
在要求隐式转换的程序上下文当中,我们可以通过将构造函数声明为 explicit 加以阻止:
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 对象。
需要注意的是,关键字 explicit 只对一个实参的构造函数有效。需要多个实参的构造函数不能用于执行隐式转换,所以无需将含有多个实参的构造函数指定为 explicit。只能在类内声明构造函数时使用 explicit 关键字,在类外定义不应重复(和友元的声明类似)。
explicit 构造函数只能用于直接初始化
为转换显式地使用构造函数
尽管编译器不会将 explicit 的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显式地进行强制转换:
item.combine(Sales_data(null_book)); // 正确👌, 实参是一个显式构造的 Sales_data 对象
item.combine(static_cast<Sales_data>(cin)); // 正确👌, static_cast 可以使用 explicit 构造函数
标准库中含有显式构造函数的类
7.5.5 聚合类
**聚合类(aggregate class)**使得用户可以直接访问其成员,并且具有特殊的初始化语法方式。当一个类满足如下条件时,我们说它是聚合的:
- 所有成员都是 public 的;
- 没有定义任何构造函数;
- 没有类内初始值;
- 没有基类,也没有 virtual 函数;
一个聚合类的例子如下:
struct Data {
int ival;
string s;
};
我们可以提供一个花括号括起来的成员初始值列表,并使用它来初始化聚合类的数据成员:
Data vall = { 0, "Anna" };
初始值的顺序必须与声明的顺序一致。
与初始化数组元素的规则一致,如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化。初始化列表的元素个数不能超过类的成员数量。
值得注意的是,显式地初始化类的对象的成员存在三个明显缺点:
- 要求类的所有成员都是 public;
- 将正确初始化每个对象的每个成员的重任交给了类的用户;
- 添加或删除一个成员后,所有的初始化语句都需要更新。
7.5.6 字面值常量类
在之前的学习记录当中提到过,constexpr(常量表达式)函数的参数和返回值必须是字面值类型。除了算术类型、引用和指针外,某些类也是字面值类型。
字面值类型的类可能含有 constexpr 函数成员,这样的成员必须符合 constexpr 函数的所有要求,它们是隐式 const 的。
数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,那么如果它满足下属要求,它也是一个字面值常量类:
- 数据成员必须是字面值类型;
- 类必须至少含有一个 constexpr 构造函数;
- 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;或者如果成员属于某个类类型,则初始值必须使用成员自身的 constexpr 构造函数初始化;
- 类必须使用析构函数的默认定义,该成员负责销毁类的对象;
constexpr 构造函数
类的构造函数不可以是 const 的,但字面值常量类的构造函数可以是 constexpr 的,并且字面值常量类必须包含一个 constexpr 的构造函数。
constexpr 构造函数体一般是空的,通过前置关键字 constexpr 完成声明:
class Debug {
public:
constexpr Debug(bool b = trie): 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_otehr(bool b) { other = b; }
private:
bool hw;
bool io;
bool other;
};
constexpr 构造函数必须初始化所有数据成员,初始值或使用 constexpr 构造函数或使用常量表达式。
constexpr 构造函数用于生成 constexpr 对象以及 constexpr 函数的参数或返回类型:
constexpr Debug io_sub(false, true, false); // 调试 IO
if(io_sub.any()) { // 等价于 if(true)
cerr << "print appropriate error messages" << endl; // 输出适当的错误信息
constexpr Debug prob(false); // 无调试
if(prob.any()) { // 等价于 if(false)
cerr << "print an error message" << endl;
}