1. Effective C++ 学习笔记 1:让自己习惯 C++
  2. Effective C++ 学习笔记 2:构造、析构、赋值运算
  3. Effective C++ 学习笔记 3:资源管理
  4. Effective C++ 学习笔记 4:设计与声明
  5. Effective C++ 学习笔记 5:实现
  6. Effective C++ 学习笔记 6:继承与面向对象设计
  7. Effective C++ 学习笔记 7:模板与泛型编程
  8. Effective C++ 学习笔记 8:定制 new 和 delete
  9. 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
2
3
4
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:基本用法

1
2
3
4
5
6
7
8
void OutOfMem() {
std::cerr << "out of memory" << endl;
std::abort();
}
int main() {
new_handler 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 的资源类来管理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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 都继承这个基类:

1
2
3
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> 中还提供了一种不抛出异常的方式。

1
2
3
4
5
6
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 。

  1. 为了检测使用错误。比如 new 之后 delete 失败,或者多次 delete 同一块内存,还有分配额外的内存空间来保存默认数据之外的一些内容(如区块签名)。
  2. 为了提高性能。默认的 new 和 delete 的设计是为了满足所有需求的一种中庸实现,所以它的运行时间和运行空间可能会比一个自定义的版本更差。
  3. 为了收集使用统计数据。统计调用次数,内存状态等信息。
  4. 手动完成数据对齐。默认版本的 new 可能不考虑数据对齐的问题,这可能会导致性能问题或运行错误。
  5. 实现内存集簇。避免不必要的页错误,将分配的内存集中在尽可能少的内存页中。
  6. 额外行为。添加一些以上条目没涉及到的功能,比如像操作共享内存,为释放的内存写入 0 值。

总结

  • 有许多理由需要写个自定义的 new 和 delete,包括改善性能、内存调试、收集使用信息等。

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

Adhere to convention when writing new and delete.

为了实现和标准 new/delete 一致的行为,我们自定义的 new/delete 也应该完成标准规范的动作。

话题 1:new 的常规

  1. 如果有能力分配内容,就返回指向这块内存的指针;否则,调用 new_handler 函数,如果 new_handler 函数是空,则抛出 bad_alloc 异常。
  2. 如果用户传入分配 0 bytes 空间,需要能够有效应对。一种简单的方式是把其看做分配 1 bytes 空间来处理。
  3. new 是个无限循环,退出这个循环的条件有:成功分配内存、抛出 bad_alloc 异常、分配失败并调用 new_handler 函数来处理(又分为分配成功退出、抛出异常、强行退出等处理办法)。
  4. 对于继承结构,如果派生类未实现自定义的 new,则 new 一个派生类对象时,默认会使用基类自定义的 new,然而这可能会导致问题。如果不愿意实现一个派生类的 new,应该在基类的 new 中判断只处理 size 与基类大小一致的情况,而把不一致的情况交给默认的 new 来处理,比如:
    1
    2
    3
    4
    5
    void * Base::operator new(std::size_t size) throw(std::bad_alloc) {
    if (size != sizeof(Base))
    return ::operator new(size); // 交给标准 new 来处理不是 Base 的内存空间
    // 以下可以处理是 Base 的内存空间
    }
  5. 但是,在自定义的 new[] 中无法假设 size 与 Base 的大小,因为 array 的元素数量不可知。

话题 2:delete 的常规

  1. 标准的 delete 对传入一个空指针的情况,是不处理的。所以自定义的 delete 也要做处理。
  2. 要确保类中 new 传入的 size 和 delete 删掉的 size 一致。同样的,如果 size 不等于 Base 的大小,应当交给默认的 delete 处理,因为这可能是派生类的调用。
  3. 不要丢掉基类的 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
2
3
4
5
6
7
8
9
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 也是如此。
所以:

1
2
3
4
5
6
7
8
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:

1
2
3
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 时,小心不要遮掩了它们的正常版本。

封面图片来自:Photo by Mario Losereit on Unsplash

本文同步发布在 CSDN 账号下:Effective C++ 学习笔记 第八章:定制 new 和 delete