RAII 概念介绍
Resource Acquisition Is Initialization
-
资源获取视为初始化,
-
资源释放视为销毁
为什么资源释放视为销毁
C++ 的解构函数(destructor)是显式的,与 Java,Python 等垃圾回收语言不同。
-
可以显式调用:通过
malloc free
,new delete
和new[] delete[]
手动创建释放 -
或离开作用域自动销毁
在使用上,我们希望 离开
{}
作用域自动释放。有好处也有坏处,对高性能计算而言利大于弊
对C而言 - 避免内存泄漏
答:避免犯错误,如果没有解构函数自动调用:
-
每个带有返回的分支都要手动释放所有之前的资源
-
用 new/delete 或者 malloc/free 就很容易出现忘记释放内存的情况,造成内存泄露。
而例如 vector 会在离开作用域时,自动调用解构函数,释放内存,就不必手动释放了,更安全。
#include <vector>
#include <iostream>
int main() {
std::vector<int> v(4, 0);
int sum = 0;
for (size_t i = 0; i < v.size(); i++) {
sum += v[i];
}
std::cout << sum << std::endl;
// 可以提前释放
v.clear();
// 离开{}作用域自动释放,尽管它是申请在堆上的
return 0; // 自动释放
}
对java而言
使用需求
为什么很多面向对象语言,比如 Java,都没有构造函数全家桶这些概念?
因为java的业务需求大多是在和资源打交道,从而基本都是要explicit删除拷贝函数的那一类。
-
需求举例:打开数据库,增删改查学生数据,打开一个窗口,写入一个文件,正则匹配是不是电邮地址,应答 HTTP 请求等。
-
解决这种需求,几乎总是在用
shared_ptr<GLShader>
的模式于是 Java 和 Python 干脆简化:一切非基础类型的对象都是浅拷贝,引用计数由垃圾回收机制自动管理。
因此,以系统级编程、算法数据结构、高性能计算为主要业务的 C++
- 发展出了RAII思想
- 将拷贝/移动/指针/可变性/多线程等概念作为语言基本元素存在。
这些在我们的业务里面是非常重要的,不可替代。
异常安全的不同处理
异常安全(exception-safe)
C++ 标准保证当异常发生时,会触发栈解旋,因此 C++ 中没有(也不需要) finally 语句。
栈解旋:依次调用已创建对象的解构函数
// C++ 标准保证当异常发生时,会调用已创建对象的解构函数。(栈解旋)
// C++ 中没有(也不需要) finally 语句。
try {
test();
} catch (std::exception const &e) {
std::cout << "捕获异常:" << e.what() << std::endl;
}
而对java而言,必须使用 finally 语句: 因为如果此处不立即回收关闭资源,就意味着是要依赖 GC。
但若对时序有要求就不能依靠 GC: 比如 mutex 忘记 unlock 造成死锁等等…… 更不要说,依赖GC会对性能存在影响。
Connection c = driver.getConnection();
try {
...
} catch (SQLException e) {
...
} finally {
c.close();
}
初始化小寄巧
构造函数{}
使用 {} 和 () 调用构造函数,有什么区别?
谷歌在其 Code Style 中也明确提出别再通过 () 调用构造函数
-
更安全:
{}
是非强制转换,即不支持强制转换- int(3.14f) 不会出错,但是 int{3.14f} 会出错
- Pig(“佩奇”, 3.14f) 不会出错,但是 Pig{“佩奇”, 3.14f} 会出错
-
可读性:
Pig(1, 2)
Pig 有可能是个函数,Pig{1, 2}
看起来更明确,一定是构造函数。
需要类型转换时,显式调用static_cast<>
而不是 构造函数()
例如 int(float f)
谷歌在其 Code Style 中也明确提出别再通过 () 调用构造函数,需要类型转换时应该用:
-
static_cast<int>(3.14f)
而不是int(3.14f)
-
reinterpret_cast<void *>(0xb8000)
而不是(void *)0xb8000
这样可以更加明确用的哪一种类型转换(cast),从而避免一些像是static_cast<int>(ptr)
的错误。
explicit
explicit
拒绝隐式转换。
比如
std::vector
的构造函数vector(size_t n)
也是 explicit 的。
-
推荐为拷贝构造,移动构造设置
explicit
禁止通过 = 调用拷贝构造,移动构造
-
场景:必须用 () 强制转换
- 单参数
- 拒绝
operator=
的隐式转换
- 拒绝
- 多个参数时
- 禁止从一个 {} 表达式初始化。
在一个返回 Pig 的函数里用:return {“佩奇”, 80};的话,就不要加 explicit。
- 单参数
class Pig {
explicit Pig(int weight)
: m_name("一只重达" + std::to_string(weight) + "公斤的猪")
, m_weight(weight){}
}
// 不加 explicit
show(80); // 编译通过! 希望输入int,却被隐式转换为pig
Pig pig = 10; // 编译通过
Pig pig(10); // 编译通过
// 加 explicit
show(80); // 希望输入int,就不会被隐式转换为pig
show({80}) // 等价
Pig pig = 10; // 编译 fail,不通过
Pig pig(10); // 编译通过
// Pig pig1 = {"佩奇", 80}; // 编译错误
Pig pig2{"佩奇", 80}; // 编译通过
Pig pig3("佩奇", 80); // 编译通过
// show({"佩奇", 80}); // 编译错误
show(Pig("佩奇", 80)); // 编译通过
常引用
常引用,值引用
常引用实际只传递了一个指针,避免了拷贝。
以拷贝赋值函数而言:
-
常引用
RAII const & raii
-
RAII & operator=(RAII const & raii)
-
(推荐使用)
-
-
值引用
RAII & raii
RAII & operator=(RAII & raii)
- (不推荐)
参数类型优化
函数参数类型优化规则:按
- 常引用、
// 是数据容器类型(比如 vector,string)则按常引用传递:
int sumArray(std::vector<int> const &arr);
- 值引用、
- 值?
// 是基础类型(比如 int,float)则按值传递:
float squareRoot(float val);
// 是原始指针(比如 int *,Object *)则按值传递:
void doSomethingWith(Object *ptr);
// 数据容器不大(比如 tuple<int, int>),则其实可以按值传递:
glm::vec3 calculateGravityAt(glm::vec3 pos);
// 智能指针(比如 shared_ptr),
// - 且需要生命周期控制权,则按值传递:
void addObject(std::shared_ptr<Object> obj);
// - 但不需要生命周期,则通过 .get() 获取原始指针后,按值传递:
void modifyObject(Object *obj);
委托构造函数
一个构造函数委托同类型的另一个构造函数对对象进行初始化。
-
委派构造函数:
- 不能同时使用 初始化列表
-
执行顺序
-
将控制权交给目标构造函数
-
在目标构造函数执行完之后,再执行委托构造函数的主体。
-
如果委派构造函数要给变量赋初值,初始化代码必须放在函数体中。
-
-
目标构造函数:
- 被调用“基准版本”构造函数就是目标构造函数。
- 作为 “被委托函数” 可以使用初始化列表(如果本身不再作为委派构造)
注意避免构造死循环。
class Person
{
public:
// 语法:在委托构造函数的初始化列表中调用目标构造函数。
// 委派构造函数:
Person() :Person(1, 'a') {}
Person(int i) : Person(i, 'a') {}
Person(char ch) : Person(1, ch) {}
private:
// 目标构造函数
Person(int i, char ch) :type(i), name(ch) {
/*其他初始化信息*/
}
int type{ 1 };
char name{ 'a' };
};
你需要遵守的三五法则
移动/拷贝
F4
定义
class RAII {
public:
int* mIdPtr = new int(0);
std::string mName = "张三";
// 有参/无参构造
RAII() {
std::cout << "RAII() 无参构造 " << this->mName << *(this->mIdPtr) << std::endl;
}
RAII(int id, std::string name) {
*(this->mIdPtr) = id;
this->mName = name;
std::cout << "RAII(int) 有参构造 " << this->mName << *(this->mIdPtr) << std::endl;
}
// 拷贝构造
// 直接在未初始化的内存上构造
RAII(RAII const & raii) {
int val = 0;
if (raii.mIdPtr) {
val = *(raii.mIdPtr);
}
*(this->mIdPtr) = val;
this->mName = raii.mName;
std::cout << "RAII(RAII & raii) 拷贝构造, 原地设置值 " << this->mName
<< " " << ((this->mIdPtr)? *(this->mIdPtr):-1) << std::endl;
}
// 拷贝赋值
// 先销毁现有的 1,再重新构造 2
// ≈解构函数+拷贝构造函数
RAII & operator=(RAII const & raii) {
this->~RAII(); // 先销毁现有的
new (this) RAII(raii); // 再重新构造(placement new)
std::cout << "RAII & operator=(RAII const &) 拷贝赋值, 拷贝得到" << this->mName
<< " " << ((this->mIdPtr)? *(this->mIdPtr):-1) << std::endl;
return *this; // 支持连等号:v1 = v2 = v3
}
// 移动构造
// 缺省 移动构造≈拷贝构造+他解构+他默认构造
RAII(RAII && raii) {
this->mIdPtr = raii.mIdPtr;
raii.mIdPtr = nullptr;
this->mName = std::move(raii.mName);
std::cout << "RAII(RAII && raii) 移动构造, 移动得到的值 " << this->mName
<< " " << ((this->mIdPtr)? *(this->mIdPtr):-1) << std::endl;
}
// 移动赋值
// 缺省 移动赋值≈拷贝赋值+他解构+他默认构造
RAII & operator=(RAII && raii) {
this->mIdPtr = raii.mIdPtr;
raii.mIdPtr = nullptr;
this->mName = std::move(raii.mName);
std::cout << "RAII & operator=(RAII &&) 移动赋值函数 , 得到" << this->mName
<< " " << ((this->mIdPtr)? *(this->mIdPtr):-1) << std::endl;
return *this;
}
~RAII() {
std::cout << "~RAII() 析构调用 id = " << ((mIdPtr != nullptr)? *(this->mIdPtr) : -1) << std::endl;
if (mIdPtr != nullptr) {
delete mIdPtr;
}
}
};
调用规则
总览
std::cout << "--------------有参构造---------------\n";
RAII raii_1_1 = RAII(1, "张三1 2");
RAII raii_1_2(1, "张三1 2");
std::cout << "\n------------拷贝构造-----------------\n";
RAII raii_2_1 = raii_1_1;
RAII raii_2_2 = RAII(raii_1_1);
RAII raii_2_3(raii_1_1);
RAII raii_2_4 = FuncRetObj(); // 如果没有实现移动构造
std::cout << "\n------------拷贝赋值-----------------\n";
RAII raii_4_1 = RAII(4, "张三4 1");
raii_4_1 = raii_3_1;
std::cout << "\n------------移动构造-----------------\n";
RAII raii_3_1 = std::move(RAII(3, "张三3 1")); // 低效 创建临时对象又马上被销毁
RAII raii_3_2(std::move(raii_2_1));
RAII raii_3_3 = RAII(std::move(raii_1_1));
RAII raii_3_4 = FuncRetObj(); // 函数返回值
std::cout << "\n------------移动赋值-----------------\n";
RAII raii_5_1 = RAII(5, "张三5 1");
RAII raii_5_2 = RAII(5, "张三5 2");
raii_5_1 = std::move(raii_2_2);
raii_5_2 = RAII(42, "张三5 tmp"); // 低效 创建了临时对象
拷贝 还是 构造
如果其中一个成员不支持 拷贝构造函数 ,那么 拷贝构造函数将不会被编译器自动生成。
其他函数同理。
// 拷贝构造:直接未初始化的内存上构造1
int x = 1; // 拷贝构造函数 int(int const &myint);
// 拷贝赋值:先销毁现有的 1,再重新构造2
x = 2; // 拷贝赋值函数 int &operator=(int const &myint)
拷贝
std::cout << "\n------------拷贝构造-----------------\n";
RAII raii_2_1 = raii_1_1;
RAII raii_2_2 = RAII(raii_1_1);
RAII raii_2_3(raii_1_1);
RAII raii_2_4 = FuncRetObj(); // 如果没有实现移动构造
std::cout << "\n------------拷贝赋值-----------------\n";
RAII raii_4_1 = RAII(4, "张三4 1");
raii_4_1 = raii_3_1;
拷贝构造
直接在未初始化的内存上构造
参数
- 参数必须是引用——
RAII(RAII raii_) 是错误的
- 常引用
RAII(RAII const & raii_)
优于 值引用RAII(RAII & raii_)
。 - 常引用 和 值引用可以同时存在。重载的强大之处~
何时触发
- 显示调用,类型作为参数
- 函数返回值。没有移动构造时,指向拷贝构造。
形参 | 对应函数 | 评价 |
---|---|---|
RAII obj_new = obj_old; | RAII(RAII const & raii) | 直接在未初始化的内存上构造 |
RAII obj_new = RAII(obj_old); | 同上 | 同上 |
RAII obj_new(obj_old); | 同上 | 同上 |
RAII obj_new = funcRet(); | 未定义移动构造函数 | 低效 |
拷贝赋值
先销毁现有的 再重新构造
- 值引用 不推荐
- 常引用
RAII & operator=(RAII const & raii)
形参 | 对应函数 | 评价 |
---|---|---|
obj_exists = RAII(1); | RAII(int) ~RAII() 以及RAII & operator=(RAII const & raii) | 低效 创建临时对象又马上被销毁 |
obj_exists = obj_old; | RAII & operator=(RAII const & raii) | 正确做法 |
移动
正确实现移动语义需要你的对象容纳一个“空状态”,移动时需要将源对象置空,析构时也需要判空。
std::cout << "\n------------移动构造-----------------\n";
RAII raii_3_1 = std::move(RAII(3, "张三3 1")); // 低效 创建临时对象又马上被销毁
RAII raii_3_2(std::move(raii_2_1));
RAII raii_3_3 = RAII(std::move(raii_1_1));
RAII raii_3_4 = FuncRetObj(); // 函数返回值
std::cout << "\n------------移动赋值-----------------\n";
RAII raii_5_1 = RAII(5, "张三5 1");
RAII raii_5_2 = RAII(5, "张三5 2");
raii_5_1 = std::move(raii_2_2);
raii_5_2 = RAII(42, "张三5 tmp"); // 低效 创建了临时对象
移动构造
目标:移动构造 RAII(RAII && raii)
何时触发
- 显示调用,类型作为参数
- 函数返回值。没有移动构造时,指向拷贝构造。
形参 | 对应函数 | 评价 |
---|---|---|
RAII obj_new(std::move(obj_old)); | RAII(RAII && raii) | 正确做法 |
RAII obj_new = RAII(std::move(obj_old)); | 同上 | 同上 |
RAII obj_new = funcRet(); | 同上 | 同上 |
RAII obj_new = std::move(RAII(1)); | RAII(int) ~RAII() 以及RAII(RAII && raii) | 低效 创建临时对象又马上被销毁 |
移动赋值
移动赋值 RAII & operator=(RAII && raii)
形参 | 对应函数 | 评价 |
---|---|---|
obj_exists = std::move(RAII(1)); | RAII(int) ~RAII() 以及RAII & operator=(RAII && raii) | 低效 创建临时对象又马上被销毁 |
obj_exists = std::move(obj_old); | RAII & operator=(RAII const & raii) | 正确做法 |
缺省实现
移动语义
如果不定义移动构造和移动赋值,编译器为保证不出错,会自动实现默认的缺省实现:
虽然低效,但至少可以保证不出错。
-
缺省 移动构造
≈拷贝构造+他解构+他默认构造
-
缺省 移动赋值
-
未自定义 移动构造
≈拷贝赋值+他解构+他默认构造
-
自定义 移动构造
≈解构+移动构造
-
拷贝
拷贝赋值
三五法则
概念
修改任意一个,就需要改3个:
- 析构函数,拷贝构造,拷贝赋值。
自定义了析构函数,那就
- 把移动构造函数和拷贝构造函数全部delete掉!
- 如果确实需要移动
- 自己定义或default掉移动构造函数。(不建议尝试)
- 使用unique_ptr。
如果对提高性不能感兴趣,可以忽略
- 移动构造
- 移动赋值
要实现移动语义,需要实现5个:
-
析构函数,拷贝构造,拷贝赋值,移动构造,移动赋值。
-
正确实现移动语义需要你的对象容纳一个“空状态”,
移动时需要将源对象置空,析构时也需要判空。
不能做的事情:
如果类定义了 | 必须同时 | 函数 | 错误原因 | 解决原理 | |
---|---|---|---|---|---|
解构函数 | ~RAII() | 定义 | 拷贝构造函数 和 拷贝赋值函数 | 避免浅拷贝指针导致多次释放同一内存。 | “封装:不变性”服务。即:保证任何单个操作前后,对象都是处于正确的状态,从而避免程序读到错误数据(如空悬指针)的情况。 |
或 删除 | - | - | 我们压根就不允许这么用,在编译期就发现错误。 | ||
移动构造函数 | RAII(RAII &) | 定义 或 删除(删除仍然低效) | 移动赋值函数 | ||
拷贝构造函数 | RAII(RAII const &) | 定义 或 删除(仍然低效) | 拷贝赋值函数 | 内存的销毁重新分配可以通过realloc,从而就地利用当前现有的m_data,避免重新分配。 | |
定义 | 移动构造函数 (否则低效) | ||||
拷贝赋值函数 | 定义 | 移动赋值函数 (否则低效) |
判断安全
安全
一般来说,可以认为符合三五法则的类型是安全的。
判断方式:
- 如果不需要自定义的解构函数,那么这个类就不需要担心。
- 否则,往往意味着类成员中,包含有不安全的类型。
如果类所有成员都是安全的类型,类自动就是安全的。
则五大函数都
- 无需声明
- 或声明为 = default
不安全
不安全:一般无外乎两种情况:
-
类管理着资源。
-
这个类管理着某种资源,资源往往不能被“复制”。
-
删除拷贝函数,统一用智能指针管理
避免每个资源类实现一遍原子引用计数器(不推荐)
-
-
类是数据结构:你精心设计
- 考虑定义拷贝和移动
- 数据结构是否支持拷贝(比如 Vector 就可以),
- 支持:自定义。
- 不支持:删除(= delete)。
例子
以下类型是安全的:
// 基础类型
int id;
// STL 容器
std::vector<int> arr;
// 智能指针
std::shared_ptr<Object> child;
// 原始指针,如果是从智能指针里 .get() 出来的
Object *parent;
以下对象是不安全的:
// 原始指针,如果是通过 malloc/free 或 new/delete 分配的
char *ptr;
// 是基础类型 int,但是对应着某种资源
GLint tex;
// STL 容器,但存了不安全的对象
std::vector<Object *> objs;
默认生成规则
f4的打包删除
f4 删除一个而其它的没有显式定义,则编译器自动删除其它三个。
class Resource {
Resource();
Resource(Resource &&) = delete; // 其他三个也会被删除
};
何时生成默认拷贝构造
何时编译器生成 默认拷贝构造:编译器觉得如果没有的话你会出错,所以给你整了一个
如果不提供默认拷贝构造函数,编译器会按照位拷贝进行拷贝(位拷贝指的是按字节进行拷贝,有些时候位拷贝出现的不是我们预期的行为,会取消一些特性)
以下是编译器需要强制提供默认拷贝构造函数的必要条件:来自知乎
-
类成员
-
存在类成员,是一个有拷贝构造函数的类 。
为了让成员类的拷贝构造函数能够被调用到,不得不为类生成默认拷贝构造函数。
-
有类成员,包含一个或多个虚函数。
- 其类成员的虚函数表指针,需要调用其拷贝构造函数才不会丢失。
- 需要为类生成默认拷贝构造函数,完成类成员拷贝构造函数的调用 & 类成员虚函数表指针的拷贝,从而完成虚函数表指针的拷贝。
-
-
基类
-
基类,是一个有拷贝构造函数的类 。
-
子类执行拷贝构造函数时,先调用父类的拷贝构造函数,
-
为了能够调用到父类的拷贝构造,不得不生成默认的拷贝构造函数。
-
-
基类,有一个或多个虚函数。
-
如果不提供默认拷贝构造函数,
- 会进行位拷贝。类成员的拷贝构造函数不被调用
- 从而基类的虚函数表指针(可能)会丢失
-
需要为类生成默认拷贝构造函数,调用基类的拷贝构造函数
完成基类拷贝构造函数的调用,从而完成虚函数表指针的拷贝。
-
-
我认为以上解释存在存在虚函数的成员时,“为了避免浅拷贝”这个理由是错的。
-
浅拷贝 相当于多个对象 共用一个指针,由于没有人能确保所有权,其指向可能被释放
-
但是,虽然 虚表指针是 一 个 指 针 可对于同一个类,
-
其指向是固定的 其虚函数表在rodata区。
-
任何一个对象释放都不会去释放这个属于类的虚函数表。
-
那么为什么,有虚函数表的类 编译器会帮我们 默认拷贝构造函数呢?
我认为是使用 其同样拥有虚函数表的 子类 进行拷贝时。确保虚表指针的正确(存疑),以及正确拷贝内存中的内容 不要包含子类部分。