📄 effective c++ 2e item10.htm
字号:
<TR>
<TD bgColor=#cccc99 colSpan=5> </TD></TR></TD></TR></TBODY></TABLE>
<DIV align=center>
<DIV align=left class=fst>
<DIV class=fstdiv3 id=print2><BR><BR>
<P> </P>
<P>条款10. 如果写了operator new就要同时写operator delete</P>
<P>让我们回过头去看看这样一个基本问题:为什么有必要写自己的operator new和operator delete?</P>
<P>答案通常是:为了效率。缺省的operator new和operator
delete具有非常好的通用性,它的这种灵活性也使得在某些特定的场合下,可以进一步改善它的性能。尤其在那些需要动态分配大量的但很小的对象的应用程序里,情况更是如此。</P>
<P>例如有这样一个表示飞机的类:类Airplane只包含一个指针,它指向的是飞机对象的实际描述(此技术在条款34进行说明):</P>
<P>class AirplaneRep { ... }; //
表示一个飞机对象<BR>
// <BR>class Airplane {<BR>public:<BR> ...<BR>private:<BR>
AirplaneRep
*rep; //
指向实际描述<BR>};</P>
<P>一个Airplane对象并不大,它只包含一个指针(正如条款14和M24所说明的,如果Airplane类声明了虚函数,会隐式包含第二个指针)。但当调用operator
new来分配一个Airplane对象时,得到的内存可能要比存储这个指针(或一对指针)所需要的要多。之所以会产生这种看起来很奇怪的行为,在于operator
new和operator delete之间需要互相传递信息。</P>
<P>因为缺省版本的operator new是一种通用型的内存分配器,它必须可以分配任意大小的内存块。同样,operator
delete也要可以释放任意大小的内存块。operator delete想弄清它要释放的内存有多大,就必须知道当初operator
new分配的内存有多大。有一种常用的方法可以让operator new来告诉operator
delete当初分配的内存大小是多少,就是在它所返回的内存里预先附带一些额外信息,用来指明被分配的内存块的大小。也就是说,当你写了下面的语句,</P>
<P>Airplane *pa = new Airplane;</P>
<P>你不会得到一块看起来象这样的内存块:</P>
<P> pa——> Airplane对象的内存</P>
<P>而是得到象这样的内存块:</P>
<P> pa——> 内存块大小数据 + Airplane对象的内存</P>
<P>对于象Airplane这样很小的对象来说,这些额外的数据信息会使得动态分配对象时所需要的的内存的大小翻番(特别是类里没有虚拟函数的时候)。</P>
<P>如果软件运行在一个内存很宝贵的环境中,就承受不起这种奢侈的内存分配方案了。为Airplane类专门写一个operator
new,就可以利用每个Airplane的大小都相等的特点,不必在每个分配的内存块上加上附带信息了。</P>
<P>具体来说,有这样一个方法来实现你的自定义的operator new:先让缺省operator
new分配一些大块的原始内存,每块的大小都足以容纳很多个Airplane对象。Airplane对象的内存块就取自这些大的内存块。当前没被使用的内存块被组织成链表——称为自由链表——以备未来Airplane使用。听起来好象每个对象都要承担一个next域的开销(用于支持链表),但不会:rep域的空间也被用来存储next指针(因为只是作为Airplane对象来使用的内存块才需要rep指针;同样,只有没作为Airplane对象使用的内存块才需要next指针),这可以用union来实现。</P>
<P>具体实现时,就要修改Airplane的定义,从而支持自定义的内存管理。可以这么做:</P>
<P>class Airplane {
// 修改后的类 —
支持自定义的内存管理<BR>public:
// </P>
<P> static void * operator new(size_t size);</P>
<P> ...</P>
<P>private:<BR> union {<BR> AirplaneRep
*rep; // 用于被使用的对象<BR> Airplane
*next; // 用于没被使用的(在自由链表中)对象<BR>
};</P>
<P> // 类的常量,指定一个大的内存块中放多少个<BR> // Airplane对象,在后面初始化<BR> static
const int BLOCK_SIZE;</P>
<P> static Airplane *headOfFreeList;</P>
<P>};</P>
<P>上面的代码增加了的几个声明:一个operator
new函数,一个联合(使得rep和next域占用同样的空间),一个常量(指定大内存块的大小),一个静态指针(跟踪自由链表的表头)。表头指针声明为静态成员很重要,因为整个类只有一个自由链表,而不是每个Airplane对象都有。</P>
<P>下面该写operator new函数了:</P>
<P>void * Airplane::operator new(size_t size)<BR>{<BR> //
把“错误”大小的请求转给::operator new()处理;<BR> // 详见条款8<BR> if (size !=
sizeof(Airplane))<BR> return ::operator new(size);</P>
<P> Airplane *p
= // p指向自由链表的表头
<BR> headOfFreeList; //
</P>
<P> // p 若合法,则将表头移动到它的下一个元素<BR> // <BR> if
(p)<BR> headOfFreeList = p->next;</P>
<P> else {<BR> //
自由链表为空,则分配一个大的内存块,<BR> //
可以容纳BLOCK_SIZE个Airplane对象<BR> Airplane *newBlock
=<BR> static_cast<Airplane*>(::operator
new(BLOCK_SIZE
*<BR>
sizeof(Airplane)));</P>
<P> // 将每个小内存块链接起来形成一个新的自由链表<BR> //
跳过第0个元素,因为它要被返回给operator new的调用者<BR> // <BR>
for (int i = 1; i < BLOCK_SIZE-1; ++i)<BR>
newBlock[i].next = &newBlock[i+1];</P>
<P> // 用空指针结束链表<BR>
newBlock[BLOCK_SIZE-1].next = 0;</P>
<P> // p 设为表的头部,headOfFreeList指向的<BR> //
内存块紧跟其后<BR> p = newBlock;<BR> headOfFreeList
= &newBlock[1];<BR> }</P>
<P> return p;<BR>}</P>
<P>如果你读了条款8,就会知道在operator
new不能满足内存分配请求时,会执行一系列与new-handler函数和例外有关的例行性动作。上面的代码没有这些步骤,这是因为operator
new管理的内存都是从::operator new分配来的。这意味着只有::operator new失败时,operator
new才会失败。而如果::operator
new失败,它会去执行new-handler的动作(可能最后以抛出异常结束),所以不需要Airplane的operator
new也去处理。换句话说,其实new-handler的动作都还在,你只是没看见,它隐藏在::operator new里。</P>
<P>有了operator new,下面要做的就是给出Airplane的静态数据成员的定义:</P>
<P>Airplane *Airplane::headOfFreeList;
<BR>
<BR>const int Airplane::BLOCK_SIZE = 512;
<BR>
<BR>没必要显式地将headOfFreeList设置为空指针,因为静态成员的初始值都被缺省设为0。BLOCK_SIZE决定了要从::operator
new获得多大的内存块。</P>
<P>这个版本的operator new将会工作得非常好。它为Airplane对象分配的内存要比缺省operator
new更少,而且运行得更快,可能会快2次方的等级。这没什么奇怪的,通用型的缺省operator
new必须应付各种大小的内存请求,还要处理内部外部的碎片;而你的operator new只用操作链表中的一对指针。抛弃灵活性往往可以很容易地换来速度。</P>
<P>下面我们将讨论operator delete。还记得operator delete吗?本条款就是关于operator
delete的讨论。但直到现在为止,Airplane类只声明了operator new,还没声明operator
delete。想想如果写了下面的代码会发生什么:</P>
<P>Airplane *pa = new Airplane; //
调用<BR>
// Airplane::operator new<BR>...</P>
<P>delete
pa;
// 调用 ::operator delete</P>
<P>读这段代码时,如果你竖起耳朵,会听到飞机撞毁燃烧的声音,还有程序员的哭泣。问题出在operator
new(在Airplane里定义的那个)返回了一个不带头信息的内存的指针,而operator
delete(缺省的那个)却假设传给它的内存包含头信息。这就是悲剧产生的原因。</P>
<P>这个例子说明了一个普遍原则:operator new和operator
delete必须同时写,这样才不会出现不同的假设。如果写了一个自己的内存分配程序,就要同时写一个释放程序。(关于为什么要遵循这条规定的另一个理由,参见article
on counting objects一文的the sidebar on placement章节)</P>
<P>因而,继续设计Airplane类如下:</P>
<P>class Airplane { //
和前面的一样,只不过增加了一个<BR>public:
// operator delete的声明<BR> ...</P>
<P> static void operator delete(void
*deadObject,<BR>
size_t size);</P>
<P>};</P>
<P>// 传给operator delete的是一个内存块, 如果<BR>// 其大小正确,就加到自由内存块链表的最前面<BR>// <BR>void
Airplane::operator delete(void
*deadObject,<BR>
size_t size)<BR>{<BR> if (deadObject == 0)
return; // 见条款 8</P>
<P> if (size != sizeof(Airplane)) { // 见条款
8<BR> ::operator delete(deadObject);<BR>
return;<BR> }</P>
<P> Airplane *carcass =<BR>
static_cast<Airplane*>(deadObject);</P>
<P> carcass->next = headOfFreeList;<BR> headOfFreeList =
carcass;<BR>}</P>
<P>因为前面在operator new里将“错误”大小的请求转给了全局operator
new(见条款8),那么这里同样要将“错误”大小的对象交给全局operator
delete来处理。如果不这样,就会重现你前面费尽心思想避免的那种问题——new和delete句法上的不匹配。</P>
<P>有趣的是,如果要删除的对象是从一个没有虚析构函数的类继承而来的,那传给operator
delete的size_t值有可能不正确。这就是必须保证基类必须要有虚析构函数的原因,此外条款14还列出了第二个、理由更充足的原因。这里只要简单地记住,基类如果遗漏了虚拟构函数,operator
delete就有可能工作不正确。</P>
<P>所有一切都很好,但从你皱起的眉头我可以知道你一定在担心内存泄露。有着大量开发经验的你不会没注意到,Airplane的operator
new调用::operator new 得到了大块内存,但Airplane的operator
delete却没有释放它们。内存泄露!内存泄露!我分明听见了警钟在你脑海里回响。</P>
<P>但请仔细听我回答,这里没有内存泄露!</P>
<P>引起内存泄露的原因在于内存分配后指向内存的指针丢失了。如果没有垃圾处理或其他语言之外的机制,这些内存就不会被收回。但上面的设计没有内存泄露,因为它决不会出现内存指针丢失的情况。每个大内存块首先被分成Airplane大小的小块,然后这些小块被放在自由链表上。当客户调用Airplane::operator
new时,小块被自由链表移除,客户得到指向小块的指针。当客户调用operator
delete时,小块被放回到自由链表上。采用这种设计,所有的内存块要不被Airplane对象使用(这种情况下,是由客户来负责避免内存泄露),要不就在自由链表上(这种情况下内存块有指针)。所以说这里没有内存泄露。</P>
<P>然而确实,::operator new返回的内存块是从来没有被Airplane::operator
delete释放,这个内存块有个名字,叫内存池。但内存泄漏和内存池有一个重要的不同之处。内存泄漏会无限地增长,即使客户循规蹈矩;而内存池的大小决不会超过客户请求内存的最大值。</P>
<P>修改Airplane的内存管理程序使得::operator new返回的内存块在不被使用时自动释放并不难,但这里不会这么做,这有两个原因:</P>
<P>第一个原因和你自定义内存管理的初衷有关。你有很多理由去自定义内存管理,最基本的一条是你确认缺省的operator new和operator
delete使用了太多的内存或(并且)运行很慢。和采用内存池策略相比,跟踪和释放那些大内存块所写的每一个额外的字节和每一条额外的语句都会导致软件运行更慢,用的内存更多。在设计性能要求很高的库或程序时,如果你预计内存池的大小会在一个合理的范围之内,那采用内存池的方法再好不过了。</P>
<P>第二个原因和处理一些不合理的程序行为有关。假设Airplane的内存管理程序被修改了,Airplane的operator
delete可以释放任何没有对象存在的大块的内存。那看下面的程序:</P>
<P>int main()<BR>{<BR> Airplane *pa = new
Airplane; // 第一次分配:
得到大块内存,<BR>
// 生成自由链表,等</P>
<P> delete
pa;
//
内存块空;<BR>
// 释放它</P>
<P> pa = new
Airplane;
//
再次得到大块内存,<BR>
// 生成自由链表,等</P>
<P> delete
pa;
//
内存块再次空,<BR>
// 释放</P>
<P>
...
// 你有了想法...</P>
⌨️ 快捷键说明
复制代码
Ctrl + C
搜索代码
Ctrl + F
全屏模式
F11
切换主题
Ctrl + Shift + D
显示快捷键
?
增大字号
Ctrl + =
减小字号
Ctrl + -