CPP构造函数

前置理论

因为CPP构造函数的思想来自于RAII, 在阐述细节之前, 还是有必要说明一下.

何为RAII

资源获取即初始化(Resource Acquisition Is Initialization), 或称RAII, 是一种C++编程技术, 它将必须在使用前请求的资源(分配的堆内存, 执行线程, 打开的套接字, 打开的文件, 锁定的互斥体, 磁盘空间, 数据库连接等–任何操作受限供给中事物)的生命周期绑定与一个对象的生存期相绑定.

RAII保证资源能够用与任何会访问该对象的函数(资源可用性是一种类不变式, 这会消除冗余的运行时测试). 它也保证对象在自己生存期结束时会以获取顺序的逆序释放它控制的所有资源. 类似地, 如果资源换取失败(构造函数以异常退出), 那么已经构造完成的对象和基类子对象所获取的所有资源就算以初始化顺序的逆序释放. 这有效地利用了语言特性(对象生存期, 退出作用域, 初始化顺序以及栈回溯)以消除内存泄漏并保证异常安全. 根据RAII对象的生存期在退出作用域时结束这一基本状况, 此技术也被称为作用域界定的资源管理(Scope-Bound Resource Management, SBRM).

RAII可以总结如下:

  • 将每个资源封装入一个类, 其中:
    • 构造函数请求资源, 并建立所有类不变式, 或在它无法完成时抛出异常.
    • 析构函数释放资源并且绝不会抛出异常.
  • 在使用资源时始终通过RAII类的满足以下要求的示例:
    • 自身拥有自动存储期或临时生存期, 或
    • 具有与自动或临时对象的生存期绑定的生存期.

移动语义使得在对象间, 跨作用域, 以及在线程内外安全地移动所有权, 而同时维护资源安全称为可能.

CPP标准库遵循RAII管理其自身的资源, 以及很多其他在构造函数中获取资源(错误时抛出异常), 并在析构函数中将其释放(决不抛出)而不要求显式清理的类.

构造函数

自定义构造函数

无参数构造函数

实例化一个类, 都需初始化成员变量, 实例化成员变量本质就是调用成员变量自身的构造函数. 如果这个类没有显式指明成员变量默认值, 那么在实例化成员变量时, 其调用的是它自己的无参构造函数, 如果这个成员变量没有定义有无参构造函数. 那么在实例化的过程中或报错

1
2
3
4
5
6
7
8
9
10
11
12
13
struct A{
std::string user;
int tmp;

A(){};
};

int main(){
A a;

return 0;
}

使用初始化表达式

对于无参构造函数, 若想直接赋一个初始值给成员变量, 应当使用初始化表达式赋值, 而不是直接在无参构造函数体内部赋值.

理由:

一个类在实例化的时候, 在调用构造函数正式执行函数体里的内容之前, 会先初始化一遍这个类的成员变量(即调用成员变量自身的无参构造函数). 如果这个函数体里面有赋初值给成员变量的语句的话, 在正式执行时, 它会执行拷贝赋值函数, 意味执行了两步操作, 即先调用成员变量自身的无参构造函数, 之后再调用其拷贝赋值函数. 如果赋予成员变量的内容很多, 这会大大降低程序的运行速度

使用初始化表达式, 可以确保, 在正式执行构造函数函数体的内容之前, 执行定义的初始化表达式, 完成一次初始化, 即直接将用户指定的初始值赋予给成员变量(实际上调用拷贝构造函数). 这样就可以避免先执行无参构造函数再执行拷贝赋值函数.

此外CPP允许初始化表达式直接初始化带const或带引用的成员变量. 以及可以避免成员变量没有无参构造函数无法初始化的情况, 即以下这种情况.

1
2
3
4
5
6
7
8
9
10
11
12
13
struct A{
UserClass user; //假定这个UserClass没有无参构造函数
int tmp;

A(){};
};

int main(){
A a; // 报错, 执行A无参构造函数初始化user成员变量报错, 因为这个UserClass没有无参构造函数

return 0;
}

总结以下, 初始化表达式可以完成以下事情:

  • 直接调用成员的拷贝构造函数完成初始化, 避免重复初始化, 更加高效.
  • 避免成员没有无参构造函数
  • 初始化带const或带引用的成员变量

初始化列表

当一个类(和它的基类)没有定义任何构造函数, 这时编译器会自动生成一个参数个数和成员一样的构造函数.

它会将{}内的内容, 按顺序赋值给对象的每一个成员

目的是为了方便程序员不用手写冗长的构造函数一个个赋值个成员

不够初始化列表的构造函数只支持通过{}或={}来构造, 不支持通过()构造. 其实是为了向下兼容C++98

例如:

1
2
3
4
5
6
7
8
9
10
11
12
strcut Pig{
std::string m_name;
int m_weight;
}

int main(){
Pig pig1 = {"佩奇", 80}; // 通过
Pig pig2{"佩奇", 80}; // 通过
Pig pig3("佩奇", 80); // 不通过

return 0;
}

此外, 编译器默认生成的构造函数允许初始化一部分, 剩余的为默认值

不过得保证没指定的有在类成员定义里写明{}初始化, 否则有可能会变成内存里的随机值.

例如:

1
2
3
4
5
6
7
8
9
10
strcut Pig{
std::string m_name;
int m_weight{0};
}

int main(){
Pig pig2{"佩奇"}; // m_weight没指定, 使用{0}指定默认值

return 0;
}

小心POD陷阱

除了我们自定义的构造函数外, 编译器还会自动生成一些构造函数.

