关闭

条款8: 写operator new和operator delete时要遵循常规

1218人阅读 评论(0) 收藏 举报

条款8: 写operator new和operator delete时要遵循常规

 

自己重写operator new时(条款10解释了为什么有时要重写它),很重要的一点是函数提供的行为要和系统缺省的operator new一致。实际做起来也就是:要有正确的返回值;可用内存不够时要调用出错处理函数(见条款7);处理好0字节内存请求的情况。此外,还要避免不小心隐藏了标准形式的new,不过这是条款9的话题。

有关返回值的部分很简单。如果内存分配请求成功,就返回指向内存的指针;如果失败,则遵循条款7的规定抛出一个std::bad_alloc类型的异常。

但事情也不是那么简单。因为operator new实际上会不只一次地尝试着去分配内存,它要在每次失败后调用出错处理函数,还期望出错处理函数能想办法释放别处的内存。只有在指向出错处理函数的指针为空的情况下,operator new才抛出异常。

另外,c++标准要求,即使在请求分配0字节内存时,operator new也要返回一个合法指针。(实际上,这个听起来怪怪的要求确实给c++语言其它地方带来了简便)

这样,非类成员形式的operator new的伪代码看起来会象下面这样:
void * operator new(size_t size)        // operator new还可能有其它参数
{                                      

  if (size == 0) {                      // 处理0字节请求时,
    size = 1;                           // 把它当作1个字节请求来处理
  }                                    
  while (1) {
    分配size字节内存;

    if (分配成功)
      return (指向内存的指针);

    // 分配不成功,找出当前出错处理函数
    new_handler globalhandler = set_new_handler(0);
    set_new_handler(globalhandler);

    if (globalhandler) (*globalhandler)();
    else throw std::bad_alloc();
  }
}

处理零字节请求的技巧在于把它作为请求一个字节来处理。这看起来也很怪,但简单,合法,有效。而且,你又会多久遇到一次零字节请求的情况呢?

你又会奇怪上面的伪代码中为什么把出错处理函数置为0后又立即恢复。这是因为没有办法可以直接得到出错处理函数的指针,所以必须通过调用set_new_handler来找到。办法很笨但也有效。

条款7提到operator new内部包含一个无限循环,上面的代码清楚地说明了这一点——while (1)将导致无限循环。跳出循环的唯一办法是内存分配成功或出错处理函数完成了条款7所描述的事件中的一种:得到了更多的可用内存;安装了一个新的new-handler(出错处理函数);卸除了new-handler;抛出了一个std::bad_alloc或其派生类型的异常;或者返回失败。现在明白了为什么new-handler必须做这些工作中的一件。如果不做,operator new里面的循环就不会结束。

很多人没有认识到的一点是operator new经常会被子类继承。这会导致某些复杂性。上面的伪代码中,函数会去分配size字节的内存(除非size为0)。size很重要,因为它是传递给函数的参数。但是大多数针对类所写的operator new(包括条款10中的那种)都是只为特定的类设计的,不是为所有的类,也不是为它所有的子类设计的。这意味着,对于一个类x的operator new来说,函数内部的行为在涉及到对象的大小时,都是精确的sizeof(x):不会大也不会小。但由于存在继承,基类中的operator new可能会被调用去为一个子类对象分配内存:
class base {
public:
  static void * operator new(size_t size);
  ...
};

class derived: public base       // derived类没有声明operator new
{ ... };                         //

derived *p = new derived;        // 调用base::operator new

如果base类的operator new不想费功夫专门去处理这种情况——这种情况出现的可能性不大——那最简单的办法是把这个“错误”数量的内存分配请求转给标准operator new来处理,象下面这样:
void * base::operator new(size_t size)
{
  if (size != sizeof(base))             // 如果数量“错误”,让标准operator new
    return ::operator new(size);        // 去处理这个请求
                                        //

  ...                                   // 否则处理这个请求
}

“停!”我听见你在叫,“你忘了检查一种虽然不合理但是有可能出现的一种情况——size有可能为零!”是的,我没检查,但拜托下次再叫出声的时候不要这么文绉绉的。:)但实际上检查还是做了,只不过融合到size != sizeof(base)语句中了。c++标准很怪异,其中之一就是规定所以独立的(freestanding)类的大小都是非零值。所以sizeof(base)永远不可能是零(即使base类没有成员),如果size为零,请求会转到::operator new,由它来以一种合理的方式对请求进行处理。(有趣的是,如果base不是独立的类,sizeof(base)有可能是零,详细说明参见"my article on counting objects")。

