您的位置:首页 > 编程语言 > C语言/C++

C++内存管理学习笔记(4)

2013-06-14 14:59 218 查看
/****************************************************************/

/* 学习是合作和分享式的!

/* Author:Atlas Email:wdzxl198@163.com

/* 转载请注明本文出处:

* /article/3679191.html

/****************************************************************/
上期内容回顾:传送门1传送门2传送门3

1.C++内存管理

1.1c语言和C++内存分配

1.2区分堆、栈、静态存储区

1.3控制C++的内存分配

1.4内存管理的基本要求

1.5常见的内存错误及对策

1.6数组和指针

1.7指针参数是如何传递内存的

1.8杜绝“野指针“

1.9malloc、free和new、delete的区别

1.10-1.12 malloc,free,new,delete使用

2.C++中的健壮指针及资源管理

对于资源,就是一旦用了它,将来必须还给系统。我们最常见的资源是动态分配内存,其他常见的还有:文件描述器、互斥锁、图形界面中的字形和笔刷、数据库连接、以及网络socket等等。

2.1 引入

对于给定的资源的拥有着,是负责释放资源的一个对象或者是一段代码。所有权分立为两种级别——自动的和显式的(automatic and explicit),如果一个对象的释放是由语言本身的机制来保证的,这个对象的就是被自动地所有。例如,一个嵌入在其他对象中的对象,他的清除需要其他对象来在清除的时候保证。外面的对象被看作嵌入类的所有者。类似地,每个在栈上创建的对象(作为自动变量)的释放是在控制流离开了对象被定义的作用域的时候保证的。这种情况下,作用于被看作是对象的所有者。注意所有的自动所有权都是和语言的其他机制相容的,包括异常。无论是如何退出作用域的——正常流程控制退出、一个break语句、一个return、一个goto、或者是一个throw——自动资源都可以被清除。

OK!,在引入指针、句柄和抽象的时候产生了问题。如果通过一个指针访问一个对象的话,比如对象在堆中分配,C++不自动地关注它的释放。程序员必须明确的用适当的程序方法来释放这些资源。比如说,如果一个对象是通过调用new来创建的,它需要用delete来回收。一个文件是用CreateFile(Win32 API)打开的,它需要用CloseHandle来关闭。用EnterCritialSection进入的临界区(Critical Section)需要LeaveCriticalSection退出,等等。基本的资源管理的前提就是确保每个资源都有他们的所有者。

2.2 第一条规则RAII(Resource Acquisition Is Initialization)

RAII是指C++语言中的一个惯用法(idiom),它是“Resource Acquisition Is Initialization”的首字母缩写。中文可将其翻译为“资源获取就是初始化”。我们怎么理解这句话呢,在一本c++书中有这么一句:“使用局部对象管理资源的技术通常称为“资源获取就是初始化(RAII)”。这种通用技术依赖于构造函数和析构函数的性质以及它们与异常处理的交互作用。”通俗的说一下就是:一个指针,一个句柄,一个临界区状态只有在我们将它们封装入对象的时候才会拥有所有者。这就是我们的第一规则:在构造函数中分配资源,在析构函数中释放资源。

为什么会有这个规则?为什么资源要在构造函数中申请及初始化以及析构函数中释放?让我们一个例子来说明,

下面的UseFile函数中:

1: void UseFile(char const* fn)

2: {

3:     FILE* f = fopen(fn, "r"); // 获取资源

4:     // 在此处使用文件句柄f...    // 使用资源

5:     fclose(f); // 释放资源

6: }


调用fopen()打开文件就是获取文件句柄资源,操作完成之后,调用fclose()关闭文件就是释放该资源。资源的释放工作至关重要,如果只获取而不释放,那么资源最终会被耗尽。上面的代码是否能够保证在任何情况下都调用fclose函数呢?请考虑如下情况:

1: void UseFile(char const* fn)

2: {

3:     FILE* f = fopen(fn, "r"); // 获取资源

4:     // 使用资源

5:     if (!g()) return; // 如果操作g失败!

6:     // ...

7:     if (!h()) return; // 如果操作h失败!

8:     // ...

9:     fclose(f); // 释放资源

10: }


