[cpp primer随笔] 14. 类的构造函数
一. 构造函数基本特性
- 构造函数是类中一种特殊的成员函数,作用是控制类对象的初始化过程。
- 其名称与类相同,且没有返回值类型,可以通过参数列表进行重载。
- 构造函数不能为const。一个常量对象在构建时,会先执行完构造函数,然后再获得所谓的常量特性。
二、类成员初始化
2.1 类内初始值
我们可以为类中的数据成员提供类内初始值。当构造函数没能提供初始化时,该数据成员将自动使用类内初始值。
struct B{
B(int arg1, int arg2){};
};
struct A{
int count{0};
B b = B(10, 20);
};
需要注意的是,类内初始值只能以=
或者{}
的形式提供。
2.2 构造函数初始值列表
在构造函数参数列表与函数体之间,可以提供初始值列表,以为对象中的成员指定初始值。
class A{
private:
int b; int a;
public:
A(int arg1, int arg2): a(arg1), b(arg2){};
};
此处有三点注意事项:
-
初始化 vs 赋值
在先前的文章中说过,变量的赋值与初始化是两回事。**初始化是给变量这块内存空间赋初始值,而赋值则是将变量数据擦除,重新写入新值。**在一些场景下,这会带来某种程度上的性能差异。
初始值列表所完成的功能就是初始化。如果初始值列表中没有对一个数据成员做初始化,当进入构造函数体时,该数据成员实际上已经执行完默认初始化。而构造函数体中对成员的操作不再是初始化,而是赋值操作。
这种差别带来的影响与类型相关,内置类型还好说,一些类类型的初始化可能较为复杂,此时性能上就会有所差异。 -
初始化顺序
初始值列表仅能指明成员的初始值是多少,初始值列表中成员的顺序,并不代表成员的实际初始化顺序。初始化顺序一般与成员在类内的声明顺序相同。例如在上面的例子里,是先声明B后声明A,那么初始化的顺序就与之相同,尽管初始值列表里是先A后B。
初始化顺序在初始值存在前后依赖关系时十分重要。例如:class A{ private: int b; int a; public: A(int arg1): a(arg1), b(a){}; };
在初始值列表中,b使用a变量的值进行初始值,然而b声明先于a,初始化优先级同样也先于a进行,但此时a还未进行初始化,这里b的值将为未定义。因此,最好统一成员声明与初始值列表的顺序,这样可以降低出错概率。
-
与类内初始值的关系
正如前面所言,若该成员有类内初始值,则当初始值列表不存在,或者没能为某个成员提供初始值时,则使用该值进行初始化。
三、默认构造
3.1 声明方式
-
编译器自动合成
当类中没有显示声明构造函数时,编译器会自动合成默认构造函数。这种函数的行为非常简单,即如果成员有类内初始值,则使用类内初始值进行初始化;若没有,则按块内标准执行默认初始化。(所谓的块内标准,指的是内置类型值为未定义,类类型执行各自的默认构造) -
使用
=default
来获得默认行为
当类中存在其他有参构造函数时,编译器不会自动合成默认构造,此时需要我们对其进行显示声明。如果想简单地获得与自动合成的版本相同行为的构造函数的话,可以直接使用=default
(C++ 11)完成对默认构造的定义。struct B{ B() = default; // 默认行为,等同于自动合成的版本 B(int, int); };
此外,用户可以使用
=delete
来禁止编译器自动合成默认构造函数。(有人会说这种没有任何构造函数的类有啥用?可以联想基类、接口类、静态提供单例的类等,这里不展开讲) -
显式定义
如果希望在默认构造函数里做更多的工作,则需要显式定义它的函数体。 -
向构造函数的每一个参数都提供默认实参
此时相当于显式定义了默认构造函数。
3.2 禁用场景
默认构造函数的禁用场景指的是,无法自定义默认构造,同时编译器也无法自动合成默认构造函数的一些情况。这些情况比较多,我们这里列举经常遇到的几点,具体地可以去看cppreference上的默认构造函数一节。
首先先明确一点,数据成员的初始化是无关静态成员的。因此下面的类数据成员,均指非静态成员。在此基础上,以下情况,默认构造会被编译器自动删除,无法使用:
-
类类型成员没有默认构造
-
类数据成员存在const限定的类型且没有类内初始值(编译器会报类似下面的错误)
class A{ const int a; int b; }; int main(){ A a; // error: use of deleted function 'A::A()' // note: 'A::A()' is implicitly deleted because the default definition would be ill-formed: return 0; }
-
类数据成员存在引用类型且没有类内初始值(报错如上)
-
类内的类类型成员,其所属类别存在上述情况
3.3 默认构造的调用场景
默认构造显然可以主动指定用于创建对象,请注意正确的使用形式是A obj;
而非A obj();
。后者实际上是在声明一个函数,其返回值类型为A,参数列表为空,函数名为obj
。
此外,当对象被默认初始化或者值初始化时也将自动执行默认构造。回想初始化相关知识点,定义变量却无初始值时,块内非静态变量执行默认初始化,数组初始化残缺元素补齐、块内外静态对象、全局变量均执行值初始化。
然而,无论默认初始化还是值初始化,对于类类型而言都会自动执行默认构造函数。两种初始化在以下场景中将被触发:
默认初始化:
- 块内不适用初始值定义一个非静态变量
- 类本身含有类类型成员,并且使用默认构造函数创建对象时
- 类类型成员没有在构造函数内显式初始化
值初始化:
- 数组初始化时,提供的元素少于数组维数,剩余元素自动执行值初始化。
- 不使用初始值定义一个局部静态变量时
- 通过T()显式请求值初始化时(例如
A obj = A();
)
四、委托构造
C++委托构造函数是C++11引入的一个特性,它的设计目的是为了简化构造函数的重载和代码复用。
在传统的C++中,如果一个类有多个构造函数,它们往往会有一些共同的初始化代码。为了避免重复编写这些共同的初始化代码,我们可以将这些代码提取到一个私有的辅助函数中,并在每个构造函数中调用这个辅助函数。但这样做会导致代码冗余,而且当初始化代码发生变化时,需要修改多个构造函数。
委托构造函数的设计目的就是为了解决这个问题。它允许一个构造函数调用同一个类的另一个构造函数,从而实现代码的复用。通过委托构造函数,我们可以将共同的初始化代码放在一个构造函数中,其他构造函数只需要调用这个构造函数即可。
下面展示《C++ Primer》中的一个使用委托构造函数设计的类的例子:
class A{
public:
// 非委托构造函数使用初始值列表初始化成员
A(std::string s, unsigned c, double p):
mem1(s), mem2(c), mem3(p){};
// 委托构造函数可以将成员初始化过程委托给其他构造函数
A(): A("", 0, 0) {}
A(std::string s): A(s, 0, 0) {}
A(std::istream &is): A() { read(is, *this); }
};
可以看到,委托构造函数
其实可以传递性的委托其他委托构造函数
。接受委托的构造函数,将先后执行其初始值列表、其进一步委托的委托构造函数、然后再执行其函数体(如果有的话),最终再将执行权交还给委托者的函数体。