Back to Basics: RAII and The Rule of Zero
本文整理了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_back,ptr_所指向的资源将会被释放,然后指向一个新申请的资源。当离开作用域时,局部对象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来让编译器生成默认的成员函数。如果某个类不直接管理任何资源,而仅使用vector和string之类的库,那么我们就不应该为它编写任何特殊的成员函数,使用默认的即可。这就是我们所说的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的更多相关文章
- CppCon 2019 | Back to Basics: RAII and The Rule of Zero
		
本文整理了Arthur O'Dwyer在CppCon 2019上关于RAII的演讲,演讲的slides可以在此链接进行下载. 在C++程序中,我们往往需要管理各种各样的资源.资源通常包括以下几种: A ...
 - Assembler : The Basics In Reversing
		
Assembler : The Basics In Reversing Indeed: the basics!! This is all far from complete but covers ab ...
 - [转]Virtualization Basics
		
Virtualization Basics Virtualization is not a new concept, but its complexity has been growing, and ...
 - 课程一(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 ...
 - ACM-ICPC2018沈阳网络赛 Lattice's basics in digital electronics(模拟)
		
Lattice's basics in digital electronics 44.08% 1000ms 131072K LATTICE is learning Digital Electron ...
 - upc组队赛15  Lattice's basics in digital electronics【模拟】
		
Lattice's basics in digital electronics 题目链接 题目描述 LATTICE is learning Digital Electronic Technology. ...
 - Salesforce的sharing Rule 不支持Lookup型字段解决方案
		
Salesforce 中 sharing rule 并不支持Look up 字段 和 formula 字段.但在实际项目中,有时会需要在sharing rule中直接取Look up型字段的值,解决方 ...
 - yii2权限控制rbac之rule详细讲解
		
作者:白狼 出处:http://www.manks.top/yii2_rbac_rule.html 本文版权归作者,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留 ...
 - RBAC中 permission , role, rule 的理解
		
Role Based Access Control (RBAC)——基于角色的权限控制 permission e.g. creating posts, updating posts role A ro ...
 
随机推荐
- Servlet初级学习加入数据库操作(二)
			
源代码地址:https://url56.ctfile.com/f/34653256-527822631-2e255a(访问密码:7567) 将页面中的数据逐步替换为数据库管理 准备一个连接数据库的类 ...
 - android入门--环境搭建
			
运行环境 windows 7 下载地址 环境下载 最近开接触Android(安卓)嵌入式开发,首要问题是搭建Andoid开发环境,由于本人用的是windows7的笔记本,也就只能到Windows中搭建 ...
 - python 读取配置文件ini ---ConfigParser
			
Python读取ini文件需要用到 ConfigParser 模块 关于ConfigParser模块的介绍详情请参照官网解释:https://docs.python.org/2.7/library/c ...
 - [硬拆解]拆解一个USB转CAN总线设备-PCAN-USB
			
介绍 PCAN-USB适配器可以简单地连接到CAN网络.其紧凑的塑料外壳使它适合移动应用.光电去耦版隔离了PC和CAN端之间高达500伏特的电流隔离. 该包还提供了Windows的CAN monito ...
 - 【记录一个问题】thanos receiver的日志中出现错误:conflict
			
完整的错误如下: level=debug ts=2021-08-16T09:07:43.412451Z caller=handler.go:355 component=receive componen ...
 - synergy最佳解决方案——barrier
			
synergy最佳解决方案--barrier  不知道大家有没有一套键盘鼠标控制多台电脑的需求,主流的硬件或说软件有大神整理如下: 软件方案: Windows 之间:Mouse Without Bo ...
 - gorm中的更新
			
保存所有字段 Save 会保存所有的字段,即使字段是零值. db.First(&user, 5)user.Name = sql.NullString{"王八", true} ...
 - Sping简介
			
SSH:Struct2 + Spring +Hibernate SSM:SpringMVC + Spring + Mybatis 优点 1,Sping是一个开源的免费的框架(容器) 2,Spirng是 ...
 - 开源办公套件DzzOffice安装教程
			
DzzOffice开源办公套件 DzzOffice是一套开源办公套件,适用于企业.团队搭建自己的 类似"Google企业应用套件"."微软Office365"的 ...
 - ORACLE数据库误操作DELETE并且提交数据库之后如何恢复被删除的数据
			
一:根据时间来恢复: 1.查询数据库当前时间() select to_char(sysdate,'yyyy-mm-dd hh24:mi:ss') from dual; 2.查询删除数据时间点之前的数据 ...