单个class的new的重载和全局new的重载

单个class的new的重载

  1. 重载的 new、delete(或者 new[]、delete[])操作符必须是类的静态成员函数(为什么必须是静态成员函数,这很好理解,因为 new 操作符被调用的时候,对象还未构建)或者是全局函数,函数的原型如下:
    1. void* operator new(size_t size) throw(std::bad_alloc);
    2. // 这里的 size 为分配的内存的总大小
    3. void* operator new[](size_t size) throw(std::bad_alloc);
    4.  
    5. void operator delete(void* p) throw();
    6. void operator delete[](void* p) throw();
    7.  
    8. void operator delete(void* p, size_t size) throw();
    9. // 区别于 new[] 的参数 size,这里的 size 并非释放的内存的总大小
    10. void operator delete[](void* p, size_t size) throw();

    另外,我们可以使用不同的参数来重载 new、delete(或者 new[]、delete[])操作符,例如:

    1. // 第一个参数仍为 size_t
    2. void* operator new(size_t size, const char* file, int line);
    3. // 此操作符的使用
    4. string* str = new(__FILE__, __LINE__) string;

    重载全局的 new、delete(或者 new[]、delete[])操作符会改变所有默认分配行为(包括某个类的分配行为),因此必须小心使用,如果两个库都 new 等进行了全局重载,那么就会出现链接错误(duplicated symbol link error)。而在类中定义的 new、delete(或者 new[]、delete[])操作符只会影响到本类以及派生类。 
    很多人完全没有意识到 operator new、operator delete、operator new[]、operator delete[] 成员函数会被继承(虽然它们是静态函数)。有些时候,我们只想为指定的类设置自定义的 operator new 成员函数,而不希望影响到子类的工作。《Effective C++ Third Edition》提供了如下的方案:

    1. void * Base::operator new(std::size_t size) throw(std::bad_alloc)
    2. {
    3. // 如果大小不为基类大小
    4. if (size != sizeof(Base))
    5. // 调用标准的 new 操作符
    6. return ::operator new(size);
    7. 自定义大小为基类大小的分配处理
    8. }

    这样处理的一个前提是:认为子类的大小一定大于父类。

    对于 operator new[] 来说,我们很难通过上面的方式检查到底是父类还是子类调用了操作符。通过 operator new[] 操作符的参数,我们无法得知分配的元素的个数,无法得知分配的每个元素的大小。operator new[] 的参数 size_t 表明的内存分配的大小可能大于需要分配的元素的内存大小之和,因为动态内存分配可能会分配额外的空间来保存数组元素的个数。

  2. 兼容默认的 new、delete 的错误处理方式 
    这不是个很简单的事(详细参考《Effective C++ Third Edition》 Item 51)。operator new 通常这样编写:
    1. // 这里并没有考虑多线程访问的情况
    2. void* operator new(std::size_t size) throw(std::bad_alloc)
    3. {
    4. using namespace std;
    5.  
    6. // size == 0 时 new 也必须返回一个合法的指针
    7. if (size == 0)
    8. size = 1;
    9.  
    10. while (true) {
    11.  
    12. 尝试进行内存的分配
    13.  
    14. if (内存分配成功)
    15. return (成功分配的内存的地址);
    16.  
    17. // 内存分配失败时,查找当前的 new-handling function
    18. // 因为没有直接获取到 new-handling function 的办法,因此只能这么做
    19. new_handler globalHandler = set_new_handler(0);
    20. set_new_handler(globalHandler);
    21.  
    22. // 如果存在 new-handling function 则调用
    23. if (globalHandler) (*globalHandler)();
    24. // 不存在 new-handling function 则抛出异常
    25. else throw std::bad_alloc();
    26. }
    27. }

    这一些方面是我们需要注意的:operator new 可以接受 size 为 0 的内存分配且返回一个有效的指针;如果存在 new-handling function 那么在内存分配失败时会调用它并且再次尝试内存分配;如果不存在 new-handling function 失败时抛出 bad_alloc 异常。 
    要注意的是,一旦设置了 new-handling function 内存分配就会无限循环进行下去,为了避免无限循环的发生,new-handling function 必须做以下几件事中的一件(详细参考《Effective C++ Third Edition》 Item 49):让有更多内存可用、设置另一个能发挥作用的 new-handler、删除当前的 new handler、抛出一个异常(bad_alloc 或者继承于 bad_alloc)、直接调用 abort() 或者 exit() 等函数。

    对于 operator delete 的异常处理就简单一些,只需要保证能够安全的 delete 空指针即可:

    1. void operator delete(void *rawMemory) throw()
    2. {
    3. // 操作符可以接受空指针
    4. if (rawMemory == 0) return;
    5.  
    6. 释放内存
    7. }

