前言

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

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

第二部分的阅读笔记参见effective C++ 读书笔记02

第三部分的阅读笔记参见effective C++ 读书笔记03

第四部分的阅读笔记参见effective C++ 读书笔记04

第五部分的阅读笔记参见effective C++ 读书笔记05

第六部分的阅读笔记参见effective C++ 读书笔记06

定制 new 和 delete

条款49:了解 new-handler 的行为

operator new 无法满足某个内存分配需求时,一般会抛出 std::bad_alloc 异常。比如如下程序在VS x86环境下:

int main() {
int *p = new int[0x1fffffff];
return 0;
}

std::nothrow 修饰 new 操作符,可以使内存分配阶段不会抛异常,失败了就返回 null 指针(但不能保证new 一个对象时,后续的构造函数不抛异常,比如构造函数中又new了一些内存):

int *p = new (std::nothrow) int[0x1fffffff];		// p为NULL

而当用户使用std::set_new_handler函数为new操作符指定一个new_handler函数时,operator new 无法满足某个内存分配需求,则会不断调用new_handler函数,直到找到足够的内存。其函数原型如下:

namespace std {
typedef void (*new_handler)(); // typedef 函数指针
// throw()表示不会抛出异常,现在已经使用noexcept代替
// 输入参数为新的handler函数,返回原handler函数
new_handler set_new_handler(new_handler p) throw();
}

一个使用示例如下:

void OutOfMem() {
std::cerr << "Unable to satisfy request for memory\n";
std::abort(); // 终止程序,若VS Debug模式会有弹窗提示
}

int main() {
std::set_new_handler(OutOfMem);
int *p = new int[0x1fffffff];
return 0;
}

一个设计良好的new_handler可以为使用者提供极大的设计弹性,例如:

  • 让更多的内存可被使用:提前申请内存,让new_handler触发下一次的返还分配;
  • 安装另一个new_handler:调用set_new_handler让下一次调用新的new_handler,以期新的new_handler可以解决问题;
  • 卸载new_handler:将null指针传递给set_new_handleroperator new会在内存分配不成功时抛出异常;
  • 手动抛出std::bad_alloc或其派生的异常,这样的异常不会被operator new捕获,因此会被传播到内存申请处;
  • 不返回,通常调用std::abort()std::exit();

现在考虑一个问题:是否可以给每一个C++类定值专属的new-handler呢?答案是C++标准机制没有提供,但我们可以自己实现,方法有以下两种:

  1. 针对每个特定类,类内重载static类型的operator newset_new_handler方法,为了能够恢复原本的new-handler,还需实现一个资源管理类NewhandlerHolder。具体实现如下:
class NewHandlerHolder {
public:
// 保存目前的new-handler
explicit NewHandlerHolder(std::new_handler nh) : nh_(nh) {
}
// 析构时恢复new-handler
~NewHandlerHolder() {
std::set_new_handler(nh_);
}

private:
std::new_handler nh_;

NewHandlerHolder(const NewHandlerHolder&); // 禁止copy操作

NewHandlerHolder& operator=(const NewHandlerHolder&);
};
#endif

class Widget {
public:
static std::new_handler set_new_handler(std::new_handler p) throw();

static void* operator new(std::size_t size) throw(std::bad_alloc);

private:
static std::new_handler currentHandler;
int data[0x1fffffff]; // large data to cause out of memory
};

std::new_handler Widget::currentHandler = nullptr;

std::new_handler Widget::set_new_handler(std::new_handler p) throw() {
std::new_handler oldHandler = currentHandler;
currentHandler = p;
return oldHandler;
}

void* Widget::operator new(std::size_t size) throw(std::bad_alloc) {
NewHandlerHolder h(std::set_new_handler(currentHandler));
return ::operator new(size);
}

void OutOfMem() {
std::cerr << "Unable to satisfy request for memory\n";
std::abort();
}

int main() {
Widget::set_new_handler(OutOfMem);
Widget* pw1 = new Widget;

return 0;
}

这种方法有个明显的缺点,就是你得为每一个类都重载一份static类型的operator newset_new_handler方法,但实质上重载的代码是基本相同的,这会导致代码冗余。

  1. 将上述方法1封装进一个模板类中,需要专属new-handler的类只需继承该模板基类即可,具体实现如下:
