本文整理了Arthur O'Dwyer在CppCon 2019上关于RAII的演讲,演讲的slides可以在此链接进行下载。

在C++程序中,我们往往需要管理各种各样的资源。资源通常包括以下几种:

  • Allocated memory (malloc/free, new/delete, new[]/delete[])
  • POSIX file handles (open/close)
  • C File handles (fopen/fcolse)
  • Mutex locks (pthread_mutex_lock/pthread_mutex_unlock)
  • C++ threads (spawn/join)

上面这些资源,有些的管理权是独占的(比如mutex locks),而另一些的管理权则可以是共享的(比如堆、文件句柄等)。重要的是,程序需要采取一些明确的措施才能释放资源。下面,我们将以经典的堆分配为例,来说明资源管理中的若干问题。

下面的代码实现了一个非常朴素的向量类,它提供了push_back接口,每次调用push_back都会释放旧资源,然后申请新资源。

class NaiveVector {
public:
int *ptr_;
size_t size_; NaiveVector() : ptr_(nullptr), size_(0) {}
void push_back(int newvalue) {
int *newptr = new int[size_ + 1];
std::copy(ptr_, ptr_ + size_, newptr);
delete [] ptr_;
ptr_ = newptr;
ptr_[size_++] = newvalue;
}
};

在上面代码的第6行,构造函数正确地初始化了ptr_size_。在push_back函数的实现中,也正确地实现了资源的申请和释放。到目前为止,一切看起来都如我们所愿,没有发生任何的资源泄漏。

{
NaiveVector vec; // here ptr_ is initialized with 0 elements
vec.push_back(1); // ptr_ is correctly updated with 1 element
vec.push_back(2); // ptr_ is correctly updated with 2 elements
}

考虑上面这块代码,在作用域中,我们创建了一个NaiveVector类型的对象vec,然后调用两次push_back函数。每次调用push_backptr_所指向的资源将会被释放,然后指向一个新申请的资源。当离开作用域时,局部对象vec被销毁,但此时vec对象中的ptr_成员仍然指向着某个资源,在销毁vec对象时,该资源并没有被释放,这就导致了资源的泄露。

显然,为了防止资源泄漏,我们需要在销毁vec对象时正确地释放掉它所管理的那些资源。注意到在创建某个类型的对象时,编译器会调用该类型的构造函数;相应地,当某个对象的生命周期结束时,编译器会调用析构函数来销毁该类型的对象。还是以上面的代码为例,在第2行编译器调用NaiveVector的构造函数创建对象;在第5行离开作用域时,编译器会调用析构函数销毁局部对象vec。因此,我们只需要实现一个析构函数并在其中释放掉所管理的资源,就能避免对象析构时的资源泄漏。新版的NaiveVector实现如下所示,其中第14行实现了析构函数。

class NaiveVector {
public:
int *ptr_;
size_t size_; NaiveVector() : ptr_(nullptr), size_(0) {}
void push_back(int newvalue) {
int *newptr = new int[size_ + 1];
std::copy(ptr_, ptr_ + size_, newptr);
delete [] ptr_;
ptr_ = newptr;
ptr_[size_++] = newvalue;
}
~NaiveVector() { delete [] ptr_; }
};

然而,实现了析构函数以后,NaiveVector仍然会导致资源泄漏,这是由对象的拷贝操作引起的。如果我们没有为该类实现拷贝构造函数,那么编译器会生成一个合成的拷贝构造函数。合成拷贝构造函数的行为非常简单,它会逐一拷贝对象中的每个成员。对于指针类型的成员来说,它仅拷贝指针的值。

{
NaiveVector v;
v.push_back(1);
{
NaiveVector w = v;
}
std::cout << v[0] << "\n";
}

上面代码的第5行调用了NaiveVector类型的合成拷贝构造函数。拷贝操作完成后,w.ptr_v.ptr_指向同一块内存资源。当执行到第6行时,离开了w对象的作用域,编译器会调用w的析构函数来释放w.ptr_所管理的资源并销毁该对象。由于w.ptr_v.ptr_指向同一块资源,而这一块资源已经被w的析构函数释放掉了,因此在第7行对v[0]的访问就成了未定义行为。此外,在第8行离开v对象的作用域时,编译器又会调用v的析构函数来释放资源,这就导致了对同一块资源的重复释放,这同样是一个未定义行为。