在使用文件f的过程中,因某些操作失败而造成函数提前返回的现象经常出现。在操作g或h失败之后,UseFile函数必须首先调用fclose()关闭文件,然后才能返回其调用者,否则会造成资源泄漏。因此,需要将UseFile函数修改为:

1:     void UseFile(char const* fn)

2:     {

3:     FILE* f = fopen(fn, "r");

4:     //获取资源

5:     //使用资源

6:     if (!g())

7:     {fclose(f);return; }

8:     // ...

9:     if (!h())

10:     {fclose(f);return; }

11:     // ...

12:     fclose(f);

13:     //释放资源

14: }


现在的问题是:用于释放资源的代码fclose(f)需要在不同的位置重复书写多次。如果再加入异常处理,情况会变得更加复杂。

假设UseResources函数要用到n个资源,则进行资源管理的一般模式为:

1: void UseResources()

2: {

3:     //获取资源1

4:     //

5:     ...

6:     //获取资源n

7:     //使用这些资源

8:     //释放资源n

9:     //

10:     ...

11:     //释放资源1

12: }


获取资源和释放资源要对应,这里就会面临上面示例的麻烦。释放的不彻底将会导致memory leak,致使程序臃肿、出错等。看到这里自然而然的可以想到C++中的一对特殊函数,构造函数和析构函数。在构造函数中申请资源,以及在析构函数中释放资源。类是C++中的主要抽象工具,那么就将资源抽象为类,用局部对象来表示资源,把管理资源的任务转化为管理局部对象的任务。这就是RAII惯用法,RAII有效地实现了C++资源管理的自动化。

当你按照RAII规则将所有资源封装的时候,可以保证你的程序中没有任何的资源泄露。这点在当封装对象(Encapsulating Object)在栈中建立或者嵌入在其他的对象中的时候非常明显。对于任何动态申请的东西都被看作一种资源,并且要按照上面提到的方法进行封装。这一对象封装对象的链不得不在某个地方终止。它最终终止在最高级的所有者,自动的或者是静态的。这些分别是对离开作用域或者程序时释放资源的保证。

下面是资源封装的一个经典例子。在一个多线程的应用程序中,线程之间共享对象的问题是通过用这样一个对象联系临界区来解决的。每一个需要访问共享资源的客户需要获得临界区。

1: class CritSect

2: {

3:     friend class Lock;

4: public:

5:     CritSect () { InitializeCriticalSection (&_critSection); }

6:     ~CritSect () { DeleteCriticalSection (&_critSection); }

7: private:

8:     void Acquire ()

9:     {

10:         EnterCriticalSection (&_critSection);

11:     }

12:     void Release ()

13:     {

14:         LeaveCriticalSection (&_critSection);

15:     }

16: private:

17:     CRITICAL_SECTION _critSection;

18: };


  这里聪明的部分是我们确保每一个进入临界区的客户最后都可以离开。"进入"临界区的状态是一种资源,并应当被封装。封装器通常被称作一个锁(lock)。

1: class Lock

2: {

3: public:

4:     Lock (CritSect& critSect) : _critSect (critSect)

5:     {

6:         _critSect.Acquire ();

7:     }

8:     ~Lock ()

9:     {

10:         _critSect.Release ();

11:     }

12: private

13:     CritSect & _critSect;

14: };


  锁一般的用法如下:

1: void Shared::Act () throw (char *)

2: {

3:     Lock lock (_critSect);

4:     //perform action —— may throw

5:     // automatic destructor of lock

6: }


 注意无论发生什么,临界区都会借助于语言的机制保证释放。

还有一件需要记住的事情——每一种资源都需要被分别封装。这是因为资源分配是一个非常容易出错的操作,是要资源是有限提供的。我们会假设一个失败的资源分配会导致一个异常——事实上,这会经常的发生。所以如果你想试图用一个石头打两只鸟的话,或者在一个构造函数中申请两种形式的资源,你可能就会陷入麻烦。只要想想在一种资源分配成功但另一种失败抛出异常时会发生什么。因为构造函数还没有全部完成,析构函数不可能被调用,第一种资源就会发生泄露。

