当我们在C++中写出一条new 表达式时,它的执行分为两步:
- 分配所需的内存
- 在分配的内存上构造对象
这两步中,C++允许我们控制的是第一步,第二步是我们无法控制的。默认情况下,如果用new 创建单个对象,第一步会调用一个默认版本的名为operator new 的函数;如果是用new[] 创建“动态数组”,那么第一步调用的是默认版本的名为operator new[] 的函数。它们的声明如下:
void *operator new(std::size_t size);
void *operator new[](std::size_t size);
这里的参数size 是编译器帮我们计算出的一个值。对于new Type(args) 来说,它调用operator new(sizeof(Type)) ;对于new Type[n] 来说,它调用operator new[](sizeof(Type) * n) 。这两个函数并不管你要在这片内存上放什么类型的对象,operator new[] 也并不知道你需要放多少个对象。它们的工作就是单纯分配一片内存。
同样地,当我们写出一条delete 表达式时,它的执行也分为两步:
- 析构对象
- 释放内存
我们能控制的是第二步,第一步是无法控制的。默认情况下,如果使用delete ,第二步会调用一个默认版本的名为operator delete 的函数;如果使用delete[] ,第二步会调用一个默认版本的名为operator delete[] 的函数。它们的声明如下:
void operator delete(void *ptr) noexcept;
void operator delete[](void *ptr) noexcept;
这两个函数的参数都是传给delete 或delete[] 的那个指针。注意,delete 不应该抛出异常。
这几个函数在标准库中都有默认的版本,但如果你自己定义了,那么这并不会构成redefinition,而是会让编译器优先选择你自己定义的版本。乍一看,operator new/new[]/delete/delete[] 与malloc/free 没什么区别——连参数类型和返回值都一样,但首先你要知道的两个关键信息是:
- C++
new 必然会分配一定的内存,哪怕传给它的size 为零。 - 默认版本的
operator new/new[] 在内存不足时抛出std::bad_alloc 异常。
因此,一个用malloc 实现的operator new 至少得长这样:(operator new[] 同理,下同)
void *operator new(std::size_t size) {
if (size == 0)
++size;
auto ptr = std::malloc(size);
if (ptr)
return ptr;
else
throw std::bad_alloc{};
}
而不能只是简单地调用malloc 了事。信奉实用主义的人可能会觉得:代码能跑不就行了?但是我们在C++中重载运算符的时候总是要牢记一条准则:让你的重载运算符的行为与内置行为尽可能保持一致,例如赋值运算符要返回引用、后置递增运算符要返回递增前的拷贝,等等。不然你就必须接受这一事实:你自己编写的这个运算符与你长期学习与实践中养成的某些习惯相违背,而这往往导致错误。
事实上,C++中的operator new 还要比这更复杂一些。当它在遭遇内存不足的时候,它并不是直接抛出一个异常,而是想方设法地做一些调整,并且不断尝试重新分配,直至成功分配到内存或者确信无法分配内存为止。调整的方法是调用一个所谓的“new_handler ”。一个使用malloc 的operator new 如下:
void *operator new(std::size_t size) {
if (size == 0)
++size;
while (true) {
auto ptr = std::malloc(size);
if (ptr)
return ptr;
auto global_handler = std::set_new_handler(nullptr);
std::set_new_handler(global_handler);
if (global_handler)
global_handler();
else
throw std::bad_alloc{};
}
}
我们对于std::set_new_handler 的使用可能略有些奇怪。这个函数接受一个new_handler 类型的对象,将当前全局的new-handler设置为这个对象,同时返回原来的那个new-handler。而new_handler 类型其实就是void (*)() ——指向一个不接受参数、无返回值的函数的函数指针。因此,我们这两行代码
auto global_handler = std::set_new_handler(nullptr);
std::set_new_handler(global_handler);
只是为了获取当前的new-handler而已。如果这个new-handler不是空指针,就意味着我们还有救,那么我们调用这个函数来做一些适当的调整,这个函数可能会想办法释放掉一些内存来使得有更多的内存可以被使用,然后我们再次尝试分配内存,如此循环。但如果我们发现这个new-handler是空指针,那就意味着标准库new-handler对此也无能为力了,我们只得两手一摊,抛出std::bad_alloc 异常。
operator delete 则要简单很多,你唯一需要保证的就是delete 一个空指针是无害的。例如,一个使用free 实现的operator delete 如下:
void operator delete(void *ptr) noexcept {
free(ptr);
}
这在ptr 为空指针的时候当然也是没问题的,因为free 一个空指针什么都不会发生。
讲到这里,你应该已经明白operator new/new[]/delete/delete[] 的重载与其它重载运算符的不同之处。如果我们显式地调用::operator new(N) ,其实就是在分配N bytes的内存,和我们平常写的new 表达式并不一样。
我们何时需要重载这些函数呢?可能是出于这些原因:
- 为了记录程序对于动态内存的使用,包括记录分配和释放来检测内存泄漏,或者做一些其它统计,比如程序在运行期间分配的内存的大小分布、寿命分布、最大动态分配量等等。
- 为了提升效率。这些函数的默认版本采取的策略必须能够应付各种各样的内存配置需求,因此它们的效率虽然不会太差,但也并不在任何情况下有极佳的表现。因此假如你对你的程序的内存运用有着深刻的认识,你可能会发现自己定制的
operator new/delete 无论是时间上还是空间消耗上都胜过默认的版本。
重载全局的operator new/new[]/delete/delete[] 是没有问题的,比方说当我想检查程序中的内存泄漏时,我可以在这几个函数中记录内存的分配与释放。但是注意,标准库的许多容器使用的默认allocator都会调用全局的::operator new ,所以在全局范围重载这些函数相当于将标准库的朋友们全都邀请到你家来做客,你要确保你的这些函数能够应付它们的需求。
很多时候,我们可能只是想为我们自己定义的class提供特殊的内存配置方式,并不想惊扰其他人。这时我们可以为class提供一个static 版本的operator new/new[]/delete/delete[] 重载。例如:
class Widget {
public:
static void *operator new(std::size_t size);
static void operator delete(void *ptr);
};
当我们使用new 来创建一个Widget 类型的对象时,这个Widget::operator new 将会成为比全局的::operator new 更好的匹配;delete 也是如此。《More Effective C++》上谈到了一个例子,它可以用来记录一个class的对象是否是动态分配的,并且当传给delete 的指针并不指向动态分配的对象时抛出一个异常。
class Heap_tracked {
using raw_addr = const void *;
static std::unordered_set<raw_addr> addresses;
public:
class Missing_address : public std::invalid_argument {
public:
Missing_address()
: std::invalid_argument("deleting an object that is not heap-based.") {}
};
virtual ~Heap_tracked() = 0;
static void *operator new(std::size_t size) {
auto ptr = ::operator new(size);
addresses.insert(ptr);
return ptr;
}
static void operator delete(void *ptr) {
auto pos = addresses.find(ptr);
if (pos != addresses.end()) {
addresses.erase(pos);
::operator delete(ptr);
} else
throw Missing_address{};
}
bool is_heap_based() const {
auto pos = dynamic_cast<raw_addr>(this);
return addresses.find(pos) != addresses.end();
};
Heap_tracked::~Heap_tracked() = default;
std::unordered_set<Heap_tracked::raw_addr> Heap_tracked::addresses{};
通过public继承自Heap_tracked 类,我们就有办法检测一个对象是否来自于heap。这里用到了一个特殊的语法:使用dynamic_cast 将一个指针转型为const void * ,可以获得这个对象的真正起始位置。这在出现多重继承时尤其必要,因为指向任何一个基类的指针都可以指向这个对象,但这些指针指向的位置可能各不相同。
除了这些只接受一个size 参数的operator new 和只接受一个void * 参数的operator delete 之外,C++还允许定义这些函数的带有额外参数的版本,称为“placement-new/delete”。标准库已经为我们定义好了两个著名的placement版本:
void *operator new(std::size_t size, const std::nothrow_t &) noexcept;
void *operator new[](std::size_t size, const std::nothrow_t &) noexcept;
void *operator new(std::size_t size, void *place) noexcept;
void *operator new[](std::size_t size, void *place) noexcept;
void operator delete(void *ptr, const std::nothrow_t &) noexcept;
void operator delete[](void *ptr, const std::nothrow_t &) noexcept;
void operator delete(void *ptr, void *place) noexcept;
void operator delete[](void *ptr, void *place) noexcept;
在new 表达式中调用它们的方式是在new 和类型名之间加上一对括号,在这对括号里传递额外的参数。标准库定义的这两种placement new分别是接受一个const std::nothrow_t & 参数的和接受一个地址参数的版本,它们都保证不抛出异常。接受const std::nothrow_t & 版本的placement-new类似于malloc ,在内存不足的时候返回空指针,而非抛出异常。
auto ptr = new (std::nothrow) Type(args);
事实上一直到1993年,C++中的new 在内存不足时采取的策略一直是返回空指针,后来越来越多的人接受了抛出异常这一选项,于是C++用这种方式保留了不抛出异常、返回空指针的行为。std::nothrow 是标准库定义的一个对象,其类型为std::nothrow_t ,其实就是一个特殊的tag。这个nothrow版本的placement-new是允许用户自定义的,这时编译器将选用用户自定义的版本。
接受一个地址参数的placement-new版本,其意义是“在给定的地址构造对象”,因此它其实并不分配内存,而是假定内存已经在这个地址上分配完毕。因此这个函数什么都不做,直接把place 返回出来。这个函数其实就是“placement-new”这个词的来源,同时也是许多人平常谈到“placement-new”的时候所指的函数。如果我们只是想在一个地址p 上构造对象,就可以写
new (p) Type(args);
注意,这个接受地址参数的placement-new不可以自定义。
此外,我们还可以自定义携带其它参数的placement new/delete,例如记录发出内存分配请求的行号、文件名
void *operator new(std::size_t size, long line, const char *file) { }
auto ptr = new (__LINE__, __FILE__) Type(args);
你可能会理所当然地认为,对于那些用placement new创建的对象,将它们的地址传给delete 的时候会调用对应的placement delete版本。大错特错。事实上在delete 它们的时候仍然会调用普通的operator delete ,而非placement delete。那么placement delete究竟是用来干什么的?
我们知道,一条new 表达式(当然可能是一条placement-new表达式)需要做两件事:首先调用对应的operator new 函数分配内存,然后在所分配的内存地址上构造对象。构造对象的这一步需要调用一个构造函数,但这一步是有可能抛出异常的。假如构造函数抛出异常,对于普通的new 表达式来说,它会调用普通的operator delete 来释放刚刚分配的内存,以保证不出现内存泄漏;但是对于placement-new,由于它可能采取了特殊的内存分配方式,你必须还得提供一个特殊的operator delete 来处理这一情况,这就是自定义placement-delete的用处所在。这个placement-delete所携带的额外参数与那个placement-new的额外参数相同。如果这个placement-delete不存在,那么运行期系统就无法知道究竟如何正确处理这个情况,于是什么也不做,连那个普通的operator delete 也不会被调用,这就几乎肯定会导致内存泄漏,除非你的placement-new其实没有分配内存。
本文的内容主要来自于《More Effective C++》条款8、27和《Effective C++》条款49-52。我在看《C++ Primer》19.1节介绍这些内容的时候感觉是一头雾水,而Scott Meyers的这两本书真正把这些知识点梳理清楚了。强烈推荐大家好好看一看这两本书的这些条款,其中有更详细的解读和更具体的例子。
|