如果想控制基于类的数组的内存分配,必须实现operator new的数组形式——operator new[](这个函数常被称为“数组new”,因为想不出"operator new[]")该怎么发音)。写operator new[]时,要记住你面对的是“原始”内存,不能对数组里还不存在的对象进行任何操作。实际上,你甚至还不知道数组里有多少个对象,因为不知道每个对象有多大。基类的operator new[]会通过继承的方式被用来为子类对象的数组分配内存,而子类对象往往比基类要大。所以,不能想当然认为base::operator new[]里的每个对象的大小都是sizeof(base),也就是说,数组里对象的数量不一定就是(请求字节数)/sizeof(base)。关于operator new[]的详细介绍参见条款m8。

重写operator new(和operator new[])时所有要遵循的常规就这些。对于operator delete(以及它的伙伴operator delete[]),情况更简单。所要记住的只是,c++保证删除空指针永远是安全的,所以你要充分地应用这一保证。下面是非类成员形式的operator delete的伪代码:
void operator delete(void *rawmemory)
{
  if (rawmemory == 0) return;    file://如/果指针为空,返回
                                 //

  释放rawmemory指向的内存;

  return;
}

这个函数的类成员版本也简单,只是还必须检查被删除的对象的大小。假设类的operator new将“错误”大小的分配请求转给::operator new,那么也必须将“错误”大小的删除请求转给::operator delete:

class base {                       // 和前面一样,只是这里声明了
public:                            // operator delete
  static void * operator new(size_t size);
  static void operator delete(void *rawmemory, size_t size);
  ...
};

void base::operator delete(void *rawmemory, size_t size)
{
  if (rawmemory == 0) return;      // 检查空指针

  if (size != sizeof(base)) {      // 如果size"错误",
    ::operator delete(rawmemory);  // 让标准operator来处理请求
    return;                       
  }

  释放指向rawmemory的内存;

  return;
}

可见,有关operator new和operator delete(以及他们的数组形式)的规定不是那么麻烦,重要的是必须遵守它。只要内存分配程序支持new-handler函数并正确地处理了零内存请求,就差不多了;如果内存释放程序又处理了空指针,那就没其他什么要做的了。至于在类成员版本的函数里增加继承支持,那将很快就可以完成。

条款9: 避免隐藏标准形式的new

 

因为内部范围声明的名称会隐藏掉外部范围的相同的名称,所以对于分别在类的内部

和全局声明的两个相同名字的函数f来说,类的成员函数会隐藏掉全局函数:

void f();                             // 全局函数

class x {
public:
  void f();                           // 成员函数
};

x x;

f();                                  // 调用 f

x.f();                                // 调用 x::f

这不会令人惊讶,也不会导致混淆,因为调用全局函数和成员函数时总是采用不同的

语法形式。然而如果你在类里增加了一个带多个参数的operator new函数,结果就有

可能令人大吃一惊。

class x {
public:
  void f();

  // operator new的参数指定一个
  // new-hander(new的出错处理)函数
  static void * operator new(size_t size, new_handler p);
};

void specialerrorhandler();          // 定义在别的地方

x *px1 =
  new (specialerrorhandler) x;       // 调用x::operator new

x *px2 = new x;                      // 错误!

在类里定义了一个称为“operator new”的函数后,会不经意地阻止了对标准new的访

问。条款50解释了为什么会这样,这里我们更关心的是如何想个办法避免这个问题。

一个办法是在类里写一个支持标准new调用方式的operator new,它和标准new做同样

的事。这可以用一个高效的内联函数来封装实现。

class x {
public:
  void f();

  static void * operator new(size_t size, new_handler p);

  static void * operator new(size_t size)
  { return ::operator new(size); }
};

x *px1 =
  new (specialerrorhandler) x;      // 调用 x::operator
                                    // new(size_t, new_handler)

x* px2 = new x;                     // 调用 x::operator
                                    // new(size_t)

另一种方法是为每一个增加到operator new的参数提供缺省值(见条款24):

class x {
public:
  void f();

  static
    void * operator new(size_t size,                // p缺省值为0
                        new_handler p = 0);         //
};

x *px1 = new (specialerrorhandler) x;               // 正确

x* px2 = new x;                                     // 也正确

无论哪种方法,如果以后想对“标准”形式的new定制新的功能,只需要重写这个函数。

调用者重新编译链接后就可以使用新功能了。

条款10: 如果写了operator new就要同时写operator delete

 