这种情况可以非常简单的避免。无论何时你有一个需要两种以上资源的类时,写两个小的封装器将它们嵌入你的类中。每一个嵌入的构造都可以保证删除,即使包装类没有构造完成。这是对需要管理多个资源的复杂对象来说的,下面的例子说明了这样情形,

1: class FileHandle {

2: public:

3:     FileHandle(char const* n, char const* a) { p = fopen(n, a); }

4:     ~FileHandle() { fclose(p); }

5: private:

6:     // 禁止拷贝操作

7:     FileHandle(FileHandle const&);

8:     FileHandle& operator= (FileHandle const&);

9:     FILE *p;

10: };


1: class Widget {

2: public:

3:     Widget(char const* myFile, char const* myLock)

4:     : file_(myFile),     // 获取文件myFile

5:       lock_(myLock)      // 获取互斥锁myLock

6:     {}

7:     // ...

8: private:

9:     FileHandle file_;

10:     LockHandle lock_;

11: };


Widget类的构造函数要获取两个资源:文件myFile和互斥锁myLock。每个资源的获取都有可能失败并且抛出异常。FileHandle和LockHandle类的对象作为Widget类的数据成员,分别表示需要获取的文件和互斥锁。资源的获取过程就是两个成员对象的初始化过程。在此系统会自动地为我们进行资源管理,程序员不必显式地添加任何异常处理代码。例如,当已经创建完file_,但尚未创建完lock_时,有一个异常被抛出,则系统会调用file_的析构函数,而不会调用lock_的析构函数。

综合以上的内容,RAII的本质内容是用对象代表资源,把管理资源的任务转化为管理对象的任务,将资源的获取和释放与对象的构造和析构对应起来,从而确保在对象的生存期内资源始终有效,对象销毁时资源必被释放。换句话说,拥有对象就等于拥有资源,对象存在则资源必定存在。

2.3 Smart pointers(智能指针)

在《C++内存管理技术内幕》中,是这么解释smart pointer的。

如果我们用操作符new来动态申请一个对象,此后用指针访问的一个对象。我们需要为每个对象分别定义一个封装类吗?让我们从一个极其简单、呆板但安全的东西开始。看下面的Smart Pointer模板类,它十分坚固,甚至无法实现。

1: template <class T>

2: class SmartPointer

3: {

4: public:

5:     ~SmartPointer () { delete _p; }

6:     T * operator->() { return _p; }

7:     T const * operator->() const { return _p; }

8: protected:

9:     SmartPointer (): _p (0) {}

10:     explicit SmartPointer (T* p): _p (p) {}

11:     T * _p;

12: };


  为什么要把SmartPointer的构造函数设计为protected呢?如果需要遵守第一条规则,那么就必须这样做。资源——在这里是class T的一个对象——必须在封装器的构造函数中分配。但是不能只简单的调用new T,因为我不知道T的构造函数的参数。因为,在原则上,每一个T都有一个不同的构造函数;我需要为他定义个另外一个封装器。模板的用处会很大,为每一个新的类,我可以通过继承SmartPointer定义一个新的封装器,并且提供一个特定的构造函数。

1: class SmartItem: public SmartPointer<Item>

2: {

3: public:

4:     explicit SmartItem (int i)

5:     : SmartPointer<Item> (new Item (i)) {}

6: };


  为每一个类提供一个Smart Pointer真的值得吗?说实话——不!他很有教学的价值,但是一旦你学会如何遵循第一规则的话,你就可以放松规则并使用一些高级的技术。这一技术是让SmartPointer的构造函数成为public,但是只是是用它来做资源转换(Resource Transfer)。我的意思是用new操作符的结果直接作为SmartPointer的构造函数的参数,像这样:

1: SmartPointer<Item> item (new Item (i));


  这个方法明显更需要自控性,不只是你,而且包括你的程序小组的每个成员。他们都必须发誓出了作资源转换外不把构造函数用在人以其他用途。幸运的是,这条规矩很容易得以加强。只需要在源文件中查找所有的new即可。

看到这里,你肯定和我一样会有很多疑问,不要着急,慢慢来看。下面以c++中的auto_ptr来说明。

2.4 auto_ptr类

