前言

本文是阅读《Effective C++ 改善程序与设计的55个具体做法(第三版)》的心得笔记第二部分,文章也会按照原书的顺序依次记录各个条款。

第一部分的阅读笔记参见effective C++ 读书笔记01

资源管理

C++中最常使用的资源就是动态内存分配,除此之外,其他常见的资源还有文件描述符(file descriptors)、互斥锁(mutex locks)、图形界面中的字型和笔刷、数据库连接和网络sockets等。无论是何种资源,当不再使用它时,必须将其归还给系统

条款13:以对象管理资源

关于资源泄漏的风险,考虑如下这样一种场景:

void f {
Investment* pInv = createInvestment(); // 工厂函数创建对象并返回对象指针
...
delete pInv; // 释放pInv指针所指对象
}

上面代码的问题在于可能由于...中的某些操作导致代码无法执行到delete语句,比如:

  • ...中存在某种特定的判断逻辑会提前触发return语句;
  • delete语句存在于某个循环当中,而该循环又存在continuebreak语句导致提前退出;
  • ...中的语句抛出异常;

当然你在函数设计之初可以拍着胸脯保证不会出现这样的低级错误,你可以在return前加上delete语句,可以try-catch逻辑中增加delete操作等等来规避风险。但代码不只你一个人在写,你后面的维护者依然有可能会在...中重新添加如上所述的跳过delete操作的逻辑,你不能寄希望于每个人都能完全理解他所增加的逻辑对资源管理的影响。所以不能单纯的依赖f函数总能执行到其delete语句

还有就是上面这个例子在createInvestment内分配资源,然后却把锅甩给外部,寄希望于外部去释放资源,本身就不是一个好的接口设计,这点会在条款18中进行讨论。

为了确保createInvestment分配的资源总是能被释放,可以用对象管理资源

  • 获得资源后立即放进管理对象(managing object)中。即资源取得时机便是初始化时机(Resource Acquisition Is Initialization,RAII),我们在获得资源时立即初始化(或赋值给)某个管理对象,这个管理对象的类可以是自己特制的资源管理类,但更常见的是智能指针。其中原书中提到的auto_ptr在C++11标准中以弃用,取而代之的是unique_ptr,而当时还在TR1中的shared_ptr现也正式引入到C++11标准中。关于智能指针的详细讨论,请查看这里

  • 管理对象(managing object)运用析构函数确保资源被释放。将资源释放操作放在管理对象的析构函数中,一旦管理对象将要离开作用域,对象将被销毁,其析构函数自动被调用,从而自动的释放资源。关于资源释放动作可能会抛出异常的处理方式,请参照条款08

还有一点需要注意,原书中提到tr1::shared_ptr不支持数组资源的释放,即析构函数默认调用delete,而不是delete[]这一论断也成为了过去时。新的C++标准下(不确定是14/17/20)的shared_ptr已经重载了[],即如下写法时合法的:

std::shared_ptr<Investment[]> spInv(new Investment[5]);

但通常情况,更好的选择是使用std::vector<Investment>(5)

条款14:在资源管理类中小心copying行为

并不是所有的资源都是在堆上管理的(heap-based),这时候智能指针就不适用,需要自己实现一个资源管理类。考虑如下互斥锁(一种资源)的设计:

class Mutex {/*省略实现*/};
void lock(Mutex* pm); //上锁
void unlock(Mutex* pm); //解锁

// 为了确保不会忘记给一个上锁的Mutex解锁,设计如下资源管理类,遵循RAII准则
class Lock{
public:
explicit Lock(Mutex* pm) : pm_(pm) { lock(pm_); } // 构建即获取资源
~Lock() { unlock(pm_); } // 析构即释放资源
private:
Mutex* pm_;
};

// 用法如下:
Mutex m; // 定义互斥锁
...
{ // 建立一个块用来定义critical section
Lock ml(&m); // 锁定互斥锁
... // 执行critical section 中的操作
} // 离开区块时,自动解锁互斥器

在上述用法中,Lock能工作的很好。但当Lock对象被拷贝时(虽然有点奇怪,但你需要对此负责),事情就有点不太好了:

Lock ml1(&m);		// 锁定m
Lock ml2(ml1); // 复制构造

首先,在未自定义的情况下,default版本的copy构造函数和copy赋值操作符仅单纯的复制成员变量,ml1ml2持有相同的pm_

  • ml1ml2的生命周期不同,比如ml1提前析构,这时锁已经解开,ml2已经失效;
  • 即便ml1ml2的生命周期相同,当它们被析构时,会unlock两次,这种未定义行为的危害程度取决于unlock的实现方式;

以此为例,那么该如何处理RAII对象的拷贝行为呢?有如下几个选择:

  • 禁止复制:参考条款06,私以为是最适合上述例子的处理,确实没有什么理由去拷贝Lock对象。
class Lock : private Uncopyable {
... // 如前
};
  • 共享底层资源的所有权:成员用shared_ptr管理,并自定义deleter(析构时unlock而不是释放Mutex对象)。对于上述例子,虽不那么适合,但也不至于招致危害。