让我们回过头去看看这样一个基本问题:为什么有必要写自己的operator new和operator delete?

答案通常是:为了效率。缺省的operator new和operator delete具有非常好的通用性,它的这种灵活性也使得在某些特定的场合下,可以进一步改善它的性能。尤其在那些需要动态分配大量的但很小的对象的应用程序里,情况更是如此。

例如有这样一个表示飞机的类:类airplane只包含一个指针,它指向的是飞机对象的实际描述(此技术在条款34进行说明):

class airplanerep { ... };      // 表示一个飞机对象
                                //
class airplane {
public:
  ...
private:
  airplanerep *rep;             // 指向实际描述
};

一个airplane对象并不大,它只包含一个指针(正如条款14和m24所说明的,如果airplane类声明了虚函数,会隐式包含第二个指针)。但当调用operator new来分配一个airplane对象时,得到的内存可能要比存储这个指针(或一对指针)所需要的要多。之所以会产生这种看起来很奇怪的行为,在于operator new和operator delete之间需要互相传递信息。

因为缺省版本的operator new是一种通用型的内存分配器,它必须可以分配任意大小的内存块。同样,operator delete也要可以释放任意大小的内存块。operator delete想弄清它要释放的内存有多大,就必须知道当初operator new分配的内存有多大。有一种常用的方法可以让operator new来告诉operator delete当初分配的内存大小是多少,就是在它所返回的内存里预先附带一些额外信息,用来指明被分配的内存块的大小。也就是说,当你写了下面的语句,

airplane *pa = new airplane;

你不会得到一块看起来象这样的内存块:

    pa——> airplane对象的内存

而是得到象这样的内存块:

    pa——> 内存块大小数据 + airplane对象的内存

对于象airplane这样很小的对象来说,这些额外的数据信息会使得动态分配对象时所需要的的内存的大小翻番(特别是类里没有虚拟函数的时候)。

如果软件运行在一个内存很宝贵的环境中,就承受不起这种奢侈的内存分配方案了。为airplane类专门写一个operator new,就可以利用每个airplane的大小都相等的特点,不必在每个分配的内存块上加上附带信息了。

具体来说,有这样一个方法来实现你的自定义的operator new:先让缺省operator new分配一些大块的原始内存,每块的大小都足以容纳很多个airplane对象。airplane对象的内存块就取自这些大的内存块。当前没被使用的内存块被组织成链表——称为自由链表——以备未来airplane使用。听起来好象每个对象都要承担一个next域的开销(用于支持链表),但不会:rep域的空间也被用来存储next指针(因为只是作为airplane对象来使用的内存块才需要rep指针;同样,只有没作为airplane对象使用的内存块才需要next指针),这可以用union来实现。

具体实现时,就要修改airplane的定义,从而支持自定义的内存管理。可以这么做:

class airplane {           // 修改后的类 — 支持自定义的内存管理
public:                    //

  static void * operator new(size_t size);

  ...

private:
  union {
    airplanerep *rep;      // 用于被使用的对象
    airplane *next;        // 用于没被使用的(在自由链表中)对象
  };

  // 类的常量,指定一个大的内存块中放多少个
  // airplane对象,在后面初始化
  static const int block_size;

  static airplane *headoffreelist;

};

上面的代码增加了的几个声明:一个operator new函数,一个联合(使得rep和next域占用同样的空间),一个常量(指定大内存块的大小),一个静态指针(跟踪自由链表的表头)。表头指针声明为静态成员很重要,因为整个类只有一个自由链表,而不是每个airplane对象都有。

下面该写operator new函数了:

