一、内存管理
1. 内存分配方式
在C++中,内存分成5个区,分别是:堆、栈、自由存储区、全局/静态存储区、常量存储区。
栈,在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
堆,就是那些由new分配的内存块,它们的释放编译器不管,有应用程序控制。一般一个new就要对应一个delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。
自由存储区,就是那些由malloc等分配的内存块,它和堆十分相似,不过它是用free来结束自己的生命的。
全局/静态存储区,全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,它们共同占用同一块内存区。
常量存储区,这是一块比较特殊的存储区,它们里面存放的是常量,不允许修改。
2. 堆栈区别
| 堆 | 栈 |
管理方式 | 释放工作由程序员控制,容易产生memory leak. | 由编译器自动管理,无需我们手工控制。 |
空间大小 | 一般来讲在32位系统下,堆内存可以达到4G空间,从这个角度看堆内存几乎是没有什么限制的。 | 一般都有一定的空间大小。 |
碎片产生 | 频繁的new/delete势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。 | 不会存在碎片。栈,先进后出的队列,它们是如此一一对应,以至于永远都不可能有一个内存块从栈中间弹出,在它弹出前,在它上面的后进的栈内从已经被弹出。 |
生长方向 | 向上:向着内存地址增加的方向增长。 | 向下:向着内存地址减小的方向增长。 |
分配方式 | 都是动态分配。释放需要应用程序管理 | 静态分配和动态分配。 静态分配是编译器完成的,比如局部变量的分配。 动态分配由alloca函数分配。由编译器进行释放,无需手工实现。 |
分配效率 | 堆是C/C++函数库提供的,它的机制很复杂。例如为了分配一块内存,库函数会按照一定的算法在对内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能由于内存碎片太多导致),就有可能调用系统功能区增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。效率较低。 | 栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。 |
3. 控制C++的内存分配
(1)重载全局的new和delete操作符
void * operator new(size_t size) { void *p = malloc(size); return (p); }
void operator delete(void *p); { free(p); } | class TestClass { public: void * operator new(size_t size); void operator delete(void *p); }; void *TestClass::operator new(size_t size) { void *p = malloc(size); // Replace this with alternative allocator return (p); } void TestClass::operator delete(void *p) { free(p); // Replace this with alternative de-allocator } |
(2)为单个的类重载new[]和delete[]
对象数组分配:不会调用重载的new和delete,而是需要调用new[]和delete[],因此需要重载new[]和delete[]操作符。
class TestClass
{
public:
void * operator new[ ](size_t size);
void operator delete[ ](void *p);
};
void *TestClass::operator new[ ](size_t size)
{
void *p = malloc(size);
return (p);
}
void TestClass::operator delete[ ](void *p)
{
free(p);
}
int main(void)
{
TestClass *p = new TestClass[10];
delete[ ] p;
}
//注意:
void f()
{
int* p=new int[5];
delete []p; //—>释放数组;delete p—>只释放了一个字节
}
但是注意:对于多数C++的实现,new[]操作符中的个数参数是数组的大小加上额外的存储对象数目的一些字节。在你的内存分配机制重要考虑的这一点。你应该尽量避免分配对象数组,从而使你的内存分配策略简单。
4. 常见的内存错误及其对策
问 题 | 对 策 |
内存分配未成功,却使用了它。 | 在使用内存之前检查指针是否为NULL。 如果指针p是函数的参数,那么在函数的入口处用assert(p!=NULL)进行检查。 如果使用malloc或new来申请内存,应该用if(p==NULL)或if(p!=NULL)进行防错处理。 |
内存分配虽然成功,但是尚未初始化就引用它。 | 犯这种错误主要有两个起因:一是没有初始化的观念;二是误以为内存的缺省初值全为零,导致引用初值错误(例如数组)。 内存的缺省初值究竟是什么并没有统一的标准,尽管有些时候为零值,所以无论用何种方式创建数组,都别忘了赋初值,即便是赋零值也不可省略。 |
内存分配成功并且已经初始化,但操作越过了内存的边界。 | 例如在使用数组时经常发生下标“多1”或者“少1”的操作。特别是在for循环语句中,循环次数很容易搞错,导致数组操作越界。 |
忘记了释放内存,造成内存泄露。 | 动态内存的申请与释放必须配对,程序中malloc与free的使用次数一定要相同,否则肯定有错误(new/delete同理)。 |
释放了内存却继续使用它。 | (1)程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局面。 |
(2)函数的return语句写错了,注意不要返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。 | |
(3)使用free或delete释放了内存后,没有将指针设置为NULL。导致产生“野指针”。 |
【规则1】用malloc或new申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。
【规则2】不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。
【规则3】避免数组或指针的下标越界,特别要当心发生“多1”或者“少1”操作。
【规则4】动态内存的申请与释放必须配对,防止内存泄漏。
【规则5】用free或delete释放了内存之后,立即将指针设置为NULL,防止产生“野指针”。
5. 指针与数组的对比
- 数组要么在静态存储区被创建(如全局数组),要么在栈上被创建。
- 数组名对应着(而不是指向)一块内存,其地址与容量在生命期内保持不变,只有数组的内容可以改变。
(1)修改内容
字符数组a的容量是6个字符,其内容为hello。a的内容可以改变,如a[0]= ‘X’。指针p指向常量字符串“world”(位于静态存储区,内容为world),常量字符串的内容是不可以被修改的。
char a[] = “hello”;
a[0] = ‘X’;
cout << a << endl;
char *p = “world”; // 注意p指向常量字符串
p[0] = ‘X’; // 错误:编译器不能发现该错误
cout << p << endl;
(2)内容复制与比较
字符串比较均需要使用strcmp比较,返回值大于0,则第一个字符串大,返回值小于0,则第二个字符串大,返回值等于为0,则字符串相等。
// 数组…
char a[] = "hello";
char b[10];
strcpy(b, a); // 不能用 b = a;
if(strcmp(b, a) == 0) // 不能用 if (b == a)
// 指针
int len = strlen(a);
char *p = (char *)malloc(sizeof(char)*(len+1));
strcpy(p,a); // 不要用 p = a;
if(strcmp(p, a) == 0) // 不要用 if (p == a)
(3)计算内存容量
用运算符sizeof可以计算出数组的容量(字节数),但是无法计算指针指向的内存地址所占有的字节数。
char a[] = "hello";
char *p = a;
cout<< sizeof(a) << endl; // 6字节
cout<< sizeof(p) << endl; // 4字节
注意当数组作为函数的参数进行传递时,该数组自动退化为同类型的指针。如下示例中,不论数组a的容量是多少,sizeof(a)始终等于sizeof(char *)。
void Func(char a[100])
{
cout<< sizeof(a) << endl; // 4字节,与入参无关
}
6. 指针参数传递内存方式
如果函数的参数是一个指针,不要指望用该指针去申请动态内存。
| 类 型 | 原 因 |
错 误 | void GetMemory(char *p, int num) { p = (char *)malloc(sizeof(char) * num); }
void Test(void) { char *str = NULL; GetMemory(str, 100); // str 仍然为 NULL strcpy(str, "hello"); // 运行错误 } | 毛病出在函数GetMemory中。编译器总是要为函数的每个参数制作临时副本,指针参数p的副本是 _p,编译器使 _p = p。如果函数体内的程序修改了_p的内容,就导致参数p的内容作相应的修改。这就是指针可以用作输出参数的原因。在本例中,_p申请了新的内存,只是把_p所指的内存地址改变了,但是p丝毫未变。所以函数GetMemory并不能输出任何东西。事实上,每执行一次GetMemory就会泄露一块内存,因为没有用free释放内存。 |
正 确 | void GetMemory2(char **p, int num) { *p = (char *)malloc(sizeof(char) * num); }
void Test2(void) { char *str = NULL; //注意参数是 &str,而不是str GetMemory2(&str, 100); strcpy(str, "hello"); cout<< str << endl; free(str); } | 如果非得要用指针参数去申请内存,那么应该改用“指向指针的指针”。 |
正 确 | char *GetMemory3(int num) { char *p = (char *)malloc(sizeof(char) * num); return p; }
void Test3(void) { char *str = NULL; str = GetMemory3(100); strcpy(str, "hello"); cout<< str << endl; free(str); } | 也可以用函数返回值来传递动态内存。这种方法更加简单。 |
错 误 | char *GetString(void) { char p[] = "hello world"; return p; //编译器将提出警告 }
void Test4(void) { char *str = NULL; str = GetString(); // str 的内容是垃圾 cout<< str << endl; } | 不要用return语句返回指向“栈内存”的指针,因为该内存在函数结束时自动消亡。 |
错 误 | char *GetString2(void) { char *p = "hello world"; //常量字符串 return p; }
void Test5(void) { char *str = NULL; str = GetString2(); cout<< str << endl; } | 可以正确运行,但是设计概念却是错误的。 因为GetString2内的“hello world”是常量字符串,位于静态存储区,它在程序生命期内恒定不变。无论什么时候调用GetString2,它返回的始终是同一个“只读”的内存块。 |
7. 杜绝“野指针”
“野指针”不是NULL指针,是指向“垃圾”内存的指针。“野指针”是很危险的,if语句对它不起作用。
char *p = NULL; char *str = (char *) malloc(100); | (1)指针变量没有被初始化。 任何指针变量刚被创建时不会自动成为NULL指针,它的缺省值是随机的,它会乱指一气。所以,指针变量在创建的同时应当被初始化,要么将指针设置为NULL,要么让它指向合法的内存。 |
char *str = (char *) malloc(100); free(str); str = NULL; //释放内存后,指针置空 | (2)指针p被free或者delete之后,没有置为NULL,让人误以为p是个合法的指针。 |
class A { public: void Func(void) { cout << “Func of class A” << endl; } };
void Test(void) { A *p; { A a; p = &a; // 注意 a 的生命期 } p->Func(); // p是“野指针” } | (3)指针操作超越了变量的作用域范围。 |
8. malloc/free与new/delete
malloc与free是C++/C语言的标准库函数,new/delete是C++的运算符。它们都可用于申请动态内存和释放内存。
对于非内部数据类型的对象而言,只用malloc/free无法满足动态对象的要求。对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。由于malloc/free是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于malloc/free。因此C++语言需要一个能完成动态内存分配和初始化工作的运算符new,以及一个能完成清理与释放内存工作的运算符delete。
class Obj { public : Obj(void){ cout << “Initialization” << endl; } ~Obj(void){ cout << “Destroy” << endl; } void Initialize(void){ cout << “Initialization” << endl; } void Destroy(void){ cout << “Destroy” << endl; } };
void UseMallocFree(void) { Obj *a = (obj *)malloc(sizeof(obj)); // 申请动态内存 a->Initialize(); // 初始化 a->Destroy(); // 清除工作 free(a); // 释放内存 }
void UseNewDelete(void) { Obj *a = new Obj; // 申请动态内存并且初始化 delete a; // 清除并且释放内存 } | 类Obj的函数Initialize模拟了构造函数的功能,函数Destroy模拟了析构函数的功能。函数UseMallocFree中,由于malloc/free不能执行构造函数与析构函数,必须调用成员函数Initialize和Destroy来完成初始化与清除工作。函数UseNewDelete则简单得多。 |
- 不要企图用malloc/free来完成动态对象的内存管理,应该用new/delete。
- 由于内部数据类型的“对象”没有构造与析构的过程,对它们而言malloc/free和new/delete是等价的。
- 如果用free释放“new创建的动态对象”,那么该对象因无法执行析构函数而可能导致程序出错。
- 如果用delete释放“malloc申请的动态内存”,结果也会导致程序出错。
- new/delete必须配对使用,malloc/free也一样。
既然new/delete的功能完全覆盖了malloc/free,为什么C++不把malloc/free淘汰出局呢?这是因为C++程序经常要调用C函数,而C程序只能用malloc/free管理动态内存。
9. 内存耗尽解决办法
如果在申请动态内存时找不到足够大的内存块,malloc和new将返回NULL指针,宣告内存申请失败。通常有三种方式处理“内存耗尽”问题。
(1)判断指针是否为NULL,如果是则马上用return语句终止本函数。
void Func(void)
{
A *a = new A;
if(a == NULL)
{
return;
}
}
(2)判断指针是否为NULL,如果是则马上用exit(1)终止整个程序的运行。
void Func(void)
{
A *a = new A;
if(a == NULL)
{
cout << “Memory Exhausted” << endl;
exit(1);
}
}
(3)为new和malloc设置异常处理函数。例如Visual C++可以用_set_new_hander函数为new设置用户自己定义的异常处理函数,也可以让malloc享用与new相同的异常处理函数。
10. malloc/free的使用要点
- malloc
函数malloc的原型:void * malloc(size_t size);
用malloc申请一块长度为length的整数类型的内存:int *p = (int *) malloc(sizeof(int) * length);
我们应当把注意力集中在两个要素上:“类型转换”和“sizeof”:
- malloc返回值的类型是void *,所以在调用malloc时要显式地进行类型转换,将void * 转换成所需要的指针类型。
- malloc函数本身并不识别要申请的内存是什么类型,它只关心内存的总字节数,因此在malloc的“()”中使用sizeof运算符是良好的风格。
- free
函数free的原型如下:void free( void * memblock );
指针p的类型以及它所指的内存的容量事先都是知道的,语句free(p)能正确地释放内存,不需要强制类型转换。
如果p是NULL指针,那么free对p无论操作多少次都不会出问题。如果p不是NULL指针,那么free对p连续操作两次就会导致程序运行错误。
11. new/delete的使用要点
int *p1 = (int *)malloc(sizeof(int) * length);
int *p2 = new int[length];
运算符new使用起来要比函数malloc简单得多,这是因为new内置了sizeof、类型转换和类型安全检查功能。对于非内部数据类型的对象而言,new在创建动态对象的同时完成了初始化工作。
如果对象有多个构造函数,那么new的语句也可以有多种形式:
class Obj
{
public :
Obj(void); // 无参数的构造函数
Obj(int x); // 带一个参数的构造函数
}
void Test(void)
{
Obj *a = new Obj;
Obj *b = new Obj(1); // 初值为1
delete a;
delete b;
}
对象数组的创建与释放:
| 解 释 | 正 确 | 错 误 |
创 建 | 如果用new创建对象数组,那么只能使用对象的无参数构造函数。 | Obj *objects = new Obj[100]; // 创建100个动态对象 | Obj *objects = new Obj[100](1); // 创建100个动态对象的同时赋初值1 |
释 放 | 在用delete释放对象数组时,留意不要丢了符号‘[]’。 | delete []objects; //释放数组 | delete objects; //释放对象 |
二、健壮指针和资源管理
如果通过一个指针访问一个对象的话,比如对象在堆中分配,C++不自动地关注它的释放。程序员必须明确的用适当的程序方法来释放这些资源。比如说,如果一个对象是通过调用new来创建的,它需要用delete来回收。一个文件是用CreateFile(Win32 API)打开的,它需要用CloseHandle来关闭。用EnterCritialSection进入的临界区(Critical Section)需要LeaveCriticalSection退出,等等。一个"裸"指针,文件句柄,或者临界区状态没有所有者来确保它们的最终释放。基本的资源管理的前提就是确保每个资源都有他们的所有者。
1. 第一条规则(RAII)
RAII:也称为“资源获取就是初始化”,是C++等编程语言常用的管理资源、避免内存泄露的方法。它保证在任何情况下,使用对象时先构造对象,最后析构对象。
一个指针,一个句柄,一个临界区状态只有在我们将它们封装入对象的时候才会拥有所有者。这就是我们的第一规则:在构造函数中分配资源,在析构函数中释放资源。
下面是资源封装的一个经典例子。在一个多线程的应用程序中,线程之间共享对象的问题是通过用这样一个对象联系临界区来解决的。每一个需要访问共享资源的客户需要获得临界区。例如,这可能是Win32下临界区的实现方法。
class CritSect
{
friend class Lock;
public:
CritSect () { InitializeCriticalSection (&_critSection); }
~CritSect () { DeleteCriticalSection (&_critSection); }
private:
void Acquire (){ EnterCriticalSection (&_critSection); }
void Release (){ LeaveCriticalSection (&_critSection); }
private:
CRITICAL_SECTION _critSection;
};
这里聪明的部分是我们确保每一个进入临界区的客户最后都可以离开。"进入"临界区的状态是一种资源,并应当被封装。封装器通常被称作一个锁(lock)。
class Lock
{
public:
Lock (CritSect& critSect) : _critSect (critSect)
{
_critSect.Acquire ();
}
~Lock ()
{
_critSect.Release ();
}
private:
CritSect & _critSect;
};
锁一般的用法如下:
void Shared::Act () throw (char *)
{
Lock lock (_critSect);
// perform action —— may throw
// automatic destructor of lock
}
注意:无论发生什么,临界区都会借助于语言的机制保证释放。
还有一件需要记住的事情——每一种资源都需要被分别封装。这是因为资源分配是一个非常容易出错的操作,是要资源是有限提供的。我们会假设一个失败的资源分配会导致一个异常——事实上,这会经常的发生。所以如果你想试图用一个石头打两只鸟的话,或者在一个构造函数中申请两种形式的资源,你可能就会陷入麻烦。只要想想在一种资源分配成功但另一种失败抛出异常时会发生什么。因为构造函数还没有全部完成,析构函数不可能被调用,第一种资源就会发生泄露。这种情况可以非常简单的避免。无论何时你有一个需要两种以上资源的类时,写两个小的封装器将它们嵌入你的类中。每一个嵌入的构造都可以保证删除,即使包装类没有构造完成。
2. Smart Pointers
我们至今还没有讨论最常见类型的资源——用操作符new分配,此后用指针访问的一个对象。我们需要为每个对象分别定义一个封装类吗?(事实上,C++标准模板库已经有了一个模板类,叫做auto_ptr,其作用就是提供这种封装。我们一会儿在回到auto_ptr。)让我们从一个极其简单、呆板但安全的东西开始。看下面的Smart Pointer模板类,它十分坚固,甚至无法实现。
template <class T>
class SmartPointer
{
public:
~SmartPointer () { delete _p; }
T * operator->() { return _p; }
T const * operator->() const { return _p; }
protected:
SmartPointer (): _p (0) {}
explicit SmartPointer (T* p): _p (p) {}
T * _p;
};
为什么要把SmartPointer的构造函数设计为protected呢?如果我需要遵守第一条规则,那么我就必须这样做。资源——在这里是class T的一个对象——必须在封装器的构造函数中分配。但是我不能只简单的调用new T,因为我不知道T的构造函数的参数。因为,在原则上,每一个T都有一个不同的构造函数;我需要为它定义另外一个封装器。模板的用处会很大,为每一个新的类,我可以通过继承SmartPointer定义一个新的封装器,并且提供一个特定的构造函数。
class SmartItem: public SmartPointer<Item>
{
public:
explicit SmartItem (int i): SmartPointer<Item> (new Item (i)) {}
};
为每一个类提供一个Smart Pointer真的值得吗?说实话——不!它很有教学的价值,但是一旦你学会如何遵循第一规则的话,你就可以放松规则并使用一些高级的技术。这一技术是让SmartPointer的构造函数成为public,但是只是用它来做资源转换(Resource Transfer),我的意思是用new操作符的结果直接作为SmartPointer的构造函数的参数,像这样:
SmartPointer<Item> item (new Item (i));
这个方法明显更需要自控性,不只是你,而且包括你的程序小组的每个成员。他们都必须发誓出了作资源转换外不把构造函数用在其他用途。幸运的是,这条规矩很容易得以加强。只需要在源文件中查找所有的new即可。
3. Resource Transfer
如何在不同的作用域间安全的传递资源?
这一问题在当你处理容器的时候会变得十分明显。你可以动态的创建一串对象,将它们存放至一个容器中,然后将它们取出,并且在最终安排它们。为了能够让这安全的工作——没有泄露——对象需要改变其所有者。这个问题的一个非常显而易见的解决方法是使用Smart Pointer,无论是在加入容器前还是还找到它们以后。这是它如何运作的,你加入Release方法到Smart Pointer中:
template <class T>
T * SmartPointer<T>::Release ()
{
T * pTmp = _p;
_p = 0;
return pTmp;
}
注意在Release调用以后,Smart Pointer就不再是对象的所有者了——它内部的指针指向空。现在,调用了Release都必须是一个负责的人并且迅速隐藏返回的指针到新的所有者对象中。在我们的例子中,容器调用了Release,比如这个Stack的例子:
void Stack::Push (SmartPointer <Item> & item) throw (char *)
{
if (_top == maxStack)
throw "Stack overflow";
_arr [_top++] = item.Release ();
};
同样的,你也可以在你的代码中用加强Release的可靠性。
相应的Pop方法要做些什么呢?他应该释放了资源并祈祷调用它的是一个负责的人而且立即作一个资源传递它到一个Smart Pointer?这听起来并不好。
4. Strong Pointers
一个Strong Pointer会在许多地方和我们这个SmartPointer相似——它在超出它的作用域后会清除他所指向的对象。资源传递会以强指针赋值的形式进行。也可以有Weak Pointer存在,它们用来访问对象而不需要所有对象——比如可赋值的引用。
任何指针都必须声明为Strong或者Weak,并且语言应该来关注类型转换的规定。例如,你不可以将Weak Pointer传递到一个需要Strong Pointer的地方,但是相反却可以。Push方法可以接受一个Strong Pointer并且将它转移到Stack中的Strong Pointer的序列中。Pop方法将会返回一个Strong Pointer。把Strong Pointer的引入语言将会使垃圾回收成为历史。
然后我突然想到一个念头。我可以自己实现Strong Pointers。毕竟,它们都很想Smart Pointers。给它们一个拷贝构造函数并重载赋值操作符并不是一个大问题。事实上,这正是标准库中的auto_ptr有的。重要的是对这些操作给出一个资源转移的语法,但是这也不是很难。
template <class T>
SmartPointer<T>::SmartPointer (SmartPointer<T> & ptr)
{
_p = ptr.Release ();
}
template <class T>
void SmartPointer<T>::operator = (SmartPointer<T> & ptr)
{
if (_p != ptr._p)
{
delete _p;
_p = ptr.Release ();
}
}
使这整个想法迅速成功的原因之一是我可以以值方式传递这种封装指针!看这个Stack的新的实现:
class Stack
{
enum { maxStack = 3 };
public:
Stack (): _top (0){}
void Push (SmartPointer<Item> & item) throw (char *)
{
if (_top >= maxStack)
throw "Stack overflow";
_arr [_top++] = item;
}
SmartPointer<Item> Pop ()
{
if (_top == 0)
return SmartPointer<Item> ();
return _arr [--_top];
}
private:
int _top;
SmartPointer<Item> _arr [maxStack];
};
Pop方法强制客户将其返回值赋给一个Strong Pointer,SmartPointer<Item>。任何试图将他对一个普通指针的赋值都会产生一个编译期错误,因为类型不匹配。此外,因为Pop以值方式返回一个Strong Pointer(在Pop的声明时SmartPointer<Item>后面没有&符号),编译器在return时自动进行了一个资源转换。他调用了operator =来从数组中提取一个Item,拷贝构造函数将他传递给调用者。调用者最后拥有了指向Pop赋值的Strong Pointer指向的一个Item。
5. Parser
分析器的作用是在分析树中生成节点,节点是动态分配的。例如分析器的Expression方法生成一个表达式节点。令Expression、Term和Factor方法以传值的方式将Strong Pointer返回到Node中。看下面的Expression方法的实现:
SmartPointer<Node> Parser::Expression()
{
// Parse a term
SmartPointer<Node> pNode = Term ();
EToken token = _scanner.Token();
if ( token == tPlus || token == tMinus )
{
// Expr := Term { ('+' | '-') Term }
SmartPointer<MultiNode> pMultiNode = new SumNode (pNode);
do
{
_scanner.Accept();
SmartPointer<Node> pRight = Term ();
pMultiNode->AddChild (pRight, (token == tPlus));
token = _scanner.Token();
} while (token == tPlus || token == tMinus);
pNode = up_cast<Node, MultiNode> (pMultiNode);
}
// otherwise Expr := Term
return pNode; // by value!
}
最开始,Term方法被调用。他传值返回一个指向Node的Strong Pointer并且立刻把它保存到我们自己的Strong Pointer,pNode中。如果下一个符号不是加号或者减号,我们就简单的把这个SmartPointer以值返回,这样就释放了Node的所有权。另外一方面,如果下一个符号是加号或者减号,我们创建一个新的SumMode并且立刻(直接传递)将它储存到MultiNode的一个Strong Pointer中。这里,SumNode是从MultiMode中继承而来的,而MulitNode是从Node继承而来的。原来的Node的所有权转给了SumNode。
只要是他们在被加号和减号分开的时候,我们就不断的创建terms,我们将这些term转移到我们的MultiNode中,同时MultiNode得到了所有权。最后,我们将指向MultiNode的Strong Pointer向上映射为指向Mode的Strong Pointer,并且将他返回调用着。
我们需要对Strong Pointers进行显式的向上映射,即使指针是被隐式的封装。例如,一个MultiNode是一个Node,但是相同的is-a关系在SmartPointer<MultiNode>和SmartPointer<Node>之间并不存在,因为它们是分离的类(模板实例)并不存在继承关系。up-cast模板是像下面这样定义的:
template<class To, class From>
inline SmartPointer<To> up_cast (SmartPointer<From> & from)
{
return SmartPointer<To> (from.Release ());
}
如果你的编译器支持新加入标准的成员模板(member template)的话,你可以为SmartPointer<T>定义一个新的构造函数用来从接受一个class U。
template <class T>
template <class U> SmartPointer<T>::SmartPointer (SPrt<U> & uptr) : p (uptr.Release ()){}
这里的这个花招是模板在U不是T的子类的时候就不会编译成功(换句话说,只在U is-a T的时候才会编译)。这是因为uptr的缘故。Release()方法返回一个指向U的指针,并被赋值为_p,一个指向T的指针。所以如果U不是一个T的话,赋值会导致一个编译时刻错误。
std::auto_ptr
后来我意识到在STL中的auto_ptr模板,就是我的Strong Pointer。在那时候还有许多的实现差异(auto_ptr的Release方法并不将内部的指针清零--你的编译器的库很可能用的就是这种陈旧的实现),但是最后在标准被广泛接受之前都被解决了。
6. Transfer Semantics
目前为止,我们一直在讨论在C++程序中资源管理的方法。宗旨是将资源封装到一些轻量级的类中,并由类负责它们的释放。特别的是,所有用new操作符分配的资源都会被储存并传递进Strong Pointer(标准库中的auto_ptr)的内部。
这里的关键词是传递(passing)。一个容器可以通过传值返回一个Strong Pointer来安全的释放资源。容器的客户只能够通过提供一个相应的Strong Pointer来保存这个资源。任何一个将结果赋给一个"裸"指针的做法都立即会被编译器发现。
auto_ptr<Item> item = stack.Pop (); // ok
Item * p = stack.Pop (); // Error! Type mismatch.
以传值方式被传递的对象有value semantics 或者称为 copy semantics。Strong Pointers是以值方式传递的--但是我们能说它们有copy semantics吗?不是这样的!它们所指向的对象肯定没有被拷贝过。事实上,传递过后,源auto_ptr不在访问原有的对象,并且目标auto_ptr成为了对象的唯一拥有者(但是往往auto_ptr的旧的实现即使在释放后仍然保持着对对象的所有权)。自然而然的我们可以将这种新的行为称作Transfer Semantics。
拷贝构造函数(copy construcor)和赋值操作符定义了auto_ptr的Transfer Semantics,它们用了非const的auto_ptr引用作为它们的参数。
auto_ptr (auto_ptr<T> & ptr);
auto_ptr & operator = (auto_ptr<T> & ptr);
这是因为它们确实改变了他们的源--剥夺了对资源的所有权。
通过定义相应的拷贝构造函数和重载赋值操作符,你可以将Transfer Semantics加入到许多对象中。例如,许多Windows中的资源,比如动态建立的菜单或者位图,可以用有Transfer Semantics的类来封装。
7. Strong Vectors
标准库只在auto_ptr中支持资源管理。甚至连最简单的容器也不支持ownership semantics。你可能想将auto_ptr和标准容器组合到一起可能会管用,但是并不是这样的。例如,你可能会这样做,但是会发现你不能够用标准的方法来进行索引。
vector< auto_ptr<Item> > autoVector;
这种建造不会编译成功;
Item * item = autoVector [0];
另一方面,这会导致一个从autoVect到auto_ptr的所有权转换:
auto_ptr<Item> item = autoVector [0];
我们没有选择,只能够构造我们自己的Strong Vector。最小的接口应该如下:
template <class T>
class auto_vector
{
public:
explicit auto_vector (size_t capacity = 0);
T const * operator [] (size_t i) const;
T * operator [] (size_t i);
void assign (size_t i, auto_ptr<T> & p);
void assign_direct (size_t i, T * p);
void push_back (auto_ptr<T> & p);
auto_ptr<T> pop_back ();
};
你也许会发现一个非常防御性的设计态度。我决定不提供一个对vector的左值索引的访问,取而代之,如果你想设定(set)一个值的话,你必须用assign或者assign_direct方法。我的观点是,资源管理不应该被忽视,同时,也不应该在所有的地方滥用。在我的经验里,一个strong vector经常被许多push_back方法充斥着。
Strong vector最好用一个动态的Strong Pointers的数组来实现:
template <class T>
class auto_vector
{
private:
void grow (size_t reqCapacity);
auto_ptr<T> *_arr;
size_t _capacity;
size_t _end;
};
grow方法申请了一个很大的auto_ptr<T>的数组,将所有的东西从老的书组类转移出来,在其中交换,并且删除原来的数组。
auto_vector的其他实现都是十分直接的,因为所有资源管理的复杂度都在auto_ptr中。例如,assign方法简单的利用了重载的赋值操作符来删除原有的对象并转移资源到新的对象:
void assign (size_t i, auto_ptr<T> & p)
{
_arr [i] = p;
}
我已经讨论了push_back和pop_back方法。push_back方法传值返回一个auto_ptr,因为它将所有权从auto_vector转换到auto_ptr中。
对auto_vector的索引访问是借助auto_ptr的get方法来实现的,get简单的返回一个内部指针。
T * operator [] (size_t i)
{
return _arr [i].get ();
}
没有容器可以没有iterator。我们需要一个iterator让auto_vector看起来更像一个普通的指针向量。特别是,当我们废弃iterator的时候,我们需要的是一个指针而不是auto_ptr。我们不希望一个auto_vector的iterator在无意中进行资源转换。
template<class T>
class auto_iterator: public
iterator<random_access_iterator_tag, T *>
{
public:
auto_iterator () : _pp (0) {}
auto_iterator (auto_ptr<T> * pp) : _pp (pp) {}
bool operator != (auto_iterator<T> const & it) const
{
return it._pp != _pp;
}
auto_iterator const & operator++ (int)
{
return _pp++;
}
auto_iterator operator++ ()
{
return ++_pp;
}
T * operator * ()
{
return _pp->get ();
}
private:
auto_ptr<T> * _pp;
};
我们给auto_vect提供了标准的begin和end方法来找回iterator:
class auto_vector
{
public:
typedef auto_iterator<T> iterator;
iterator begin () { return _arr; }
iterator end () { return _arr + _end; }
};
你也许会问我们是否要利用资源管理重新实现每一个标准的容器?幸运的是,不;事实是strong vector解决了大部分所有权的需求。当你把你的对象都安全的放置到一个strong vector中,你可以用所有其它的容器来重新安排(weak)pointer。
设想,例如,你需要对一些动态分配的对象排序的时候。你将它们的指针保存到一个strong vector中。然后你用一个标准的vector来保存从strong vector中获得的weak指针。你可以用标准的算法对这个vector进行排序。这种中介vector叫做permutation vector。相似的,你也可以用标准的maps, priority queues, heaps, hash tables等等。
8. Code Inspection
如果你严格遵照资源管理的条款,你就不会再资源泄露或者两次删除的地方遇到麻烦。你也降低了访问野指针的几率。同样的,遵循原有的规则,用delete删除用new申请的德指针,不要两次删除一个指针。你也不会遇到麻烦。但是,那个是更好的注意呢?
这两个方法有一个很大的不同点。就是和寻找传统方法的bug相比,找到违反资源管理的规定要容易的多。后者仅需要一个代码检测或者一个运行测试,而前者则在代码中隐藏得很深,并需要很深的检查。
设想你要做一段传统的代码的内存泄露检查。第一件事,你要做的就是grep所有在代码中出现的new,你需要找出被分配空间地指针都作了什么。你需要确定导致删除这个指针的所有的执行路径。你需要检查break语句,过程返回,异常。原有的指针可能赋给另一个指针,你对这个指针也要做相同的事。
相比之下,对于一段用资源管理技术实现的代码。你也用grep检查所有的new,但是这次你只需要检查邻近的调用:
● 这是一个直接的Strong Pointer转换,还是我们在一个构造函数的函数体中?
● 调用的返回知是否立即保存到对象中,构造函数中是否有可以产生异常的代码。?
● 如果这样的话析构函数中时候有delete?
下一步,你需要用grep查找所有的release方法,并实施相同的检查。
不同点是需要检查、理解单个执行路径和只需要做一些本地的检验。这难道不是提醒你非结构化的和结构化的程序设计的不同吗?原理上,你可以认为你可以应付goto,并且跟踪所有的可能分支。另一方面,你可以将你的怀疑本地化为一段代码。本地化在两种情况下都是关键所在。
在资源管理中的错误模式也比较容易调试。最常见的bug是试图访问一个释放过的strong pointer。这将导致一个错误,并且很容易跟踪。
9. 共享的所有权
为每一个程序中的资源都找出或者指定一个所有者是一件很容易的事情吗?答案是出乎意料的,是!如果你发现了一些问题,这可能说明你的设计上存在问题。还有另一种情况就是共享所有权是最好的甚至是唯一的选择。
共享的责任分配给被共享的对象和它的客户(client)。一个共享资源必须为它的所有者保持一个引用计数。另一方面,所有者再释放资源的时候必须通报共享对象。最后一个释放资源的需要在最后负责free的工作。
最简单的共享的实现是共享对象继承引用计数的类RefCounted:
class RefCounted
{
public:
RefCounted () : _count (1) {}
int GetRefCount () const { return _count; }
void IncRefCount () { _count++; }
int DecRefCount () { return --_count; }
private:
int _count;
};
按照资源管理,一个引用计数是一种资源。如果你遵守它,你需要释放它。当你意识到这一事实的时候,剩下的就变得简单了。简单的遵循规则:在构造函数中获得引用计数,在析构函数中释放。甚至有一个RefCounted的smart pointer等价物:
template <class T>
class RefPtr
{
public:
RefPtr (T * p) : _p (p) {}
RefPtr (RefPtr<T> & p)
{
_p = p._p;
_p->IncRefCount ();
}
~RefPtr ()
{
if (_p->DecRefCount () == 0)
delete _p;
}
private:
T * _p;
};
注意模板中的T不必成为RefCounted的后代,但是它必须有IncRefCount和DecRefCount的方法。当然,一个便于使用的RefPtr需要有一个重载的指针访问操作符。在RefPtr中加入转换语义学(transfer semantics)是读者的工作。
10. 所有权网络
链表是资源管理分析中的一个很有意思的例子。如果你选择表成为链(link)的所有者的话,你会陷入实现递归的所有权。每一个link都是它的继承者的所有者。下面是用smart pointer实现的一个表单元:
class Link
{
private:
auto_ptr<Link> _next;
};
最好的方法是,将连接控制封装到一个能够进行资源转换的类中。
对于双链表呢?安全的做法是指明一个方向,如forward:
class DoubleLink
{
private:
DoubleLink *_prev;
auto_ptr<DoubleLink> _next;
};
这给我们带来了另外一个有趣的问题--资源管理可以处理环形的所有权吗?它可以,用一个mark-and-sweep的算法。这里是实现这种方法的一个例子:注意不要创建环形链表。
template<class T>
class CyclPtr
{
public:
CyclPtr (T * p):_p (p), _isBeingDeleted (false){}
~CyclPtr ()
{
_isBeingDeleted = true;
if (!_p->IsBeingDeleted ())
delete _p;
}
void Set (T * p)
{
_p = p;
}
bool IsBeingDeleted () const { return _isBeingDeleted; }
private:
T * _p;
bool _isBeingDeleted;
};
注意我们需要用class T来实现方法IsBeingDeleted,就像从CyclPtr继承。对特殊的所有权网络普通化是十分直接的。
将原有代码转换为资源管理代码:
① 首先,在你的工程中建立基本的Strong Pointer。然后通过查找代码中的new来开始封装裸指针。
② 最先封装的是在过程中定义的临时指针。简单的将它们替换为auto_ptr并且删除相应的delete。如果一个指针在过程中没有被删除而是被返回,用auto_ptr替换并在返回前调用release方法。在你做第二次传递的时候,你需要处理对release的调用。注意,即使是在这点,你的代码也可能更加"精力充沛"--你会移出代码中潜在的资源泄漏问题。
③ 下面是指向资源的裸指针。确保它们被独立的封装到auto_ptr中,或者在构造函数中分配在析构函数中释放。如果你有传递所有权的行为的话,需要调用release方法。如果你有容器所有对象,用Strong Pointers重新实现它们。
④ 接下来,找到所有对release的方法调用并且尽力清除所有,如果一个release调用返回一个指针,将它修改传值返回一个auto_ptr。
⑤ 重复这一过程,直到最后所有new和release的调用都在构造函数或者资源转换的时候发生。这样,你在你的代码中处理了资源泄漏的问题。对其他资源进行相似的操作。
⑥ 你会发现资源管理清除了许多错误和异常处理带来的复杂性。不仅仅你的代码会变得精力充沛,它也会变得简单并容易维护。
三、内存泄露及应对方法
1. 复制构造函数
String类中函数的实现:
//String.h class String { private: char *str; int len; public: String(const char *s); String(); String(const String& a); ~String(); friend ostream & operator <<(ostream & os, const String &st); };
//String.cpp String::String(const char *s) { cout << "Call Construction Function." << endl; len = strlen(s); str = new char[len+1]; strcpy(str,s); }
String::String() { len = 0; str = new char[len+1]; str[0] = '\0'; }
String::~String() { cout << "delete the code: " << str << endl; delete[] str; }
ostream& operator<<(ostream & os, const String & st) { os << st.str; return os; } (同上页)
String类中函数的实现:
//String.h class String { private: char *str; int len; public: String(const char *s); String(); String(const String& a); ~String(); friend ostream & operator <<(ostream & os, const String &st); };
String::String(const char *s) { cout << "Call Construction Function." << endl; len = strlen(s); str = new char[len+1]; strcpy(str,s); }
String::String() { len = 0; str = new char[len+1]; str[0] = '\0'; }
String::~String() { cout << "delete the code: " << str << endl; delete[] str; }
ostream& operator<<(ostream & os, const String & st) { os << st.str; return os; }
| 错 误 | 正 确 | 原 因 |
int main(int argc, char *argv[]) { String test1("Example 1");
//复制构造函数 String *string1 = new String(test1); cout << "string1: " << *string1 << endl; delete string1; string1 = NULL;
//错误 cout << "test1:" << test1 << endl; } 结果:
| 添加构造函数:
声明: String(const String& a);
实现: String::String(const String& a) { len=a.len; str=new char[len+1]; strcpy(str,a.str); } | 以上的函数就是调用了赋值构造函数实行的浅复制,它只是复制了指针,而并没有复制指针指向的数据。 错误样例中的string1和test1指向同一个内存位置,当delete string1后,内存被释放,再访问test1时,导致内存泄露。 | |
void show_right(const String &a) { cout << "show_right: " << a << endl; }
void show_String(const String a) { cout << "show_String: " << a << endl; }
int main(int argc, char *argv[]) { String test2("Example 2");
//正确,地址传递 show_right(test2); cout << "right: " << test2 << endl;
//错误,值传递 show_String(test2); cout << "wrong:" << test2 << endl; }
| 添加构造函数:
声明: String(const String& a);
实现: String::String(const String& a) { len=a.len; str=new char[len+1]; strcpy(str,a.str); } | show_right函数是引用传递,是重新开辟内存区域,将数值复制了一份,a与test2指向不同的内存位置,当函数show_right结束后,a的内存被释放,不影响test2的内存,因此没有问题。 show_String函数是按值传递的,所以a与test2指向的内存地址相同。 当调用函数时,相当于执行了这样的代码:String a=test2。函数执行完毕,由于生存周期的缘故,对象a被析构函数删除,内存被释放,导致再访问test2时内存泄露。 | |
int main(int argc, char *argv[]) { String test3("Example 3"); String string3(test3); //浅复制 cout << "string3:" << string3 << endl; }
| 添加构造函数:
声明: String(const String& a);
实现: String::String(const String& a) { len=a.len; str=new char[len+1]; strcpy(str,a.str); } | String string3(test3)中执行的是浅复制,因此string3与test3指向内存的同一个位置,当对象使用结束后,先释放string3(内存栈结构,后进先出),string3指向的内存区域被释放。再次调用析构函数释放test3时,内存被重复释放,导致内存泄露。 | |
int main(int argc, char *argv[]) { String test4("Example 4"); String string4; string4 = test4; cout << "string4: " << string4 << endl; }
| 添加操作符重载函数:
声明: String& operator=(const String& a);
实现: String& String::operator=(const String& a) { //避免A==A这种情况 if(this==&a) return *this;
delete [] str; len=a.len; str=new char[len+1]; strcpy(str,a.str);
//返回自身的引用 return *this; } | 可以直接使用string4 = test4的原因是C++中提供了这样的一个操作符重载函数:operator=。
它同样是执行了浅复制,出了与上一个样例同样的问题。 |
2. C/C++内存泄露及其检测工具
2.1 内存泄露定义
一般我们常说的内存泄漏是指堆内存的泄漏。堆内存是指程序从堆中分配的,大小任意的(内存块的大小可以在程序运行期决定),使用完后必须显示释放的内存。应用程序一般使用malloc,realloc,new等函数从堆中分配到一块内存,使用完后,程序必须负责相应的调用free或delete释放该内存块,否则,这块内存就不能被再次使用,我们就说这块内存泄漏了。
广义的说,内存泄漏不仅仅包含堆内存的泄漏,还包含系统资源的泄漏(resource leak),比如核心态HANDLE,GDI Object,SOCKET, Interface等,从根本上说这些由操作系统分配的对象也消耗内存,如果这些对象发生泄漏最终也会导致内存的泄漏。而且,某些对象消耗的是核心态内存,这些对象严重泄漏时会导致整个操作系统不稳定。所以相比之下,系统资源的泄漏比堆内存的泄漏更为严重。
2.2 内存泄露的发生方式
以发生的方式来分类,内存泄漏可以分为4类:
(1)常发性内存泄漏。发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄漏。
(2)偶发性内存泄漏。发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。
(3)一次性内存泄漏。发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。比如,在类的构造函数中分配内存,在析构函数中却没有释放该内存,但是因为这个类是一个Singleton,所以内存泄漏只会发生一次。
(4)隐式内存泄漏。程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。所以,我们称这类内存泄漏为隐式内存泄漏。举例如下:
class Connection
{
public:
Connection( SOCKET s);
~Connection();
private:
SOCKET _socket;
};
class ConnectionManager
{
public:
ConnectionManager(){}
~ConnectionManager()
{
list::iterator it;
for( it = _connlist.begin(); it != _connlist.end(); ++it )
{
delete (*it);
}
_connlist.clear();
}
void OnClientConnected( SOCKET s )
{
Connection* p = new Connection(s);
_connlist.push_back(p);
}
void OnClientDisconnected( Connection* pconn )
{
_connlist.remove( pconn );
delete pconn;
}
private:
list _connlist;
};
假设在Client从Server端断开后,Server并没有呼叫OnClientDisconnected()函数,那么代表那次连接的Connection对象就不会被及时的删除(在Server程序退出的时候,所有Connection对象会在ConnectionManager的析构函数里被删除)。当不断的有连接建立、断开时隐式内存泄漏就发生了。 |
2.3 检测内存泄漏
基本原理:检测内存泄漏的关键是要能截获住对分配内存和释放内存的函数的调用。截获住这两个函数,就能跟踪每一块内存的生命周期。
比如,每当成功的分配一块内存后,就把它的指针加入一个全局的list中;每当释放一块内存,再把它的指针从list中删除。这样,当程序结束的时候,list中剩余的指针就是指向那些没有被释放的内存。
3. C/C++内存回收
3.1 基本概念
3.1.1 栈
- 栈,一般用于存放局部变量或对象,如我们在函数定义中用类似“Type stack_object ;”语句声明的对象,便是栈对象。它的生命期是从定义点开始,当所在函数返回时,生命结束。
- 几乎所有的临时对象都是栈对象。例如:Type fun(Type object); 这个函数至少产生两个临时对象,首先,参数是按值传递的,所以会调用拷贝构造函数生成一个临时对象object_copy1 ,在函数内部使用的不是使用的不是object,而是object_copy1,自然,object_copy1是一个栈对象,它在函数返回时被释放;还有这个函数是值返回的,在函数返回时,如果我们不考虑返回值优化(NRV),那么也会产生一个临时对象object_copy2,这个临时对象会在函数返回后一段时间内被释放。
- 生成这些临时对象的时间和空间的开销可能是很大的,所以,对于“大”对象最好用const引用传递代替按值进行函数参数传递了。
3.1.2 堆
堆,又叫自由存储区,它是在程序执行的过程中动态分配的,所以它最大的特性就是动态性。在C++中,所有堆对象的创建和销毁都要由程序员负责,所以,如果处理不好,就会发生内存问题。如果分配了堆对象,却忘记了释放,就会产生内存泄漏;而如果已释放了对象,却没有将相应的指针置为NULL,该指针就是所谓的“悬挂指针”,再度使用此指针时,就会出现非法访问,严重时就导致程序崩溃。
C++中是怎样分配堆对象的?唯一的方法就是用new(当然,用类malloc指令也可获得C式堆内存),只要使用new,就会在堆中分配一块内存,并且返回指向该堆对象的指针。
3.1.3 静态存储区
所有的静态对象、全局对象都于静态存储区分配。关于全局对象,是在main()函数执行前就分配好了的。其实,在main()函数中的显示代码执行之前,会调用一个由编译器生成的_main()函数,而_main()函数会进行所有全局对象的的构造及初始化工作。而在main()函数结束之前,会调用由编译器生成的exit函数,来释放所有的全局对象。比如下面的代码:
void main(void)
{
… …// 显式代码
}
会被转化为下面的代码:
void main(void)
{
_main();
//隐式代码,由编译器产生,用以构造所有全局对象
… … // 显式代码
exit();
// 隐式代码,由编译器产生,用以释放所有全局对象
}
由此引出一些技巧,如,假设要在main()函数执行之前做某些准备工作,那么可以将这些准备工作写到一个自定义的全局对象的构造函数中,这样,在main()函数的显式代码执行之前,这个全局对象的构造函数会被调用,执行预期的动作,这样就达到了目的。
刚才讲的是静态存储区中的全局对象,那么,局部静态对象怎么办?局部静态对象通常也是在函数中定义的,就像栈对象一样,只不过,其前面多了个static关键字。局部静态对象的生命期是从其所在函数第一次被调用,更确切地说,是当第一次执行到该静态对象的声明代码时,产生该静态局部对象,直到整个程序结束时,才销毁该对象。
还有一种静态对象,那就是它作为class的静态成员。
第一个问题是class的静态成员对象的生命期,class的静态成员对象随着第一个class object的产生而产生,在整个程序结束时消亡。也就是有这样的情况存在,在程序中我们定义了一个class,该类中有一个静态对象作为成员,但是在程序执行过程中,如果我们没有创建任何一个该class object,那么也就不会产生该class所包含的那个静态对象。还有,如果创建了多个class object,那么所有这些object都共享那个静态对象成员。
第二个问题是,当出现下列情况时:
class Base
{
public:
static Type s_object ;
}
class Derived1 : public Base / / 公共继承
{
… …// other data
}
class Derived2 : public Base / / 公共继承
{
… …// other data
}
Base example ;
Derivde1 example1 ;
Derivde2 example2 ;
//以下3行代码访问的s_object是同一个对象
example.s_object = …… ;
example1.s_object = …… ;
example2.s_object = …… ;
当一个类比如Derived1,从另一个类比如Base继承时,那么,可以看作一个Derived1对象中含有一个Base型的对象,这就是一个subobject。将一个Derived1型的对象传给一个接受非引用Base型参数的函数时会发生切割,那么是怎么切割的呢?那就是仅仅取出了Derived1型的对象中的subobject,而忽略了所有Derived1自定义的其它数据成员,然后将这个subobject传递给函数(实际上,函数中使用的是这个subobject的拷贝)。所有继承Base类的派生类的对象都含有一个Base型的subobject(这是能用Base型指针指向一个Derived1对象的关键所在,自然也是多态的关键了),而所有的subobject和所有Base型的对象都共用同一个s_object对象,自然,从Base类派生的整个继承体系中的类的实例都会共用同一个s_object对象了。
3.2 三种内存对象的比较
栈对象的优势是在适当的时候自动生成,又在适当的时候自动销毁,不需要程序员操心;而且栈对象的创建速度一般较堆对象快,因为分配堆对象时,会调用operator new操作,operator new会采用某种内存空间搜索算法,而该搜索过程可能是很费时间的,产生栈对象则没有这么麻烦,它仅仅需要移动栈顶指针就可以了。但是要注意的是,通常栈空间容量比较小,一般是1MB~2MB,所以体积比较大的对象不适合在栈中分配。特别要注意递归函数中最好不要使用栈对象,因为随着递归调用深度的增加,所需的栈空间也会线性增加,当所需栈空间不够时,便会导致栈溢出,这样就会产生运行时错误。
堆对象,其产生时刻和销毁时刻都要程序员精确定义,也就是说,程序员对堆对象的生命具有完全的控制权。我们常常需要这样的对象,比如,我们需要创建一个对象,能够被多个函数所访问,但是又不想使其成为全局的,那么这个时候创建一个堆对象无疑是良好的选择,然后在各个函数之间传递这个堆对象的指针,便可以实现对该对象的共享。另外,相比于栈空间,堆的容量要大得多。实际上,当物理内存不够时,如果这时还需要生成新的堆对象,通常不会产生运行时错误,而是系统会使用虚拟内存来扩展实际的物理内存。
static对象:
全局对象。全局对象为类间通信和函数间通信提供了一种最简单的方式,虽然这种方式并不优雅。一般而言,在完全的面向对象语言中,是不存在全局对象的,比如C#,因为全局对象意味着不安全和高耦合,在程序中过多地使用全局对象将大大降低程序的健壮性、稳定性、可维护性和可复用性。C++也完全可以剔除全局对象,但是最终没有,也许原因之一是为了兼容C。
类的静态成员,上面已经提到,基类及其派生类的所有对象都共享这个静态成员对象,所以当需要在这些class之间或这些class objects之间进行数据共享或通信时,这样的静态成员无疑是很好的选择。
静态局部对象,主要可用于保存该对象所在函数被屡次调用期间的中间状态,其中一个最显著的例子就是递归函数,我们都知道递归函数是自己调用自己的函数,如果在递归函数中定义一个nonstatic局部对象,那么当递归次数相当大时,所产生的开销也是巨大的。这是因为nonstatic局部对象是栈对象,每递归调用一次,就会产生一个这样的对象,每返回一次,就会释放这个对象,而且,这样的对象只局限于当前调用层,对于更深入的嵌套层和更浅露的外层,都是不可见的。每个层都有自己的局部对象和参数。因此,在递归函数设计中,可以使用static对象替代nonstatic局部对象(即栈对象),这不仅可以减少每次递归调用和返回时产生和释放nonstatic对象的开销,而且static对象还可以保存递归调用的中间状态,并且可为各个调用层所访问。
3.2 使用栈对象的意外收获
栈对象是在适当的时候创建,然后在适当的时候自动释放的,也就是栈对象有自动管理功能。那么栈对象会在什么会自动释放了?第一,在其生命期结束的时候;第二,在其所在的函数发生异常的时候。
栈对象,自动释放时,会调用它自己的析构函数。如果我们在栈对象中封装资源,而且在栈对象的析构函数中执行释放资源的动作,那么就会使资源泄漏的概率大大降低,因为栈对象可以自动的释放资源,即使在所在函数发生异常的时候。实际的过程是这样的:函数抛出异常时,会发生所谓的stack_unwinding(堆栈回滚),即堆栈会展开,由于是栈对象,自然存在于栈中,所以在堆栈回滚的过程中,栈对象的析构函数会被执行,从而释放其所封装的资源。除非,除非在析构函数执行的过程中再次抛出异常――而这种可能性是很小的,所以用栈对象封装资源是比较安全的。基于此认识,我们就可以创建一个自己的句柄或代理来封装资源了。智能指针(auto_ptr)中就使用了这种技术。在有这种需要的时候,我们就希望我们的资源封装类只能在栈中创建,也就是要限制在堆中创建该资源封装类的实例。
3.3 禁止产生堆对象
上面已经提到,决定禁止产生某种类型的堆对象,可以创建一个资源封装类,该类对象只能在栈中产生,这样就能在异常的情况下自动释放封装的资源。
那么怎样禁止产生堆对象?产生堆对象的唯一方法是使用new操作,如果禁止使用new不就行了么。再进一步,new操作执行时会调用operator new,而operator new是可以重载的。方法有了,就是使new operator 为private,为了对称,最好将operator delete也重载为private。现在,也许又有疑问了,难道创建栈对象不需要调用new吗?是的,不需要,因为创建栈对象不需要搜索内存,而是直接调整堆栈指针,将对象压栈,而operator new的主要任务是搜索合适的堆内存,为堆对象分配空间,这在上面已经提到过了。看看下面的示例代码:
#include <stdlib.h> //需要用到C式内存分配函数
class Resource ; //代表需要被封装的资源类
class NoHashObject
{
private:
Resource* ptr ;//指向被封装的资源
void* operator new(size_t size) //非严格实现,仅作示意之用
{
return malloc(size) ;
}
void operator delete(void* pp) //非严格实现,仅作示意之用
{
free(pp) ;
}
public:
NoHashObject()
{
//此处可以获得需要封装的资源,并让ptr指针指向该资源
ptr = new Resource() ;
}
~NoHashObject()
{
delete ptr ; //释放封装的资源
}
};
NoHashObject现在就是一个禁止堆对象的类了,如果你写下如下代码:
NoHashObject* fp = new NoHashObject() ; //编译期错误!
delete fp ;
难道在类NoHashObject的定义不能改变的情况下,就一定不能产生该类型的堆对象了吗?不,还是有办法的,用到的是技巧是指针类型的强制转换。
void main(void)
{
char* temp = new char[sizeof(NoHashObject)] ;
//强制类型转换,现在ptr是一个指向NoHashObject对象的指针
NoHashObject* obj_ptr = (NoHashObject*)temp ;
temp = NULL ; //防止通过temp指针修改NoHashObject对象
//再一次强制类型转换,让rp指针指向堆中NoHashObject对象的ptr成员
Resource* rp = (Resource*)obj_ptr ;
//初始化obj_ptr指向的NoHashObject对象的ptr成员
rp = new Resource() ;
//现在可以通过使用obj_ptr指针使用堆中的NoHashObject对象成员了
delete rp ;//释放资源
temp = (char*)obj_ptr;
obj_ptr = NULL ;//防止悬挂指针产生
delete [] temp ;//释放NoHashObject对象所占的堆空间。
}
可以这样理解:某块内存中的数据是不变的,而类型就是我们戴上的眼镜,当我们戴上一种眼镜后,我们就会用对应的类型来解释内存中的数据,这样不同的解释就得到了不同的信息。所谓强制类型转换实际上就是换上另一副眼镜后再来看同样的那块内存数据。
另外要提醒的是,不同的编译器对对象的成员数据的布局安排可能是不一样的,比如,大多数编译器将NoHashObject的ptr指针成员安排在对象空间的头4个字节,这样才会保证Resource* rp = (Resource*)obj_ptr ;这条语句的转换动作像我们预期的那样执行,但是,并不一定所有的编译器都是如此。
3.4 禁止产生栈对象
前面已经提到了,创建栈对象时会移动栈顶指针以“挪出”适当大小的空间,然后在这个空间上直接调用对应的构造函数以形成一个栈对象,而当函数返回时,会调用其析构函数释放这个对象,然后再调整栈顶指针收回那块栈内存。在这个过程中是不需要operator new/delete操作的,所以将operator new/delete设置为private不能达到目的。当然从上面的叙述中,也许可以想到:将构造函数或析构函数设为私有的,这样系统就不能调用构造/析构函数了,当然就不能在栈中生成对象了。
这样的确可以,而且我也打算采用这种方案。但是在此之前,有一点需要考虑清楚,那就是,如果我们将构造函数设置为私有,那么我们也就不能用new来直接产生堆对象了,因为new在为对象分配空间后也会调用它的构造函数啊。所以,我打算只将析构函数设置为private。再进一步,将析构函数设为private除了会限制栈对象生成外,还有其它影响吗?是的,这还会限制继承。
如果一个类不打算作为基类,通常采用的方案就是将其析构函数声明为private。
为了限制栈对象,却不限制继承,我们可以将析构函数声明为protected,这样就两全其美了。如下代码所示:
class NoStackObject
{
protected:
~NoStackObject() { }
public:
void destroy()
{
delete this ;//调用保护析构函数
}
};
接着,可以像这样使用NoStackObject类:
NoStackObject* hash_ptr = new NoStackObject() ;
... ... //对hash_ptr指向的对象进行操作
hash_ptr->destroy() ;
是不是觉得有点怪怪的,我们用new创建一个对象,却不是用delete去删除它,而是要用destroy方法。很显然,用户是不习惯这种怪异的使用方式的。所以,我决定将构造函数也设为private或protected。这又回到了上面曾试图避免的问题,即不用new,那么该用什么方式来生成一个对象了?我们可以用间接的办法完成,即让这个类提供一个static成员函数专门用于产生该类型的堆对象。(设计模式中的singleton模式就可以用这种方式实现。)让我们来看看:
class NoStackObject
{
protected:
NoStackObject() { }
~NoStackObject() { }
public:
static NoStackObject* creatInstance()
{
return new NoStackObject() ;//调用保护的构造函数
}
void destroy()
{
delete this ;//调用保护的析构函数
}
};
现在可以这样使用NoStackObject类了:
NoStackObject* hash_ptr = NoStackObject::creatInstance() ;
... ... //对hash_ptr指向的对象进行操作
hash_ptr->destroy() ;
hash_ptr = NULL ; //防止使用悬挂指针
4. 浅议C++中的垃圾回收方法
许多 C 或者 C++ 程序员对垃圾回收嗤之以鼻,认为垃圾回收肯定比自己来管理动态内存要低效,而且在回收的时候一定会让程序停顿在那里,而如果自己控制内存管理的话,分配和释放时间都是稳定的,不会导致程序停顿。最后,很多 C/C++ 程序员坚信在C/C++ 中无法实现垃圾回收机制。这些错误的观点都是由于不了解垃圾回收的算法而臆想出来的。4. 浅议C++中的垃圾回收方法
其实垃圾回收机制并不慢,甚至比动态内存分配更高效。因为我们可以只分配不释放,那么分配内存的时候只需要从堆上一直的获得新的内存,移动堆顶的指针就够了;而释放的过程被省略了,自然也加快了速度。现代的垃圾回收算法已经发展了很多,增量收集算法已经可以让垃圾回收过程分段进行,避免打断程序的运行了。而传统的动态内存管理的算法同样有在适当的时间收集内存碎片的工作要做,并不比垃圾回收更有优势。
而垃圾回收的算法的基础通常基于扫描并标记当前可能被使用的所有内存块,从已经被分配的所有内存中把未标记的内存回收来做的。C/C++ 中无法实现垃圾回收的观点通常基于无法正确扫描出所有可能还会被使用的内存块,但是,看似不可能的事情实际上实现起来却并不复杂。首先,通过扫描内存的数据,指向堆上动态分配出来内存的指针是很容易被识别出来的,如果有识别错误,也只能是把一些不是指针的数据当成指针,而不会把指针当成非指针数据。这样,回收垃圾的过程只会漏回收掉而不会错误的把不应该回收的内存清理。其次,如果回溯所有内存块被引用的根,只可能存在于全局变量和当前的栈内,而全局变量(包括函数内的静态变量)都是集中存在于 bss 段或 data段中。
垃圾回收的时候,只需要扫描 bss 段, data 段以及当前被使用着的栈空间,找到可能是动态内存指针的量,把引用到的内存递归扫描就可以得到当前正在使用的所有动态内存了。
如果肯为你的工程实现一个不错的垃圾回收器,提高内存管理的速度,甚至减少总的内存消耗都是可能的。如果有兴趣的话,可以搜索一下网上已有的关于垃圾回收的论文和实现了的库。