智能指针与内存
1.智能指针
1.1 概述
我们知道除了静态内存和栈内存外,每个程序还有一个内存池,这部分内存被称为自由空间或者堆。程序用堆来存储动态分配的对象即那些在程序运行时分配的对象,当动态对象不再使用时,我们的代码必须显式的销毁它们。
在C++中,动态内存的管理是用一对运算符完成的:new和delete。 new:在动态内存中为对象分配一块空间并返回一个指向该对象的指针; delete:指向一个动态独享的指针,销毁对象,并释放与之关联的内存。
动态内存管理经常会出现两种问题: (1)一种是忘记释放内存,会造成内存泄漏; (2)一种是尚有指针引用内存的情况下就释放了它,就会产生引用非法内存的指针。
为了更加容易(更加安全)的使用动态内存,引入了智能指针的概念。智能指针的行为类似常规指针,重要的区别是它负责自动释放所指向的对象。
标准库提供的两种智能指针的区别在于管理底层指针的方法不同:shared_ptr 和 unique_ptr。 (1)shared_ptr允许多个指针指向同一个对象; (2)unique_ptr则“独占”所指向的对象。
标准库还定义了一种名为weak_ptr的伴随类,它是一种弱引用,指向shared_ptr所管理的对象,这三种智能指针都定义在memory头文件中。
1.2 shared_ptr(一种强引用指针)
多个shared_ptr指向同一处资源,当所有shared_ptr都全部释放时,该处资源才释放。 (有某个对象的所有权(访问权,生命控制权) 即是 强引用,所以shared_ptr是一种强引用型指针)
(1)内部大概实现:每次复制,多一个共享同处资源的shared_ptr时,计数+1。每次释放shared_ptr时,计数-1。 当shared计数为0时,则证明所有指向同一处资源的shared_ptr们全都释放了,则随即释放该资源(哦,还会释放new出来的SharedPtrControlBlock)。
//shared计数放在这个结构体里面,实际上结构体里还应该有另一个weak计数。下文介绍weak_ptr时会解释。
struct SharedPtrControlBlock
{
int shared_count;
};
//大概长这个样子(简化版)
template<class T>
class shared_ptr
{
T* ptr;
SharedPtrControlBlock* count;
};
(2)示例用法:
void runGame()
{
std::shared_ptr<Monster> monster1(new Monster()); //计数加到1
do{
std::shared_ptr<Monster> monster2 = monster1; //计数加到2
}while(0);
//该栈退出后,计数减为1,monster1指向的堆对象仍存在
std::shared_ptr<Monster> monster3 = monster1; //计数加到2
}//该栈退出后,shared_ptr都释放了,计数减为0,它们指向的堆对象也能跟着释放.
缺陷:模型循环依赖(互相引用或环引用)时,计数会不正常
假如有这么一个怪物模型,它有2个亲人关系:
class Monster
{
std::shared_ptr<Monster> m_father;
std::shared_ptr<Monster> m_son;
public:
void setFather(std::shared_ptr<Monster>& father);
void setSon(std::shared_ptr<Monster>& son);
~Monster(){std::cout << "A monster die!";}
};
void runGame()
{
std::shared_ptr<Monster> father = new Monster();
std::shared_ptr<Monster> son = new Monster();
father->setSon(son);
son->setFather(father);
}
函数退出时栈的shared_ptr对象陆续释放后的情形: (1)一开始:father,son指向的堆对象 shared计数都是为2; (2)son智能指针退出栈:son指向的堆对象 计数减为1,father指向的堆对象 计数仍为2。 (3)father智能指针退出栈:father指向的堆对象 计数减为1 , son指向的堆对象 计数仍为1。 (4)函数结束:所有计数都没有变0,也就是说中途没有释放任何堆对象。
为了解决这一缺陷的存在,弱引用指针weak_ptr的出现很有必要。
创建智能指针时必须提供额外的信息,指针可以指向的类型:
shared_ptr<string>p1;
shared_ptr<list<int>> p2;
默认初始化的智能指针中保存着一个空指针。 智能指针的使用方式和普通指针类似,解引用一个智能指针返回它指向的对象,在一个条件判断中使用智能指针就是检测它是不是空。
if(p1 && p1->empty())
*p1 = "hi";
3.函数说明 make_share 最安全的分配和使用动态内存的方法就是调用一个名为make_shared的标准库函数,此函数在动态内存中分配一个对象并初始化它,返回指向此对象的shared_ptr。头文件和share_ptr相同,在memory中 。
必须指定想要创建对象的类型,定义格式见下面例子:
shared_ptr<int> p3 = make_shared<int>(42);
shared_ptr<string> p4 = make_shared<string>(10,'9');
shared_ptr<int> p5 = make_shared<int>();
make_shared用其参数来构造给定类型的对象,如果我们不传递任何参数,对象就会进行值初始化。
make_shared用其参数来构造给定类型的对象,如果我们不传递任何参数,对象就会进行值初始化。
1)shared_ptr的拷贝和赋值 当进行拷贝和赋值时,每个shared_ptr都会记录1有多少个其他shared_ptr都会记录有多少个其他shared_ptr指向相同的对象。
auto p = make_shared<int>(42);
auto q(p);
我们可以认为每个shared_ptr都有一个关联的计数器,通常称其为引用计数,无论何时我们拷贝一个shared_ptr,计数器都会递增。当我们给shared_ptr赋予一个新值或是shared_ptr被销毁(例如一个局部的shared_ptr离开其作用域)时,计数器就会递减,一旦一个shared_ptr的计数器变为0,它就会自动释放自己所管理的对象。
auto r = make_shared<int>(42); //r指向的int只有一个引用者
r=q; //给r赋值,令它指向另一个地址
//递增q指向的对象的引用计数
//递减r原来指向的对象的引用计数
//r原来指向的对象已没有引用者,会自动释放
2)shared_ptr自动销毁所管理的对象 当指向一个对象的最后一个shared_ptr被销毁时,shared_ptr类会自动销毁此对象,它是通过另一个特殊的成员函数-析构函数完成销毁工作的,类似于构造函数,每个类都有一个析构函数。
析构函数控制对象销毁时做什么操作。析构函数一般用来释放对象所分配的资源。shared_ptr的析构函数会递减它所指向的对象的引用计数。如果引用计数变为0,shared_ptr的析构函数就会销毁对象,并释放它所占用的内存。
3)shared_ptr还会自动释放相关联的内存 当动态对象不再被使用时,shared_ptr类还会自动释放动态对象,这一特性使得动态内存的使用变得非常容易。如果你将shared_ptr存放于一个容器中,而后不再需要全部元素,而只使用其中一部分,要记得用erase删除不再需要的那些元素。
使用了动态生存期的资源的类,程序使用动态内存的原因:
程序不知道自己需要使用多少对象 程序不知道所需对象的准确类型 程序需要在多个对象间共享数据
4)不要混合使用普通指针和智能指针 如果混合使用的话,智能指针自动释放之后,普通指针有时就会变成悬空指针,当将一个shared_ptr绑定到一个普通指针时,我们就将内存的管理责任交给了这个shared_ptr。一旦这样做了,我们就不应该再使用内置指针来访问shared_ptr所指向的内存了。
也不要使用get初始化另一个智能指针或为智能指针赋值。
shared_ptr<int> p(new int(42));//引用计数为1
int *q = p.get();//正确:但使用q时要注意,不要让它管理的指针被释放
{
//新程序块
//未定义:两个独立的share_ptr指向相同的内存
shared_ptr(q);
}//程序块结束,q被销毁,它指向的内存被释放
int foo = *p;//未定义,p指向的内存已经被释放了
5)其他shared_ptr操作 可以使用reset来将一个新的指针赋予一个shared_ptr:
p = new int(1024);//错误:不能将一个指针赋予shared_ptr
p.reset(new int(1024));//正确。p指向一个新对象
与赋值类似,reset会更新引用计数,如果需要的话,会释放p的对象。reset成员经常和unique一起使用,来控制多个shared_ptr共享的对象。在改变底层对象之前,我们检查自己是否是当前对象仅有的用户。如果不是,在改变之前要制作一份新的拷贝:
f(!p.unique())
p.reset(new string(*p));//我们不是唯一用户,分配新的拷贝
*p+=newVal;//现在我们知道自己是唯一的用户,可以改变对象的值
6)智能指针和异常 如果使用智能指针,即使程序块过早结束,智能指针也能确保在内存不再需要时将其释放,sp是一个shared_ptr,因此sp销毁时会检测引用计数,当发生异常时,我们直接管理的内存是不会自动释放的。如果使用内置指针管理内存,且在new之后在对应的delete之前发生了异常,则内存不会被释放。
使用我们自己的释放操作: 默认情况下,shared_ptr假定他们指向的是动态内存,因此当一个shared_ptr被销毁时,会自动执行delete操作,为了用shared_ptr来管理一个connection,我们必须首先必须定义一个函数来代替delete。这个删除器函数必须能够完成对shared_ptr中保存的指针进行释放的操作。
7)智能指针陷阱:
不使用相同的内置指针值初始化(或reset)多个智能指针。 不delete get()返回的指针 不使用get()初始化或reset另一个智能指针 如果你使用get()返回的指针,记住当最后一个对应的智能指针销毁后,你的指针就变为无效了 如果你使用智能指针管理的资源不是new分配的内存,记住传递给它一个删除器。
1.3 weak_ptr(一种弱引用指针)
weak_ptr是为了辅助shared_ptr的存在,它只提供了对管理对象的一个访问手段,同时也可以实时动态地知道指向的对象是否存活。
(只有某个对象的访问权,而没有它的生命控制权 即是 弱引用,所以weak_ptr是一种弱引用型指针)
(1)内部大概实现
计数区域(SharedPtrControlBlock)结构体引进新的int变量weak_count,来作为弱引用计数。 每个weak_ptr都占指针的两倍空间,一个装着原始指针,一个装着计数区域的指针(和shared_ptr一样的成员)。 weak_ptr可以由一个shared_ptr或者另一个weak_ptr构造。 weak_ptr的构造和析构不会引起shared_count的增加或减少,只会引起weak_count的增加或减少。 被管理资源的释放只取决于shared计数,当shared计数为0,才会释放被管理资源,也就是说weak_ptr不控制资源的生命周期。 但是计数区域的释放却取决于shared计数和weak计数,当两者均为0时,才会释放计数区域。
//shared引用计数和weak引用计数
//之前的计数区域实际最终应该长这个样子
struct SharedPtrControlBlock
{
int shared_count;
int weak_count;
};
//大概长这个样子(简化版)
template<class T>
class weak_ptr
{
T* ptr;
SharedPtrControlBlock* count;
};
(2)针对空悬指针问题 空悬指针问题是指:无法知道指针指向的堆内存是否已经释放。
得益于引入的weak_count,weak_ptr指针可以使计数区域的生命周期受weak_ptr控制,
从而能使weak_ptr获取 被管理资源的shared计数,从而判断被管理对象是否已被释放。(可以实时动态地知道指向的对象是否被释放,从而有效解决空悬指针问题)
它的成员函数expired()就是判断指向的对象是否存活。
(3)针对循环引用问题
class Monster{
//尽管父子可以互相访问,但是彼此都是独立的个体,无论是谁都不应该拥有另一个人的所有权。
std::weak_ptr<Monster> m_father; //所以都把shared_ptr换成了weak_ptr
std::weak_ptr<Monster> m_son; //同上
public:
void setFather(std::shared_ptr<Monster>& father); //实现细节懒得写了
void setSon(std::shared_ptr<Monster>& son); //懒
~Monster(){std::cout << "A monster die!";} //析构时发出死亡的悲鸣
};
然后执行下面的函数:
void runGame()
{
std::shared_ptr<Monster> father(new Monster());
std::shared_ptr<Monster> son(new Monster());
father->setSon(son);
son->setFather(father);
}
那么我们再来模拟一遍,函数退出时栈的shared_ptr对象陆续释放后的情形:
一开始:father指向的堆对象 shared计数为1,weak计数为1;son指向的堆对象 shared计数为1,weak计数为1; son智能指针退出栈:son指向的堆对象 shared计数减为0,weak计数为1,释放son的堆对象;father指向的堆对象 shared计数为1,weak计数减为0; father智能指针退出栈:father指向的堆对象 shared计数减为0,weak计数为0;释放father的堆对象和father的计数区域;son指向的堆对象 shared计数为0,weak计数减为0;释放son的计数区域。 函数结束,释放行为正确。 (可以说,当生命控制权没有彼此互相掌握时,才能正确解决循环引用问题,而弱引用的使用可以使生命控制权互相掌握的情况消失)
此外: weak_ptr没有重载 * 和 - > ,所以并不能直接使用资源。但可以使用lock()获得一个可用的shared_ptr对象,如果对象已经死了,lock()会失败,返回一个空的shared_ptr。
void runGame()
{
std::shared_ptr<Monster> monster1(new Monster());
std::weak_ptr<Monster> r_monster1 = monster1;
r_monster1->doSomething();//Error! 编译器出错!weak_ptr没有重载* 和 -> ,无法直接当指针用 std::shared_ptr<Monster> s_monster1 = r_monster1.lock();//OK!可以通过weak_ptr的lock方法获得shared_ptr。
}
1.4 unique_ptr(一种强引用)
正如它的名字,独占 是它最大的特点。
(1)内部大概实现 它其实算是auto_ptr的翻版(都是独占资源的指针,内部实现也基本差不多).
但是unique_ptr的名字能更好的体现它的语义,而且在语法上比auto_ptr更安全(尝试复制unique_ptr时会编译期出错,而auto_ptr能通过编译期从而在运行期埋下出错的隐患)
假如你真的需要转移所有权(独占权),那么你就需要用std::move(std::unique_ptr对象)语法,尽管转移所有权后 还是有可能出现原有指针调用(调用就崩溃)的情况。 但是这个语法能强调你是在转移所有权,让你清晰的知道自己在做什么,从而不乱调用原有指针。
(2)示例用法
void runGame()
{
std::unique_ptr<Monster> monster1(new Monster());//monster1 指向 一个怪物
std::unique_ptr<Monster> monster2 = monster1;//Error!编译期出错,不允许复制指针指向同一个资源。 std::unique_ptr<Monster> monster3 = std::move(monster1);//转移所有权给monster3.
monster1->doSomething();//Oops!monster1指向nullptr,运行期崩溃
}
(额外:boost库的boost::scoped_ptr也是一个独占性智能指针,但是它不允许转移所有权,从始而终都只对一个资源负责,它更安全谨慎,但是应用的范围也更狭窄。)
虽然我们不能拷贝或者赋值unique_ptr,但是可以通过调用release或reset将指针所有权从一个(非const)unique_ptr转移给另一个unique。
//将所有权从p1(指向string Stegosaurus)转移给p2
unique_ptr<string> p2(p1.release());//release将p1置为空
unique_ptr<string>p3(new string("Trex"));
//将所有权从p3转移到p2
p2.reset(p3.release());//reset释放了p2原来指向的内存
release成员返回unique_ptr当前保存的指针并将其置为空。因此,p2被初始化为p1原来保存的指针,而p1被置为空。
reset成员接受一个可选的指针参数,令unique_ptr重新指向给定的指针。
调用release会切断unique_ptr和它原来管理的的对象间的联系。release返回的指针通常被用来初始化另一个智能指针或给另一个智能指针赋值。
不能拷贝unique_ptr有一个例外:我们可以拷贝或赋值一个将要被销毁的unique_ptr。 最常见的例子是从函数返回一个unique_ptr:
unique_ptr<int> clone(int p)
{
//正确:从int*创建一个
unique_ptr<int> return unique_ptr<int>(new int(p));
}
还可以返回一个局部对象的拷贝:
unique_ptr<int> clone(int p)
{
unique_ptr<int> ret(new int(p));
return ret;
}
向后兼容:auto_ptr 标准库的较早版本包含了一个名为auto_ptr的类,它具有uniqued_ptr的部分特性,但不是全部。
用unique_ptr传递删除器 unique_ptr默认使用delete释放它指向的对象,我们可以重载一个unique_ptr中默认的删除器
我们必须在尖括号中unique_ptr指向类型之后提供删除器类型。在创建或reset一个这种unique_ptr类型的对象时,必须提供一个指定类型的可调用对象删除器。
6、小结 1)不要使用std::auto_ptr 2)当你需要一个独占资源所有权(访问权+生命控制权)的指针,且不允许任何外界访问,请使用std::unique_ptr 3)当你需要一个共享资源所有权(访问权+生命控制权)的指针,请使用std::shared_ptr 4)当你需要一个能访问资源,但不控制其生命周期的指针,请使用std::weak_ptr
推荐用法:一个shared_ptr和n个weak_ptr搭配使用 而不是n个shared_ptr。 逻辑上,大部分模型的生命在直观上总是受某一样东西直接控制而不是多样东西共同控制。 程序上,能够完全避免生命周期互相控制引发的 循环引用问题。
2.C++内存管理
2.1 简述
内存管理是C++最令人切齿痛恨的问题,也是C++最有争议的问题,C++高手从中获得了更好的性能,更大的自由,C++菜鸟的收获则是一遍一遍的检查代码和对C++的痛恨,但内存管理在C++中无处不在,内存泄漏几乎在每个C++程序中都会发生,因此要想成为C++高手,内存管理一关是必须要过的,除非放弃C++,转到Java或者.NET,他们的内存管理基本是自动的,当然你也放弃了自由和对内存的支配权,还放弃了C++超绝的性能。本期专题将从内存管理、内存泄漏、内存回收这三个方面来探讨C++内存管理问题。
2.2 C++内存管理详解
(1)分配方式简介 在C++中,内存分成5个区,分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。
1.栈,在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。 2.堆,就是那些由new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。 3.自由存储区,就是那些由malloc等分配的内存块,他和堆是十分相似的,不过它是用free来结束自己的生命的。 4.全局/静态存储区,全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,他们共同占用同一块内存区。 5.常量存储区,这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改。
(2)明确区分堆与栈 堆与栈的区分问题,似乎是一个永恒的话题,由此可见,初学者对此往往是混淆不清的,所以我决定拿他第一个开刀。首先,我们举一个例子:
void f() {
int* p=new int[5];
}
这条短短的一句话就包含了堆与栈,看到new,我们首先就应该想到,我们分配了一块堆内存,那么指针p呢?他分配的是一块栈内存,所以这句话的意思就是:在栈内存中存放了一个指向一块堆内存的指针p。在程序会先确定在堆中分配内存的大小,然后调用operator new分配内存,然后返回这块内存的首地址,放入栈中。
这里,我们为了简单并没有释放内存,那么该怎么去释放呢?是delete p么? 噢,错了,应该是delete []p,这是为了告诉编译器:我删除的是一个数组。
(3)堆和栈究竟有什么区别?
管理方式不同:对于栈来讲,是由编译器自动管理,无需我们手工控制;对于堆来说,释放工作由程序员控制,容易产生memory leak。
空间大小不同:一般来讲在32位系统下,堆内存可以达到4G的空间,从这个角度来看堆内存几乎是没有什么限制的。但是对于栈来讲,一般都是有一定的空间大小的。
能否产生碎片不同:对于堆来讲,频繁的new/delete势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈来讲,则不会存在这个问题,因为栈是先进后出的队列,它们是如此的一一对应,以至于永远都不可能有一个内存块从栈中间弹出,在它弹出之前,在它上面的后进的栈内容已经被弹出,详细的可以参考数据结构,这里我们就不再一一讨论了。
生长方向不同:对于堆来讲,生长方向是向上的,也就是向着内存地址增加的方向;对于栈来讲,它的生长方向是向下的,是向着内存地址减小的方向增长。
分配方式不同:堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由alloca函数进行分配,但是栈的动态分配和堆是不同的,它的动态分配是由编译器进行释放,无需我们手工实现。
分配效率不同:栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是C/C++函数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法(具体的算法可以参考数据结构/操作系统)在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。
从这里我们可以看到,堆和栈相比,由于大量new/delete的使用,容易造成大量的内存碎片;由于没有专门的系统支持,效率很低;由于可能引发用户态和核心态的切换,内存的申请,代价变得更加昂贵。所以栈在程序中是应用最广泛的,就算是函数的调用也利用栈去完成,函数调用过程中的参数,返回地址,EBP和局部变量都采用栈的方式存放。所以,我们推荐大家尽量用栈,而不是用堆。
虽然栈有如此众多的好处,但是由于和堆相比不是那么灵活,有时候分配大量的内存空间,还是用堆好一些。
无论是堆还是栈,都要防止越界现象的发生(除非你是故意使其越界),因为越界的结果要么是程序崩溃,要么是摧毁程序的堆、栈结构,产生以想不到的结果,就算是在你的程序运行过程中,没有发生上面的问题,你还是要小心,说不定什么时候就崩掉,那时候debug可是相当困难的。
2.3 控制C++的内存分配
(1)重载全局的new和delete操作符
可以很容易地重载new 和 delete 操作符,如下所示:
void * operator new(size_t size)
{
void *p = malloc(size);
return (p);
}
void operator delete(void *p);
{
free(p);
}
这段代码可以代替默认的操作符来满足内存分配的请求。出于解释C++的目的,我们也可以直接调用malloc() 和free()。 也可以对单个类的new 和 delete 操作符重载。这是你能灵活的控制对象的内存分配。
class TestClass {
public:
void * operator new(size_t size);
void operator delete(void *p);
// .. other members here ...
};
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
}
所有TestClass 对象的内存分配都采用这段代码。更进一步,任何从TestClass 继承的类也都采用这一方式,除非它自己也重载了new 和 delete 操作符。通过重载new 和 delete 操作符的方法,你可以自由地采用不同的分配策略,从不同的内存池中分配不同的类对象。
(2)为单个的类重载 new[ ]和delete[ ]
class TestClass {
public:
void * operator new[ ](size_t size);
void operator delete[ ](void *p);
// .. other members here ..
};
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];
// ... etc ...
delete[ ] p;
}
但是注意:对于多数C++的实现,new[]操作符中的个数参数是数组的大小加上额外的存储对象数目的一些字节。在你的内存分配机制重要考虑的这一点。你应该尽量避免分配对象数组,从而使你的内存分配策略简单。
2.4 常见的内存错误及其对策
发生内存错误是件非常麻烦的事情。编译器不能自动发现这些错误,通常是在程序运行时才能捕捉到。而这些错误大多没有明显的症状,时隐时现,增加了改错的难度。有时用户怒气冲冲地把你找来,程序却没有发生任何问题,你一走,错误又发作了。 常见的内存错误及其对策如下:
(1)内存分配未成功,却使用了它 编程新手常犯这种错误,因为他们没有意识到内存分配会不成功。常用解决办法是,在使用内存之前检查指针是否为NULL。如果指针p是函数的参数,那么在函数的入口处用assert(NULL != p)进行检查。如果是用malloc或new来申请内存,应该用if(NULL == p) 或if(NULL!=p)进行防错处理。
(2)内存分配虽然成功,但是尚未初始化就引用它 犯这种错误主要有两个起因:一是没有初始化的观念;二是误以为内存的缺省初值全为零,导致引用初值错误(例如数组)。 内存的缺省初值究竟是什么并没有统一的标准,尽管有些时候为零值,我们宁可信其无不可信其有。所以无论用何种方式创建数组,都别忘了赋初值,即便是赋零值也不可省略,不要嫌麻烦。
(3)内存分配成功并且已经初始化,但操作越过了内存的边界 例如在使用数组时经常发生下标“多1”或者“少1”的操作。特别是在for循环语句中,循环次数很容易搞错,导致数组操作越界。
(4)忘记了释放内存,造成内存泄露 含有这种错误的函数每被调用一次就丢失一块内存。刚开始时系统的内存充足,你看不到错误。终有一次程序突然死掉,系统出现提示:内存耗尽。动态内存的申请与释放必须配对,程序中malloc与free的使用次数一定要相同,否则肯定有错误(new/delete同理)。
(5)释放了内存却继续使用它,有3种情况:
程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局面。 函数的return语句写错了,注意不要返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。 使用free或delete释放了内存后,没有将指针设置为NULL。导致产生“野指针”。
对策: 【规则1】用malloc或new申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。 【规则2】不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。 【规则3】避免数组或指针的下标越界,特别要当心发生“多1”或者“少1”操作。 【规则4】动态内存的申请与释放必须配对,防止内存泄漏。 【规则5】用free或delete释放了内存之后,立即将指针设置为NULL,防止产生“野指针”。
2.5 指针与数组的对比
C++/C程序中,指针和数组在不少地方可以相互替换着用,让人产生一种错觉,以为两者是等价的。
数组要么在静态存储区被创建(如全局数组),要么在栈上被创建。数组名对应着(而不是指向)一块内存,其地址与容量在生命期内保持不变,只有数组的内容可以改变。 指针可以随时指向任意类型的内存块,它的特征是“可变”,所以我们常用指针来操作动态内存。指针远比数组灵活,但也更危险。 下面以字符串为例比较指针与数组的特性:
(1)修改内容
下面示例中,字符数组a的容量是6个字符,其内容为hello。a的内容可以改变,如a[0]= ‘X’。指针p指向常量字符串“world”(位于静态存储区,内容为world),常量字符串的内容是不可以被修改的。从语法上看,编译器并不觉得语句p[0]= ‘X’有什么不妥,但是该语句企图修改常量字符串的内容而导致运行错误。
char a[] = “hello”;
a[0] = ‘X’;
cout << a << endl;
char *p = “world”; // 注意p指向常量字符串
p[0] = ‘X’; // 编译器不能发现该错误
cout << p << endl;
(2)内容复制与比较
不能对数组名进行直接复制与比较。若想把数组a的内容复制给数组b,不能用语句 b = a ,否则将产生编译错误。应该用标准库函数strcpy进行复制。同理,比较b和a的内容是否相同,不能用if(b==a) 来判断,应该用标准库函数strcmp进行比较。
语句p = a 并不能把a的内容复制指针p,而是把a的地址赋给了p。要想复制a的内容,可以先用库函数malloc为p申请一块容量为strlen(a)+1个字符的内存,再用strcpy进行字符串复制。同理,语句if(p==a) 比较的不是内容而是地址,应该用库函数strcmp来比较。
// 数组…
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可以计算出数组的容量(字节数)。 如下示例中,sizeof(a)的值是12(注意别忘了"/0")。指针p指向a,但是sizeof的值却是4。这是因为sizeof得到的是一个指针变量的字节数,相当于sizeof(char*),而不是p所指的内存容量。C++/C语言没有办法知道指针所指的内存容量,除非在申请内存时记住它。
char a[] = "hello world";
char *p = a;
cout<< sizeof(a) << endl; // 12字节
cout<< sizeof(p) << endl; // 4字节
注意当数组作为函数的参数进行传递时,该数组自动退化为同类型的指针。如下示例中,不论数组a的容量是多少,sizeof(a)始终等于sizeof(char *)。
void Func(char a[100])
{
cout<< sizeof(a) << endl; // 4字节而不是100字节
}
2.6 指针参数是如何传递内存的?
如果函数的参数是一个指针,不要指望用该指针去申请动态内存。如下示例中,Test函数的语句GetMemory(str, 200)并没有使str获得期望的内存,str依旧是NULL,为什么?
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;
GetMemory2(&str, 100); // 注意参数是 &str,而不是str
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);
}
用函数返回值来传递动态内存这种方法虽然好用,但是常常有人把return语句用错了。这里强调不要用return语句返回指向“栈内存”的指针,因为该内存在函数结束时自动消亡,见示例:
char *GetString(void)
{
char p[] = "hello world";
return p; // 编译器将提出警告
}
void Test4(void)
{
char *str = NULL;
str = GetString(); // str 的内容是垃圾
cout<< str << endl;
}
用调试器逐步跟踪Test4,发现执行str = GetString语句后str不再是NULL指针,但是str的内容不是“hello world”而是垃圾。
如果把上述示例改写成如下示例,会怎么样?
char *GetString2(void)
{
char *p = "hello world";
return p;
}
void Test5(void)
{
char *str = NULL;
str = GetString2();
cout<< str << endl;
}
函数Test5运行虽然不会出错,但是函数GetString2的设计概念却是错误的。因为GetString2内的“hello world”是常量字符串,位于静态存储区,它在程序生命期内恒定不变。无论什么时候调用GetString2,它返回的始终是同一个“只读”的内存块。
2.7 杜绝“野指针”
“野指针”不是NULL指针,是指向“垃圾”内存的指针。人们一般不会错用NULL指针,因为用if语句很容易判断。但是“野指针”是很危险的,if语句对它不起作用。 “野指针”的成因主要有两种:
(1)指针变量没有被初始化。任何指针变量刚被创建时不会自动成为NULL指针,它的缺省值是随机的,它会乱指一气。所以,指针变量在创建的同时应当被初始化,要么将指针设置为NULL,要么让它指向合法的内存。例如
char *p = NULL;
char *str = (char *) malloc(100);
(2)指针p被free或者delete之后,没有置为NULL,让人误以为p是个合法的指针。
(3)指针操作超越了变量的作用域范围。这种情况让人防不胜防,示例程序如下:
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是“野指针”
}
函数Test在执行语句p->Func()时,对象a已经消失,而p是指向a的,所以p就成了“野指针”。但奇怪的是我运行这个程序时居然没有出错,这可能与编译器有关。
2.8 有了malloc/free为什么还要new/delete?
malloc与free是C++/C语言的标准库函数,new/delete是C++的运算符。它们都可用于申请动态内存和释放内存。
对于非内部数据类型的对象而言,光用maloc/free无法满足动态对象的要求。对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。由于malloc/free是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于malloc/free。
因此C++语言需要一个能完成动态内存分配和初始化工作的运算符new,以及一个能完成清理与释放内存工作的运算符delete。注意new/delete不是库函数。我们先看一看malloc/free和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是等价的。
既然new/delete的功能完全覆盖了malloc/free,为什么C++不把malloc/free淘汰出局呢?这是因为C++程序经常要调用C函数,而C程序只能用malloc/free管理动态内存。
如果用free释放“new创建的动态对象”,那么该对象因无法执行析构函数而可能导致程序出错。如果用delete释放“malloc申请的动态内存”,结果也会导致程序出错,但是该程序的可读性很差。所以new/delete必须配对使用,malloc/free也一样。
2.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设置异常处理函数。
上述(1)(2)方式使用最普遍。如果一个函数内有多处需要申请动态内存,那么方式(1)就显得力不从心(释放内存很麻烦),应该用方式(2)来处理。
很多人不忍心用exit(1),问:“不编写出错处理程序,让操作系统自己解决行不行?”
不行。如果发生“内存耗尽”这样的事情,一般说来应用程序已经无药可救。如果不用exit(1) 把坏程序杀死,它可能会害死操作系统。道理如同:如果不把歹徒击毙,歹徒在老死之前会犯下更多的罪。
有一个很重要的现象要告诉大家。对于32位以上的应用程序而言,无论怎样使用malloc与new,几乎不可能导致“内存耗尽”。 但必须强调:不加错误处理将导致程序的质量很差,千万不可因小失大。
2.10 malloc/free的使用要点
(1)函数malloc的原型:void * malloc(size_t size);
用malloc申请一块长度为length的整数类型的内存,程序如下:
int *p = (int *) malloc(sizeof(int) * length);
我们应当把注意力集中在两个要素上:“类型转换”和“sizeof”。
malloc返回值的类型是void ,所以在调用malloc时要显式地进行类型转换,将void 转换成所需要的指针类型。 malloc函数本身并不识别要申请的内存是什么类型,它只关心内存的总字节数。我们通常记不住int, float等数据类型的变量的确切字节数。 在malloc的“()”中使用sizeof运算符是良好的风格,但要当心有时我们会昏了头,写出 p = malloc(sizeof( p ))这样的程序来。
(2)函数free的原型:
void free( void * memblock );
为什么free函数不象malloc函数那样复杂呢?这是因为指针p的类型以及它所指的内存的容量事先都是知道的,语句free能正确地释放内存。如果p是NULL指针,那么free对p无论操作多少次都不会出问题。如果p不是NULL指针,那么free对p连续操作两次就会导致程序运行错误。
2.11 new/delete的使用要点
运算符new使用起来要比函数malloc简单得多,例如:
int *p1 = (int *)malloc(sizeof(int) * length);
int *p2 = new int[length];
这是因为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; // 错误的用法
2.12 C++中的健壮指针和资源管理
1. 第一条规则(RAII)
一个指针,一个句柄,一个临界区状态只有在我们将它们封装入对象的时候才会拥有所有者。 这就是我们的第一规则:在构造函数中分配资源,在析构函数中释放资源。
当你按照规则将所有资源封装的时候,你可以保证你的程序中没有任何的资源泄露。这点在当封装对象(Encapsulating Object)在栈中建立或者嵌入在其他的对象中的时候非常明显。
但是对那些动态申请的对象呢?不要急!任何动态申请的东西都被看作一种资源,并且要按照上面提到的方法进行封装。这一对象封装对象的链不得不在某个地方终止。它最终终止在最高级的所有者,自动的或者是静态的。这些分别是对离开作用域或者程序时释放资源的保证。
下面是资源封装的一个经典例子。在一个多线程的应用程序中,线程之间共享对象的问题是通过用这样一个对象联系临界区来解决的。每一个需要访问共享资源的客户需要获得临界区。例如,这可能是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));
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的可靠性。
2.2.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。任何试图将他对一个普通指针的赋值都会产生一个编译期错误,因为类型不匹配。此外,因为Pop以值方式返回一个Strong Pointer(在Pop的声明时SmartPointer后面没有&符号),编译器在return时自动进行了一个资源转换。他调用了operator =来从数组中提取一个Item,拷贝构造函数将他传递给调用者。调用者最后拥有了指向Pop赋值的Strong Pointer指向的一个Item。
3.浅拷贝与深拷贝
浅拷贝:简单的赋值拷贝操作。
深拷贝:在堆区重新申请空间,进行拷贝操作。
C++中在对一个已知对象进行拷贝的时候,会调用类中的拷贝构造函数,如果程序员未定义拷贝构造函数,则会调用编译器默认的拷贝构造函数进行值拷贝!
在我们没有自己定义拷贝构造函数时,会在拷贝对象时调用默认拷贝构造函数,进行的是浅拷贝即对指针拷贝后会出现两个指针指向同一个内存空间。
由于栈区的规则是先进后出,当执行完拷贝构造函数的时候,就会执行p2的析构函数,导致释放堆区开辟的数据。因此当执行p1的析构函数时就会导致内存释放2次,程序崩溃。
所以,在对含有指针成员的对象进行拷贝时,必须自己定义拷贝构造函数,达到深拷贝的目的,才能必变内存重复释放。
即在堆区重新开辟一片内存保存数据,这样就可以避免内存重复释放!!!