多态的问题(详细参考《ISO/IEC 14882》) 
前面谈到了 new、delete(new[]、delete[])操作符的继承,这里额外讨论一下多态的问题,显然我们只需要讨论 delete、delete[] 操作符:

  1. struct B {
  2. virtual ~B();
  3. void operator delete(void*, size_t);
  4. };
  5.  
  6. struct D : B {
  7. void operator delete(void*);
  8. };
  9.  
  10. void f()
  11. {
  12. B* bp = new D;
  13. delete bp; //1: uses D::operator delete(void*)
  14. }

通过上面的例子,我们可以看到,delete 时正确的调用了 D 的 operator delete 操作符。但是同样的,对于 delete[] 操作符工作就不正常了(因为对于 delete[] 操作符的检查是静态的):

 

  1. struct B {
  2. virtual ~B();
  3. void operator delete[](void*, size_t);
  4. };
  5.  
  6. struct D : B {
  7. void operator delete[](void*, size_t);
  8. };
  9.  
  10. void f(int i)
  11. {
  12. D* dp = new D[i];
  13. delete [] dp; //uses D::operator delete[](void*, size_t)
  14. B* bp = new D[i];
  15. delete[] bp; //undefined behavior
  16. }

 

 

全局new的重载

 

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
38
39
40
41
42
#include "iostream"
#include "malloc.h"
 
 
using namespace std;
void* operator new(unsigned int size)
{
    if(size == 0) 
      size = 1; 
     void *res; 
     for(;;) 
     
       //allocate memory block 
       res = malloc(size); 
       //if successful allocation, return pointer to memory 
       if(res) 
           break
      //call installed new handler 
 
     cout<<"global new Override"<<endl;
 return res; 
}
class A{
public:
    int a;
 
};
 
class B{
public:    
    char b;
};
 
int main()
{
 
    A* pa = new A();
    B* pb = new B();
    delete pa;
    delete pb;
    return 0;
}

 

image

 

 

下面代码有一些不同的地方

 

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
38
39
40
41
42
43
#include "iostream"
#include "malloc.h"
 
 
using namespace std;
void* operator new(unsigned int size)
{
    if(size == 0) 
      size = 1; 
     void *res; 
     for(;;) 
     
       //allocate memory block 
       res = malloc(size); 
       //if successful allocation, return pointer to memory 
       if(res) 
           break
      //call installed new handler 
 
     cout<<"global new Override"<<endl;
 return res; 
}
class A{
public:
    int a;
    A(){cout<<"A construtor"<<endl;}
    A(int i){cout<<"A construtor int i "<<endl;a=i;}
};
 
class B{
public:    
    char b;
};
 
int main()
{
 
    A* pa = new A(2);
    B* pb = new B();
    delete pa;
    delete pb;
    return 0;
}

image

 

看到重载new后并不会阻止调用构造函数

所以new的过程其实分为两个步骤 一个是调用分配内存,一个是调用构造函数。

 

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
38
39
40
41
42
43
44
45
46
47
#include "iostream"
#include "malloc.h"
 
 
using namespace std;
void* operator new(unsigned int size)
{
    if(size == 0) 
      size = 1; 
     void *res; 
     for(;;) 
     
       //allocate memory block 
       res = malloc(size); 
       //if successful allocation, return pointer to memory 
       if(res) 
           break
      //call installed new handler 
 
     cout<<"global new Override"<<endl;
 return res; 
}
class A{
public:
    int a;
    A(){cout<<"A construtor"<<endl;}
    A(int i){cout<<"A construtor int i "<<endl;a=i;}
    void* operator new(unsigned int size){
        cout<<"class new Override"<<endl;
         return ::operator new(size);
    }
};
 
class B{
public:    
    char b;
};
 
int main()
{
 
    A* pa = new A;
    B* pb = new B();
    delete pa;
    delete pb;
    return 0;
}

image

观点:

是否应该为单独的class重载new