class NewHandlerHolder {
public:
// 保存目前的new-handler
explicit NewHandlerHolder(std::new_handler nh) : nh_(nh) {
}
// 析构时恢复new-handler
~NewHandlerHolder() {
std::set_new_handler(nh_);
}

private:
std::new_handler nh_;

NewHandlerHolder(const NewHandlerHolder&); // 禁止copy操作

NewHandlerHolder& operator=(const NewHandlerHolder&);
};

template <typename T>
class NewHandlerSupport {
public:
static std::new_handler set_new_handler(std::new_handler p) throw();

static void* operator new(std::size_t size) throw(std::bad_alloc);

private:
static std::new_handler currentHandler;
};

template <typename T>
std::new_handler NewHandlerSupport<T>::currentHandler = nullptr;

template <typename T>
std::new_handler NewHandlerSupport<T>::set_new_handler(std::new_handler p) throw() {
std::new_handler oldHandler = currentHandler;
currentHandler = p;
return oldHandler;
}

template <typename T>
void* NewHandlerSupport<T>::operator new(std::size_t size) throw(std::bad_alloc) {
NewHandlerHolder h(std::set_new_handler(currentHandler));
return ::operator new(size);
}

class Widget : public NewHandlerSupport<Widget>{
private:
int data[0x1fffffff];
};

void OutOfMem() {
std::cerr << "Unable to satisfy request for memory\n";
std::abort();
}

int main() {
Widget::set_new_handler(OutOfMem);

Widget* pw1 = new Widget;

return 0;
}

其中Widget类继承自NewHandlerSupport<Widget>,而且NewHandlerSupport模板类的类型参数T从未使用,这可能会让你感到困惑。

其实,将NewHandlerSupport设计成模板类主要是为了让每一个继承自NewHandlerSupport的类,都拥有实体互异的NewHandlerSupport副本,更明确的说,是为了让每个类拥有一份专属的static成员变量currentHandler。这种派生类继承自一个模板化的基类,而后者又以派生类作为类型参数的技术被称为怪异的循环模板模式(CRTP,curiously recurring template pattern)

条款50:了解new和delete的合理替换时机

替换编译器提供的operator newoperator delete有以下常见理由:

  1. 用来检测运用上的错误。自定义的new/delete可以比较容易地检测到内存越界行为,比如在new时超额申请内存,在真正使用的内存区块前后的额外空间中写入特定的数值,即内存签名,在delete时检查这些内存签名是否被改写,以监测越界写行为。
  2. 为了强化效能。编译器提供的new/delete为了适用于各种各样的内存分配场景,在内存碎片、额外空间开销和时间性能上都是保持中庸的水平。对于特定的需求和场景,使用定制化的内存管理会有更好的性能表现。
  3. 为了收集使用上的统计数据。例如,内存分配的时空分布,内存块的申请和归还的次序(FIFO、LIFO或随机),内存峰值等。
  4. 为了弥补缺省分配器中的非最佳对齐。例如,在X86体系结构下,double类型在内存地址8-byte齐位的情况访问最快。如果编译器自带的operator new不保证动态分配的double类型采用8-byte齐位,则可以自行实现一个,以提高程序效率。
  5. 为了将相关对象成簇集中。比如已知某个数据结构往往一起使用,那么分配的时候应该尽量让所有数据的内存集中一些,避免频繁触发换页中断(page faults) ,提升访问效率。
  6. 为了获得非传统的行为。比如当希望分配和归还共享内存(shared memory)内的区块,但能够管理该内存的只有C API(shmget等),那么可以自行实现定制版的new/delete,为C API 封装一层C++接口。

条款51:编写new和delete时需固守常规

条款50中解释了什么情况下需要自定义new/delete,本条款将解释自定义new/delete时需遵守的规则。

  1. 正确处理申请内存失败的情况。当有足够内存用于申请时,new就返回一个指针指向那块内存;如果申请失败,若new-handler函数不为null,就会调用new-handler函数(以期其能解决当前内存不足的问题,如释放某些内存出来),并再次尝试分配内存;若new-handler函数为null,则会抛出std::bad_alloc异常。
  2. 正确处理申请0-bytes的情况。即使用户申请0-bytes的内存,我们也要为其返回一个合法的指针。常用的方法就是申请1-bytes的内存并返回。
  3. 正确理解operator new内含的无穷循环。退出循环的办法有:内存被成功分配;new-handler函数做了条款49中描述的事:让更多的内存可用、安装另一个new-handler、卸载new-handler、抛出std::bad_alloc异常或其派生、承认失败,调用std::abort()std::exit()。一个非成员函数版本的伪代码如下:
