入门C++中的RAII

RAII 概念介绍

Resource Acquisition Is Initialization

  • 资源获取视为初始化,

  • 资源释放视为销毁

为什么资源释放视为销毁

C++ 的解构函数(destructor)是显式的,与 Java,Python 等垃圾回收语言不同。

  • 可以显式调用:通过malloc freenew deletenew[] 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个:

  • 析构函数,拷贝构造,拷贝赋值。

自定义了析构函数,那就

  1. 把移动构造函数和拷贝构造函数全部delete掉!
  2. 如果确实需要移动
    • 自己定义或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; // 其他三个也会被删除
};
何时生成默认拷贝构造

何时编译器生成 默认拷贝构造:编译器觉得如果没有的话你会出错,所以给你整了一个

如果不提供默认拷贝构造函数,编译器会按照位拷贝进行拷贝(位拷贝指的是按字节进行拷贝,有些时候位拷贝出现的不是我们预期的行为,会取消一些特性)

以下是编译器需要强制提供默认拷贝构造函数的必要条件:来自知乎

  • 类成员

    1. 存在类成员,是一个有拷贝构造函数的类 。

      为了让成员类的拷贝构造函数能够被调用到,不得不为类生成默认拷贝构造函数。

    2. 有类成员,包含一个或多个虚函数。

      • 其类成员的虚函数表指针,需要调用其拷贝构造函数才不会丢失。
    • 需要为类生成默认拷贝构造函数,完成类成员拷贝构造函数的调用 & 类成员虚函数表指针的拷贝,从而完成虚函数表指针的拷贝。
  • 基类

    1. 基类,是一个有拷贝构造函数的类 。

      • 子类执行拷贝构造函数时,先调用父类的拷贝构造函数,

      • 为了能够调用到父类的拷贝构造,不得不生成默认的拷贝构造函数。

    2. 基类,有一个或多个虚函数。

      • 如果不提供默认拷贝构造函数,

        • 会进行位拷贝。类成员的拷贝构造函数不被调用
        • 从而基类的虚函数表指针(可能)会丢失
      • 需要为类生成默认拷贝构造函数,调用基类的拷贝构造函数

        完成基类拷贝构造函数的调用,从而完成虚函数表指针的拷贝。

我认为以上解释存在存在虚函数的成员时,“为了避免浅拷贝”这个理由是错的。

  • 浅拷贝 相当于多个对象 共用一个指针,由于没有人能确保所有权,其指向可能被释放

  • 但是,虽然 虚表指针是 一 个 指 针 可对于同一个类,

    • 其指向是固定的 其虚函数表在rodata区。

    • 任何一个对象释放都不会去释放这个属于类的虚函数表。

那么为什么,有虚函数表的类 编译器会帮我们 默认拷贝构造函数呢?

我认为是使用 其同样拥有虚函数表的 子类 进行拷贝时。确保虚表指针的正确(存疑),以及正确拷贝内存中的内容 不要包含子类部分。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值