void * airplane::operator new(size_t size)
{
  // 把“错误”大小的请求转给::operator new()处理;
  // 详见条款8
  if (size != sizeof(airplane))
    return ::operator new(size);

  airplane *p =           // p指向自由链表的表头
    headoffreelist;       //

  // p 若合法,则将表头移动到它的下一个元素
  //
  if (p)
    headoffreelist = p->next;

  else {
    // 自由链表为空,则分配一个大的内存块,
    // 可以容纳block_size个airplane对象
    airplane *newblock =
      static_cast<airplane*>(::operator new(block_size *
                                            sizeof(airplane)));

    // 将每个小内存块链接起来形成一个新的自由链表
    // 跳过第0个元素,因为它要被返回给operator new的调用者
    //
    for (int i = 1; i < block_size-1; ++i)
      newblock[i].next = &newblock[i+1];

    // 用空指针结束链表
    newblock[block_size-1].next = 0;

    // p 设为表的头部,headoffreelist指向的
    // 内存块紧跟其后
    p = newblock;
    headoffreelist = &newblock[1];
  }

  return 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里。

有了operator new,下面要做的就是给出airplane的静态数据成员的定义:

airplane *airplane::headoffreelist;     
                                        
const int airplane::block_size = 512;   
                                        
没必要显式地将headoffreelist设置为空指针,因为静态成员的初始值都被缺省设为0。block_size决定了要从::operator new获得多大的内存块。

这个版本的operator new将会工作得非常好。它为airplane对象分配的内存要比缺省operator new更少,而且运行得更快,可能会快2次方的等级。这没什么奇怪的,通用型的缺省operator new必须应付各种大小的内存请求,还要处理内部外部的碎片;而你的operator new只用操作链表中的一对指针。抛弃灵活性往往可以很容易地换来速度。

下面我们将讨论operator delete。还记得operator delete吗?本条款就是关于operator delete的讨论。但直到现在为止,airplane类只声明了operator new,还没声明operator delete。想想如果写了下面的代码会发生什么:

airplane *pa = new airplane;        // 调用
                                    // airplane::operator new
...

delete pa;                          // 调用 ::operator delete

读这段代码时,如果你竖起耳朵,会听到飞机撞毁燃烧的声音,还有程序员的哭泣。问题出在operator new(在airplane里定义的那个)返回了一个不带头信息的内存的指针,而operator delete(缺省的那个)却假设传给它的内存包含头信息。这就是悲剧产生的原因。

这个例子说明了一个普遍原则:operator new和operator delete必须同时写,这样才不会出现不同的假设。如果写了一个自己的内存分配程序,就要同时写一个释放程序。(关于为什么要遵循这条规定的另一个理由,参见article on counting objects一文的the sidebar on placement章节)

因而,继续设计airplane类如下:

class airplane {        // 和前面的一样,只不过增加了一个
public:                 // operator delete的声明
  ...

  static void operator delete(void *deadobject,
                              size_t size);

};

// 传给operator delete的是一个内存块, 如果
// 其大小正确,就加到自由内存块链表的最前面
//
void airplane::operator delete(void *deadobject,
                               size_t size)
{
  if (deadobject == 0) return;         // 见条款 8

  if (size != sizeof(airplane))     {  // 见条款 8
    ::operator delete(deadobject);
    return;
  }

  airplane *carcass =
    static_cast<airplane*>(deadobject);

  carcass->next = headoffreelist;
  headoffreelist = carcass;
}

因为前面在operator new里将“错误”大小的请求转给了全局operator new(见条款8),那么这里同样要将“错误”大小的对象交给全局operator delete来处理。如果不这样,就会重现你前面费尽心思想避免的那种问题——new和delete句法上的不匹配。

有趣的是,如果要删除的对象是从一个没有虚析构函数的类继承而来的,那传给operator delete的size_t值有可能不正确。这就是必须保证基类必须要有虚析构函数的原因,此外条款14还列出了第二个、理由更充足的原因。这里只要简单地记住,基类如果遗漏了虚拟构函数,operator delete就有可能工作不正确。

所有一切都很好,但从你皱起的眉头我可以知道你一定在担心内存泄露。有着大量开发经验的你不会没注意到,airplane的operator new调用::operator new 得到了大块内存,但airplane的operator delete却没有释放它们。内存泄露!内存泄露!我分明听见了警钟在你脑海里回响。

但请仔细听我回答,这里没有内存泄露!

引起内存泄露的原因在于内存分配后指向内存的指针丢失了。如果没有垃圾处理或其他语言之外的机制,这些内存就不会被收回。但上面的设计没有内存泄露,因为它决不会出现内存指针丢失的情况。每个大内存块首先被分成airplane大小的小块,然后这些小块被放在自由链表上。当客户调用airplane::operator new时,小块被自由链表移除,客户得到指向小块的指针。当客户调用operator delete时,小块被放回到自由链表上。采用这种设计,所有的内存块要不被airplane对象使用(这种情况下,是由客户来负责避免内存泄露),要不就在自由链表上(这种情况下内存块有指针)。所以说这里没有内存泄露。

然而确实,::operator new返回的内存块是从来没有被airplane::operator delete释放,这个内存块有个名字,叫内存池。但内存泄漏和内存池有一个重要的不同之处。内存泄漏会无限地增长,即使客户循规蹈矩;而内存池的大小决不会超过客户请求内存的最大值。

修改airplane的内存管理程序使得::operator new返回的内存块在不被使用时自动释放并不难,但这里不会这么做,这有两个原因:

第一个原因和你自定义内存管理的初衷有关。你有很多理由去自定义内存管理,最基本的一条是你确认缺省的operator new和operator delete使用了太多的内存或(并且)运行很慢。和采用内存池策略相比,跟踪和释放那些大内存块所写的每一个额外的字节和每一条额外的语句都会导致软件运行更慢,用的内存更多。在设计性能要求很高的库或程序时,如果你预计内存池的大小会在一个合理的范围之内,那采用内存池的方法再好不过了。

第二个原因和处理一些不合理的程序行为有关。假设airplane的内存管理程序被修改了,airplane的operator delete可以释放任何没有对象存在的大块的内存。那看下面的程序:

int main()
{
  airplane *pa = new airplane;     // 第一次分配: 得到大块内存,
                                   // 生成自由链表,等

  delete pa;                       // 内存块空;
                                   // 释放它

  pa = new airplane;               // 再次得到大块内存,
                                   // 生成自由链表,等

  delete pa;                       // 内存块再次空,
                                   // 释放

  ...                              // 你有了想法...

  return 0;
}

这个糟糕的小程序会比用缺省的operator new和operator delete写的程序运行得还慢,占用还要多的内存,更不要和用内存池写的程序比了。

当然有办法处理这种不合理的情况,但考虑的特殊情况越多,就越有可能要重新实现内存管理函数,而最后你又会得到什么呢?内存池不能解决所有的内存管理问题,在很多情况下是很适合的。

实际开发中,你会经常要给许多不同的类实现基于内存池的功能。你会想,“一定有什么办法把这种固定大小内存的分配器封装起来,从而可以方便地使用”。是的,有办法。虽然我在这个条款已经唠叨这么长时间了,但还是要简单介绍一下,具体实现留给读者做练习。

下面简单给出了一个pool类的最小接口(见条款18),pool类的每个对象是某类对象(其大小在pool的构造函数里指定)的内存分配器。

class pool {
public:
  pool(size_t n);                      // 为大小为n的对象创建
                                       // 一个分配器


  void * alloc(size_t n)  ;            // 为一个对象分配足够内存
                                       // 遵循条款8的operator new常规

  void free(  void *p, size_t n);      // 将p所指的内存返回到内存池;
                                       // 遵循条款8的operator delete常规

  ~pool();                             // 释放内存池中全部内存

};

这个类支持pool对象的创建,执行分配和释放操作,以及被摧毁。pool对象被摧毁时,会释放它分配的所有内存。这就是说,现在有办法避免airplane的函数里所表现的内存泄漏似的行为了。然而这也意味着,如果pool的析构函数调用太快(使用内存池的对象没有全部被摧毁),一些对象就会发现它正在使用的内存猛然间没了。这造成的结果通常是不可预测的。

有了这个pool类,即使java程序员也可以不费吹灰之力地在airplane类里增加自己的内存管理功能:

class airplane {
public:

  ...                               // 普通airplane功能

  static void * operator new(size_t size);
  static void operator delete(void *p, size_t size);

private:
  airplanerep *rep;                 // 指向实际描述的指针
  static pool mempool;              // airplanes的内存池

};

inline void * airplane::operator new(size_t size)
{ return mempool.alloc(size); }

inline void airplane::operator delete(void *p,
                                      size_t size)
{ mempool.free(p, size); }

// 为airplane对象创建一个内存池,
// 在类的实现文件里实现
pool airplane::mempool(sizeof(airplane));

这个设计比前面的要清楚、干净得多,因为airplane类不再和非airplane的代码混在一起。union,自由链表头指针,定义原始内存块大小的常量都不见了,它们都隐藏在它们应该呆的地方——pool类里。让写pool的程序员去操心内存管理的细节吧,你的工作只是让airplane类正常工作。

现在应该明白了,自定义的内存管理程序可以很好地改善程序的性能,而且它们可以封装在象pool这样的类里。但请不要忘记主要的一点,operator new和operator delete需要同时工作,那么你写了operator new,就也一定要写operator delete。

1
0

查看评论
* 以上用户言论只代表其个人观点,不代表CSDN网站的观点或立场
    个人资料
    • 访问:759701次
    • 积分:8846
    • 等级:
    • 排名:第2137名
    • 原创:135篇
    • 转载:160篇
    • 译文:1篇
    • 评论:307条
    最新评论