void* operator new(std::size_t size) throw(std::bad_alloc) {
using namespace std;
if (size == 0) { // 处理0-bytes申请
size = 1;
}
while(true) {
尝试分配 size bytes;
if (分配成功)
return (指向分配内存的指针);
// 分配失败:get到目前使用的new-handler函数,并调用
new_handler globalHandler = set_new_handler(0); // get
set_new_handler(globalHandler); // set

if (globalHandler) (*globalHandler()); // exec
else throw std::bad_alloc();
}
}
  1. 正确处理基类的operator new被派生类继承的情况。若需要实现特定类的operator new成员函数,需要考虑通常情况下,派生类对象要比基类对象大,因此派生类调用基类的operator new会有问题,如下:
class Base {
public:
static void* operator new(std::size_t size) throw(std::bad_alloc);
...
};

class Derived : public Base { ... }; // 假设Derived未声明 operator new

Derived* p = new Derived; // 这里会调用Base::operator new

处理这个问题的方法就是在基类中增加内存申请量错误的防御式编程,使得基类的operator new只适用于基类,派生类若不重新实现,则默认调用编译器提供的operator new,具体如下:

void* Base::operator new(std::size_t size) throw(std::bad_alloc) {
if (size != sizeof(Base)) {
return ::operator new(size);
}
...
}

需要注意的是,自定义的operator new[]并不适用上述方法,因为通常情况下,需要额外的内存空间来保存元素个数等信息,所以不能简单的认为size = 元素个数 * sizeof(Base)

  1. 正确处理operator delete删除null指针的情况。如下是非成员函数版本的伪代码:
void operator delete(void *rawMemory) throw() {
if (rawMemory == 0) return; // 直接返回
... // 归还内存
}
  1. 同样考虑派生类继承operator delete的问题。伪代码如下:
class Base {
public:
static void* operator new(std::size_t size) throw(std::bad_alloc);
static void operator delete(void* rawMemory, std::size_t size) throw();
...
};

void Base::operator delete(void* rawMemory, std::size_t size) throw() {
if (rawMemory == 0) return; // 直接返回
if (size != sizeof(Base)) {
::operator delete(rawMemory);
return;
}
... // 归还内存
};

条款52:写了 placement new 也要写 placement delete

所谓 placement new/delete,是指除固定的size_t参数以外,还接受其他额外参数的特殊operator new/delete。

