文章目录
- [13、term13:Use objects to manage resources](#13、term13:Use objects to manage resources)
- [14、term14:Think carefully about copying behavior in resource-managing classes](#14、term14:Think carefully about copying behavior in resource-managing classes)
- [15、term15:Provide access to raw resources in resource-managing classes](#15、term15:Provide access to raw resources in resource-managing classes)
-
- [法一: 使用智能指针的get进行显示转换](#法一: 使用智能指针的get进行显示转换)
- 法二:使用智能指针的解引用进行隐式转换
- 法三:自己实现get进行显示转换
- [法四:自己实现operator() 进行隐式转换](#法四:自己实现operator() 进行隐式转换)
- 结论:
- [16、term16:Use the same form in corresponding uses of new and delete](#16、term16:Use the same form in corresponding uses of new and delete)
- [17、term17:Stored newed objects in smart pointers in standalone statements](#17、term17:Stored newed objects in smart pointers in standalone statements)
- 4、总结
- 5、参考
系统资源是一个很大的概念,例如内存,文件描述器,网络都算是系统的资源;不管是何种资源,为了保证系统能够安全,高效地运行,在你不使用他的时候,你要及时地将他还给操作系统。
13、term13:Use objects to manage resources
首先我们写一个root class
cpp
class Investment{...};
Investment* createInvestment();
void f(){
Investment* pInv = createInvestment();//调用factory函数
...
delete pInv //释放pInv所指向的对象
}
动态分配对象时,对象存储在heap上,若不及时或者忘了delete对象指针,会造成内存泄露
即便最后没有忘记delete对象指针,在函数运行到delete语句之前,可能会遇到以下状况使得delete语句不被执行:
- new和delete之间有一个过早的return;
- new和delete位于某个循环内,该循环由于某个continue、break或者goto过早退出;
- delete语句之前抛出异常,直接跳转到异常处理函数;
当然,我们在编程时可以特意防止这一类错误,但是在后期的维护中,可能会添加return 语句,continue语句,也有可能f()会抛出一个异常,导致pInv所指向的对象不能正确被释放。
为了解决此类问题,C++提供了智能指针解决这个问题:在分配资源时,资源动态分配与heap内,在控制流离开那个区域时被释放。
常见的资源管理对象有auto_ptr、shared_ptr
首先介绍auto_ptr
cpp
void f()
{
std::auto_ptr<Investment> pInv(createInvestment());
...
}
从auto_ptr的使用。我们获得了两点启示:
- 获得资源后立即放进资源的管理对象内,createInvestment()返回的资源当作auto_ptr的初始值
- 资源的管理对象执行析构函数来释放资源,一旦离开作用域,自动调用析构函数将资源释放。
但是auto_ptr存在一个缺陷,不能让多个auto_ptr同时指向同一个对象,若通过copy构造函数,copy assignment操作符复制他们,复制所得到的指针获得资源的唯一拥有权。
举个栗子:
cpp
void f()
{
std::auto_ptr<Investment> pInv1(createInvestment());
//pInv1指向createInvestment()的返回物
std::auto_ptr<Investment> pInv2(pInv1);
//pInv2指向对象,pInv1设为null;
pInv1 = pInv2;
//pInv1指向对象,pInv2设为null;
...
}
对于其容器一些正常的"复制行为",auto_ptr是无法满足这个需求的。
shared_ptr
shared_ptr是auto_ptr的替代方案,他能持续追踪共有多少对象指向某笔资源,并在无人指向他时自动删除资源。
举个栗子:
cpp
void f()
{
std::tr1::shared_ptr<Investment> pInv(createInvestment());
...
}
void f()
{
std::tr1::shared_ptr<Investment> pInv1(createInvestment());
//pInv1指向createInvestment()的返回物
std::tr1::shared_ptr<Investment> pInv2(pInv1);
//pInv2,pInv1指向同一个对象
pInv1 = pInv2;
//pInv2,pInv1指向同一个对象
...
//当pInv2,pInv1被销毁,他们所指向的对象就会自动销毁
}
14、term14:Think carefully about copying behavior in resource-managing classes
资源的类型并非都是heap_based,有时候需要建立自己的资源管理类
举个栗子:
cpp
#include <iostream>
using namespace std;
class Lock
{
public:
explicit Lock(int* pm): m_p(pm)
{
lock(m_p);
}
~Lock()
{
unlock(m_p);
}
private:
int *m_p;
void lock(int* pm)
{
cout << "Address = " << pm << " is locked" << endl;
}
void unlock(int *pm)
{
cout << "Address = " << pm << " is unlocked" << endl;
}
};
int main()
{
int m = 5;
Lock m1(&m);
return 0;
}
运行结果如下:
cpp
Address = 0x7fff0b0a385c is locked
Address = 0x7fff0b0a385c is unlocked
...Program finished with exit code 0
Press ENTER to exit console.
这符合预期,当m1获得资源的时候,将之锁住,而m1生命周期结束后,也将资源的锁释放。
注意到Lock类中有一个指针成员,那么如果使用默认的析构函数、拷贝构造函数和赋值运算符,很可能会有严重的bug。
我们不妨在main函数中添加一句话,变成下面这样:
cpp
int main()
{
int m = 5;
Lock m1(&m);
Lock m2(m1);
}
再次运行,可以看到结果:
cpp
Address = 0x7ffc56116f34 is locked
Address = 0x7ffc56116f34 is unlocked
Address = 0x7ffc56116f34 is unlocked
...Program finished with exit code 0
Press ENTER to exit console.
可见,锁被释放了两次,这就出问题了。原因是析构函数被调用了两次,在main()函数中生成了两个Lock对象,分别是m1和m2,Lock m2(m1)这句话使得m2.m_p = m1.m_p,这样这两个指针就指向了同一块资源。根据后生成的对象先析构的原则,所以m2先被析构,调用他的析构函数,释放资源锁,但释放的消息并没有通知到m1,所以m1在随后析构函数中,也会释放资源锁。
如果这里的释放不是简单的一句输出,而是真的对内存进行操作的话,程序就会崩溃。
归根到底,是程序使用了默认了拷贝构造函数造成的(当然,如果使用赋值运算的话,也会出现相同的bug),那么解决方案就是围绕如何正确摆平这个拷贝构造函数(和赋值运算符)。
书中提出了四种解决方案:
方案一:很简单直观,就是干脆不让程序员使用类似于Lock m2(m1)这样的语句,一用就报编译错。这可以通过自己写一个私有的拷贝构造函数和赋值运算符的声明来解决。注意这里只要写声明就行了(见条款6)。
cpp
// 私有拷贝构造函数声明,删除拷贝构造函数
Lock(const Lock&) = delete;
// 私有赋值运算符声明,删除赋值运算符
Lock& operator=(const Lock&) = delete;
或者:
cpp
// 私有拷贝构造函数声明,只是声明,但是不定义;
Lock(const Lock&) ;
// 私有赋值运算符声明,只是声明,但是不定义;
Lock& operator=(const Lock&) ;
这样编译就不会通过了:
cpp
main.cpp: In function 'int main()':
main.cpp:47:15: error: 'Lock::Lock(const Lock&)' is private within this context
47 | Lock m2(m1);
| ^
main.cpp:37:5: note: declared private here
37 | Lock(const Lock&);
当然也可以像书上写的一样,写一个Uncopyable的类,把它作为基类。在基类中把它的拷贝构造函数和赋值运算写成私有的(为了防止生成基类的对象,但又想允许派生类生成对象,可以把构造函数和析构函数的修饰符变成protected。
然后
cpp
class Uncopyable
{
protected:
Uncopyable(){}
~Uncopyable(){}
private:
Uncopyable(const Uncopyable&);
Uncopyable& operator= (const Uncopyable&);
};
class Lock: public Uncopyable
{...}
方案二:使用shared_ptr来进行资源管理,但还有一个问题,我想在生命周期结束后调用Unlock的方法,其实shared_ptr里面的删除器可以帮到我们。
cpp
class Lock
{
public:
explicit Lock(int *pm): m_p(pm, unlock){...}
private:
shared_ptr<int> m_p;
}
这样在Lock的对象的生命周期结束后,就可以自动调用unlock了。tr1::shared_ptr允许指定所谓的"删除器",当一个函数或者函数对象引用次数为0时就会调用这个删除器。
在条款十三的基础上,我改了一下自定义的shared_ptr,使之也支持删除器的操作了,代码如下:
cpp
#ifndef MY_SHARED_PTR_H
#define MY_SHARED_PTR_H
#include <iostream>
using namespace std;
typedef void (*FP)();
template <class T>
class MySharedPtr
{
private:
T *ptr;
size_t *count;
FP Del; // 声明一个删除器
static void swap(MySharedPtr& obj1, MySharedPtr& obj2)
{
std::swap(obj1.ptr, obj2.ptr);
std::swap(obj1.count, obj2.count);
std::swap(obj1.Del, obj2.Del);
}
public:
MySharedPtr(T* p = NULL): ptr(p), count(new size_t(1)),Del(NULL){}
// 添加带删除器的构造函数
MySharedPtr(T* p, FP fun): ptr(p), count(new size_t(1)), Del(fun){}
MySharedPtr(MySharedPtr& p): ptr(p.ptr), count(p.count), Del(p.Del)
{
++ *p.count;
}
MySharedPtr& operator= (MySharedPtr& p)
{
if(this != &p && (*this).ptr != p.ptr)
{
MySharedPtr temp(p);
swap(*this, temp);
}
return *this;
}
~MySharedPtr()
{
if(Del != NULL)
{
Del();
}
reset();
}
T& operator* () const
{
return *ptr;
}
T* operator-> () const
{
return ptr;
}
T* get() const
{
return ptr;
}
void reset()
{
-- *count;
if(*count == 0)
{
delete ptr;
ptr = 0;
delete count;
count = 0;
//cout << "真正删除" << endl;
}
}
bool unique() const
{
return *count == 1;
}
size_t use_count() const
{
return *count;
}
friend ostream& operator<< (ostream& out, const MySharedPtr<T>& obj)
{
out << *obj.ptr;
return out;
}
};
#endif /* MY_SHARED_PTR_H */
方案三:复制底部资源,就是将原来的浅拷贝转换成深拷贝,需要自己显示定义拷贝构造函数和赋值运算符。这个也在之前的条款说过了,放到这里,其实就是在拷贝的时候对锁的计数次数进行+1,析构函数里就是对锁的计数次数进行-1,如果减到0就去unlock(其实思想还是类似于shared_ptr进行资源管理)
方案四:转移底部资源的控制权,这就是auto_ptr干的活了,在第二个方法中把shared_ptr换成auto_ptr就行了。
15、term15:Provide access to raw resources in resource-managing classes
资源管理类很好,它们是我们对抗资源泄漏的堡垒。排除此等泄漏是良好设计系统的根本性质。在一个完美的世界中,你需要依赖这样的类来同资源进行交互,而不是访问原生(raw)资源而玷污你的双手。但是世界不是完美的,许多API会直接引用资源,所以除非你放弃使用这样的API(这是不实际的想法),你将会绕开资源管理类而时不时的处理原生资源。
cpp
//创建一个类
class Investment {};
//创建一个函数,会返回一个Investment指针对象
Investment* createInvestment();
//我们使用shared_ptr类来管理获得得到的Investment动态对象
std::shared_ptr<Investment> pInv(createInvestment());//见条款13
//假设你希望以某个函数处理 Investment 对象,如:
int daysHeld(const Investment* pi);
cpp
//如下我们这样调用它是错误的。
int days=daysHeld(pInv);
代码将不能通过编译:因为dayHeld想要使用一个原生Investment*指针,这里却传递了一个shared_ptr类型的对象。
解决方法 :显示转换和隐式转换
我们需要一种方法将一个RAII类对象(在这个例子中是shared_ptr)转换成它所包含的原生资源类型。有两种常见的方法来实现它:显示转换 和隐式转换。
法一: 使用智能指针的get进行显示转换
shared_ptr和auto_ptr都提供了一个get成员函数来执行显示转换,也就是返回智能指针对象内部的原生指针(的复件。
以shared_ptr的get函数为例,shared_ptr有一个get函数,可以直接得到内部的资源,如:
cpp
//get:得到pInv内部的Investment指针
int days=daysHeld(pInv.get());
法二:使用智能指针的解引用进行隐式转换
如果你觉得显式转换不好,可能会增加泄漏内部资源的可能性,那么可以使用隐式转换函数。
事实上像所有的智能指针一样,shared_ptr和auto_ptr也重载了指针的解引用运算符(operator->和operator*),这就允许将其隐式的转换成底层原生指针:
cpp
class Investment {
public:
bool isTaxFree() const;
...
};
Investment* createInvestment(); // 工厂函数
std::shared_ptr<Investment>
pi1(createInvestment()); // 管理资源
bool taxable1 = !(pi1->isTaxFree()); //由operator->访问资源
// via operator->
...
std::auto_ptr<Investment> pi2(createInvestment()); // 使用auto_ptr管理资源
bool taxable2 = !((*pi2).isTaxFree()); // 由operator*访问资源
法三:自己实现get进行显示转换
因为有时候获取RAII对象中的原生资源是必要的,一些RAII类的设计者通过提供一个隐式转换函数来顺利达到此目的。举个例子,考虑下面的字体RAII类,字体对于C API来说是原生数据结构:
cpp
FontHandle getFont(); //得到某种字体
void releaseFont(FontHandle fh);//释放字体
//FontHandle资源管理类
class Font
{
public:
explicit Font(FontHandle fh) :f(fh) {}
~Font() { releaseFont(f); }
private:
FontHandle f;//原始字体资源
};
假设有大量的字体相关的C API用于处理FontHandles,因此会有频繁的需求将Font对象转换成FontHandles对象。
cpp
//Font类可以提供一个显示的转换函数,比如说:get:
class Font {
public:
...
FontHandle get() const { return f; } // 显示转换函数
...
};
不幸的是,如果它们想同API进行通讯,每次都需要调用get函数:
cpp
void changeFontSize(FontHandle f, int newSize); // C API
Font f(getFont());
int newFontSize;
...
changeFontSize(f.get(), newFontSize); // 明白地将Font转换为FontHandle
某些程序员会发现显示请求这些转换是如此令人不愉快以至于不想使用RAII类。但是这会增加泄漏字体资源的机会,这正是设计Font类要预防的事情。
法四:自己实现operator() 进行隐式转换
另一种办法是令 Font 提供隐式转换函数,转型为 FontHandle:
cpp
class Font {
public:
...
operator FontHandle() const // 隐式转换函数
{ return f; }
...
};
//这使客户调用C API的调用变得轻松且自然:
Font f(getFont());
int newFontSize;
...
changeFontSize(f, newFontSize); // 将Font隐式转换为FontHandle
//但这个隐式转换增加了出错的机会。
//举个例子,客户端本来想要一个Font却创建了一个FontHandle:
Font f1(getFont());
...
FontHandle f2 = f1; // 本意是要拷贝一个Font对象,反而将f1隐式转换为其底部的FontHandle,然后才复制它。
上面的程序拥有一个被Font对象 f1管理的FontHandle,但是直接使用f2也可以获得这个FontHandle。这就不好了。例如:当f1被销毁,字体资源被释放,f2就变成了悬挂指针。
补充说明:
- (1)隐式转换和显示转换如何选择
提供从RAII类对象到底层资源的显示转换(通过一个get成员函数)还是提供隐式转换依赖于设计出来的RAII类需要执行的特殊任务以及使用的场景。最好的设计看上去要遵守条款18的建议:使接口容易被正确使用,很难被误用。通常情况下,像get一样的显示转换函数会是更好的选择,因为它减少了类型误转换的机会。然而有时候,使用隐式类型转换的自然特性会使局面发生扭转。 - (2)封装和原始资源背道而驰?
函数返回一个RAII类中的原生资源同封装是背道而驰的,这已经发生了。这不是设计的灾难,RAII类的存在不是用来封装一些东西;他们的存在是用来保证资源的释放会发生。如果需要,资源封装可以在这个基本功能之上进行实现,但这不是必要的。此外,一些RAII类将实现的真正封装同底层资源非常松散的封装组合到一块。举个例子:shared_ptr封装了所有的引用计数,但是仍然可以非常容易的访问它所包含的原生指针。像一些设计良好的类,它隐藏了客户没有必要看到的东西,但是它提供了客户端确实需要访问的东西。
结论:
(1)APIs往往要求访问原始资源,所以每一个RAII类应该提供一个"取得其所管理的资源"的办法。
(2)对原始资源的访问可能经由显示转换或隐式转换。一般而言显示转换比较安全 ,但隐式转换对客户比较方便。
16、term16:Use the same form in corresponding uses of new and delete
这个条款强调了new与delete一一对应的重要性,即new对应delete,new[]对应delete[]。
- 首先看一下new与new[]之间的区别:
new用于分配单个对象的内存 ,而new[]用于分配对象数组的内存 。当使用new时,它只调用对象的构造函数;而当使用new[]时,它会依次调用每个对象的构造函数。
此外,new和new[]在内部实现上也有所不同。new和delete通常通过调用自定义的内存管理器 来分配和释放内存,而new[]和delete[]则通常通过调用标准库中的数组分配函数来分配和释放内存。
- 再看一下delete与delete[]之间的区别:
delete 和 delete[] 在 C++ 中用于释放动态分配的内存。它们之间的主要区别在于如何处理内存块。
- delete 用于释放单个对象。当你使用 new 创建一个对象时,应该使用 delete 来释放它。
- delete[] 用于释放对象数组。当你使用 new[] 创建一个对象数组时,应该使用 delete[] 来释放它。
如果你错误地使用 delete 来释放对象数组,或者使用 delete[] 来释放单个对象,可能会导致未定义的行为,包括内存泄漏、程序崩溃或其他问题。
举个反面栗子:
cpp
std::string* stringArray = new std::string[100];
std::string* strPtr = new std::string("Hello, world!");
...
delete stringArray;
delete[] strPtr;
错误使用会产生未定义的行为。
举正面的栗子:
cpp
std::string* stringArray = new std::string[100];
std::string* strPtr = new std::string("Hello, world!");
...
delete strPtr;
delete[] stringArray;
另外减少对数组的需求,因为数组名就是一个地址,如果对字符串数组做出typedef的操作,很有可能让人误用。
举个反面栗子:
cpp
//数组的typedef
typedef std::string AddressLines[4];
std::string* pal = new AddressLines;
...
delete pal; //产生未定义的行为
delete [] pal;
//vector的typedef
typedef std::vector<std::string> AddressLines;
std::string* pal = new AddressLines;
...
delete pal; // 正确使用
所以为了避免诸如此类的错误,尽量不要对数组做typedefs的动作,可以讲本例的AddressLines定义为strings组成的一个vector,这样直接删除pal就ok,不会引起不必要的误会。
正面的栗子:
cpp
//vector的typedef
typedef std::vector<std::string> AddressLines;
std::string* pal = new AddressLines;
...
delete pal; // 正确使用
17、term17:Stored newed objects in smart pointers in standalone statements
最好以独立语句将newed对象置入智能指针,否则,可能导致资源泄漏。
举个栗子:
cpp
//对于下述函数:
int priority();
void processWidget(std::tr1::shared_ptr<Widget> pw,int priority);
//processWidget决定对其动态分配得来的Widget运用智能指针
//(这里采用tr1::shared_ptr),考虑调用processWidget:
//反面例子:
processWidget(new Widget,priority());//不能通过编译
tr1::shared_ptr构造函数需要一个原始指针,但该构造函数是个explicit构造函数,无法进行隐式转换,将得自"new Widget"的原始指针转换为processWidget所要求的tr1::shared_ptr。
cpp
//如这样写就能通过编译:
processWidget(std::shared_ptr<Widget>(new Widget),priority());
但上述调用却可能泄漏资源。
编译器产出一个processWidget调用码之前,必须首先核算即将被传递的各个实参。上述第二实参只是一个单纯的对priority函数的调用,但第一实参std::shared_ptr(new Widget)由两部分组成:
1.执行"new Widget"表达式
2.调用tr1::shared_ptr构造函数
因此在调用processWidget之前,编译器必须创建代码,做如下三件事情(不分次序):
- 调用prioriry()函数
- 执行new Widget
- 调用shared_ptr的构造函数
processWidget的参数执行顺序
C++对于函数参数的调用顺序会不同,C++不像java和C#那样以特定的次序完成函数参数的核算
在上面的processWidget函数调用中,我们可以确定"new Widget"一定是在shared_ptr的构造函数前执行的,但是prioriry()函数的调用次序我们就不一定知晓了
因此在参数执行次序中一共会有下面3种情况:
情况①:
- 调用prioriry()函数
- 执行new Widget
- 调用shared_ptr的构造函数
情况②:
- 执行new Widget
- 调用prioriry()函数
- 调用shared_ptr的构造函数
情况③:
- 执行new Widget
- 调用shared_ptr的构造函数
- 调用prioriry()函数
分析情况②:如果在调用prioriry()函数的时候程序抛出了异常,那么new Widget返回的指针将会丢失,没有被放入到shared_ptr的构造函数中,那么就造成内存泄漏了。
解决办法:以独立语句将newed对象置入智能指针
在上面的分析中,我们可以看到在"资源创建(new)"和"资源被使用"之间如果发生了异常,那么就会造成资源泄漏;避免这类问题就是分离语句,将"创建的对象"与"放入智能指针对象"这两个步骤合成一步完成,而不是在函数调用中完成。
例如,下面的函数调用就不会产生错误:
cpp
std::tr1::shared_ptr<Widget> pw(new Widget); //以单独语句存储对象
processWidget(pw, priority()); //安全调用函数
总结:
以独立语句将newed对象存储于(置于)智能指针内。如果不这样做,一旦异常被抛出,有可能导致难以觉察的资源泄漏。
4、总结
书山有路勤为径,学海无涯苦作舟。
5、参考
4.1 《Effective C++》