[TC++PL] new/delete 操作符
Cpp Operators of new and delete
1. 动态内存分配与释放(new and delete)
一般说来,一个对象的生命期是由它被创建时所处的区域决定的。例如,在一对{}类定义的一个对象,在离开这个由{}所界定的区域时,该对象就会被销毁,在这个区域之外这个对象是不存在的,程序的其他部分不能再引用这个对象了。
如果希望在离开了创建这个对象时所处的区域后,还希望这个对象存在并能继续引用它,就必须用new操作符在自由存储空间来分配一个对象。这个过程也叫做动态内存分配,也叫堆对象。任何由new操作符分配的对象都应该用delete操作符手动地销毁掉。C++标准并没有定义任何形式的“垃圾收集”机制。delete操作符只能用于由new返回的指针,或者是零。当delete的参数是0时,不会产生任何效果,也就是说这个delete操作符对应的函数根本就不会被执行。
new(delete)既可以分配(释放)单个的对象(当然也包括内建类型),也可以分配(释放)对象数组。下面是其函数原型:
#include <new>
void* operator new(size_t); // 参数是单个对象的大小
void* operator new[](size_t); // 参数是对象数组的总的大小
void delete(void*);
void delete[](void*);
C++标准中并没有要求new操作符对分配出来的空间进行初始化。下面是使用new分配一个字符数组的例子:
char* save_string(const char* p)
{
char* s = new char[strlen(p)+1];
// ...
return s;
}
char* p = save_string(argv[1]);
// ...
delete[] p;
class X { /* ... */ }
X* p = new[10] X;
X* p2 = new X[10];
vector<int>* pv = new vector<int>(5);
在new分配出足够的空间后,编译器会紧接着调用X的缺省构造函数对空间进行初始化。注意,上述两种形式都是可以的,无论X是内建类型还是自定义用户类型。对于类来说,还可以使用类的构造函数形式,如上面创建vector类型对象的例子。此外,一个用非数组形式的new操作符创建的对象,不能用数组形式的delete操作符来销毁。
2. 提供自己的内存管理:重载new/delete操作符
我们可以为new/delete定义自己的内存管理方式,但是替换全局的new/delete操作符的实现是不够好的,原因很明显:有些人可能需要缺省的new/delete操作的一些方面,而另一些人则可能完全使用另外一种版本的实现。所以最好的办法是为某个特定的类提供它自己的内存管理方式。
一个类的operator new()和operator delete()成员函数,隐式地成为静态成员函数。因此它们没有this指针,也不能修改对象(很好理解,当调用new的时候对象还没有真正创建呢,当然不能修改对象了!)。当然在重载定义的时候,原型还是要与前面提到的一致。看下面这个例子:
void* Employee::operator new(size_t s)
{
// 分配s字节的内存空间,并返回这个空间的地址
}
void Employee::operator delete(void* p, size_t s)
{
// 假定指针p是指向由Employee::operator new()分配的大小为s字节的内存空间。
// 释放这块空间以供系统在以后重用。
}
任何一个operator new()的操作符定义,都以一个尺寸值作为第一个参数,且待分配对象的大小隐式给定,其值就作为new操作符函数的第一个参数值。
在这里分配空间的具体实现可以是多种多样的,可以直接使用malloc/free(缺省的全局new/delete大部分都是用的这种),可以在指定的内存块中分配空间(下节将要详述),也可能还有其他的更好的更适合你的应用的方式。
那么如何重载数组形式的new[]/delete[]操作符呢?与普通形式一样,只不过delete[]的参数形式稍有不同,如下所示:
class Employee {
public:
void* operator new[](size_t);
void operator delete[](void*); // 单参数形式,少了一个size_t参数
void operator delete[](void*,size_t); //两个参数形式也是可以的,但无必要
// ...
};
在编译器的内部实现中,传入new/delete[]的尺寸值可能是数组的大小s加上一个delta。这个delta量是编译器的内部实现所定义的某种额外开销。为什么delete操作符不需要第二个尺寸参数呢?因为这个数组的大小s以及delta量都由系统“记住”了。但是delete[]的两个参数形式的原型也是可以声明的,在调用的时候会把s*sizeof(SomeClass)+delta作为第二个参数值传入。delta量是与编译器实现相关的,因此对于用户程序员来说是不必要知道的。故而这里只提供单参数版本就可以了。(这倒是提供了一种查看这个delta量的方法。根据实际测试,GCC 4.1采用了4个字节的delta量。)
到这里应该注意到,当我们调用operator delete()的时候,只给出了指针,并没有给出对象大小的参数。那么编译器是怎么知道应该给operator delete()提供正确的尺寸值的呢?如果delete参数类型就是该对象的确切型别,那么这是一个简单的事情,但是事情并不是总是这样。看下面的例子:
class Manager : public Employee {
int level;
// ...
};
void f()
{
Employee* p = new Manager; // 麻烦:确切型别丢失了!
delete p;
}
这个时候编译器不能得到正确的对象的尺寸。这就需要用户的帮助了:只需要将基类的析构函数声明称为虚函数即可。
3. 在指定位置安放对象(Placement of Objects)
new操作符的缺省方式是在自由内存空间中创建对象。如果希望在指定的地方分配对象,就应该使用这里介绍的方法。看下面的例子:
class X {
public:
X(int);
//...
};
当需要把对象放置到指定地方的时候,只需要为分配函数提供一个额外的参数(既指定的某处内存的地址),然后在使用new的时候提供这样的一个额外参数即可。看下面的例子:
void* operator new(size_t, void* p) { return p; } // 显示安放操作符
void* buf = reinterpret_cast<void*>(0xF00F); // 某个重要的地址
X* p2 = new(buf) X; // 在buf地址处创建一个X对象,
// 实际调用函数operator new(sizeof(X),buf)
4. 内存分配失败与new_handler
如果new操作符不能分配出内存,会发生什么呢?默认情况下,这个分配器会抛出一个bad_alloc异常对象。看下面的例子:
void f()
{
try{
for(;;) new char [10000];
}
catch(bad_alloc) {
cerr << "Memory exhausted!\n";
}
}
[疑问:构造一个异常对象也需要内存空间,既然已经内存耗尽了,那这个内存又从哪里来呢?]
可以自定义内存耗尽时的处理方法(new_handler)。当new操作失败时,首先会调用一个由set_new_handler()指定的函数。我们可以自定义这个函数,然后用set_new_handler()来登记。最后当new操作失败时可以调用适当的自定义处理过程。看下面的例子:
#include <new> // set_new_handler()原型在此头文件中
void out_of_store()
{
cerr << "operator new failed: out of store\n";
throw bad_alloc();
}
set_new_handler(out_of_store);
for(;;) new char[10000];
cout << "done\n";
上述例子中控制流不会到达最后一句输出,也就是说永远不会输出done。而是会输出:
operator new failed: out of store
自定义的new_handler函数的原型如下:
typedef void (*new_handler)();
5. 标准头文件<new>中的原型
下面是标准头文件中的各种原型声明:
class bad_alloc : public exception { /* ... */ }
struct nothrow_t { };
extern struct nothrow_t nothrow; // 内存分配器将不会抛出异常
typedef void (*new_handler)();
new_handler set_new_handler(new_handler new_p) throw();
(1)普通的内存分配,失败时抛出bad_alloc异常
// 单个对象的分配与释放
void* operator new(size_t) throw(bad_alloc);
void operator delete(void*) throw();
// 对象数组分配与释放
void* operator new[](size_t) throw(bad_alloc);
void operator delete[](void*) throw();
(2)与C方式兼容的内存分配,失败时返回0,不抛出异常
// 单个对象分配与释放
void* operator new(size_t, const nothrow_t&) throw();
void operator delete(void*, const nothrow_t&) throw();
// 对象数组分配与释放
void* operator new[](size_t, const nothrow_t&) throw();
void operator delete[](void*, const nothrow_t&) throw();
(3)从指定空间中分配内存
// 分配已有空间给单个对象使用
void* operator new(size_t, void* p) throw() { return p; }
void operator delete(void* p, void*) throw() { } //什么都不做!
// 分配已有空间给对象数组使用
void* operator new[](size_t, void* p) throw() {return p;}
void operator delete[](void* p, void*) throw() { } //什么也不做!
在上述原型中,抛出空异常的函数都没有办法通过抛出std::bad_alloc发出内存耗尽的信号;它们在内存分配失败时返回0。
上述原型的使用方法:
class X {
public:
X(){};
X(int n){};
// ...
};
(1)可以抛出异常的new/delete操作符。
原型的第一个参数,即对象(或对象数组)的大小,因此在使用时如下所示:
X* p = new X;
X* p1 = new X(5);
X* pa = new X[10];
分配对象数组时要注意:只能用这种形式,不能用带参数的形式,例如下面的方式是错误的:
X* pa2 = new[20] X(5);
你想分配一个X数组,每个数组元素都用5进行初始化,这是不能做到的。
(2)不抛出异常而返回0的new/delete操作符
原型的第二个参数要求一个nothrow_t的引用,因此必须以<new>中定义的nothrow全局对象作为new/delete的参数,如下所示:
void f()
{
int* p = new int[10000]; // 可能会抛出bad_alloc异常
if(int* q = new(nothrow) int[100000]; {
// 内存分配成功
delete(nothrow)[]q;
}
else {
// 内存分配失败
}
}
6. new与异常
如果在使用new构造对象时,构造函数抛出了异常,结果会怎样?由new分配的内存释放了吗?在通常情况下答案是肯定的;但如果是在指定位置上分配对象空间,那么答案就不是这么简单了。如果这个内存块是由某个类的new函数分配的,那么就会调用其相应的delete函数(如果有的话),否则不会有释放内存的动作发生。这种策略很好地处理了标准库中的使用指定内存的new操作符,以及提供了成对的分配与释放函数的任何情形。
看下面这个例子:
void f(Arena& a, X* buffer)
{
X* p1 = new X;
X* p2 = new X[10];
X* p3 = new(buffer[10]) X;
X* p4 = new(buffer[11]) X[10];
X* p5 = new(a) X;
X* p6 = new(a) X[10];
}
分析:p1和p2将能正确释放其分配的内存,不会造成内存泄漏,这属于一种正常情况。后面的四种情况则比较复杂。对象a如果是采用普通方式分配的内存,那么将能够正确释放其拥有的内存。
7. malloc/free没用了吗?
new能够完全替代malloc吗?绝大部分情况下,答案都是肯定的。但是有一种情况则非用malloc不可了。根据new的定义,其第一个参数是待分配对象的大小,但在使用时不需要明确地给出这个值。这个值是由编译器暗中替你完成的。倘若在某种情况下,需要在分配一个对象的同时还要分配出一些额外的空间用来管理某些相关的信息。这个额外空间与对象的空间要求连续。这个时候new就帮不上了。必须用malloc把对象和额外空间的总大小作为malloc的参数。在分配出来了后,可能需要调用new的放置形式的调用在该块内存上构造对象。
8. 垃圾收集
当我们为自己的类提供了自己的内存管理方法时,有可能会出现内存分配失败的情况。因此我们可能会通过set_new_handler()提供一个更灵巧的内存释放与重用机制。这就为实现垃圾收集提供了一个实现思路。垃圾收集机制的基本思想是,当一个对象不再被引用时,它的内存就可以安全地被新的对象所使用。
当比较垃圾收集机制与手工管理方式的代价时,从一下几个方面进行比较:
运行时间,内存的使用,可靠性,移植性,编程的费用,垃圾收集器的费用,性能的预期。
垃圾收集器必须要处理几个重要的问题:
(1)指针的伪装
通常若以非指针的形式来存储一个指针,则把这个指针叫做“伪装的指针”。看下面这个例子:
void f()
{
int* p = new int;
long i1 = reinterpret_cast<long>(p) & 0xFFFF0000;
long i2 = reinterpret_cast<long>(p) & 0x0000FFFF;
p = 0;
// 这里就不存在指向那个整型数的指针了!
p = reinterpret_cast<int*>(i1|i2);
// 现在这个整型数又被引用了!
}
上例中原本由p持有的指针被伪装成两个整型数i1和i2。垃圾收集器必须关注这种伪装的指针。
指针伪装还有另外一种形式,即同时有指针和非指针成员的union结构,也会给垃圾收集器带来特殊的问题。看下面的例子:
union U {
int* p;
int i;
};
void f(U u, U u2, U u3)
{
u.p = new int;
u2.i = 99999;
u.i = 8;
// ...
}
通常这是不可能知道union中包含的是指针还是整数。
(2)delete函数
通常若使用了自动垃圾收集,那么delete和delete[]函数是不再需要了的。但是delete和delete[]函数除了释放内存的功能外,还会调用析构函数。因此在这种情况下,下列调用
delete p;
就只调用析构函数,而内存的复用则向后推迟,直到内存块被收集。一次回收多个对象,有助于减少碎片。
(3)析构函数
当垃圾收集器准备回收对象时,有两种办法可选:
[1] 为这个对象调用析构函数(如果有的话);
[2] 将这个对象当作原始内存(即不调用析构函数)。
一般垃圾收集器会选择第二中方法。这种方法gc就成为模拟一种无限内存的机制。
也有可能设计一种gc,它能调用向它注册了的对象的析构函数。这种设计的一个重要方面是防止析构函数重复删除一个之前已经删除的对象。
(4)内存的碎片
处理内存碎片的问题上,有两种主要的GC类型:拷贝型和保守型。拷贝型GC通过移动对象使得碎片空间紧凑;而保守型则通过分配方式的改善来减少碎片。C++的观点看来,更倾向于保守型的。因为移动对象将导致大量的指针和引用等失效,所以拷贝型GC在C++中几乎是不可能实现的。此外保守型GC也可以让C++的代码段与C代码段共存。