Effective C++ 学习笔记 第八章:定制 new 和 delete

it2025-05-06  30

第一章见 Effective C++ 学习笔记 第一章:让自己习惯 C++ 第二章见 Effective C++ 学习笔记 第二章:构造、析构、赋值运算 第三章见 Effective C++ 学习笔记 第三章:资源管理 第四章见 Effective C++ 学习笔记 第四章:设计与声明 第五章见 Effective C++ 学习笔记 第五章:实现 第六章见 Effective C++ 学习笔记 第六章:继承与面向对象设计 第七章见 Effective C++ 学习笔记 第七章:模板与泛型编程 第八章见 Effective C++ 学习笔记 第八章:定制 new 和 delete 第九章见 Effective C++ 学习笔记 第九章:杂项讨论

本章中,若不特别说明,则 new 和 delete 的描述也适用于 new[] 和 delete[]。

文章目录

条款 49:了解 new-handler 的行为话题 1:基本用法话题 2:类的自建 new_handler话题 3:复用这套机制话题 4:返回 null 的 new总结 条款 50:了解 new 和 delete 的合理替换时机总结 条款 51:编写 new 和 delete 时需要遵守常规话题 1:new 的常规话题 2:delete 的常规总结 条款 52:写了 placement new 就需要写 placement delete话题 1:placement new 和 placement delete话题 2:作用域遮掩总结

条款 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> 文件中:

namespace std { typedef void (*new_handler) (); new_handler set_new_handler(new_handler p) throw(); }

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:基本用法

void OutOfMem() { std::cerr << "out of memory" << endl; std::abort(); } int main() { new_hanndler old_new_handler = std::set_new_handler(OutOfMem); int* p = new int[1000000000000L]; }

如果 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 的资源类来管理。

class NewHandlerHolder { public: explicit NewHandlerHolder(std::new_handler nh) : old_new_handler(nh) {} ~NewHandlerHolder() { std::set_new_handler(old_new_handler); } private: std::new_handler old_new_handler; // 保存替换之前的旧的 handler NewHandlerHolder(const NewHandlerHolder&); // 禁止 copy 操作 NewHandlerHolder& operator=(const NewHandlerHolder&); } // 以下是使用,假设一个 W 类需要重载 operator new,以下是实现 class W { 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; // 可以定义 class 自有的 Handler }; std::new_handler W::currentHandler = 0; std::new_handler W::set_new_handler(std::new_handler p) throw() { std::new_handler old_new_handler = currentHandler; currentHandler = p; return currentHandler; } void* W::operator new(std::size_t size) throw(std::bad_alloc) { new_handler old_new_handler = std::set_new_handler(currentHandler)); NewHandlerHolder h(old_new_handler); return ::operator new(size); // 调用公共的 new 操作,留意 RAII 特性会在退出函数时, // 也就是 new 成功之后,调用 h 的析构函数,从而还原旧的 handler } // 以下为使用 void OutOfMem(); // 自定义的处理函数 W::set_new_handler(OutOfMem); // 设定 class W 默认的 handler W* pw = new W; // 如果 new 失败,会调用 OutOfMem std::string* ps = new std::string; // 如果 new 失败,会抛出异常,也就是说不影响其他 class W::set_new_handler(0); // 设定 class W 默认的 handler 为 null W* pw2 = new W; // 这次如果 new 失败,会抛出异常

话题 3:复用这套机制

如果我们有多个 class,都想设计这套机制,如何能复用已有代码呢?本话题引出称为 mixin 的编程风格。 定义一个模板基类,模板参数是不同的 class,基类中的实现就是话题 2 的这套设计,代码几乎不变,模板参数在基类中也不会使用。 然后每个 class 都继承这个基类:

template<typename T> class BaseNewHandlerHolder { ... }; // 实现代码和话题 2 中的 NewHandlerHolder 一致 class W : public BaseNewHandlerHolder<W> { ... }; // 特殊写法

这里我们注意到有个特殊写法,派生类继承的基类中的模板参数,是派生类类型,这是允许的,被称为 “怪异的循环模板模式”(curiously recurring template pattern, CRTP)。

这样,我们就能够利用模板类的特点,针对每个不同的类 (如 W)来设计只属于它的 new handler 机制。

话题 4:返回 null 的 new

最早的 C++ 设计时,new 不会抛出异常,而是在分配失败时返回 null,为了兼容这种老的设计,<new> 中还提供了一种不抛出异常的方式。

class W { ... }; W* p1 = new W; if (p1 == 0) { ... } // 没意义,new 不会返回错误的 null W* p2 = new (std::nothrow) W; if (p2 == 0) { ... } // 当 new 失败时,能够检查到 p2 为 0 // 不过有个前提,就是 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 来处理,比如: void * 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 是什么的问题放到一边。 如果我们写的一个:

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。 比如:

class W { public: static void* operator new(std::size_t size) throw(std::bad_alloc); // 标准 new static void* operator delete(void *pMemory) throw(); // 标准 delete static void* operator new(std::size_t size, std::ostream& logStream) throw(std::bad_alloc); // placement new static void* operator delete(void *pMemory, std::ostream& logStream) throw(); // 与之对应的 placement delete }; W *pw = new (std::cerr) W; // 调用 placement new delete pw; // 需要注意,手动调用的 delete,其实是标准的 delete,placement delete 只会在失败时自动调用

应当在编写了一个 placement new 之后,也同步编写一个对应的 placement delete,来避免出现 new 时意外构造失败,导致内存无法自动释放的问题。

话题 2:作用域遮掩

在条款 33 中提到,内部作用域的同名函数会遮掩外部作用域的函数(注意是同名,无所谓参数列表),对于 new 和 delete 也是如此。 所以:

class Base { public: static void operator new(std::size_t size, std::ostream& logStream) throw(std::bad_alloc); // 这个 new 遮掩了默认外部标准 new // 假设这个类中没有实现默认的标准 new }; Base *pb = new Base; // 会失败 Base *pb = new (std::cerr) Base; // 成功,调用类内的 placement new

同理的是继承结构,派生类内的 new 会遮掩基类中的 new。 所以,我们应该默认在类内定义好默认的标准 new 和 delete,以防止出现问题。

C++ 标准提供 3 中 new:

void* operator new(std::size_t) throw(bad_alloc); // 标准 new void* operator new(std::size_t, void*) throw(); // 默认的 placement new void* operator new(std::size_t, const std::nothrow_t&) throw(); // 不抛出异常的 new

我们可以将这 3 种 new 实现在基类中,然后在派生类中使用 using 来暴露基类内的 new,当然也包括对应的 delete。在派生类中也可以定义自定义的 new 来遮掩这 3 种默认 new。

总结

当你写一个 placement new 时,也确认实现了对应的 placement delete。如果没有这么做,程序可能会发生隐藏的、时断时续的内存泄漏。当声明 placement new 和 placement delete 时,小心不要遮掩了它们的正常版本。
最新回复(0)