与全局 ::operator new() 不同,per-class operator new() 和 operator delete () 的影响面要小得多,它只影响本 class 及其派生类。似乎重载 member operator new() 是可行的。我对此持反对态度。

如果一个 class Node 需要重载 member operator new(),说明它用到了特殊的内存分配策略,常见的情况是使用了内存池或对象池。我宁愿把这一事实明显地摆出来,而不是改变 new Node 的默认行为。具体地说,是用 factory 来创建对象,比如 static Node* Node::createNode() 或者 static shared_ptr Node::createNode();。

这可以归结为最小惊讶原则:如果我在代码里读到 Node* p = new Node,我会认为它在 heap 上分配了内存,如果 Node class 重载了 member operator new(),那么我要事先仔细阅读 node.h 才能发现其实这行代码使用了私有的内存池。为什么不写得明确一点呢?写成 Node* p = Node::createNode(),那么我能猜到 Node::createNode() 肯定做了什么与 new Node 不一样的事情,免得将来大吃一惊。

The Zen of Python 说 explicit is better than implicit,我深信不疑。

 

 

是否应该重载全局的new

我们常常会设法优化性能,如果 profiling 表明 hot spot 在内存分配和释放上,重载全局的 ::operator new() 和 ::operator delete() 似乎是一个一劳永逸好办法(以下简写为“重载 ::operator new()”),本文试图说明这个办法往往行不通。

 

如果只考虑分配和释放,内存管理基本要求是“不重不漏”:既不重复 delete,也不漏掉 delete。也就说我们常说的 new/delete 要配对,“配对”不仅是个数相等,还隐含了 new 和 delete 的调用本身要匹配,不要“东家借的东西西家还”。例如:

  • 用系统默认的 malloc() 分配的内存要交给系统默认的 free() 去释放;
  • 用系统默认的 new 表达式创建的对象要交给系统默认的 delete 表达式去析构并释放;
  • 用系统默认的 new[] 表达式创建的对象要交给系统默认的 delete[] 表达式去析构并释放;
  • 用系统默认的 ::operator new() 分配的的内存要交给系统默认的 ::operator delete() 去释放;
  • 用 placement new 创建的对象要用 placement delete (为了表述方便,姑且这么说吧)去析构(其实就是直接调用析构函数);
  • 从某个内存池 A 分配的内存要还给这个内存池。
  • 如果定制 new/delete,那么要按规矩来。见 Effective C++ 相关条款。

做到以上这些不难,是每个 C++ 开发人员的基本功。不过,如果你想重载全局的 ::operator new(),事情就麻烦了。

 

重载 ::operator new() 的理由

Effective C++ 第三版第 50 条列举了定制 new/delete 的几点理由:

  • 检测代码中的内存错误
  • 优化性能
  • 获得内存使用的统计数据

这些都是正当的需求,文末我们将会看到,不重载 ::operator new() 也能达到同样的目的。

 

::operator new() 的两种重载方式

1. 不改变其签名,无缝直接替换系统原有的版本,例如:

#include

void* operator new(size_t size);

void operator delete(void* p);

用这种方式的重载,使用方不需要包含任何特殊的头文件,也就是说不需要看见这两个函数声明。“性能优化”通常用这种方式。

科普:签名------ 方法签名由方法名称和一个参数列表(方法的参数的顺序和类型)组成。 
 

2. 增加新的参数,调用时也提供这些额外的参数,例如:

void* operator new(size_t size, const char* file, int line);  // 其返回的指针必须能被普通的 ::operator delete(void*) 释放

void operator delete(void* p, const char* file, int line);   // 这个函数只在析构函数抛异常的情况下才会被调用

然后用的时候是

Foo* p = new (__FILE, __LINE__) Foo;   // 这样能跟踪是哪个文件哪一行代码分配的内存

我们也可以用宏替换 new 来节省打字。用这第二种方式重载,使用方需要看到这两个函数声明,也就是说要主动包含你提供的头文件。“检测内存错误”和“统计内存使用情况”通常会用这种方式重载。当然,这不是绝对的。

 

在学习 C++ 的阶段,每个人都可以写个一两百行的程序来验证教科书上的说法,重载 ::operator new() 在这样的玩具程序里边不会造成什么麻烦。

不过,我认为在现实的产品开发中,重载 ::operator new() 乃是下策,我们有更简单安全的办法来到达以上目标。

 

现实的开发环境

