本文整理了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就是这么做的 。

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

根据上面的描述,我们衍生出The Rule of Four (and a half)——如果某个类直接管理某种资源,那么我们可能需要实现以下四个特殊的成员函数,以确保正确性和性能:

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

另外,我们还需要实现两个版本的swap函数,一个作为成员函数,一个作为非成员函数。根据The Rule of Four (and a half),我们实现了一个较为高效的Vec类:

class Vec {
public:
int *ptr_;
int size_; Vec(const Vec& rhs) {
ptr_ = new int[rhs.size_];
size_ = rhs.size_;
std::copy(rhs.ptr_, rhs.ptr_ + size_, ptr_);
} Vec(Vec&& rhs) noexcept {
ptr_ = std::exchange(rhs.ptr_, nullptr);
size_ = std::exchange(rhs.size_, 0);
}
// two-argument swap, to make efficiently "std::swappable"
friend void swap(Vec& a, Vec& b) noexcept {
a.swap(b);
} ~Vec() {
delete [] ptr_;
} Vec& operator=(Vec copy) {
copy.swap(*this);
return *this;
}
// member swap, for simplicity
void swap(Vec& rhs) noexcept {
using std::swap;
swap(ptr_, rhs.ptr_);
swap(size_, rhs.size_);
}
};

通过将原始指针更换为unique_ptr,我们可以实现一个接近The Rule of Zero的Vec类:

class Vec {
public:
std::unique_ptr<int[]> uptr_;
int size_; // copy the resource
Vec(const Vec& rhs) {
uptr_ = std::make_unique<int[]>(rhs.size_);
size_ = rhs.size_;
std::copy(rhs.uptr_, rhs.uptr_ + rhs.size_, uptr_);
} // transfer ownership
Vec(Vec&& rhs) noexcept = default; friend void swap(Vec& a, Vec& b) noexcept {
a.swap(b);
} // free the resource
~Vec() = default; // free and transfer ownership
Vec& operator=(Vec copy) {
copy.swap(*this);
return *this;
} // swap ownership
void swap(Vec& rhs) noexcept {
using std::swap;
swap(uptr_, rhs.uptr_);
swap(size_, rhs.size_);
}
};

当然,真正的The Rule of Zero,还是得靠std::vector来实现:

class Vec {
public:
std::vector<int> vec_; Vec(const Vec& rhs) = default;
Vec(Vec&& rhs) noexcept = default;
Vec& operator=(const Vec& rhs) = default;
Vec& operator=(Vec&& rhs) = default;
~Vec() = default; // swap ownership
// now only for performance, not correctness
void swap(Vec& rhs) noexcept {
vec_.swap(rhs.vec_);
} friend void swap(Vec& a, Vec& b) {
a.swap(b);
}
};

总结一下,如果某个类需要直接管理资源,那么为了保证正确性,我们需要为该类实现析构函数、拷贝构造函数和拷贝赋值运算符(The Rule of Three);为了保证性能,我们还可以实现移动构造函数和移动拷贝赋值运算符(The Rule of Five)。如果某个类不直接管理资源,那么就不要实现任何特殊的成员函数(The Rule of Zero)。

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

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

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

  2. Assembler : The Basics In Reversing

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

  3. [转]Virtualization Basics

    Virtualization Basics Virtualization is not a new concept, but its complexity has been growing, and ...

  4. 课程一(Neural Networks and Deep Learning),第二周(Basics of Neural Network programming)—— 4、Logistic Regression with a Neural Network mindset

    Logistic Regression with a Neural Network mindset Welcome to the first (required) programming exerci ...

  5. ACM-ICPC2018沈阳网络赛 Lattice's basics in digital electronics(模拟)

    Lattice's basics in digital electronics 44.08% 1000ms 131072K   LATTICE is learning Digital Electron ...

  6. upc组队赛15 Lattice's basics in digital electronics【模拟】

    Lattice's basics in digital electronics 题目链接 题目描述 LATTICE is learning Digital Electronic Technology. ...

  7. Salesforce的sharing Rule 不支持Lookup型字段解决方案

    Salesforce 中 sharing rule 并不支持Look up 字段 和 formula 字段.但在实际项目中,有时会需要在sharing rule中直接取Look up型字段的值,解决方 ...

  8. yii2权限控制rbac之rule详细讲解

    作者:白狼 出处:http://www.manks.top/yii2_rbac_rule.html 本文版权归作者,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留 ...

  9. RBAC中 permission , role, rule 的理解

    Role Based Access Control (RBAC)——基于角色的权限控制 permission e.g. creating posts, updating posts role A ro ...

随机推荐

  1. 用jquery实现省市联动

    <!-- 需求: [1] 动态生成省份选择框. [2] 当选择了省份的某一项时, 动态改变 城市选择中的列表项. --> <!DOCTYPE html> <html la ...

  2. 机器学习术语表——Beta

    机器学习术语表 Beta 提示:你可以通过中文名称拼音首字母快速检索. C 超参数|Hyperparameter 在机器学习中,超参数是在开始学习过程之前设置值的参数,而不是通过训练得到的参数数据.通 ...

  3. XCTF-反序列化中_wakeup()函数

    跳过_wakeup()魔法函数__wakeup(): 将在序列化之后立即被调用漏洞原理: 当反序列化字符串中,表示属性个数的值大于其真实值,则跳过__wakeup()执行 对于该题,先可以看到类xct ...

  4. 记一次线上SpringCloud-Feign请求服务超时异常排查

    由于近期线上单量暴涨,第三方反馈部分工单业务存在查询处理失败现象,经排查是当前系统通过FeignClient调用下游系统出现部分超时失败(异常代码贴在下方). Caused by: feign.Ret ...

  5. Android-ViewPager中调用notifyDataSetChanged失效问题--setItemPosition--POSITION_NONE

    最基本的方法: 针对于child view比较简单的情况(例如仅有TextView.ImageView等,没有ListView等展示数据的情况),可以在自己的Adapter中加入代码: @Overri ...

  6. Spring中的单例模式

    Spring中的单例模式 单例模式的介绍 1.1 简介 ​ 保证整个应用中某个实例有且只有一个 1.2作用 保证一个类仅有一个实例,并且提供一个访问它的全局访问点. 单例模式的优点和缺点 单例模式的优 ...

  7. Vue3.2中的setup语法糖,保证你看的明明白白!

    vue3.2 到底更新了什么? 根据原文内容的更新的内容主要有以下 5 块: 1.SSR:服务端渲染优化.@vue/server-renderer包加了一个ES模块创建, 与Node.js解耦,使在非 ...

  8. 码风QwQ

    注:卡常.压行时怎么有效怎么来QwQ 快读真香.( 不喜欢用字符数组,使用string. 此时cin cout输入前会加这样三句以优化: ios::sync_with_stdio(0); cin.ti ...

  9. %r和%s的区别

    理解%r和%s的区别 %r会重现所表达的对象,%s会将所有转成字符串 eg1: print('i am %s years old' % 22) print('i am %r years old' % ...

  10. plsql 储存过程 参数的传递方式?

    /* 存储过程 一.oracel存储过程 1.没有返回值 return 值: 2.用输出参数来代替返回值: 3.输出参数可以有多个 二.参数的传递方式 1. 按位置传递 2. 按名字传递 3.混合传递 ...