class Lock{
public:
explicit Lock(Mutex* pm) : pm_(pm, unlock) { lock(pm_.get()); } // 构建即获取资源
// 使用default析构函数即可,pm_在引用计数为0时得以释放,并调用自定义的deleter(即unlock函数)
private:
std::shared_ptr<Mutex> pm_;
};
  • 深度复制底层资源:自定义copy构造函数和copy赋值操作符,在拷贝RAII对象时,连带其所管理的资源(不仅是指针,还有指针所指的内容)一并做一份深拷贝。并不适合用于处理上述例子。
  • 转移底层资源的所有权:当你希望资源只有一份时,将底层资源用独占所有权的unique_ptr进行管理,自定义copy构造函数和copy赋值操作符以完成所有权转移std::move)的动作。
class Lock {
public:
explicit Lock(Mutex* pm) : pm_(pm, unlock) {
lock(pm_.get());
} // 构建即获取资源

explicit Lock(const Lock& ml) {
pm_ = std::move(const_cast<Lock&>(ml).pm_);
}

// 使用default析构函数即可,pm_在引用计数为0时得以释放,并调用自定义的deleter(即unlock函数)
private:
std::unique_ptr<Mutex, std::function<void(Mutex*)>> pm_;
};

既然自己定义了包含std::move的copy构造函数,你应当清楚地知道,所有权转移后原对象ml1便不可用了,之后你应当对错误的使用ml1负责。

条款15:在资源管理类中提供对原始资源的访问

很多时候,APIs直接指涉资源管理类中的原始资源(raw resources),这就需要资源管理类要提供访问原始资源的方法。

对智能指针而言,提供了get方法来获取其中的裸指针;此外,智能指针还重载了operator->operator*操作符,使其能够隐式地转换成裸指针,从而能像裸指针一样去访问类的成员函数。

对于自定义的资源管理类,同样也有显式隐式两种方式转换成原始资源。一般来讲,显示转换更安全,隐式转换更方便,具体怎么选,我个人是倾向于显示转换,坚持条款18的忠告,尤其是在多人合作开发的时候,因为你无法保证所有人都与你的想法一致。一个例子如下:

FontHandle getFont();							// C API 获取字体资源
void releaseFont(FontHandle fh); // C API 释放字体资源
void changeFontSize(FontHandle f, int newSize); // C API 直接指涉原始资源

// 自定义资源管理类
class Font {
public:
explicit Font(FontHandle fh) : f(fh){}
~Font() { releaseFont(f); }

// 显示转换函数
FontHandle get() const { return f; }
// 隐式转换函数
operator FontHandle() const { return f; }
private:
FontHandle f;
};

// 用法
Font f(getFont());
int newFontSize = 10;
...
// 显示转换
changeFontSize(f.get(), newFontSize);
// 隐式转换
changeFontSize(f, newFontSize);

// 一种隐式转换的可能的误用
Font f1(getFont());
FontHandle f2 = f1; // 本意是复制一个Font对象,却复制了一个原始资源

最后要澄清一点,RAII 类提供对原始资源的访问也许看起来违反了封装性,但这并不是什么设计缺陷。因为RAII类的设计初衷是保证资源得以合理释放,而不是保证封装。封装并不是什么都不暴露,而是暴露有必要暴露的部分。

条款16:成对使用new和delete时要采取相同形式

一句话总结:newdelete搭配使用,new[]delete[]搭配使用。不可遗漏,也不可错配。

尽量不要对数组形式使用typedef,容易造成newdelete的错配。

typedef std::string AddressLines[4];

std::string* pal = new AddressLines; // 这里实际是new std::string[4]
delete pal; // 错配,未定义行为,可能导致后续对象不能成功析构和释放内存
delete[] pal; // 正解!

条款17:以独立语句将 newed 对象置入智能指针

考虑如下代码:

int getPriority();		// 获取优先级,可能抛出异常
void processWidget(std::shared_ptr<Widget> pw, int priority);

// 用法1
processWidget(new Widget, getPriority()); // 编译不通过,std::shared_ptr的构造函数是explicit构造函数,无法进行隐式转换
// 用法2
processWidget(std::shared_ptr<Widget>(new Widget), getPriority()); // 编译通过,但有资源泄漏的风险
// 用法3
std::shared_ptr<Widget> pw(new Widget); // 在独立语句中将 newed 对象置入智能指针
processWidget(pw, getPriority()); // 正解

解释一下为什么用法2中会有资源泄漏的风险:

line7代码总共做了3件事:

    1. 调用getPriority;
    1. 执行new Widget;
    1. 调用std::shared_ptr的构造函数;

C++编译器总能保证先步骤2后步骤3的执行次序(因为2是3的入参),但却无法保证步骤1的执行次序不在2和3之间(可能出于优化性能的考虑)。若在这种情况下,步骤1的执行过程中抛出异常,步骤2返回的指针将会被遗弃,无法置入智能指针中,从而导致了资源泄漏。以独立语句将 newed 对象置入智能指针(用法3)可以规避该风险,因为编译器对于跨越语句的各项操作,没有重新排列的自由。