八、定制new和delete
条款49:了解new-handler的行为
new异常会发生什么事?
在旧式的编译器中,operator new分配内存失败的时候,会返回一个null指针。而现在则是会抛出一个异常。
而在抛出这个异常之前,还会先调用一个客户指定的错误处理函数:所谓的new-handler: set_new_handler函数。 在标准程序库中有声明:
namespace std
{
typedef void (*new_handler)(); // 函数指针,返回为void,参数为空
new_handler set_new_handler(new_handler p) throw();
}
set_new_handler参数是一个函数指针,指向无法分配足够内存的时候所调用的函数。其返回值也是一个函数指针,指向此函数调用前的那个new-handler函数。
现在我们指定一个分配内存失败后的施行代码作为样例:
void OutOfMem() // 返回型为void,参数为空
{
std::cerr << "Unable to satisfy request for memory" << std::endl;
std::abort();
}
接着在main函数中:
int main()
{
std::set_new_handler(OutOfMem); // 相当于注册一个失败之后的执行函数
int *pBigData = new int[100000000L];
return 0;
}
在本例中,如果分配失败,那么就会调用OutOfMem,然后执行abort函数。
事实上,一个良好的new-handler函数应该会不断的调用,在内部处理会释放一些内存,然后再尝试可能能不能成功分配内存等等。作者总结了一系列设计良好的new-handler函数应该有的特点:
- 让更多的内存可被使用。 实现的一个策略:一开始就分配一个大内存,第一次的new-handler被调用的时候让还给程序。
- 安装另一个new-handler。 如果知道另一个new-handler能够获取足够的内存,那么就安装它吧。
- 卸除new-handler。 将null指针传给set_new_handler,那么分配失败就会直接抛出异常。
- 不返回。 通常调用exit或者abort。
现在我们来写一下基于Widget类的定制版本的new。
class Widget
{
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;
};
看了上面的代码,首先你可能会有个疑问:为什么都要声明为static的函数呢?
先想一下static的特性,如果我们不声明我static会怎样?那就是说,这几个函数会成为Widget的成员函数,也就是说,只有在Widget成功被构造的时候,这些函数才会存在。但是我们的new是构造这个Widget的,怎么可能调用尚不存在的函数呢?
所以我们要声明为静态函数,调用这些静态函数在构造出Widget对象来。
再来看这几个静态成员函数的的实现。
std::new_handler Widget::currentHandler = 0;
当前错误处理函数要初始化为空,表示直接抛出异常,不作处理。
std::new_handler Widget::set_new_handler(std::new_handler p) throw()
{
std::new_handler oldHandler = currentHandler;
currentHandler = p;
return oldHandler;
}
还记得我们在本条款开始的时候说的吗?set_new_handler函数就是将传入的错误处理函数设置为当前的错误处理函数,返回一个旧的出来。上面的代码就是一个标准版的set_new_handler。
现在我们的重点在于operator new函数,实际上我们只是在global的new上面做了一层封装,使得成为自己定制的new。简单来说,就是先注册一个属于Widget类的专属错误处理函数,然后调用全局operator new函数,但是结束后或者抛出异常后要注册回去。 关键是注册回去,这里采用条款13的RAII方法来管理:一旦析构就注册回去。
class NewHandlerHolder
{
public:
explicit NewHandlerHolder(std::new_hander nh)
: handler(nh){}
~NewHandlerHolder()
{
std::set_new_handler(handler);
}
private:
std::new_handler handler;
// 阻止copying
NewHandlerHolde(const NewHandlerHolder &);
NewHandlerHolde &operator=(const NewHandlerHolder&);
};
OK,现在我们就可以放心的写我们定制的new而不用担心内存泄漏的问题了。
void *Widget::operator new(std::size_t size) throw(std::bad_alloc)
{
NewHandlerHolder h(std::set_new_handler(currentHandler));
return ::new(size);
}
这个函数的第一个语句做了两件事情:
(1) 把new失败时候错误函数设置为currentHandler。
(2) set_new_handler返回原本错误处理函数,这个函数将作为构造函数的参数传进去,保存下来,随后还要设置回去。
现在来看我们main函数中可能存在的调用方式:
void OutOfMem(); // 错误处理函数
Widget::set_new_handler(OutOfMem); // 设置为Widget的专属错误处理函数
Widget *pw1 = new Widget; // 内存分配失败会调用OutOfMem函数
这个条款的最后还介绍了nothrow的使用:如果我们想要在分配失败的时候返回一个空指针,而不是抛出异常, 那么可以这样:
Widget *pw2 = new(std::nothrow) Widget;
作者总结
set_new_handler允许客户指定一个函数,在内存分配无法获得满足时被调用。
Nothrow new是一个颇为局限的工具,因为它只适用于内存分配;后续的构造函数调用还是可能抛出异常。
条款50:了解new和delete的合理替换时机
这条款就比较文字上的东西了,本人觉得还是需要在开发的时候考虑到各种的情形才能充分领悟。下面仅仅是把这些要点进行阐述:
- 用来检测运用上的错误。
- 为了强化效能。 编译器所提供给我们的new和delete使用太过于中庸,它必须考虑到大小内存的分配,考虑破碎问题等,当我们需要效率更高的时候可以不考虑这些。使用定制版本会更好。
- 为了收集使用上的统计数据。
- 为了检测运用错误。
- 为了增加分配和归还的速度。 举个例子来说,编译器提供的是线程安全的版本,而我们是单线程,不存在不安全情况。自己定制的效率更高。
- 为了降低缺省内存管理器带来的空间额外开销。 缺省的空间开销更大,它还需要一个额外的标记。
- 为了将相关对象成簇集中。
- 为了获得非传统行为。
作者总结
有许多理由需要写个自定的new和delete,包括改善性能、对heap运用错误进行调试,手机heap使用信息。
条款51:编写new和delete时需要固守常规
new要返回正确值
operator new的返回值应该很简单:如果成功分配了内存,返回一个指针指向它。如果失败了,就抛出一个bad_alloc的异常。
C++规定,如果客户要一个0byte,也要返回一个合法指针。所以对0要特殊处理。下面提供一个伪代码:
void* operator new(std::size_t size)
{
if(size == 0)
{
size = 1;
}
while(true)
{
// 尝试分配size bytes
if(分配成功)
{
return (一个指向分配内存的指针);
}
// 分配失败,找出目前的错误处理函数并处理
new_handler globalHandler = set_new_handler(0);
set_new_handler(globalHandler);
if(globalHandler)
{
(*globalHandler)();
}
else
{
throw std::bad_alloc();
}
}
}
上面令人费解的一段大概就是为什么要先把错误处理函数设置成一个空,然后再设置回去呢?只是为了取出错误处理函数,并且执行它。
设置一个While(true),通常说来,如果分配失败,会在这个循环内回收已经释放的内存,然后重新分配,如果失败就调用错误处理函数,如果成功就取地址。
继承体系下的new
看下面这段代码你就会清楚发现继承体系下的错误new:
class Base
{
public:
static void *operator new(std::size_t size) throw(std::bad_alloc);
...
};
class Derive : public Base
{
public:
....
};
紧接着执行:
Derive *p = new Derive; // 这里调用的是Base的new!
很显然,如果Derive中未声明一个自己的定制new,那它调用的将是继承而来的Base的new函数。通常来说,Base和Derive的内存大小都不相等,这样调用将会出错。
如果Derive没有自己定制,想要使用global的,那就应该改成这样:
void *Base::operator(std::size_t size) throw(std::bad_alloc)
{
if(size != sizeof(Base))
{
return ::operator(size));
}
... // 这里是处理Base类的new
}
delete的编写注意
delete就相对比较简单了。但是我们要保证,delete一个null也是有效的。另外,在继承体系下应该这么写:
void operator delete(void *rawMemory,std::size_t size) throw()
{
if(rawMemory = null)
return ;
if(size != sizeof(Base))
{
::operator delete(rawMemory);
return ;
}
// 归还内存等操作
}
作者总结
operator new应该内含一个无穷循环,并在其中尝试分配内存,如果它无法满足内存需求,就应该调用new-handler. 它也应该有能力处理0bytes的申请。class专属版本泽还应该处理“比正确大小更大的(错误)申请”。
operator delete应该在收到null指针时不做任何事。Class专属版本还应该处理“比正确大小更大的(错误)申请”。
条款52:写了placement new也要写placement delete
什么样的叫做placement
简单说就是在operator new或者delete函数中,除了必须要有的参数之外还需要有的其它参数,这样的一个operator placement版本。
如我们典型版本的new和delete是这样的:
void *operator new(std::size_t size);
void *operator delete(void* rawMemory) throw(); //global作用域的典型声明
void *operator delete(void* rawMemory,std::size_t size); // class作用域的典型声明
比如在class中,不属于void*和size_t的就是一个额外的参数。
placement版本是为了防止内存泄漏的一种方式。
考虑以下代码:
Widget *pw = new Widget;
这里会调用一个new和一个Widget的默认构造函数。假设第一个函数调用成功,内存得以分配。第二个函数却失败了,那么pw就还没被赋值,客户端没有能力归还内存,也就会造成内存泄漏!
这样取消内存分配并且回复旧观的任务就落在了C++运行期系统上了。现在有一个问题,运行期要怎么知道调用对应的delete版本呢? 如果调用错误的delete版本将不会获得预期的结果。
让运行期系统找到正确的delete版本
在我们有了placement版本的new时,如果构造函数抛出了异常,运行期系统首先寻找的是“(额外)参数个数和类型都与operator new相同”的某个operator delete。如果找到就进行调用,如果没有,那么将不会调用任何的delete函数!!!
如果不调用delete,在这种情况下内存百分百会泄漏,因此,我们必须为我们的placement new声明定义对应的operator delete:
class Widget
{
public:
static void *operator new(std::size_t size,std::ostream &logStream) throw(std::bad_allc);
static void operator delete(void *pMemory) throw();
static void operator delete(void *pMemory,std::ostream& logStream) throw();
};
像这样的,最后一个delete函数就是一个对应的placement delete函数,它有一个额外参数logStream。
现在我们看调用:
Widget *pw = new(std::cerr) Widget;
delete pw;
这次我们就确保了内存不会被泄漏:
(1) new成功了并且构造函数也成功了,那么delete pw删除的就是正确的内存。
(2) new成功了但是构造函数却失败了,那么delete调用的是placement delete版本,运行期系统将正确处理。
作者总结
当你写一个placement operator new,请确定也写出了对应placement operator delete。如果没有这样做,你的程序可能会发生隐微而时断时续的内存泄漏。
当你声明placement new和placement delete,请确定不要无意识(非故意)地遮掩了它们正常的版本。