正确地实现拷贝构造函数可以解决上述问题。换句话说,如果我们为某个类实现了析构函数,那么我们同样需要为它实现拷贝构造函数。析构函数负责释放资源以避免泄漏,而拷贝构造函数负责拷贝资源以避免重复释放。下面的代码实现了相应的拷贝构造函数。

class NaiveVector {
public:
int *ptr_;
size_t size_; NaiveVector() : ptr_(nullptr), size_(0) {}
~NaiveVector() { delete [] ptr_; } NaiveVector(const NaiveVector& rhs) {
ptr_ = new int[rhs.size_];
size_ = rhs.size_;
std::copy(rhs.ptr_, rhs.ptr_ + size_, ptr_);
}
};

仅仅实现拷贝构造函数还不够,我们还需要实现拷贝赋值运算符。类似于合成拷贝构造函数,合成拷贝赋值运算符同样是拷贝每个成员的值。当离开对象的作用域时,合成拷贝赋值运算符同样会导致资源的重复释放。因此,我们还需要实现拷贝赋值运算符。下面的代码正确地实现了拷贝赋值运算符。

class NaiveVector {
int *ptr_;
size_t size_;
public:
NaiveVector() : ptr_(nullptr), size_(0) {}
~NaiveVector() { delete [] ptr_; } NaiveVector(const NaiveVector& rhs) {
ptr_ = new int[rhs.size_];
size_ = rhs.size_;
std::copy(rhs.ptr_, rhs.ptr_ + size_, ptr_);
} NaiveVector& operator=(const NaiveVector& rhs) {
NaiveVector copy = rhs;
copy.swap(*this);
return *this;
}
};

综合上面的分析,我们可以得出结论——如果一个类需要直接管理某些资源,那么我们就要收手动地为这个类实现三个特殊的成员函数:

  • 析构函数,负责释放资源
  • 拷贝构造函数,负责拷贝资源
  • 拷贝赋值运算符,负责释放运算符左边的资源并拷贝运算符右面的资源

    这就是大名鼎鼎的The Rule of Three。另外,需要注意的是,我们可以通过拷贝并交换原语(copy-and-swap idiom)来实现拷贝复制运算符。欸,为什么需要通过拷贝并交换来实现拷贝赋值运算符呢?直接像下面这样,先释放旧资源再申请新资源不行吗?
NaiveVector& NaiveVector::operator=(const NaiveVector& rhs) {
delete ptr_;
ptr_ = new int[rhs.size_];
size_ = rhs.size_;
std::copy(rhs.ptr_, rhs.ptr_ + size_, ptr_);
return *this;
}

答案显然是不行,因为上面的这种实现不能正确地处理自我赋值(self-assignment)的情况。在自我赋值的情况下,ptr_所指向的资源被释放,新申请的资源中包含的均是未定义的值,此时显然已经无法进行正确的拷贝操作。而在下面的拷贝并交换实现中,我们在修改*this对象之前就对rhs进行了一次完整的拷贝(通过拷贝构造函数),这就避免了自我赋值中的陷阱。

NaiveVector& NaiveVector::operator=(const NaiveVector& rhs) {
NaiveVector copy(rhs);
copy.swap(*this);
return *this;
}

RAII的全称为Resource Acquisition Is Initialization,意思是资源获取即初始化。表面上看,RAII是关于初始化的,但实际上RAII更注重于资源的正确释放。使用RAII有助于我们写出异常安全的代码。考虑下面的代码,在第3行我们申请了内存资源,如果此时程序抛出异常,那么已经申请的资源就不能正确地被释放,从而导致内存泄漏。

int main() {
try {
int *arr = new int[4];
throw std::runtime_error("for example");
delete [] arr; // clean up
} catch (const std::exception& e) {
std::cout << "Caught an exception: " << e.what() << "\n";
}
return 0;
}

为了避免这个问题,我们可以使用RAII技术,将资源释放操作放到析构函数中。这样的话,即使程序抛出了异常,也能够正确地释放掉相应的资源。