作为 C++ 应用程序的开发人员,在编写稍具规模的程序时,我们通常会用到一些 library。我们可以根据 library 的提供方把它们大致分为这么几大类:

  1. C 语言的标准库,也包括 Linux 编程环境提供的 Posix 系列函数。
  2. 第三方的 C 语言库,例如 OpenSSL。
  3. C++ 语言的标准库,主要是 STL。(我想没有人在产品中使用 IOStream 吧?)
  4. 第三方的通用 C++ 库,例如 Boost.Regex,或者某款 XML 库。
  5. 公司其他团队的人开发的内部基础 C++ 库,比如网络通信和日志等基础设施。
  6. 本项目组的同事自己开发的针对本应用的基础库,比如某三维模型的仿射变换模块。

在使用这些 library 的时候,不可避免地要在各个 library 之间交换数据。比方说 library A 的输出作为 library B 的输入,而 library A 的输出本身常常会用到动态分配的内存(比如 std::vector)。

如果所有的 C++ library 都用同一套内存分配器(就是系统默认的 new/delete ),那么内存的释放就很方便,直接交给 delete 去释放就行。如果不是这样,那就得时时刻刻记住“这一块内存是属于哪个分配器,是系统默认的还是我们定制的,释放的时候不要还错了地方”。

(由于 C 语言不像 C++ 一样提过了那么多的定制性,C library 通常都会默认直接用 malloc/free 来分配和释放内存,不存在上面提到的“内存还错地方”问题。或者有的考虑更全面的 C library 会让你注册两个函数,用于它内部分配和释放内存,这就就能完全掌控该 library 的内存使用。

 

但是,如果重载了 ::operator new(),事情恐怕就没有这么简单了。

重载 ::operator new() 的困境

首先,重载 ::operator new() 不会给 C 语言的库带来任何麻烦,当然,重载它得到的三点好处也无法让 C 语言的库享受到。

以下仅考虑 C++ library 和 C++ 主程序。

规则 1:绝对不能在 library 里重载 ::operator new()

如果你是某个 library 的作者,你的 library 要提供给别人使用,那么你无权重载全局 ::operator new(size_t) (注意这是上面提到的第一种重载方式),因为这非常具有侵略性:任何用到你的 library 的程序都被迫使用了你重载的 ::operator new(),而别人很可能不愿意这么做。另外,如果有两个 library 都试图重载 ::operator new(size_t),那么它们会打架,我估计会发生 duplicated symbol link error。干脆,作为 library 的编写者,大家都不要重载 ::operator new(size_t) 好了。

那么第二种重载方式呢?首先 ,::operator new(size_t size, const char* file, int line) 这种方式得到的 void* 指针必须同时能被 ::operator delete(void*) 和 ::operator delete(void* p, const char* file, int line) 这两个函数释放。这时候你需要决定,你的 ::operator new(size_t size, const char* file, int line) 返回的指针是不是兼容系统默认的 ::operator delete(void*)。

  • 如果不兼容(也就是说不能用系统默认的 ::operator delete(void*) 来释放内存),那么你得重载 ::operator delete(void*),让它的行为与你的 operator new(size_t size, const char* file, int line) 匹配。一旦你决定重载 ::operator delete(void*),那么你必须重载 ::operator new(size_t),这就回到了情况 1:你无权重载全局 ::operator new(size_t)。
  • 如果选择兼容系统默认的 ::operator delete(void*),那么你在 operator new(size_t size, const char* file, int line) 里能做的事情非常有限,比方说你不能额外动态分配内存来做 house keeping 或保存统计数据(无论显示还是隐式),因为系统默认的 ::operator delete(void*) 不会释放你额外分配的内存。(这里隐式分配内存指的是往 std::map<> 这样的容器里添加元素。)

看到这里估计很多人已经晕了,但这还没完。

 

其次 ,在 library 里重载 operator new(size_t size, const char* file, int line) 还涉及到你的重载要不要暴露给 library 的使用者(其他 library 或主程序)。这里“暴露”有两层意思:1) 包含你的头文件的代码会不会用你重载的 ::operator new(),2) 重载之后的 ::operator new() 分配的内存能不能在你的 library 之外被安全地释放。如果不行,那么你是不是要暴露某个接口函数来让使用者安全地释放内存?或者返回 shared_ptr ,利用其“捕获”deleter 的特性?听上去好像挺复杂?这里就不一一展开讨论了,总之,作为 library 的作者,绝对不要动“重载 operator new()”的念头。

