Effective C++ 读书笔记07
前言
本文是阅读《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() { |
用 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 { |
一个使用示例如下:
void OutOfMem() { |
一个设计良好的new_handler
可以为使用者提供极大的设计弹性,例如:
- 让更多的内存可被使用:提前申请内存,让
new_handler
触发下一次的返还分配; - 安装另一个
new_handler
:调用set_new_handler
让下一次调用新的new_handler
,以期新的new_handler
可以解决问题; - 卸载
new_handler
:将null
指针传递给set_new_handler
,operator new
会在内存分配不成功时抛出异常; - 手动抛出
std::bad_alloc
或其派生的异常,这样的异常不会被operator new
捕获,因此会被传播到内存申请处; - 不返回,通常调用
std::abort()
或std::exit()
;
现在考虑一个问题:是否可以给每一个C++类定值专属的new-handler
呢?答案是C++标准机制没有提供,但我们可以自己实现,方法有以下两种:
- 针对每个特定类,类内重载static类型的
operator new
和set_new_handler
方法,为了能够恢复原本的new-handler
,还需实现一个资源管理类NewhandlerHolder
。具体实现如下:
class NewHandlerHolder { |
这种方法有个明显的缺点,就是你得为每一个类都重载一份static类型的operator new
和set_new_handler
方法,但实质上重载的代码是基本相同的,这会导致代码冗余。
- 将上述方法1封装进一个模板类中,需要专属new-handler的类只需继承该模板基类即可,具体实现如下:
class NewHandlerHolder { |
其中Widget
类继承自NewHandlerSupport<Widget>
,而且NewHandlerSupport
模板类的类型参数T
从未使用,这可能会让你感到困惑。
其实,将NewHandlerSupport
设计成模板类主要是为了让每一个继承自NewHandlerSupport
的类,都拥有实体互异的NewHandlerSupport
副本,更明确的说,是为了让每个类拥有一份专属的static成员变量currentHandler
。这种派生类继承自一个模板化的基类,而后者又以派生类作为类型参数的技术被称为怪异的循环模板模式(CRTP,curiously recurring template pattern)。
条款50:了解new和delete的合理替换时机
替换编译器提供的operator new
和operator delete
有以下常见理由:
- 用来检测运用上的错误。自定义的new/delete可以比较容易地检测到内存越界行为,比如在new时超额申请内存,在真正使用的内存区块前后的额外空间中写入特定的数值,即内存签名,在delete时检查这些内存签名是否被改写,以监测越界写行为。
- 为了强化效能。编译器提供的new/delete为了适用于各种各样的内存分配场景,在内存碎片、额外空间开销和时间性能上都是保持中庸的水平。对于特定的需求和场景,使用定制化的内存管理会有更好的性能表现。
- 为了收集使用上的统计数据。例如,内存分配的时空分布,内存块的申请和归还的次序(FIFO、LIFO或随机),内存峰值等。
- 为了弥补缺省分配器中的非最佳对齐。例如,在X86体系结构下,double类型在内存地址8-byte齐位的情况访问最快。如果编译器自带的
operator new
不保证动态分配的double类型采用8-byte齐位,则可以自行实现一个,以提高程序效率。 - 为了将相关对象成簇集中。比如已知某个数据结构往往一起使用,那么分配的时候应该尽量让所有数据的内存集中一些,避免频繁触发换页中断(page faults) ,提升访问效率。
- 为了获得非传统的行为。比如当希望分配和归还共享内存(shared memory)内的区块,但能够管理该内存的只有C API(
shmget
等),那么可以自行实现定制版的new/delete,为C API 封装一层C++接口。
条款51:编写new和delete时需固守常规
条款50中解释了什么情况下需要自定义new/delete,本条款将解释自定义new/delete时需遵守的规则。
- 正确处理申请内存失败的情况。当有足够内存用于申请时,new就返回一个指针指向那块内存;如果申请失败,若new-handler函数不为null,就会调用new-handler函数(以期其能解决当前内存不足的问题,如释放某些内存出来),并再次尝试分配内存;若new-handler函数为null,则会抛出
std::bad_alloc
异常。 - 正确处理申请0-bytes的情况。即使用户申请0-bytes的内存,我们也要为其返回一个合法的指针。常用的方法就是申请1-bytes的内存并返回。
- 正确理解
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) { |
- 正确处理基类的
operator new
被派生类继承的情况。若需要实现特定类的operator new
成员函数,需要考虑通常情况下,派生类对象要比基类对象大,因此派生类调用基类的operator new
会有问题,如下:
class Base { |
处理这个问题的方法就是在基类中增加内存申请量错误的防御式编程,使得基类的operator new
只适用于基类,派生类若不重新实现,则默认调用编译器提供的operator new
,具体如下:
void* Base::operator new(std::size_t size) throw(std::bad_alloc) { |
需要注意的是,自定义的operator new[]
并不适用上述方法,因为通常情况下,需要额外的内存空间来保存元素个数等信息,所以不能简单的认为size = 元素个数 * sizeof(Base)
。
- 正确处理
operator delete
删除null指针的情况。如下是非成员函数版本的伪代码:
void operator delete(void *rawMemory) throw() { |
- 同样考虑派生类继承
operator delete
的问题。伪代码如下:
class Base { |
条款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 { |
众所周知,对于new对象的过程,可以分为以下两个步骤:
operator new
分配对象所需的内存空间;- 在该内存空间上执行对应的构造函数;
当步骤1成功, 步骤2抛出异常时,已经申请的内存空间需要被回收,已防止内存泄漏。这在使用普通的operator new
时是可以做到的,因为运行期系统知道与其对应的operator delete
,并自动调用。然而,当使用placement new
时,系统会尝试寻找并调用与placement new
额外参数个数与类型均一致的operator delete
,若没有找到,则系统什么都不会做,从而造成了内存泄漏。
所以规则很简单:当我们自定义一个placement new
时,也要提供一个带相同额外参数的placement delete
,以规避可能的内存泄漏。如下:
class Widget { |
需要注意的是,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 |
实现这一目的的方法也很简单,就是实现一个基类,其中包含所有缺省的new/delete,再利用继承机制和using 声明式取得缺省版本:
class StandardNewDeleteForms { |
杂项讨论
条款53:不要轻忽编译器的警告
- 严肃对待编译器发出的警告信息。
我们经常会忽略编译器给出的警告信息,但是no-warning是值得被推崇的,除非你清楚的了解warning信息确实无伤大雅。
- 但也不要过度依赖编译器的报警能力。
不同的编译器之间可能存在差异,一旦移植到另一个编译器上,原本依赖的警告信息有可能就消失。
条款54:让自己熟悉包括TR1在内的标准程序库
C++ TR1(Technical Report 1)并非标准,而是一份对C++98标准补充新特性的草稿文件。TR1详细阐述了14个新组件,放在std::tr1
命名空间下,其中绝大部分已经定版,收录到了C++11标准中。14个组件(如已收录,会在开头标注)详情如下:
- 【C++11】智能指针:老朋友了,具体可以看这篇;
- 【C++11】
tr1::function
:可以表示任意可调用物(callable entity),即任意函数或函数对象,只要签名一致即可。在条款35中有用法示例; - 【C++11】
tr1::bind
:绑定器,可用于绑定可调用物的参数。在条款35中有用法示例; - 【C++11】Hash Tables:以哈希表为基础的新容器,以
unordered_
开头的set
、multiset
、map
、multimap
,容器中元素无任何可预期的次序; - 【C++11】正则表达式(Regular Expressions):
#include<regex>
; - 【C++11】元组(Tuples,又称变量组):
std::pair
(只能持有两个元素)的泛化,可以持有任意个数的元素; - 【C++11】
tr1::array
:本质上是一个STL化的定长数组,提供诸如begin()
和end()
等实用的成员函数; - 【C++11】
tr1::mem_fn
:成员函数指针包装器,传入一个成员函数指针,返回一个可调用物,可调用物接受类对象作为(第一个)输入参数,执行该类对象的成员函数; - 【C++11】
tr1::reference_wrapper
:将引用封装为对象,通常用于对引用进行封装然后装入标准容器(直接往容器塞引用是不行的); - 【C++11】随机数生成工具:
std::random_device
,可以直接生成或者使用不同的 随机数引擎 和 随机分布算法 进行生成,比C标准库的rand
要更强大,头文件是#include<random>
; - 【C++17】数学特殊函数:包括Laguerre多项式、Bessel 函数、完全椭圆积分等特殊数学函数,注意,这些 在 C++17 才引入C++标准,可参考cppreference,头文件在
#include<cmath>
; - C99兼容扩充 :C99标准是C语言的官方标准第二版,1999年发布,TR1对其进行了兼容;
- 【C++11】类型萃取(Type traits):头文件
#include<type_traits>
,在条款47中有详细介绍; - 【C++11】
tr1::result_of
:可以对函数返回值做推断,得到返回值类型,头文件为#include<type_traits>
,示例用法如下:
// 假设有个函数 double calcDaySale(int); |
条款55:让自己熟悉Boost
Boost是一个C++开发者集结的社群,也是个可自由下载的程序库集,网址是 http://boost.org。
其特殊性:和C++标准委员会有着独一无二的密切关系,且具有很深影响力;接纳程序库非常严谨,需要一次以上的同行专家评审。
Boost 程序库集可处理的场景有许多(且囊括了TR1的实现),可区分出数十个类别,并且还在持续增加,列举一小部分如下:
- 字符串与文本处理
- 容器
- 函数对象与高级编程
- 泛型编程:覆盖一大组 traits classes
- 模板元编程:覆盖一个针对编译器 assertions 而写的程序库,以及 Boost MPL程序库
- 数学和数值:包括有理数、八元数、四元数、公约数、多重运算、随机数等等
- 正确性与测试性
- 数据结构
- 语言间的支持:允许 C++ 和 Python 之间的无缝互联
- 内存:覆盖Pool程序库和智能指针等
- 杂项:包括 CRC 校验、日期和时间的处理、文件系统等内容
总的来说,Boost 是一个社群,也是个网站。致力于免费、源码开放、同行复审的 C++ 程序库开发,非常值得经常访问与学习。