struct RAIIPtr {
int *ptr_;
RAIIPtr(int *p) ptr_(p) {}
~RAIIPtr() { delete [] ptr_; }
}; int main() {
try {
RAIIPtr arr = new int[4];
throw std::runtime_error("for example");
} catch (const std::exception& e) {
std::cout << "Caught an exception: " << e.what() << "\n";
}
return 0;
}

注意上面的RAIIPtr实现仍然可能会导致资源泄漏,因为我们没有实现拷贝构造函数和拷贝赋值运算符。当然,通过向拷贝构造函数和拷贝赋值运算符添加=delete,我们可以让RAIIPtr变成不可拷贝的(non-copyable)。

struct RAIIPtr {
int *ptr_;
RAIIPtr(int *p) ptr_(p) {}
~RAIIPtr() { delete [] ptr_; } RAIIPtr(const RAIIPtr&) = delete;
RAIIPtr& operator=(const RAIIPtr&) = delete;
};

使用=delete之后,编译器就不会为RAIIPtr生成任何拷贝构造函数和拷贝赋值运算符,任何拷贝操作都会被拒绝。类似地,我们可以通过=default来让编译器生成默认的成员函数。如果某个类不直接管理任何资源,而仅使用vectorstring之类的库,那么我们就不应该为它编写任何特殊的成员函数,使用默认的即可。这就是我们所说的The Rule of Zero。

移动语义和The Rule of Five

C++11中引入了右值引用和移动语义,由此产生了移动构造函数和移动拷贝赋值运算符。一般来说,移动一个对象比拷贝一个对象的速度要快,尤其是当对象较大的时候。

class NaiveVector {
// copy constructor
NaiveVector(const NaiveVector& rhs) {
ptr_ = new int[rhs.size_];
size_ = rhs.size_;
std::copy(rhs.ptr_, rhs.ptr_ + size_, ptr_);
} // move constructor
NaiveVector(NaiveVector&& rhs) {
ptr_ = std::exchange(rhs.ptr_, nullptr);
size_ = std::exchange(rhs.size_, 0);
}
};

因此,为了保证正确性和性能,我们有了The Rule of Five——如果某个类直接管理某种资源,那么我们可能需要实现以下五个特殊的成员函数:

  • 析构函数,负责释放资源
  • 拷贝构造函数,负责拷贝资源
  • 移动构造函数,负责转移资源的所有权
  • 拷贝赋值运算符,负责释放运算符左边的资源并拷贝运算符右边的资源
  • 移动赋值运算符,负责释放运算符左边的资源并转移运算符右边资源的所有权

需要注意的是,拷贝赋值运算符和移动赋值运算符的实现几乎一致,仅有微小的差别:

NaiveVector& NaiveVector::operator=(const NaiveVector& rhs) {
NaiveVector copy(rhs);
copy.swap(*this);
return *this;
} NaiveVector& NaiveVector::operator=(NaiveVector&& rhs) {
NaiveVector copy(std::move(rhs));
copy.swap(*this);
return *this;
}

因此,一种想法是只实现一个赋值运算符(by-value assignment operator),将拷贝和移动的选择权交给函数的调用者,如下所示。不过这种实现方式并不常见,最好还是将拷贝赋值和移动赋值分开实现,毕竟STL就是这么做的

