前言:如何有效运用C++,包括一般性的设计策略,以及带有具体细节的特定语言特性。知道细节很重要,否则如果疏忽几乎总是导致不可预期的程序行为(undefined behavior)。本文总结对于如何使用C++的一些建议,从而让你成为一个有战斗力的C++程序员。
文章目录
资源,就是一旦用了它,将来必须还给系统。C++中最常使用的资源就是动态分配内存,但内存只是你必须管理的众多资源之一。其他常见的资源还包括文件描述符,互斥锁,数据库连接,网络sockets等。
不论哪一种资源,重要的是,当你不再使用它时,必须将它还给系统。但是,难点是,当你考虑到异常、函数内多重回传路径、程序维护不正确的改动等情况,就很难做到上面的保证。
1 以对象管理资源
Use objects to manage resources.
void f()
{
Investment* pInv = createInvestment(); // 调用factory函数,返回一个动态对象
// do something
// ...
delete pInv; // 释放动态对象
}
问题:在若干情况下(例如,中途过早返回,或抛出异常等),f可能无法删除动态对象。当然,谨慎地编写程序可以防止上面的错误,但是随着代码的修改和维护,这种保障总是显得吃力。
好的做法:为确保返回的资源总是被释放,我们需要将资源放进对象内,当控制流离开f,该对象的析构函数会自动释放那些资源。许多资源被动态分配于heap内,而后被用于单一区块或函数内。它们应该在控制流离开那个区域或函数时被释放。
解决方案,使用auto_ptr
标准程序库提供的auto_ptr
正是针对这种形势而设计的,auto_ptr
是个类指针对象(智能指针),其析构函数自动对其所指对象调用delete。
void f()
{
std::auto_ptr<Investment> pInv(createInvestment());
// do something
// ...
// 最后由auto_ptr的析构函数自动删除pInv
}
想法:
- 获得资源后立刻放进管理对象内。
即,资源取得时机便是初始化时机(Resource Acquisition Is Initialization; RAII
)
- 管理对象运用析构函数确保资源被释放。
不论控制流如何离开区块,一旦对象被销毁,其析构函数会被自动调用,于是资源被释放。
注意:由于auto_ptr被销毁时会自动删除它所指之物,所以一定要注意别让多个auto_ptr同时指向同一对象,否则对象会被删除一次以上。为了预防这个问题,auto_ptr有一个特性是,若通过copy构造函数或copy assignment操作符复制它们,它们会变成null,而复制所得的指针将取得资源的唯一拥有权。
// pInv1指向createInvestment返回物
std::auto_ptr<Investment> pInv1(createInvestment());
// 现在pInv2指向对象,pInv1被设为null
std::auto_ptr<Investment> pInv2(pInv1);
// 现在pInv1指向对象,pInv2被设为null
pInv1 = pInv2;
带来的问题:由于STL容器要求其元素发挥**“正常的”复制行为**,而auto_ptr的这种诡异的复制行为,导致其不符合STL的容器要求。
替代方案
auto_ptr的替代方案是**“引用计数型智能指针(reference-counting smart pointer; RCSP)**”。
所谓RCSP,也是个智能指针,持续追踪共有多少对象指向某笔资源,并在无人指向它时自动删除该资源。RCSP提供的行为类似垃圾回收(garbage collection),不同的是,RCSP无法打破环状引用(cycles of references)。例如,两个其实已经没被使用的对象彼此互指,因而好像还处在“被使用”的状态。
TR1的tr1::shared_ptr
就是个RCSP,所以你可以这么写f:
void f()
{
// 使用shared_ptr
std::tr1::shared_ptr<Investment> pInv(createInvestment());
// do something
// ...
// 经由shared_ptr析构函数自动删除pInv
}
// 看下复制行为
void f()
{
// pInv1指向createInvestment返回物
std::tr1::shared_ptr<Investment> pInv1(createInvestment());
// pInv1和pInv2指向同一个对象
std::tr1::shared_ptr<investment> pInv2(pInv1);
// 同上,无任何改变
pInv1 = pInv2;
// pInv1和pInv2被销毁,它们所指的对象也就被自动销毁
}
解决auto_ptr的问题:由于tr1::shared_ptr的复制行为"一如预期",因此,它们可以被用于STL容器。
注意:
auto_ptr
和tr1::shared_ptr
两者都在其析构函数内做delete,而不是delete[]动作,那么意味着,在动态分配而得的array身上使用auto_ptr或tr1::shared_ptr是个馊主意。
问题
#include <iostream>
#include <string>
#include <memory>
using namespace std;
int main()
{
// 馊主意,会用上错误的delete形式,而且编译器不是提示编译错误
std::auto_ptr<std::string> aps(new std::string[10]);
std::shared_ptr<int> spi(new int[1024]);
}
你会发现:并没有特别针对“C++动态分配数组”而设计的类似auto_ptr或tr1::shared_ptr那样的东西。那是因为,vecotr
和string
几乎总是可以取代动态分配而得的数组。
请记住:
- 为防止资源泄露,请使用
RAII对象
,它们在构造函数中获得资源,并在析构函数中释放资源。- 两个常被使用的RAII classes分别是
tr1::shared_ptr
和auto_ptr
。前者通常是较佳选择,因为其copy行为比较直观。若选择auto_ptr,复制动作会使它(被复制物)指向null。
TODO scope_ptr
2 在资源管理中小心coping行为
问题:
auto_ptr
和shared_ptr
可以实现对heap-based
资源的RAII
,然后对于非heap-based
的资源并不合适。因此,有时需要建立自己的资源管理类
。
例如:
void lock(Mutex* pm); // 加锁
void unlock(Mutex* pm); // 解锁
为了确保不会将一个被锁住的Mutex解锁,需要建立一个class用来管理锁。这样的class的基本结构由RAII守则支配,也就是“资源在构造期间获得,在析构期间释放”。
class Lock
{
public:
// 获得资源
explicit Lock(Mutex* pm) : mutexPtr(pm)
{ lock(mutexPtr); }
// 释放资源
~Lock()
{ unlock(mutexPtr); }
private:
Mutex *mutexPtr;
};
客户在使用时:
Mutex m; // 定义互斥器
// 建立一个区块用来定义critical section
{
Lock m1(&m); // 锁定互斥器
// 执行critical section内的操作
// 在区块最末尾,自动解除互斥器锁定
}
问题:如果Lock对象被复制,会发生什么事情?
Lock m1(&m); // 锁定m
Lock m2(m1); // 将m1复制到m2身上,会发生什么?
一般有两种选择:
- 禁止复制。
许多时候允许RAII对象被复制并不合理。如果复制动作对RAII class并不合理,你便应该禁止之。
- 对底层资源进行"引用计数法(reference-count)"
有时候,我们希望保有资源直到它的最后一个使用者被销毁。这种情况下,复制RAII对象时,应该将资源的“被引用数”递增。shared_ptr
便是如此。
请记住:
- 复制RAII对象,必须一并复制它所管理的资源。
- 普通常见的RAII class copying行为是,抑制copying,使用引用计数。
3 在资源管理类中提供对原始资源的访问
Provide access to raw resources in resource-managing classes.
问题
我们期望通过使用resource-managing classes对抗资源泄露,但是许多APIs直接指涉资源,导致下面问题。
std::tr1::shared_ptr<Investment> pInv(createInvestment());
// 假设需要下面的函数处理Investment对象
int daysHeld(const Investment* pi);// 返回投资天数
// 正常需要这样调用
int days = daysHeld(pInv); // 错误,无法通过编译,因为daysHeld需要的是Investment* 指针,而我们传递的却是个类型为tr1::shared_ptr<Investment>的对象
解决方法
这个时候需要一个函数可将RAII class对象(tr1::shared_ptr)转换为其所内含之原始资源(Investment*)。有两个方法可以达到:
- 显示转换
shared_ptr
和auto_ptr
都提供了一个get成员函数
(它会返回智能指针内部的原始指针),用来执行显式转换。
int days = daysHeld(pInv.get()); // ok
- 隐式转换
shared_ptr
和auto_ptr
也重载了指针取值操作符(operator->
和operator*
),它们允许隐式转换至底部原始指针。
bool taxable1 = !(pInv->isTaxFree()); // 经由operator->访问资源
bool taxable2 = !((*pInv).isTaxFree()); // 经由operator*访问资源
最佳实践
是否应该提供一个显式转换函数
将RAII class
转换为其底部资源,或是应该提供隐式转换
,答案主要取决于RAII class
被设计执行的特定工作,以及它被使用的情况。最佳的设计原则是:让接口容易被正确使用,不易被误用。
请记住
- APIs往往要求访问原始资源(raw resources),所以每一个RAII class应该提供一个"取得其所管理之资源"的办法。
- 对原始资源的访问可能经由显式转换或隐式转换。一般而言,显式转换比较安全,但隐式转换对客户比较方便。
4 成对使用new和delete时要采取相同形式
问题
std::string* array = new std::string[100];
// ...
delete array;
上面array所含的100个string对象中的99个不太可能被适当删除,因为它们的析构函数很可能没有被调用。
当你使用new
,有两件事情发生:
- 内存被分配出来(通过
operator new
的函数) - 针对此内存会有一个(或更多)构造函数被调用
当你使用delete
,也有两件事情发生:
- 针对此内存会有一个(或更多)构造函数被调用
- 内存被释放(通过
operator delete
的函数)
delete的最大问题在于:即将被删除的内存究竟存有多少对象,这个问题的答案决定了有多少个析构函数必须被调用。
单一对象 | 对象数组 |
---|---|
object | n Object Object Object … |
当你对着一个指针使用delete,唯一能够让delete知道内存中是否存在一个数组大小记录的办法就是:使用delete时加上中括号(方括号),delete便认定指针指向一个数组,否则,它便认定指针指向单一对象。
正确做法
std::string* ptr1 = new std::string;
std::string* ptr2 = new std::string[100];
// ...
delete ptr1; // 删除一个对象
delete [] ptr2; // 删除一个由对象组成的数组
请记住
如果调用new
时使用[]
,那么必须在对应调用delete
时也使用[]
。如果调用new
时没有使用[]
,那么也不应该在对应调用delete
时使用[]
。
5 以独立语句将newed对象置入智能指针
问题
processWidget(std::tr1::shared_ptr<Widget>(new Widget), priority());
new Widget
一定执行于tr1::shared_ptr
构造函数被调用之前,因为这个表达式的结果还要被传递作为tr1::shared_ptr
构造函数的一个实参,但对priority
的调用则可以排在第一或第二或第三执行。如果编译器选择以第二执行它,则操作序列为:
- 执行
new Widget
- 调用
priority
- 调用
tr1:shared_ptr
构造函数
如果对
priority
的调用导致异常,会发生什么?
在此情况下,new Widget
返回的指针将会遗失,因为它尚未被置入tr1::shared_ptr
内。因此,避免此类问题的办法是,使用分离语句。
std::tr1::shared_ptr<Widget> pw(new Widget);
// 这个调用动作不会造成泄漏
processWidget(pw, priority());
请记住
以独立语句将new
对象存储于智能指针内。如果不这样做,一旦异常被抛出,有可能导致难以察觉的资源泄漏。