当一个类没有定义任何构造函数, 且所有成员都有无参构造函数时, 编译器会自动生成一个无参构造函数Pig(), 它会调用每一个成员的无参构造函数.

但需要注意的是, 这些类型不会被初始化为0

  1. int, float, double等基础类型
  2. void*, Object* 等指针类型
  3. 完全由这些类型组成的类
    • 这些类型被称为POD(plain-old-data)
    • POD的存在是出于兼容性和性能的考虑
POD陷阱解决方案
  • 可以手动指定初始化weight为0
  • 通过{}语法指定的初始化值, 不仅会在编译器自动生成的构造函数里执行, 也会用户自定义构造函数里执行

例如:

1
2
3
4
5
6
7
strcut Pig{
std::string m_name;
int m_weight{0};

Pig(std::string namel): m_name(name){}; // 注意: m_weight已默认初始化为0

}

有参数构造函数

有参数构造函数情况下避免使用等号=传递参数来实例化类.

此外鼓励使用花括号{} 代替括号()来初始化类, 例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct Pig{
std::string m_name;
int m_weight;

Pig(std::string name, int weight)
: m_name(name)
, m_weight(weight)
{};

};

int main(){
Pig pig1{"佩奇", 80}; // 通过, 推荐
Pig pig1("佩奇", 80); // 通过, 但不推荐
Pig pig1 = {"佩奇", 80}; // 通过, 但不推荐
}

理由:

使用等号=实例化类, 表示CPP会对等号=右侧的对象做一次隐式转换. 这是一个不好的行为.

为了避免这种情况, 可在构造函数前面加入explicit关键字, 声明不允许赋值对象进行隐式转换. 和使用等号=进行初始化, 例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
struct Pig{
std::string m_name;
int m_weight;

explicit Pig(std::string name, int weight)
: m_name(name)
, m_weight(weight)
{};

};

Pig func1(){
return {"佩奇", 80}; // 不通过
}

Pig func2(){
return Pig{"佩奇", 80}; // 通过
}

Pig func3(){
return Pig("佩奇", 80); // 通过
}

int main(){
Pig pig1{"佩奇", 80}; // 通过, 推荐
Pig pig1("佩奇", 80); // 通过, 但不推荐
Pig pig1 = {"佩奇", 80}; //不通过,

func1(); // 不通过
func2(); // 不通过
func3(); // 不通过

return 0;
}

此外, explicit关键字对多个参数也起作用

  • 多个参数时, explicit的作用体现在**禁止从一个{}**表达式初始化.
  • 如果希望在一个函数中返回这个类的话, 就不要加explicit

使用{}和()调用构造函数, 有什么区别?

  1. int(3.14f)不会出错, 因为()会进行强制转换, 但是int{3.14f}会出错, 因为{}是非强制转换.
  2. Pig(“佩奇”, 3.14f)不会出错, 但是Pig(“佩奇”, 3.14f)会出错, 原因同上, 更安全.
  3. 可读性: Pig(1, 2)则Pig有可能是一个函数, Pig{1,2}看起来更明确.

其实谷歌在其Code Style中也明确提出别再通过()调用构造函数, 需要类型转换时应该用:

  1. static_cast(3.14f)而不是int(3.14f)
  2. reinterpret_cast(void*>(0xb8000)而不是(void *)0xb8000

更加明确用的哪一种类型转换(cast), 从而避免一些像是static_cast(ptr)的错误.

拷贝构造函数

除了无参和初始化列表构造函数外, 编译器默认还会生成这样的一个特殊的构造函数: Pig(Pig const& other)

拷贝赋值函数

处理拷贝构造函数外, 编译器默认还会生成这样一个重载=这个运算符的函数: Pig &operator=(Pig const &other)

拷贝构造函数的作用是在Pig尚未初始化时, 将另一个Pig拷贝进来, 以初始化当前Pig

1
Pig pig = pig2 // 拷贝构造

拷贝赋值函数的作用是在Pig已经**初始化时, 将当前Pig销毁, 同时将另一个Pig拷贝进来.

1
2
3
Pig pig // 无参构造
pig = pig2 // 拷贝赋值

追求性能时推荐同拷贝构造, 因为可以避免一次无参构造, 拷贝赋值是出于需要临时修改对象的灵活性需要

三五法则

规则类怪谈

如果一个类定义了解构函数, 那么必须同时定义或删除拷贝构造函数拷贝赋值函数, 否则出错.

如果一个类定义了拷贝构造函数, 那么必须同时定义或删除拷贝赋值函数, 否则出错, 删除可导致低效.

如果一个类定义了移动构造函数, 那么必须同时定义或删除移动赋值函数, 否则出错, 删除可导致低效

如果一个类定义了拷贝构造函数拷贝赋值函数, 那么必须最好同时定义移动构造函数移动赋值函数, 否则低效.

函数参数类型优化规则: 按引用还是按值

  • 如果是基础类型(比如 int , float)则按值传递: float squareRoot(float val)
  • 如果是原始指针(比如 int*, Oject*)则按值传递: void doSomthingWith(Object *ptr)
  • 如果是数据容器类型(比如vector, string)则按常引用传递: int sumArray(std::vector<int> const &arr)
  • 如果数据容易不大(比如tuple<int, int>), 则其实可以按值传递
  • 如果是智能指针(如shared_ptr), 且需要生命周期控制权, 则按值传递
  • 如果是智能指针, 但不需要生命周期, 则通过.get()获取原始指针厚, 按值传递