CppCon 2019 | Back to Basics: RAII and The Rule of Zero的更多相关文章

  1. Back to Basics: RAII and The Rule of Zero

    本文整理了Arthur O'Dwyer在CppCon 2019上关于RAII的演讲,演讲的slides可以在此链接进行下载. 在C++程序中,我们往往需要管理各种各样的资源.资源通常包括以下几种: A ...

  2. [转]awsome c++

    原文链接 Awesome C++ A curated list of awesome C++ (or C) frameworks, libraries, resources, and shiny th ...

  3. 42028: Assignment 1 – Autumn 2019

    42028: Assignment 1 – Autumn 2019 Page 1 of 4Faculty of Engineering and Information TechnologySchool ...

  4. 2019 DevOps 技术指南

    原文链接:https://hackernoon.com/the-2018-devops-roadmap-31588d8670cb 原文作者:javinpaul 翻译君:CODING 戴维奥普斯 写在前 ...

  5. 计算机电子书 2019 BiliDrive 备份

    下载方式 pip install BiliDriveEx bdex download <link> 链接 文档 链接 传智播客轻松搞定系列 C.C++.Linux.设计模式.7z (33. ...

  6. 2019年台积电进军AR芯片,将用于下一代iPhone

    近日,有报道表示台积电10nm 芯片可怜的收益率可能会对 2017 年多款高端移动设备的推出产生较大的影响,其中自然包括下一代 iPhone 和 iPad 机型.不过,台积电正式驳斥了这一说法,表明1 ...

  7. The RAII Programming Idiom

    https://www.hackcraft.net/raii/ https://en.wikipedia.org/wiki/Resource_Acquisition_Is_Initialization

  8. Resource Acquisition Is Initialization(RAII Idiom)

    原文链接:http://en.wikibooks.org/wiki/More_C%2B%2B_Idioms/Resource_Acquisition_Is_Initialization Intent ...

  9. Assembler : The Basics In Reversing

    Assembler : The Basics In Reversing Indeed: the basics!! This is all far from complete but covers ab ...

随机推荐

  1. std::unordered_map与std::map

    前者查找更快.后者自动排序,并可指定排序方式. 资料参考: https://blog.csdn.net/photon222/article/details/102947597

  2. CMD 中运行 xx 命令提示 不是内部或外部命令,也不是可运行的程序或批处理文件的问题

    出现这个问题的原因一般有2个 这个命令依赖某个软件,而你又没有安装 这里你只需要去下载安装好对应的软件,基本上就可以解决上面的问题了. 软件安装好了,但是需要配置环境变量 第二个原因就按照下图,去设置 ...

  3. Python 装饰器原理剖析

    以下内容仅用于帮助个人理解装饰器这个概念,案例可能并不准确. 什么是装饰器? 我们知道iPhone 应用商店中有成千上万的APP,我们也知道苹果系统每年都会大版本更新增加很多新功能.这些功能要想发挥出 ...

  4. 字节码增强技术-Byte Buddy

    本文转载自字节码增强技术-Byte Buddy 为什么需要在运行时生成代码? Java 是一个强类型语言系统,要求变量和对象都有一个确定的类型,不兼容类型赋值都会造成转换异常,通常情况下这种错误都会被 ...

  5. (十一) 数据库查询处理之连接(Join)

    (十一) 数据库查询处理之连接(Join) 1. 连接操作的一个例子 把外层关系和内层关系中满足一定关系的属性值拼接成一个新的元组 一种现在仍然十分有用的优化思路Late Materializatio ...

  6. Python爬虫系统化学习(2)

    Python爬虫系统学习(2) 动态网页爬取 当网页使用Javascript时候,很多内容不会出现在HTML源代码中,所以爬取静态页面的技术可能无法使用.因此我们需要用动态网页抓取的两种技术:通过浏览 ...

  7. 微信支付 V3 的 Java 实现 Payment Spring Boot-1.0.7.RELEASE 发布

    Payment Spring Boot 是微信支付V3的Java实现,仅仅依赖Spring内置的一些类库.配置简单方便,可以让开发者快速为Spring Boot应用接入微信支付. 功能特性 实现微信支 ...

  8. Chome 88如何正确隐藏 webdriver?

    从 Chrome 88开始,它的 V8 引擎升级了,一些接口发生了改变. 使用 Selenium 调用 Chrome 的时候,只需要增加一个配置参数: chrome_options.add_argum ...

  9. Ext.Net一般处理程序上传文件

    引言 最近公司项目全部转向前端化,故所有aspx页面业务逻辑尽可能的转到用户控件前台页面完成.以方便每次发布项目时只是替换前端页面不会影响客户体验. 既然转到前台逻辑,那么必须走后台的业务也就单独封装 ...

  10. Redis持久化操作RDB和AOF 对比于HDFS的SecondaryNode

    写在前面的话 最近学习比较多流行的大数据框架和完成两个大数据项目后,又突然学起了Redis.之所以之前的框架不学习记录呢,是因为之前的学习都是为了完成参加服创比赛的项目所以时间较紧,现在基本架构和编码 ...