首先,什么是smart pointer? 智能指针(Smart pointer)是一种抽象的数据类型。在程序设计中,它通常是经由类模板(class template)来做,借由模板(template)来达成泛型,通常借由类型(class)的析构函数来达成自动释放指针所指向的存储器或对象。

什么是类模板和泛型编程?---《C++ primer》

1 类模板(class template)

模板定义:模板就是实现代码重用机制的一种工具,它可以实现类型参数化,即把类型定义为参数, 从而实现了真正的代码可重用性。模版可以分为两类,一个是函数模版,另外一个是类模版。
函数模板的一般形式如下:

1: Template <class或者也可以用typename T>

2: 返回类型 函数名(形参表)

3: {//函数定义体 }


说明: template是一个声明模板的关键字,表示声明一个模板关键字class不能省略,如果类型形参多余一个 ,每个形参前都要加class <类型 形参表>可以包含基本数据类型可以包含类类型.

定义一个类模板:

1: template<class 模板参数表>

2: class 类名{

3: // 类定义......

4: };


说明:其中,template是声明各模板的关键字,表示声明一个模板,模板参数可以是一个,也可以是多个。

2泛型编程

泛型编程就是以独立于任何特定的方式编写代码。 泛型编程最初诞生于C++中,,由Alexander Stepanov和David Musser创立。目的是为了实现C++的STL(标准模板库)。其语言支持机制就是模板(Templates)。

泛型编程的核心活动是抽象:将一个特定于某些类型的算法中那些类型无关的共性抽象出来,比如,在STL的概念体系里面,管你是一个数组还是一个链表,反正都是一个区间,这就是一层抽象。管你是一个内建函数还是一个自定义类,反正都是一个Callable(可调用)的对象(在C++里面通过仿函数来表示),这就是一层抽象。泛型编程的过程就是一个不断将这些抽象提升(lift)出来的过程,最终的目的是形成一个最大程度上通用的算法或类。

说了这么一大堆,肯定会茫茫然,这是正常的,想研究泛型编程的请仔细阅读《C++ Primer》一书。这里主要是为解释smart pointer而做的铺垫。
在C++ primer上面提供了了两种解决方案,设置拥有权的转移和使用引用计数的方式。针对这个两个解决方案,出现了两种风格的智能指针,STL中的auto_ptr属于拥有权转移指针,boost中的shared_ptr属于引用计数型(boost里面的智能指针有6个,这里只是其中一个)。

本文这里主要讲解其中的auto_ptr类方式,为了更好的理解后续笔记的内容提前做一个铺垫。

(1)auto_ptr类

C++标准模板库有一个模板类,叫做auto_ptr,其作用就是提供这种封装。它是上一节介绍的RAII规则的例子。auto_ptr类是接收一个类型形参的模板,它为动态分配的对象提供异常安全。我们来看一个例子,auto_ptr的部分实现,说明什么是auto_ptr:

1: template <class T> class auto_ptr

2: {

3:     T* ptr;

4: public:

5:     explicit auto_ptr(T* p = 0) : ptr(p) {}

6:     ~auto_ptr()                 {delete ptr;}

7:     T& operator*()              {return *ptr;}

8:     T* operator->()             {return ptr;}

9:     //...

10: };


auto_ptr is a simple wrapper around a regular pointer. It forwards all meaningful operations to this pointer (dereferencing and indirection). Its smartness in the destructor: the destructor takes care of deleting the pointer.(auto_ptr 只是简单的包含一个常规指针T*
p,它(间接的和非关联的)指向所有有意义的操作。在析构函数中更加智能化:析构函数负责删除指针。)

(2)auto_ptr操作

auto_ptr<T> ap; 创建名为 ap 的未绑定的 auto_ptr 对象

auto_ptr<T> ap(p); 创建名为 ap 的 auto_ptr 对象,ap 拥有指针 p 指向的对象。该构造函数为explicit

auto_ptr<T> ap1(ap2); 创建名为 ap1 的 auto_ptr 对象,ap1 保存原来存储在ap2 中的指针。将所有权转给 ap1,ap2 成为未绑定的auto_ptr 对象

ap1 = ap2 将所有权 ap2 转给 ap1。删除 ap1 指向的对象并且使 ap1指向 ap2 指向的对象,使 ap2 成为未绑定的

~ap 析构函数。删除 ap 指向的对象

*ap 返回对 ap 所绑定的对象的引用

ap-> 返回 ap 保存的指针

ap.reset(p) 如果 p 与 ap 的值不同,则删除 ap 指向的对象并且将 ap绑定到 p

ap.release() 返回 ap 所保存的指针并且使 ap 成为未绑定的

ap.get() 返回 ap 保存的指针

注意:

auto_ptr只能用于管理从new返回的一个对象,它不能管理动态分配的数组。当auto_ptr被复制或赋值的时候,有不寻找的行为,因此不能将auto_ptr存储在标准库容器类中。
每个auto_ptr对象绑定到一个对象或者指向一个对象。当auto_ptr对象指向一个对象的时候,可以说它“拥有”该对象。当auto_ptr对象超出作用域或者另外撤销的时候,就自动回收auto_ptr所指向的动态分配对象。

(3)内存分配中使用auto_ptr

如果通过常规指针分配内存,而且在执行delete之前发生异常,就不会自动释放内存,

1: void f()

2: {

3:     int *ip = ne int(42);   //dynamically allocate a new object

4:     //code that throws an exception that is not caugth inside f

5:     delete ip;             //return the memory before exiting

6: }


如果在new和delet之间发生异常,并且该异常不被局部捕获,就不会执行delet,永远也收不回该内存,若使用auto_ptr对象来替代,将会自动释放内存,即使提早退出这个块,

1: void f()

2: {

3:     auto_ptr<int> ap(new int(42)); // allocate a new object

4:     //code that throws an exception that is not caught inside f

5: }   // auto_ptr freed automatically when function ends


这个例子中,编译器保证在展开栈越过f之前运行ap的析构函数。

(4)auto_ptr是可以保存任何类型指针的模板

auto_ptr类是接收单个类型形参的模板,该类型指定auto_ptr可以绑定的对象类型,因此,可以创建任何类型的auto_ptr:

1: auto_ptr<string> ap1(new string("Brontosaurus"));


(5)将auto_ptr绑定到指针

在最常见的情况下,将auto_ptr对象初始化为由new表达式返回的对象的地址:

1: auto_ptr<int> pi(new int(1024));


注意,接受指针的构造函数为explicit构造函数,所以必须用初始化的直接形式来创建auto_ptr对象。

1: auto_ptr<int> pi(new int(1024)); // ok: uses direct initialization

2: auto_ptr<int> pi = new int(1024);// error: constructor that takes a pointer is explicit and can't be used implicitly


pi所指的由new表达式创建的对象在超出作用域时自动删除。

(6)使用auto_ptr对象

1: auto_ptr<string> ap1(new string("Hellobaby!"));

2: *ap1 = "TRex"; // assigns a new value to the object to which ap1 points

3: string s = *ap1; // initializes s as a copy of the object to which ap1 points

4: if (ap1->empty()) // runs empty on the string to which ap1 points


auto_ptr的主要目的是在保证自动删除auto_ptr对象引用的对象的同时,支持普通指针式行为。

(7)auto_ptr对象的赋值和复制是破坏性操作

auto_ptr与普通指针的复制和赋值有区别。普通指针赋值或复制后两个指针指向同一对象,而auto_ptr对象复制或赋值后,将基础对象的所有权从原来的auto_ptr对象转给副本,原来的auto_ptr对象重置成为未绑定状态。

1: auto_ptr<string> ap1(new string("stegosaurus"));

2: //after the copy ap1 is unbound

3: auto_ptr<string> ap2(ap1); //ownership transferred from ap1 to ap2


auto_ptr的复制和赋值改变右操作数,因此,auto_ptr赋值的左右操作数必须是可修改的左值。auto_ptr不能存储在标准容器中,因为标准库容器要求在复制或赋值后两对象相等,auto_ptr不满足這条件,如果将ap2赋值给ap1,则在赋值后ap1!=ap2,复制也类似。

(8)赋值删除左操作数指向的对象

除了将所有权从右操作数转给左操作数外,赋值还删除左操作数原来指向的对象--假如两个对象不同,通常自身赋值没有效果。

1: auto_ptr<string> ap3(new string("pterodacty1"));

2: //object pointed to by ap3 is deleted and ownership transferred from ap2 to ap3;

3: ap3 = ap2; //after the assignment,ap2 is unbound


将ap2赋值给ap3后,1)删除了ap3指向的对象;2)将ap3置为指向ap2指向的对象;3)ap2是未绑定的auto_ptr对象