事实 2:在主程序里重载 ::operator new() 作用不大

这不是一条规则,而是我试图说明这么做没有多大意义。

如果用第一种方式重载全局 ::operator new(size_t),会影响本程序用到的所有 C++ library,这么做或许不会有什么问题,不过我建议你使用下一节介绍的更简单的“替代办法”。

如果用第二种方式重载 ::operator new(size_t size, const char* file, int line),那么你的行为是否惠及本程序用到的其他 C++ library 呢?比方说你要不要统计 C++ library 中的内存使用情况?如果某个 library 会返回它自己用 new 分配的内存和对象,让你用完之后自己释放,那么是否打算对错误释放内存做检查?

C++ library 从代码组织上有两种形式:1) 以头文件方式提供(如以 STL 和 Boost 为代表的模板库);2) 以头文件+二进制库文件方式提供(大多数非模板库以此方式发布)。

对于纯以头文件方式实现的 library,那么你可以在你的程序的每个 .cpp 文件的第一行包含重载 ::operator new 的头文件,这样程序里用到的其他 C++ library 也会转而使用你的 ::operator new 来分配内存。当然这是一种相当有侵略性的做法,如果运气好,编译和运行都没问题;如果运气差一点,可能会遇到编译错误,这其实还不算坏事;运气更差一点,编译没有错误,运行的时候时不时出现非法访问,导致 segment fault;或者在某些情况下你定制的分配策略与 library 有冲突,内存数据损坏,出现莫名其妙的行为。

对于以库文件方式实现的 library,这么做并不能让其受惠,因为 library 的源文件已经编译成了二进制代码,它不会调用你新重载的 ::operator new(想想看,已经编译的二进制代码怎么可能提供额外的 new (__FILE__, __LINE__) 参数呢?)更麻烦的是,如果某些头文件有 inline function,还会引起诡异的“串扰”。即 library 有的部分用了你的分配器,有的部分用了系统默认的分配器,然后在释放内存的时候没有给对地方,造成分配器的数据结构被破坏。

总之,第二种重载方式看似功能更丰富,但其实与程序里使用的其他 C++ library 很难无缝配合。

综上,对于现实生活中的 C++ 项目,重载 ::operator new() 几乎没有用武之地,因为很难处理好与程序所用的 C++ library 的关系,毕竟大多数 library 在设计的时候没有考虑到你会重载 ::operator new() 并强塞给它。

如果确实需要定制内存分配,该如何办?

替代办法

很简单,替换 malloc。如果需要,直接从 malloc 层面入手,通过 LD_PRELOAD 来加载一个 .so,其中有 malloc/free 的替代实现(drop-in replacement),这样能同时为 C 和 C++ 代码服务,而且避免 C++ 重载 ::operator new() 的阴暗角落。

对于“检测内存错误”这一用法,我们可以用 valgrind 或者 dmalloc 或者 efence 来达到相同的目的专业的除错工具比自己山寨一个内存检查器要靠谱。

对于“统计内存使用数据”,替换 malloc 同样能得到足够的信息,因为我们可以用 backtrace() 函数来获得调用栈,这比 new (__FILE__, __LINE__) 的信息更丰富。比方说你通过分析 (__FILE__, __LINE__) 发现 std::string 大量分配释放内存,有超出预期的开销,但是你却不知道代码里哪一部分在反复创建和销毁 std::string 对象,因为 (__FILE__, __LINE__) 只能告诉你最内层的调用函数。用 backtrace() 能找到真正的发起调用者。

对于“性能优化”这一用法,我认为这目前的多线程开发中,自己实现一个能打败系统默认的 malloc 的内存分配器是不现实的。一个通用的内存分配器本来就有相当的难度,为多线程程序实现一个安全和高效的通用(全局)内存分配器超出了一般开发人员的能力。不如使用现有的针对多核多线程优化的 malloc,例如 Google tcmalloc 和Intel TBB 2.2 里的内存分配器 。好在这些 allocator 都不是侵入式的,也无须重载 ::operator new()。

 

总结:重载 ::operator new() 或许在某些临时的场合能应个急,但是不应该作为一种策略来使用。如果需要,我们可以从 malloc 层面入手,彻底而全面地替换内存分配器。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章