资源管理
条款13: 以对象管理资源
在C++中,资源多数是指动态分配的内存。如果你只用new来分配内存却不在使用完后delete掉,将会导致内存泄漏。
内存泄漏:程序在堆中申请的动态内存,在程序使用完成时没有得到及时的释放。当这些变量的生命周期已结束时,该变量在堆中所占用的内存未能得到释放,从而就导致了堆中可使用的内存越来越少,最终可能产生系统运行较慢或者系统因内存不足而崩溃的问题。
假设我们在为不同类型的投资写一个库:
class Investment{...}; //Investment继承层级的基类
Investment* createInvestment(); //返回一个动态分配的Investment层级对象指针
返回一个指针就说明我们要负责在用毕后及时释放资源:
void f(){
Intestment* pInv = createInvestment(); //分配
... //使用
delete pInv; //释放
}
存在的问题:若中间部分存在并触发了return
语句,则delete
会被跳过;若delete
语句在某个循环中,若触发了break
或goto
语句,delete
也不会被执行;若中间代码抛出异常,该指针也不会被删除……
解决方案1(已被弃用):利用C++的对象析构函数自动调用机制,把资源封装在对象里面,这样当对象完成了它的使用周期,资源就会保证被释放。使用标准库的模板智能指针auto_ptr
,它的析构函数会自动调用delete来释放它所指向的对象。
void f(){
std::auto_ptr<Investment> pInv(createInvestment());
// auto_ptr (deprecated in C++11; removed in C++17)
...
} // 无需手动释放
RAII(Resource Acquisition Is Initialization)
获得资源后要立即传递给资源管理对象用来初始化。
资源管理类要利用析构函数来释放资源。当对象超出了它的作用域(scope)时,它的析构函数会自动调用,所以用析构函数来释放资源是保证安全的。
⚠️因为auto_ptr会在使用周期后自动删除资源,所以不要使用多个auto_ptr指向同一个对象,否则同一个对象会被释放两次,是一个非法操作。为了防止这种操作,标准库给auto_ptr定义了一个奇怪的特性: 拷贝旧指针到一个新指针,旧指针会被设为NULL:
std::auto_ptr<Investment> pInv1(createInvestment());
std::auto_ptr<Investment> pInv2(pInv1); //通过构造函数来拷贝,pInv1现在是NULL
pInv1 = pInv2; //通过赋值运算符来拷贝,pInv2现在是NULL
解决方案2:使用引用计数的智能指针(Reference-Counting Smart Pointer, RCSP),它在运行时会统计有多少对象指向当前的资源,然后当没有任何对象指向当前资源时便会自动释放。C++标准库shared_ptr
为例:
void f(){
std::shared_ptr<Investment> pInv(createInvestment());
...
} //同样无需手动释放
shared_ptr可以在STL容器中使用,成为一个更好的选择,因为shared_ptr
没有auto_ptr
自动把原对象设为NULL
的拷贝特性,也因为当使用容器的算法功能生成本地拷贝时,此时有两个对象指向了这个资源。即使拷贝的析构函数被调用了,原有的对象依然在指向这个资源,该资源便不会被提前释放。
使用智能指针只是用对象管理资源的方法之一,而且也存在着局限性。例如我们不能使用标准库的智能指针来指向一个动态分配的数组,因为析构函数调用的是delete
而不是delete[]
,虽然这样做依然可以通过编译。
std::auto_ptr<std::string> aps(new std::string[10]);
std::tr1::shared_ptr<int> spi(new int[10]);
//析构函数并不会调用delete[]
条款14: 小心考虑资源管理类的拷贝行为
上一条款提到了如何用智能指针管理基于堆(heap)的资源,但对于堆以外的资源(如mutex锁)智能指针不再那么好用了,因此需要写自己的资源管理类。
假设使用C API函数处理类型为Mutex
的互斥器对象(mutex objects),有两个函数可用:
void lock(Mutex* pm); //锁住pm指向的锁
void unlock(Mutex* pm); //解锁pm指向的锁
同时我们有一个符合RAII规范的类来管理这些锁,RAII即获取资源在对象构造过程中,释放资源在对象析构过程中:
class Lock{
public:
explicit Lock(Mutex* pm)
:mutexPtr(pm)
{lock(mutexPtr);} //在构造时获取资源,上锁
~Lock(){ unlock(mutexPtr); } //在析构时释放资源,解锁
private:
Mutex* mutexPtr;
};
例如访问临界区(critical section), 临界区即线程必须互斥地访问某些资源,这些资源必须只能由最多一个线程访问,我们就需要以RAII的方式来进行操作:
Mutex m; //定义需要的互斥器
...
{ //创建一个代码块来定义临界区
Lock ml(&m); //构造锁ml,锁住m
... //执行临界区操作
} //临界区结束,调用ml的析构函数,自动解锁
在创建自己的RAII资源管理类时,我们必须要思考需要如何规定这个类的拷贝行为。
解决方案1:禁止拷贝
有些对象的拷贝是没有意义的,就比如栗子中的这个锁,没有人会给同一个资源上两个锁,对于这样的类,我们就干脆禁止掉拷贝。(见条款06)
class Lock : private Uncopyable{ //见条款06
public:
... //与之前相同的定义
};
解决方案2:给资源引用计数:
可以替代裸指针把shared_ptr
作为RAII对象的数据成员来实现这个功能,将mutexPtr的类型从Mutex*
变成shared_ptr<Mutex>
。shared_ptr提供了一个特殊的可定义函数,删除器(deleter),即在引用计数为零时调用的函数,是shared_ptr构造函数的一个附加参数。这个函数在auto_ptr中是不存在的,因此它不能有自定义的删除行为,只能删除掉它包括的指针。
class Lock{
public:
explicit Lock(Mutex* pm)
:mutexPtr(pm, unlock) //将unlock函数绑定到删除器
{lock(mutexPtr.get());}
//这里其实不需要定义析构函数(告诉别人我们没有忘记析构函数,只是利用了C++的特性。)
private:
std::shared_ptr<Mutex> mutexPtr; //使用shared_ptr,不使用裸指针
};
这里没有定义析构函数,因为类的析构函数会调用它的非静态数据成员的析构函数(条款05)。这个例子中,Lock
类的析构函数会调用它的成员mutexPtr
的析构函数,而在当mutexPtr
的引用计数为零时,它的析构函数则会调用删除器,即我们绑定的unlock
函数。
常用的RAII类的拷贝行为有禁止拷贝,使用引用计数,拷贝资源,转移所有权,但也可以用其他做法来符合特殊需要。
条款15: 在资源管理类中提供对原始资源的访问
假设在为不同类型的投资写函数库:
//某个Investment的智能指针对象
std::shared_ptr<Investment> pInv(createInvestment());
//假设有个daysHeld函数
int daysHeld(const Investment* pi); //返回某个Investment对象的持有时间
//若直接把智能指针对象传入该函数
int days = daysHeld(pInv); // error!
此时编译器会报错,因为函数需要裸指针类型的参数,而你传入的是智能指针类型。你需要做的也很简单,把智能指针转换为裸指针,使用隐式转换或者显式转换。
shared_ptr
和auto_ptr
都有一个成员函数get()
,用来执行显式转换,返回智能指针对象所包含的裸指针:
int days = daysHeld(pInv.get());
shared_ptr和auto_ptr也重载了指针的解引用运算符,即->和*,这意味着我们可以通过它们来实现隐式转换:
class Investment{ //定义Investment继承层次的基类
public:
boos isTaxFree() const;
...
};
Investment* createInvestment(); //用来返回指向Investment对象的指针
std::shared_ptr<Investment> pi(createInvestment()); //使用shared_ptr管理资源
bool taxable = !(pi->isTaxFree()); //使用->操作符接触资源
taxable = !((*pi).isTaxFree()); //使用*操作符接触资源
定义自己的RAII资源管理类:
class Font{
public:
explicit Font(FontHandle fh): //C只能使用值传递
f(fh) {} //构造时获取资源
~Font() {releaseFont(f);} //析构时释放资源
...
private:
FontHandle f;
};
如果我们要使用某些C API只能使用FontHandle
类型,我们就需要把Font
类型显式转换为FontHandle
类型,因此我们定义一个显式转换的函数get()
:
class Font{
public:
...
FontHandle get() const {return f;} //显式转换函数
...
};
但是这样显式转换的缺点就是每次使用都要调用get()
函数,比较麻烦:
void changeFontSize(FontHandle f, int newSize); //改变字体大小的C API
Font f(getFont());
int newFontSize;
...
changeFontSize(f.get(), newFontSize); //需要使用get()来显式转换,麻烦
另外一个缺点就是,我们既然写了RAII资源管理类,为什么还要每次只使用它的原始资源,这不是跟我们希望避免资源泄漏的初衷背道而驰吗?我们下面来看看隐式转换:
class Font{
public:
...
operator FontHandle() const //隐式转换函数
{return f;}
...
};
这样调用API就会简单很多:
Font f(getFont());
int newFontSize;
...
changeFontSize(f, newFontSize); //隐式转换
但是隐式转换也有缺点,某些类型错误就不会被编译器探测到了:
Font f1(getFont());
...
FontHandle f2 = f1;
我们希望把一个Font
对象拷贝进另一个Font
对象,但倘若输入的Font
变成了FontHandle
,这样就把我们的资源管理对象变成了原始资源对象,编译器也不会报错。
结果就是,程序有一个FontHandle
对象被f1
封装和管理,但这个FontHandle
通过上面的操作被拷贝进了f2
,这样f1
和f2
同时控制着一个FontHandle
。如果f1
被释放,这个FontHandle
也将被释放,就会导致f2
的字体被损坏。
条款16: new
和delete
要对应使用
std::string strPtr1 = new std::string; //分配单个对象
std::string strPtr2 = new str::string[100]; //分配一个数组
...
delete strPtr1; //使用delete删除单个对象
delete[] strPtr2; //使用delete[]删除数组
如若未对应起来使用,结果皆为未定义。
在typedef类型中需要另加注意,最好在代码注释中标出要使用什么版本的delete(最好别用typedef定义数组):
typedef std::string AddressLines[4]; //定义AddressLines类型为
//一个包含4个字符串的数组
//所以需要对其使用delete[]
std::string *pal = new AddressLines; //相当于new string[4]
...
delete pal; //错误
delete[] pal; //正确
条款17: 使用单独的语句创建智能指针对象
假设我们有如下函数:
int priority(); //返回处理优先级
void processWidget(std::shared_ptr<Widget> pw, int priority); //处理对象根据不同优先级
现在用如下的语句调用这些函数:
processWidget(new Widget, priority());
这句调用必然会导致编译器报错,因为除非定义过隐式转换的运算符,裸指针对象不能被隐式转换为智能指针对象,下面才是语法正确的调用方式:
processWidget(std::shared_ptr<Widget>(new Widget), priority());
⚠️即使你在这里使用了智能指针,内存泄漏依然有发生的可能。
解析该函数的参数分为三步:
- 调用priority()函数
- 执行new语句
- 调用智能指针构造函数
不像Java或者C#的编译器只会以固定的顺序解析参数,C++编译器多种多样,而且根据优化选项的不同,编译器可能会改变这三步的执行顺序,以此利用指令流水线停顿的周期(pipeline stall),获得更高效率的代码。假设某编译器生成的机器代码实际执行顺序如下:
- 执行new语句
- 调用priority()函数
- 调用智能指针函数
如果priority()函数抛出了异常呢?那么从new语句动态分配的资源在到达智能指针构造函数之前就会泄露了。解决方法也很简单,使用一个单独的语句来创建智能指针对象:
std::shared_ptr<Widget> pw(new Widget); //放在单独的语句里
processWidget(pw,priority()); //这样就不会泄露资源了
编译器是逐语句编译的,通过使用一个单独的语句来构造智能指针对象,编译器就不会随意改动解析顺序,保证了生成的机器代码顺序是异常安全的,以及这样的代码写出来也更加美观。
「用一个单独的语句把裸指针储存到智能指针中。否则资源泄漏可能就会这么意想不到地发生了。」
支持!
一起加油!