(9)auto_ptr的默认构造函数

如果不给定初始式,auto_ptr对象是未绑定的,它不指向任何对象,默认情况下,auto_ptr的内部指针值置为0。

(10)测试auto_ptr对象

例子中第一种条件测试是错误的, auto_ptr 类型没有定义到可用作条件的类型的转换,相反,要测试auto_ptr 对象,必须使用它的 get 成员,该成员返回包含在 auto_ptr 对象中的基础指针。

示例:

1: // error: cannot use an auto_ptr as a condition

2: if (p_auto)

3:     *p_auto = 1024;

4:

5: // revised test to guarantee p_auto refers to an object

6: if (p_auto.get())

7:     *p_auto = 1024;


应该只用 get 询问 auto_ptr 对象或者使用返回的指针值,不能用 get 作为创建其他 auto_ptr 对象的实参。(原因:使用get成员初始化其他auto_ptr对象,违反了auto_ptr类的设计原则,在任意时刻只有一个auto_ptr对象保存给定指针,如果两个auto_ptr对象保存相同指针,则该指针会被delete两次!!!)

(11)reset操作

auto_ptr对象与内置指针的另一个区别是不能直接将一个地址(或其它指针)赋给auto_ptr对象。

1: #include <iostream>

2: #include "memory"

3: using namespace std;

