Effective C++ 学习笔记 8:定制 new 和 delete
- Effective C++ 学习笔记 1:让自己习惯 C++
- Effective C++ 学习笔记 2:构造、析构、赋值运算
- Effective C++ 学习笔记 3:资源管理
- Effective C++ 学习笔记 4:设计与声明
- Effective C++ 学习笔记 5:实现
- Effective C++ 学习笔记 6:继承与面向对象设计
- Effective C++ 学习笔记 7:模板与泛型编程
- Effective C++ 学习笔记 8:定制 new 和 delete
- Effective C++ 学习笔记 9:杂项讨论
条款 49:了解 new-handler 的行为
Understand the behavior of the new-handler.
现代 C++ 中的 new 在无法分配有效空间时,会抛出一个 no_alloc 异常,有些时候,我们不希望抛出异常,而是能让分配失败时进入到一个处理函数中,这个机制 C++ 是支持的,它叫做 new_handler。
提示:new_handler 中的 new 是指 operator new 的意思,表示保存 new 操作处理函数的意思,而不是指新的处理函数。下文可能出现 old_new_handler,则表示旧的 new 操作处理函数。
声明在 <new> 文件中:
1 | namespace std { |
new_handler 是一个 typedef,它实际是一个函数指针,默认是 null,操作它的接口 set_new_handler 接受一个 new_handler,返回一个 new_handler,接受的参数是我们用户提供的处理函数的指针,返回的是之前的处理函数的指针,它用来将我们用户提供的处理函数替换到默认位置,并把替换前默认的处理函数返回。
备注:题外话,C++ 11 中已经弃用了异常声明符 throw(),取而代之的是 noexcept。后者可以阻止异常的抛出,当然还包括 noexcept(false) 和 noexcept(true),noexcept(true) 与 noexcept 一致,noexcept(false) 表示可能会抛出异常。
话题 1:基本用法
1 | void OutOfMem() { |
如果 p 的分配失败,则会调用到 OutOfMem 函数,打印错误信息并 abort。
可以自定义的处理函数为我们提供了更多的灵活性,避免了无脑抛异常,比如我们可以在处理函数中打印信息,想办法分配内存,或直接终止程序。
话题 2:类的自建 new_handler
这块比较简单。在类内除了重载 operator new 之外,还需要重载 set_new_handler 函数,同时还要提供一个 static 的 new_handler 成员,这个数据成员用来保存旧的(替换前的)new_handler,从而可以在类之外能够让其他代码调用到大环境下的 new_handler (而不是类内做了私自修改的那个),也就是说,程序在某一时刻只会维护一个有效的 new_handler。
类内的 set_new_handler 需要替换新的 new_handler 并维护旧的 new_handler,在 new 失败时需要恢复旧的 new_handler,而类内的 new 便完成调用 std::operator new 和维护旧的 new_handler 的动作。
一种更优雅的方式是使用 RAII,建立一个 new_handler 的资源类来管理。
1 | class NewHandlerHolder { |
话题 3:复用这套机制
如果我们有多个 class,都想设计这套机制,如何能复用已有代码呢?本话题引出称为 mixin 的编程风格。
定义一个模板基类,模板参数是不同的 class,基类中的实现就是话题 2 的这套设计,代码几乎不变,模板参数在基类中也不会使用。
然后每个 class 都继承这个基类:
1 | template<typename T> |
这里我们注意到有个特殊写法,派生类继承的基类中的模板参数,是派生类类型,这是允许的,被称为 “怪异的循环模板模式”(curiously recurring template pattern, CRTP)。
这样,我们就能够利用模板类的特点,针对每个不同的类 (如 W)来设计只属于它的 new handler 机制。
话题 4:返回 null 的 new
最早的 C++ 设计时,new 不会抛出异常,而是在分配失败时返回 null,为了兼容这种老的设计,<new> 中还提供了一种不抛出异常的方式。
1 | class W { ... }; |
总结
- set_new_handler 允许客户指定一个函数,在内存分配失败时被调用,而替换掉抛出异常的动作。
- nothrow new 是比较局限的工具,它只能保证 new 的动作本身不抛出异常,但无法控制语句中有其他异常。
条款 50:了解 new 和 delete 的合理替换时机
Understand when it makes sense to replace new and delete.
当有以下几点需求时,才需要主动去写一个自定义的 new 和 delete 。
- 为了检测使用错误。比如 new 之后 delete 失败,或者多次 delete 同一块内存,还有分配额外的内存空间来保存默认数据之外的一些内容(如区块签名)。
- 为了提高性能。默认的 new 和 delete 的设计是为了满足所有需求的一种中庸实现,所以它的运行时间和运行空间可能会比一个自定义的版本更差。
- 为了收集使用统计数据。统计调用次数,内存状态等信息。
- 手动完成数据对齐。默认版本的 new 可能不考虑数据对齐的问题,这可能会导致性能问题或运行错误。
- 实现内存集簇。避免不必要的页错误,将分配的内存集中在尽可能少的内存页中。
- 额外行为。添加一些以上条目没涉及到的功能,比如像操作共享内存,为释放的内存写入 0 值。
总结
- 有许多理由需要写个自定义的 new 和 delete,包括改善性能、内存调试、收集使用信息等。
条款 51:编写 new 和 delete 时需要遵守常规
Adhere to convention when writing new and delete.
为了实现和标准 new/delete 一致的行为,我们自定义的 new/delete 也应该完成标准规范的动作。
话题 1:new 的常规
- 如果有能力分配内容,就返回指向这块内存的指针;否则,调用 new_handler 函数,如果 new_handler 函数是空,则抛出 bad_alloc 异常。
- 如果用户传入分配 0 bytes 空间,需要能够有效应对。一种简单的方式是把其看做分配 1 bytes 空间来处理。
- new 是个无限循环,退出这个循环的条件有:成功分配内存、抛出 bad_alloc 异常、分配失败并调用 new_handler 函数来处理(又分为分配成功退出、抛出异常、强行退出等处理办法)。
- 对于继承结构,如果派生类未实现自定义的 new,则 new 一个派生类对象时,默认会使用基类自定义的 new,然而这可能会导致问题。如果不愿意实现一个派生类的 new,应该在基类的 new 中判断只处理 size 与基类大小一致的情况,而把不一致的情况交给默认的 new 来处理,比如:
1
2
3
4
5void * Base::operator new(std::size_t size) throw(std::bad_alloc) {
if (size != sizeof(Base))
return ::operator new(size); // 交给标准 new 来处理不是 Base 的内存空间
// 以下可以处理是 Base 的内存空间
} - 但是,在自定义的 new[] 中无法假设 size 与 Base 的大小,因为 array 的元素数量不可知。
话题 2:delete 的常规
- 标准的 delete 对传入一个空指针的情况,是不处理的。所以自定义的 delete 也要做处理。
- 要确保类中 new 传入的 size 和 delete 删掉的 size 一致。同样的,如果 size 不等于 Base 的大小,应当交给默认的 delete 处理,因为这可能是派生类的调用。
- 不要丢掉基类的 virtual 析构函数,delete 继承结构时可能会导致问题。
总结
- operator new 应该是一个无穷循环,并在其中尝试分配内存,如果无法满足内存需求,应该调用 new-handler 函数。它也应该能够处理 0 bytes 申请。 Class 自定义的 new 版本还应该处理比类大小不一致的内存申请的问题。
- operator delete 应该在收到 null 指针时不做任何事情。Class 自定义的 delete 还应该处理比类大小不一致的内存释放的问题。
条款 52:写了 placement new 就需要写 placement delete
Write placement delete if you write placement new.
先把 placement new 是什么的问题放到一边。
如果我们写的一个:
1 | W *pw = new W; |
这个表达式会调用两个 W 的成员函数,分别是 W 的 new 和 W 的默认构造函数。如果 W 的 new 分配成功,但 W 的默认构造函数却失败,那么因为我们无法取得 pw 指向的对象,也就无法去将 new 的内存释放,从而可能导致内存泄漏。
不过还好,如果 new 是一个标准的 new (只接受一个 size_t 参数作为内存大小),运行时系统会自动调用对应的那个 delete 来将内存释放。
但是,如果 new 是一个自定义的版本,那运行期系统也会找与其对应的一个 delete 来调用,如果找不到,就不会调用。
话题 1:placement new 和 placement delete
如果有一个自定义版本的 new,这个 new 除了 size_t 参数以外还有其他参数,就被叫做 placement new,而与其对应的相同参数的一个 delete,就叫与其对应的 placement delete。
比如:
1 | class W { |
应当在编写了一个 placement new 之后,也同步编写一个对应的 placement delete,来避免出现 new 时意外构造失败,导致内存无法自动释放的问题。
话题 2:作用域遮掩
在条款 33 中提到,内部作用域的同名函数会遮掩外部作用域的函数(注意是同名,无所谓参数列表),对于 new 和 delete 也是如此。
所以:
1 | class Base { |
同理的是继承结构,派生类内的 new 会遮掩基类中的 new。
所以,我们应该默认在类内定义好默认的标准 new 和 delete,以防止出现问题。
C++ 标准提供 3 种 new:
1 | void* operator new(std::size_t) throw(bad_alloc); // 标准 new |
我们可以将这 3 种 new 实现在基类中,然后在派生类中使用 using 来暴露基类内的 new,当然也包括对应的 delete。在派生类中也可以定义自定义的 new 来遮掩这 3 种默认 new。
总结
- 当你写一个 placement new 时,也确认实现了对应的 placement delete。如果没有这么做,程序可能会发生隐藏的、时断时续的内存泄漏。
- 当声明 placement new 和 placement delete 时,小心不要遮掩了它们的正常版本。
封面图片来自:Photo by Mario Losereit on Unsplash
本文同步发布在 CSDN 账号下:Effective C++ 学习笔记 第八章:定制 new 和 delete



















