C++拷贝、赋值和销毁

如果在构造函数中,采用赋值初始化某个变量,并且参数和变量名一样,会某些情况下出现赋值失败的情况。

1. 拷贝构造函数

如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数时拷贝构造函数

直接初始化:要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。

拷贝初始化:要求编译器将右侧运算对象拷贝到我们正在创建的对象中,如果需要的话还要进行类型转换。

拷贝初始化发生在下列情况:

  • 将一个对象作为实参传递给一个非引用类型的形参;
  • 从一个返回非引用类型的函数返回一个对象;
  • 从花括号列表初始化一个数组中的元素或一个聚合类中的成员。

2. 拷贝赋值运算符

如果我们不定义拷贝赋值运算符,编译器会为我们自动合成一个,但这个合成的赋值运算符没法处理深拷贝的情况。

3. 析构函数

析构函数释放对象使用的资源,并销毁对象的非static数据成员。

什么时候会调用析构函数?

  • 变量在离开其作用域时被销毁;
  • 当一个对象被销毁时,其成员被销毁;
  • 容器被销毁时,其元素被销毁;
  • 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁;
  • 对于临时对象,当创建它的完整表达式结束时被销毁。

4. 三/五法则

  1. 需要析构函数的类也需要拷贝和赋值操作;
  2. 需要拷贝操作的类也需要赋值操作,反之亦然。但是,无论是需要拷贝构造函数还是需要拷贝赋值运算符都不必然意味着也需要析构函数。

5. 拷贝控制和资源管理

5.1 行为像值的类

csharp 复制代码
 class HasPtr {
 public:
     //有点子复杂;
     HasPtr(const string& s = string()): ps(new string(s)), i(0) { }
 ​
     //定义一个拷贝构造函数;
     HasPtr(const HasPtr& p): ps(new string(*p.ps)), i(p.i) { }
 ​
     //拷贝赋值运算符;
     //先把右边的保存,可以防止自我赋值;
     HasPtr& operator = (const HasPtr& rhs) {
         auto newp = new string(*rhs.ps);
         delete ps;
         ps = newp;
         i = rhs.i;
         return *this;
     }
 ​
     ~HasPtr() {
         delete ps;
     }
 private:
     string *ps;
     int i;
 };

5.2 行为像指针的类

定义像指针的类,需要自己管理引用计数。唯一的难题时确定在哪里存放引用计数,计数器不能直接作为HasPtr对象的成员。 下面的例子说明了原因:

scss 复制代码
 HasPtr p1("Hiya!");
 HasPte p2(p1);
 HasPtr p3(p1);

如果引用计数保存在每个对象中,当创建p3时如何正确地更新它呢?解决此问题的一种办法时将计数器保存在动态内存中。

引用计数地的工作方式如下:

  • 除了初始化对象,每个构造函数还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享窗台。当我们创建一个对象时,只有一个对象共享状态,因此将计数器初始化为1;
  • 拷贝赋值运算符递增左侧运算对象的计数器,递减右侧运算对象的计数器。注意防止自我赋值
  • 析构函数递减运算器,指出共享状态的用户少了一个。如果计数器变为0,则析构函数释放状态。
ini 复制代码
 class HasPtr {
 public:
     //构造函数;
     HasPtr(const string& s = string()): ps(new string(s)), i(0), use(new std::size_t(1)) { }
 ​
     //拷贝构造函数;
     HasPtr(const HasPtr& p): ps(p.ps), i(p.i), use(p.use) {
         ++(*use);
     }
 ​
     HasPtr& operator = (const HasPtr& rhs) {
         //先递增右边的,可以防止自我赋值;
         ++(*rhs.use);
         if(--(*use) == 0) {
             delete ps;
             delete use;
         }
         ps = rhs.ps;
         i = rhs.i;
         use = rhs.use;
         return *this;
     }
 ​
     ~HasPtr() {
         if(--(*use) == 0) {
             delete ps;
             delete use;
         }
     }
 private:
     string *ps;
     int i;
     std::size_t  *use;
 };

5.3 交换操作

除了定义拷贝控制成员,管理资源的类通常还定义了一个名为swap的函数。如果一个类定义了自己的swap,那么算法将使用类自定义版本。否则,算法将使用标准库定义的swap。

理论上交换两个对象需要进行一次拷贝和两次赋值。我们更希望swap交换指针,而不是分配string的新副本。

可以在我们自己的类上定义一个自己版本的swap。

arduino 复制代码
 class HasPtr {
     friend void swap(HasPtr& , HasPtr&);
     //其它定义不变;
 }
 ​
 inline
 void swap(HasPtr &lhs, HasPtr &rhs) {
     using std::swap;
     swap(lhs.ps, rhs,ps);           //交换指针,而不是string;
     swap(lhs.i, rhs.i);
 }

需要注意的是:每个swap调用应该是未限定的。即,每个调用都应该是swap,而不是std::swap。如果存在特定类型的swap版本,其匹配程度会优于std中定义的版本。

在赋值运算符中使用swap:

kotlin 复制代码
 //注意rhs时按值传递的,意味着HasPtr的拷贝构造函数;
 //将右侧运算对象中的string拷贝到rhs;
 HasPtr& HasPtr::operatoe = (HasPtr rhs) {
     swap(*this, rhs);
     return *this;                
 }

使用拷贝并交换的赋值运算符自动就是异常安全的,且能正确处理自赋值。

6. 动态内存管理类

某些类需要在运行时分配可变大小的内存空间。我们这里实现标准库vector类的一个简化版本,这个类只适用于string。因此被命名为StrVec。

c 复制代码
 #include <memory>
 #include <string>
 ​
 //类vector类内存分配策略的简化实现;
 class StrVec {
 public:
     //allocator成员进行默认初始化;
     StrVec():elements(nullptr), first_free(nullptr), cap(nullptr) {}
     StrVec(const StrVec&);              //拷贝构造函数;
     StrVec& operator=(const StrVec&); //拷贝赋值运算符;
     ~StrVec();                          //析构函数;
     void push_back(const std::string&); //添加元素;
     size_t size() const { return first_free - elements; }
     size_t capacity() const { return cap - elements; }
     std::string* begin() const { return elements; }
     std::string* end() const { return first_free; }
 ​
 private:
     static std::allocator<std::string > alloc;  //分配元素:
     //被添加元素的函数所使用;
     void chk_n_alloc() {
         if(size() == capacity()) reallocate();
     }
     //工具函数,被拷贝构造函数、赋值运算符和析构函数所使用;
     std::pair<std::string*, std::string*> alloc_n_copy(const std::string*, const std::string*);
     void free();              //销毁元素并释放内存;
     void reallocate();        //获得更多内存并拷贝已有元素;
 ​
     //私有成员变量;
     std::string* elements;    //指向数组首元素的指针;
     std::string* first_free;  //指向数组第一个空闲元素的指针;
     std::string* cap;         //指向数组尾后位置的指针;
 };
 ​
 void StrVec::push_back(const std::string& s) {
     //先检查是否有充足空间容纳新元素;
     chk_n_alloc();
     alloc.construct(first_free++, s);
 }
 ​
 std::pair<std::string*, std::string*>
 StrVec::alloc_n_copy(const std::string *b, const std::string *e) {
     //分配空间保存给定范围中的元素;
     auto data = alloc.allocate(e - b);
     //初始化并返回一个pair;
     //uninitialized_copy从迭代器b和e指出的输入范围内拷贝元素到迭代器data指定的未构造的内存中;
     return {data, std::uninitialized_copy(b, e, data)};
 }
 ​
 void StrVec::free() {
     if(elements) {
         //逆序销毁旧元素;
         for(auto p = first_free; p != elements; /* 空 */)
             //这里设计很妙,对于这种左闭右开区间的遍历思路很好;
             alloc.destroy(--p);
         alloc.deallocate(elements, cap - elements);
     }
 }
 ​
 //拷贝构造函数;
 StrVec::StrVec(const StrVec &s) {
     //调用alloc_n_copy分配空间以容纳与s中一样多的元素;
     auto newdata = alloc_n_copy(s.begin(), s.end());
     elements = newdata.first;
     first_free = cap = newdata.second;
 }
 ​
 StrVec::~StrVec() { free(); }
 ​
 StrVec& StrVec::operator=(const StrVec &rhs) {
     auto data = alloc_n_copy(rhs.begin(), rhs.end());
     free();
     elements = data.first;
     first_free = cap = data.second;
     return *this;
 }
 ​
 void StrVec::reallocate() {
     //将分配当前两倍大小的空间;
     //因为只有size() == cacacity()时才调用reallocate,所以此时可以用size();
     auto newcapacity = size() ? 2 * size() : 1;
     auto newdata = alloc.allocate(newcapacity);
     //将数据从旧内存迁移到新内存;
     auto dest = newdata; //指向新数组中的下一个空闲位置;
     auto elem = elements;//指向旧数组中的下一个元素;
     for(size_t i = 0; i != size(); i++)
         alloc.construct(dest++, std::move(*elem++));
     //释放旧内存:
     free();
     //更新数据元素;
     elements = newdata;
     first_free = dest;
     cap = newdata + newcapacity;
 }

6.1 移动构造函数和std::move

移动构造函数可以避免拷贝,而是通过转移对对象的控制。

另外一个就是名为move的标准库函数,它定义在utility头文件中。它把左值转化为右值。

左值持久;右值短暂

返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的;

返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都生成右值。

我们不能将一个左值引用绑定到这类表达式上,但我们可以将一个const的左值引用或一个右值引用绑定到这类表达上。

由于右值引用只能绑定到临时对象,我们得知:

  • 所引用的对象就要被销毁;
  • 该对象没有其它用户。
ini 复制代码
 int i = 42;
 int &r = i;
 int &&rr = i*23;   //右值引用
 int &&rr1 = std::move(i);  //move告诉编译器,我们有一个左值,但希望像一个右值一样处理它;

调用move就意味着承诺:除了对i赋值或销毁外,我们将不再使用它。在调用move后,我们不能对移后源对象的值做任何假设。

6.2 移动构造函数和移动赋值运算符

移动构造函数第一个参数是该类型的引用,不同于拷贝构造函数的时,这个引用参数在移动构造函数中是一个右值引用。除了完成资源移动,移动构造函数必须确保移后源对象处于这样一个状态--销毁它是无害的。 特别是,一旦资源完成移动,源对象必须不再指向被移动的资源--这些资源的所有权已经归属新创建的对象。

arduino 复制代码
 StrVec::StrVec(StrVec &&s) noexcept //移动构造函数不应抛出任何异常;
     //成员初始化器接管s的资源
     : elements(s.elements), first_free(s.first_free), cap(s.cap){
     //令s进入这样的状态--对其运行析构函数是安全的
     s.elements = s.first_free = s.cap = nullptr;
     }

使用noexcept确保编译器对我们使用移动构造函数的时候不会因为异常而调用拷贝构造函数。

移动赋值运算符执行与析构函数和移动构造函数相同的工作。与移动构造函数一样,如果我们的移动赋值运算符不抛出任何异常,我们就应该将它标记为noexcept。

ini 复制代码
StrVec& StrVec::operator=(StrVec &&rhs) noexcept {
    //直接检测自赋值
    if(this != &rhs) {
        free();
        elements = rhs.elements;
        first_free = rhs.first_free;
        cap = rhs.cap;
        //将rhs置于可析构的状态;
        rhs.elements = rhs.first_free = rhs.cap = nullp
    }
}
相关推荐
鬼火儿7 小时前
SpringBoot】Spring Boot 项目的打包配置
java·后端
cr7xin7 小时前
缓存三大问题及解决方案
redis·后端·缓存
间彧8 小时前
Kubernetes的Pod与Docker Compose中的服务在概念上有何异同?
后端
间彧8 小时前
从开发到生产,如何将Docker Compose项目平滑迁移到Kubernetes?
后端
间彧8 小时前
如何结合CI/CD流水线自动选择正确的Docker Compose配置?
后端
间彧8 小时前
在多环境(开发、测试、生产)下,如何管理不同的Docker Compose配置?
后端
间彧8 小时前
如何为Docker Compose中的服务配置健康检查,确保服务真正可用?
后端
间彧8 小时前
Docker Compose和Kubernetes在编排服务时有哪些核心区别?
后端
间彧8 小时前
如何在实际项目中集成Arthas Tunnel Server实现Kubernetes集群的远程诊断?
后端
brzhang9 小时前
读懂 MiniMax Agent 的设计逻辑,然后我复刻了一个MiniMax Agent
前端·后端·架构