其中,接受一个指针指定对象该被构造的内存地址placement new最早/最常用的一个,已经被纳入C++标准库(#include <new>),而且是placement new命名的由来:即一个特定位置上的new。具体形式如下:

void *operator new(std::size_t, void*) throw();

当然,我们也可以自定义一个placement new,如接受一个ostream用来log分配信息,如下:

class Widget {
public:
...
// placement new 成员函数
static void* operator new(std::size_t size, std::ostream& logStream) throw(std::bad_alloc);
// 普通operator delete 成员函数
static void operator delete(void* pMemory, std::size_t size);
};

// 用户代码
Widget* pw = new(std::cerr) Widget; // 调用placement new,存在潜在的内存泄漏风险

众所周知,对于new对象的过程,可以分为以下两个步骤:

  1. operator new分配对象所需的内存空间;
  2. 在该内存空间上执行对应的构造函数;

当步骤1成功, 步骤2抛出异常时,已经申请的内存空间需要被回收,已防止内存泄漏。这在使用普通的operator new时是可以做到的,因为运行期系统知道与其对应的operator delete,并自动调用。然而,当使用placement new时,系统会尝试寻找并调用与placement new额外参数个数与类型均一致的operator delete,若没有找到,则系统什么都不会做,从而造成了内存泄漏。

所以规则很简单:当我们自定义一个placement new时,也要提供一个带相同额外参数的placement delete,以规避可能的内存泄漏。如下:

class Widget {
public:
Widget() {
throw std::exception(); // 测试代码,必定抛异常导致构造失败
}
// placement new 成员函数
static void* operator new(std::size_t size, std::ostream& logStream) throw(std::bad_alloc) {
std::cout << "placement new" << std::endl;
return ::operator new(size);
}
// 普通operator delete 成员函数
static void operator delete(void* pMemory, std::size_t size) throw() {
if (pMemory == 0)
return; // 直接返回
if (size != sizeof(Widget)) {
std::cout << "size mismatch delete" << std::endl;
::operator delete(pMemory);
return;
}
std::cout << "size match delete" << std::endl;
::operator delete(pMemory);
}
// placement delete 成员函数
static void operator delete(void* pMemory, std::ostream& logStream) throw() {
std::cout << "deal with exception" << std::endl;
::operator delete(pMemory);
}
};

int main() {
try {
// Widget* pw = new Widget; // 报错,名称遮掩
Widget* pw = new (std::cerr) Widget;

delete pw;
} catch (std::exception) {
std::cout << "std::exception" << std::endl;
}

return 0;
}

需要注意的是,placement delete只是为了应对未能完成构造而导致的内存泄漏问题的。也就是说,当没有抛出异常,对象成功构造后,用户自己delete对象时,调用的仍然是普通的operator delete。所以,必须同时提供两者。

此外,还有一个问题,那就是名称遮掩问题(参见条款33),即operator new成员函数的名称会遮掩global作用域下的operator new,从而导致这些标准的operator new无法再用与创建类对象。同理,派生类中的operator new也会遮掩global版本和从基类继承而来的operator new版本。除非你刻意为之,你应当保证这些缺省的new/delete仍然可用。global作用域下缺省的operator new包含以下几种:

void* operator new(std::size_t) throw(std::bad_alloc);			// normal new
void* operator new(std::size_t, void*) throw(); // placement new
void* operator new(std::size_t, const std::nothrow_t&) throw(); // nothrow new[条款49]

实现这一目的的方法也很简单,就是实现一个基类,其中包含所有缺省的new/delete,再利用继承机制和using 声明式取得缺省版本:

class StandardNewDeleteForms {
public:
// normal new/delete
static void* operator new(std::size_t size) throw(std::bad_alloc) {
return ::operator new(size);
}
static void operator delete(void* pMemory) throw() {
::operator delete(pMemory);
}
// placement new/delete
static void* operator new(std::size_t size, void* ptr) throw() {
return ::operator new(size, ptr);
}
static void operator delete(void* pMemory, void* ptr) throw() {
::operator delete(pMemory, ptr);
}
// nothrow new/delete
static void* operator new(std::size_t size, const std::nothrow_t& nt) throw() {
return ::operator new(size, nt);
}
static void operator delete(void* pMemory, const std::nothrow_t& nt) throw() {
::operator delete(pMemory, nt);
}
};

class Widget : public StandardNewDeleteForms {
public:
using StandardNewDeleteForms::operator new;
using StandardNewDeleteForms::operator delete;

Widget() {
throw std::exception(); // 测试代码,必定抛异常导致构造失败
}
// placement new 成员函数
static void* operator new(std::size_t size, std::ostream& logStream) throw(std::bad_alloc) {
std::cout << "placement new" << std::endl;
return ::operator new(size);
}
// 普通operator delete 成员函数
static void operator delete(void* pMemory, std::size_t size) throw() {
if (pMemory == 0)
return; // 直接返回
if (size != sizeof(Widget)) {
std::cout << "size mismatch delete" << std::endl;
::operator delete(pMemory);
return;
}
std::cout << "size match delete" << std::endl;
::operator delete(pMemory);
}
// placement delete 成员函数
static void operator delete(void* pMemory, std::ostream& logStream) throw() {
std::cout << "deal with exception" << std::endl;
::operator delete(pMemory);
}
};

int main() {
try {
Widget* pw = new Widget; // 编译通过,将会调用缺省的global new
// Widget* pw = new (std::cerr) Widget; // 编译通过,将会调用placement new

delete pw;
} catch (std::exception) {
std::cout << "std::exception" << std::endl;
}

return 0;
}

杂项讨论

条款53:不要轻忽编译器的警告

  • 严肃对待编译器发出的警告信息。

我们经常会忽略编译器给出的警告信息,但是no-warning是值得被推崇的,除非你清楚的了解warning信息确实无伤大雅。

  • 但也不要过度依赖编译器的报警能力。

不同的编译器之间可能存在差异,一旦移植到另一个编译器上,原本依赖的警告信息有可能就消失。

条款54:让自己熟悉包括TR1在内的标准程序库

C++ TR1(Technical Report 1)并非标准,而是一份对C++98标准补充新特性的草稿文件。TR1详细阐述了14个新组件,放在std::tr1命名空间下,其中绝大部分已经定版,收录到了C++11标准中。14个组件(如已收录,会在开头标注)详情如下:

  1. 【C++11】智能指针:老朋友了,具体可以看这篇
  2. 【C++11】tr1::function:可以表示任意可调用物(callable entity),即任意函数或函数对象,只要签名一致即可。在条款35中有用法示例;
  3. 【C++11】tr1::bind:绑定器,可用于绑定可调用物的参数。在条款35中有用法示例;
  4. 【C++11】Hash Tables:以哈希表为基础的新容器,以unordered_开头的setmultisetmapmultimap,容器中元素无任何可预期的次序;
  5. 【C++11】正则表达式(Regular Expressions)#include<regex>
  6. 【C++11】元组(Tuples,又称变量组)std::pair(只能持有两个元素)的泛化,可以持有任意个数的元素;
  7. 【C++11】tr1::array:本质上是一个STL化定长数组,提供诸如begin()end()等实用的成员函数;
  8. 【C++11】tr1::mem_fn:成员函数指针包装器,传入一个成员函数指针,返回一个可调用物,可调用物接受类对象作为(第一个)输入参数,执行该类对象的成员函数;
  9. 【C++11】tr1::reference_wrapper:将引用封装为对象,通常用于对引用进行封装然后装入标准容器(直接往容器塞引用是不行的);
  10. 【C++11】随机数生成工具std::random_device,可以直接生成或者使用不同的 随机数引擎随机分布算法 进行生成,比C标准库的rand要更强大,头文件是 #include<random>
  11. 【C++17】数学特殊函数:包括Laguerre多项式、Bessel 函数、完全椭圆积分等特殊数学函数,注意,这些 在 C++17 才引入C++标准,可参考cppreference,头文件在 #include<cmath>
  12. C99兼容扩充 :C99标准是C语言的官方标准第二版,1999年发布,TR1对其进行了兼容;
  13. 【C++11】类型萃取(Type traits):头文件#include<type_traits>,在条款47中有详细介绍;
  14. 【C++11】tr1::result_of:可以对函数返回值做推断,得到返回值类型,头文件为 #include<type_traits> ,示例用法如下:
// 假设有个函数 double calcDaySale(int);
std::result_of<calcDaySale(int)>::type x = 3.14; // x就是double类型

条款55:让自己熟悉Boost

Boost是一个C++开发者集结的社群,也是个可自由下载的程序库集,网址是 http://boost.org

其特殊性:和C++标准委员会有着独一无二的密切关系,且具有很深影响力;接纳程序库非常严谨,需要一次以上的同行专家评审。

Boost 程序库集可处理的场景有许多(且囊括了TR1的实现),可区分出数十个类别,并且还在持续增加,列举一小部分如下:

  • 字符串与文本处理
  • 容器
  • 函数对象与高级编程
  • 泛型编程:覆盖一大组 traits classes
  • 模板元编程:覆盖一个针对编译器 assertions 而写的程序库,以及 Boost MPL程序库
  • 数学和数值:包括有理数、八元数、四元数、公约数、多重运算、随机数等等
  • 正确性与测试性
  • 数据结构
  • 语言间的支持:允许 C++ 和 Python 之间的无缝互联
  • 内存:覆盖Pool程序库和智能指针等
  • 杂项:包括 CRC 校验、日期和时间的处理、文件系统等内容

总的来说,Boost 是一个社群,也是个网站。致力于免费、源码开放、同行复审的 C++ 程序库开发,非常值得经常访问与学习。