C++当心资源管理类中的拷贝行为

简介: C++当心资源管理类中的拷贝行为

1.管理堆之外的资源


昨天的文章C++中基于对象来管理资源中介绍了如何使用auto_ptr和shared_ptr来管理基于堆(heap)的资源。但对于堆之外的资源(例如Mutex锁),智能指针就不那么好用了,因此我们需要写自己的资源管理类。假设我们现在正在操作一个Mutex锁,如下所示:


1void lock(Mutex* pm);  // 锁住pm所指向的锁
2void unlock(Mutex* pm);  // 解开pm所指向的锁


同时,还有一个符合RAII规范(即获取资源在对象构造过程中,释放资源在对象析构过程中)的类来管理这些锁,如下所示:


1#include <iostream>
 2#include <memory>
 3#include <mutex>
 4
 5void lock(Mutex* pm);
 6void unlock(Mutex* pm);
 7
 8class Lock{
 9public:
10    explicit Lock(Mutex* pm): mutexPtr(pm){
11        lock(mutexPtr);   //  在构造时获取资源,上锁
12    }
13    ~Lock(){
14        unlock(mutexPtr);   //  在析构时释放资源,解锁
15    }
16private:
17    Mutex* mutexPtr;
18};


例如,访问临界区(critical section), 临界区即线程必须互斥地访问某些资源,这些资源必须只能最多由一个线程访问。因此,我们就需要以RAII的方式来进行操作。


1Mutex m;
2
3// ...
4{   // 创建一个代码块来定义临界区
5    Lock m1(&m);  // 构造锁ml,锁住m
6
7    // ... 执行临界区操作
8}  // 临界区结束,调用ml的析构函数,解锁


2.资源管理类中的拷贝行为


以上代码中的用法都是没有问题的,可如果锁被拷贝了呢?如下所示:


1Mutex m;
2
3Lock m1(&m);  // m在m1构造过程中被锁住
4Lock m2(m1);  // 把m1拷贝进m2,注意会发生什么情况?


在创建自己的RAII资源管理类时,我们必须要思考需要如何规定这个类的拷贝行为。对于这个问题,我们有如下选择:


(1).禁止拷贝


有些对象的拷贝是没有意义的,就如上例中的这个锁,没有人会给同一个资源上两个锁。对于这样的类,我们就干脆禁止拷贝。正如之前的文章如果不想使用编译器默认生成的函数,请明确拒绝它提到的需要把拷贝函数声明为私有private来禁止拷贝,如下所示:


1class Lock: private Uncopyable{  // 设为private,禁止拷贝
 2public:
 3    explicit Lock(Mutex* pm): mutexPtr(pm){
 4        lock(mutexPtr);   //  在构造时获取资源,上锁
 5    }
 6    ~Lock(){
 7        unlock(mutexPtr);   //  在析构时释放资源,解锁
 8    }
 9private:
10    Mutex* mutexPtr;
11};


(2).给资源引用计数


有时候我们需要一直持有一个资源直到最后一个对象使用完毕。要实现这样的功能,我们必须有一个计数器来统计当前有多少对象在使用这个资源。当生成一个拷贝时加一,当删除一个拷贝时减一,和shared_ptr智能指针原理是一样的。我们可以替代裸指针把shared_ptr作为RAII对象的数据成员来实现这个功能(将mutexPtr类型从Mutex*变成shared_ptr<Mutex>)。我们知道默认情况下,shared_ptr在引用计数为零时会删除掉它所包含的指针。但对于Mutex锁,我们想要的是解锁而不是删除掉,否则我们是没有办法解开一个被删除的锁。不要着急,这只是默认的情况。shared_ptr还提供了一个特殊的可定义函数,删除器(deleter),即在引用计数为零时调用的函数,是shared_ptr构造函数的一个附加参数。这个函数在auto_ptr中是不存在的,因此它不能有自定义的删除行为,只能删除掉它包括的指针。如下所示:


1class Lock{
2public:
3    explicit Lock(Mutex* pm): mutexPtr(pm, unlock){  // 将unlock函数绑定到删除器
4        lock(mutexPtr.get());   
5    }
6    //  这里不需要定义析构函数     
7private:
8    std::shared_ptr<Mutex> mutexPtr;  // 使用shared_ptr,不使用裸指针
9};


上面的程序中,我们并没有定义析构函数,因为前面的文章C++类中默认生成的函数中提到过,类的析构函数会调用它的非静态数据成员的析构函数。Lock类的析构函数会调用它的成员mutexPtr的析构函数,而当mutexPtr的引用计数为零时,它的析构函数则会调用删除器,即我们绑定的unlock函数。


(3).深拷贝封装的资源


有时候我们可以拥有某个资源的多份拷贝,那么我们的资源管理类就要确保每一份拷贝都要在使用周期结束后释放资源,并且每一份拷贝互不干涉。因此,拷贝这样的对象就要拷贝它包含的所有资源,进行深拷贝(deep copy)。例如当对象包含一个指针,我们必须先生成一个指针的拷贝,分配一个新的内存空间再把数据拷贝过来,这就是深拷贝。如果是浅拷贝,拷贝则直接使用了本体的指针成员,没有生成指针的拷贝,那么两个对象的指针成员就会指向同一个地址,删除拷贝就会导致本体被删除。


(4).转移所有权


有时候我们想要只有一个对象来持有这个资源,因此进行拷贝时,资源的所有权就要从原始对象转移到拷贝对象身上,原始对象不再持有资源,这就是auto_ptr的原理。文章C++类中默认生成的函数中指出编译器会为你生成默认的拷贝函数(即拷贝赋值运算符函数和拷贝构造函数),除非它们能实现你想要的功能,否则我们需要自己写拷贝函数来实现以上的其中一种功能。


3.总结


(1).拷贝RAII对象必须一并拷贝它所管理的资源,所以资源的拷贝行为决定RAII对象的拷贝行为。

(2).常用的RAII类拷贝行为有禁止拷贝、使用引用计数、拷贝资源、转移所有权,但也可以用其他做法来符合特殊需要。


4.参考资料


[1] https://zhuanlan.zhihu.com/p/71805363

相关文章
|
6天前
|
存储 编译器 C++
【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)
本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。
29 4
|
7天前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
26 4
|
30天前
|
存储 编译器 对象存储
【C++打怪之路Lv5】-- 类和对象(下)
【C++打怪之路Lv5】-- 类和对象(下)
27 4
|
30天前
|
编译器 C语言 C++
【C++打怪之路Lv4】-- 类和对象(中)
【C++打怪之路Lv4】-- 类和对象(中)
23 4
|
30天前
|
存储 安全 C++
【C++打怪之路Lv8】-- string类
【C++打怪之路Lv8】-- string类
21 1
|
1月前
|
存储 编译器 C++
【C++类和对象(下)】——我与C++的不解之缘(五)
【C++类和对象(下)】——我与C++的不解之缘(五)
|
1月前
|
编译器 C++
【C++类和对象(中)】—— 我与C++的不解之缘(四)
【C++类和对象(中)】—— 我与C++的不解之缘(四)
|
1月前
|
编译器 C语言 C++
C++入门3——类与对象2-2(类的6个默认成员函数)
C++入门3——类与对象2-2(类的6个默认成员函数)
23 3
|
1月前
|
C++
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
53 1
|
1月前
|
编译器 C语言 C++
C++入门4——类与对象3-1(构造函数的类型转换和友元详解)
C++入门4——类与对象3-1(构造函数的类型转换和友元详解)
19 1