4: int main()

5: {

6:     auto_ptr<int> p_auto(new int(1024));

7:     //p_auto = new int(1024); // error: cannot assign a pointer to an auto_ptr

8:     if (p_auto.get())

9:         *p_auto = 1024;

10:     else

11:         p_auto.reset(new int(1042));

12:     return 1;

13: }


正如自身赋值是没有效果的一样,如果调用该 auto_ptr 对象已经保存的同一指针的 reset 函数,也没有效果,不会删除对象。

(12)正确使用auto_ptr类的限制(auto_ptr的缺陷)

1)不要使用auto_ptr对象保存指向静态分配对象的指针,否则,当auto_ptr对象本身被撤销的时候,它将试图删除指向非动态分配对象的指针,导致未定义的行为。

2)永远不要使用两个 auto_ptrs 对象指向同一对象,导致这个错误的一种明显方式是,使用同一指针来初始化或者 reset 两个不同的 auto_ptr对象。另一种导致这个错误的微妙方式可能是,使用一个 auto_ptr 对象的 get 函数的结果来初始化或者 reset另一个 auto_ptr 对象。

3)不要使用 auto_ptr 对象保存指向动态分配数组的指针。当auto_ptr 对象被删除的时候,它只释放一个对象—它使用普通delete 操作符,而不用数组的 delete [] 操作符。

4)不要将 auto_ptr 对象存储在容器中。容器要求所保存的类型定义复制和赋值操作符,使它们表现得类似于内置类型的操作符:在复制(或者赋值)之后,两个对象必须具有相同值,auto_ptr 类不满足这个要求。

讲到这里,相信大家对智能指针中的auto_ptr对象有了清晰的认识,想多研究或者学习的请自行查找资料。文章中详细解释智能指针auto_ptr,一是为了对第一规则RAII的理解,二是为了对智能指针有个清晰的认识,怎么使用,注意些什么等等,三是为了对学习笔记后续中strong pointer等知识的理解。

如果你认为文章内容有不正确或者不准确的地方,请指出。互相学习!

参考文献详见《c++内存管理学习纲要》一文;

PS:学习是延伸的,这个我毫不怀疑!文章主线是对C++中的健壮指针和资源管理的学习,在学习过程中不断延伸到各个知识点,比如c++中的模板和泛型编程,c++标准库中的智能指针,STL中相关部分的实现等等,这些需要读者自己学习了。

Edit by Atlas

Time 2013/6/14 14:52
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: