从此开始进入定制new和delete
主题,因为C++
手动管理内存的双面性,因此了解new
和delete
是十分必要的。时至今日,垃圾回收(Garbage Collection,GC)
十分流行,主流的的语言里面也只有C
和C++
没有GC
的支持,下图是2018的TIOBE语言使用统计:
可以看到Top 10
的语言里面除C
和C++
以外都是具有GC
的语言。GC固然会带来程序性能的下降,但是免去手动管理内存带来的开发效率的提高更加明显,Top 1
的Java
就很好地说明了这点。而且在多线程
的挑战下,手动内存管理变得尤其困难,笔者曾尝试过写一个多线程Socket程序,却总是崩溃,找了三天才找出是自己没有正确管理好内存。当一个执行线程开始使用一个对象的时候,该对象已经在其他线程中被析构了。因此从这点看来,C++
需要耗费不少精力才能学好。
Effective C++
里面只有operator new
的出现,如果读者已经阅读过《深度探索C++对象模型》的话,应该也接触过new operator
,要注意这两者的区别。
条款49:了解new-handler的行为
当operator new
因无法满足所要求的内存需求而抛出异常前,其中一步会调用客户制定的错误处理函数,即所谓的new-handler
:
1 | namespace std |
也就是说一旦调用了set_new_handler
之后,在申请内存失败的时候则会调用输入p
函数。而一个设计良好的new-handler
则应该有以下的行为:
- 让更多的内存可被使用。类似GC过程,或者在程序初始化时开辟更大块的内存。
- 安装另外一个new-handler。让有能力处理的new-handler接手。
- 卸除new-handler。直接让operator new失败时抛出异常。
- 抛出bad_alloc异常。
- 不返回,调用abort()或者exit()。
一般没有太大的必要去定制new-handler
,如果非要定制的话,引用书上的实现:
1 | class Widget |
除非是const
的静态成员,否则需要把定义式写在类的定义外:
1 | std::new_handler Widget::currentHandler = 0; // 初始化为null |
标准版的set_new_handler
:
1 | std::new_handler Widget::set_new_handler(std::new_handler p) throw() |
Widget::operator new
行为如下:
- 调用标准set_new_handler,告知Widget的错误处理函数。会将Widget::currentHandler = ${global new-handler}。
- 调用global operator new,执行实际的内存分配。如果分配失败则调用Widget::currentHandler。
- global operator new成功分配,返回内存指针。Widget::~Widget()会管理global new-handler,会恢复
Widget::operator new
调用前global new-handler。
后面原作也用了详细的代码介绍了上面的三个过程,这里省略掉一是因为这部分可以查原书代码了解,二是笔者对这部分了解不深,基本上仍然处于初次接触的水平,三是该条款重点是了解set_new_handler
以及相关的过程即可。
条款50:了解new和delete的合理替换时机
对于绝大多数的C++
程序员,语言提供的原始的operator new
和operator delete
基本上可以满足程序的需求,很少人想到去重载(原书上用的“替换”)这两个operator
。书上列出了三个最常见的理由:
- 用来检测运用上的错误。
- 为了强化效能。例如插入一些
内存碎片
整理的算法。 - 为了收集使用上的统计数据。
总的来说,就是改变原有的行为,修改分配规则,或者插入一些信息记录的步骤。书上也给出定制operator new
的一般方法:
1 | // 让分配的对象具有签名 |
例子中的主要缺陷是没有遵循operator new
的原则,如应该有一个循环调用new-handling
的行为,直到所有尝试都失败,具体可以看条款51。
对齐(alignment)
的问题,因为C++
底层的代码必然接触到操作系统、甚至硬件相关的这样的细节,重载operator new
可以做到对齐的修正,提高运行速度。
一般需要修改默认的operator new
和operator delete
的理由如下:
- 为了检测运用错误。
- 为了收集动态分配内存的使用统计信息。
- 为了增加分配和归还的速度。
- 为了降低缺省内存管理器带来的额外空间开销。
- 为了弥补缺省分配器的非最佳对齐(suboptimal alignment)。例如指针值在32位系统是4倍数等。
- 为了将相关对象成簇集中。减少
缺页异常(Page Faults)
。 - 为了获得非传统的行为。
条款51:编写new和delete时固守常规
先给出结论:
operator new
应该内含一个无穷循环,并在其中尝试分配内存,如果它无法满足内存需求,就该调用new-handler
。它也应该有能力处理0 bytes
申请。Class专属版本则应该处理“比正确大小更大的(错误)申请”。operator delete
应该在收到null指针时不做任何事。Class专属版本则还应该处理“比正确大小更大的(错误)申请”。
C++规定,即便要求客户要求0 bytes
,operator new
也会返回一个合法指针。一份常规的non-member operator new
伪码:
1 | void* operator new(std::size_t size) throw(std::bad_alloc) // 定制的函数可以接受额外的参数 |
如果是Class专属版的operator new
:
1 | class Base |
那么问题就很明显了,Derived
的大小大于等于Base
,这样重载的operator new
执行出来的Derived
的尺寸是正确的吗?
虽然很怪异,但是如果Base::operator new
这样实现:
1 | void* Base::operator new(std::size_t size) throw(std::bad_alloc) |
把责任甩开可以解决问题。
如果重载operator new[]
的话,考虑到Base
的子子孙孙,bytes % sizeof(Base)
的值就不一定为零了,所以重载的时候要考虑的问题还真不少。
接下来就是operator delete
的问题,首先记住“删除null指针永远安全”,如下一份non-member operator delete
的伪码:
1 | void operator delete(void* rawMemory) throw() |
同样,考虑到Base
的子子孙孙,Base
专属版的operator delete`应该这么实现:
1 | class Base |
注意,如果没有virtual
关键字修饰的析构函数,operator delete
也不会接收到正确的size
。所以在一个继承里面,virtual
关键字是必须要考虑的。
条款52:写了placement new也要写placement delete
placement new
和placement delete
应该是更少见的语法了。什么是placement new
:
1 | 如果operator new接受的参数除了一定会有的那个size_t之外还有其他,这边是所谓的placement new。 |
有很多版本的placement new
,其中最为有用的一个是“接受一个指针指向对象该被构造之处”:
1 | void* operator new(std::size_t, void* pMemory) throw(); |
同样,如果接受额外参数的operator delete
也成为placement delete
。原书上给出了加了日志功能的placement new/delete
的例子:
1 | class Widget |
placement new
一定要有对应版本的placement delete
,若placement new
过程抛出了异常,需要恢复而调用operator delete
时,是寻找参数匹配的placement delete
,假如没有提供的话,那么就不会执行恢复步骤产生内存泄漏。例如上述代码的成对的placement new/delete
。当:
1 | Widget* pw = new(std::cerr) Widget; |
发生异常,除了原有的log输出到std::cerr
中,因为具有参数匹配的placement delete
被调用,因而不会有泄漏的问题。
还有,写了placement new
就会产生名字掩盖问题:
1 | class Base |
同理:
1 | class Derived : public Base |
所以在写自己的placement new/delete
时,要考虑该类需不需要一般形式的operator new
。
即是,声明placement new/delete
时,不要忘记了这样会掩盖正常版本的operator new/delete
。