C++容器的clear操作及其对象析构操作之小记

来源:互联网 发布:java开发 笔试题 编辑:程序博客网 时间:2024/05/16 05:51

因为在大学没有好好的研究过数据结构,现在重新拾起,好好的学习一下。最近在写线性表的顺序实现时遇到了一个小问题:对于一个已经存在有数据的线性表,如何将线性表置空?也就是如何来实现ClearList()函数?

一,clear问题的引出

先约定一下线性表的数据类型:

1.  typedef int ElemType;                  //定义数据结构元素的数据类型

2.  #define LIST_INIT-SIZE   100      //线性表存储空间的初始分配量

3.  #define LISTINCRENMENT   10       //线性表存储空间的分配增量

4.  typedef struct{

5.  ElemType * elem;         //存储空间基址

6.  int length;              //当前长度

7.  int listsize;       //当前分配的存储容量(以sizeofint)为单位)

8.  } SqList;

自己在写到这个功能的时候是这样想的,既然是清空线性表,那么要达到的目的就是,已有数据的线性表变成一个刚被初始化后的线性表,里面没有一个数据元素,线性表的长度为0。依据这样的思路,我就想到,删除所有的数据元素,然后将线性表的长度Length=0;

当我按照这样的思路写完时,去网上搜索了一些有关的资料后,自己就摸不着头脑了,原因是网上的一篇技术博客是这样实现的:原技术博客地址:http://blog.csdn.net/crescents/article/details/4397453

9.  void list_clear(List* L)  

10.  {  

11.      L->length=0;  

12.  }  

为什么没有删除元素那?这样操作岂不是元素还在?一系列的问题因此产生。

为了弄清这个问题,咱们接下来研究一下STL中的vector的源码。(不同的平台和库有不同的实现,一些成熟的库肯定会加一些优化措施,比如SGI STL则模拟了计算机底层的内存分配机制,自己在库层面维护着一套类似的内存链表结构,——本文以VS的STL为蓝本讨论。)

clear操作应该是先清除已有分配内存,再标记长度为0才更妥当。然而此处的实现显然并非如此,仅仅标记长度为空,内存不清理么?

在讨论之前再次明确一下,这个例子是C语言的,不存在神马构造和析构的问题。这个其实是比较标准的做法,clear操作仅仅在逻辑上进行标记的变动,我相信很多人如同我一样存在很多的疑惑,因为你会看到STL的vector就是那么做的,里面是erase操作。

              那么在erase操作中,vertor又是如何做的那,咱们来深入的探讨下erase操作,把stl的vector的clear操作调试了一下,取VS2010作为操作版本。
         代码很简单:

13.    std::vector<int> testint;   

14.    testint.push_back(1);

15.    testint.push_back(2);  

16.    testint.clear(); 

直接testint.clear()这一句进行调试,单步调试进入这个函数。

17.  void clear() 

18.  {// erase all  

19.     erase(begin(), end());  

20.  }  

vector的实现方式确实是erase(),是针对起始位置和终止位置之间的区间进行erase的操作,问题在于erase()如何实现。

 我们抠除那些乱七八糟的部分——主要是调试的,只看主干内容

21.    pointer _Ptr = _Move(_VIPTR(_Last),this->_Mylast,_VIPTR(_First));

22.    _Destroy(_Ptr, this->_Mylast); 

23.   this->_Mylast = _Ptr;  

也就是erase总共三步操作,第一步_Move,也就是拷贝——在低版本的VS中叫做unchecked_copy()函数,实质是一样的,这个函数将待删区间末端到容器尾部这一截数据往前拷贝,补到待删区间的起始端,覆盖原有数据,返回一个拷贝后的尾部指针Ptr;第二步,_Destroy()覆盖后的尾部到容器尾部这一截数据;第三步设置容器尾部地址为第一步返回的尾部Ptr(类似于控制length)。

无图无真相,弄个图图示意一下,随手画的,尺寸似乎不太准确,凑合着看吧。

在Vector中erase区间在First和Last迭代器中间。 


move尾部的数据。


然后对新的尾部和原有尾部之间残余的部分(下图黑色段)进行_Destroy操作,并重置容器的尾部指针_Mylast。


           

那么如果是像clear这样的,要删除的区间和正好就从原来头部开始的呢?答案是move这一步就什么都不做,直接返回区间First迭代器作为尾部Ptr,最后首尾合一.

这样的话,clear()只剩实质性的两步,_Destroy和尾部位置的重置。

重置尾部自然是无异议的,这类似于设置Length=0。_Destroy的操作则是有一定说道的,STL本身有类型萃取type_trait机制来使得不同类型的数据得到最合适的操作版本,比如我刚才的vector<int>类型,int在C++中属于标量类型(Scalar type,可以粗略地理解为内置基本类型,比如int,double等),则_Destroy()什么也不做。如下面的代码就是Destroy在Int元素类型中的最终调用,Scalar type版本的_Destroy_range函数,STL也给出了注释,“do nothing”

24.   {// destroy [_First, _Last), scalar type (do nothing)  

25.   } 

而如果不是Scalar type,则另有玄机,但此时,我们暂时把这个问题置后。

        

二,clear问题的结论  

关于容器的clear操作我们此刻已经可以得出一个基本确信的结论,那就是clear,没有做任何与内存释放相关的工作,而仅仅是进行了逻辑数据的处理,将首尾位置合并使得它逻辑上的长度等于0。这一点事实也是可以理解的,clear操作是要把容器清空,只要在数据层面它能对外展示的信息为空,然后对它的访问都基于该空间信息,比如按照索引读取和写入等操作,这些只要能基于正确的空间数据,那么我们完全没必要再去释放内存,释放内存这一步只需要等容器最终被销毁的时候一起做就可以了,“数据还在那里啊?”,在那里你访问不到跟不存在有什么区别呢,它已经是编外的孤魂野鬼,不必搭理,最后佛祖会收拾的~~。

在本例中,我们虽然使用的是vector进行调试,也尽管容器的构造千差万别,但clear的基本思想是一致的,就是仅在逻辑层面进行数据清理,而不会进行内存释放。

我们做一个这样的比喻,这就像开饭店的时候,随时有客人进出,盘子需要快速提供给吃饭的客人,那么我正常的做法应该是当我从柜子里取出许多盘子以后,客人使用完,我只要清洗一下,然后继续拿出来给以后的客人用,而不用非要洗完并回收到柜子里重新发放。我需要保证的是干净盘子和脏盘子的数量在控制之中,而不是去随时地往碗橱里回收盘子以改变柜子里面和外面的盘子数量,这些等我最后打烊的时候确认回收就可以了。

这种操作也直接导致了容器设计的一个基本概念,数据空间和实际内存空间的区别,这几乎反映在所有库上面,STL的Size和Capacity不同,ObjectArx中的容器有LogicLength和PhysicalLength的区别,这里比较另类的是MFC的容器,应该讲,MFC的容器是这些容器里最笨重的一个——毕竟也是最早的一个了,虽然也有一个MaxSize和Size的区别,但基本上是同步的,差别非常小,也是唯一个clear操作(在MFC中是RemoveAll)的时候同时清除内存的,因此如果不是十分必要,尽量不要用MFC容器的方式比如CArray来存储长度需要灵活变化的类,从代码来看效率是比较差的——对于那种一次生成然后很少变动的数据,这种倒是可能会稍微快一些。

三,_Destroy和类对象的析构

上面我们还搁置了一个话题,那就是_Destroy()操作,在NonScalar类型的数据上如何表现,最典型的,类Class。因为看到这里,难免让很多熟悉C++的人开始疑惑了,析构操作跑哪里去了?我可以接受你用数据长度标记的方式来完成clear操作,但我们知道容器一旦clear,里面的数据必须要保证析构的调用的,否则,容器就是不符合需求的,我的析构要是涉及资源的释放那么你必须保证它被调用。这就是我们接下来要讨论的话题。

我们构造一个测试类。

26.  //测试类 

27.  class A 

28.  {  

29.  public:  

30.  A(){cout << "create" << endl;};   

31.  ~A(){cout << "destroy" << endl;};   

32.  public:

33.  int m_a; 

34.  };

下面是例子代码:

36.  std::vector<A> testA; 

37.  testA.push_back(A());  

38.  testA.push_back(A());

39.  testA.clear();

我们再次进入调试,进入到_Destroy这一步,实质的核心函数是这样一个区别于Scalar type的_Destroy_range函数。

40.  {// destroy [_First, _Last), arbitrary type 

41.  for (; _First != _Last; ++_First)

42.        _Dest_val(_Al, _First);  

43. 

这是一个针对arbitray type的,也就是任意类型,当然除了Scalar type。继续调试_Dest_val(_Al, _First)函数我们进入了核心的最后一步。

44.  template<class _Ty> inline 

45.  void _Destroy(_Ty _FARQ *_Ptr) 

46.  {// destroy object at _Ptr  

47.  _Ptr->~_Ty();

48. 

做,并仅做这一步,对每一个删除区的元素调用析构函数_Ptr->~_Ty();,注意,仅此而已,它的内存完好无损,仅仅是手动地调用了析构函数。

接着使用上面的比喻,饭店里的盘子总不能用完直接脏兮兮地从2号桌放到3号桌给客人用吧,总需要清洗了才拿出来吧,是的,这就是析构,你不需要把盘子回收到柜子里重发,但你需要清洗用盘子,这就是析构函数的作用,清洗而不是回收。

我们有一种 先入为主的意识,构造函数的工作似乎就是一个对象在分配内存构造对象,而析构函数则是对象在释放内存销毁对象,这似乎已经深入我们C++程序员的意识中,而事实是,构造函数和析构函数,只是初始化和清理内部数据的入口而已,如果我们把构造函数称之为“初始化函数”,把析构函数称之为“数据清理函数”,似乎会更容易被正确理解。而事实上,如果必要,你可以在任何地方调用构造函数和析构函数,但为了防止语法被解析错误,构造函数需要明确地用::域操作符标明它是一个构造函数的调用。

四,构造函数和析构函数

49.  //构造和析构测试类 

50.  class A 

51.  {  

52.  public:  

53.  A(){m_a = 1;}  

54.  ~A(){m_a = 2;}   

55.  public:

56.  int m_a; 

57.  };

我们继续做一个玩具代码,它不是实际中会用到的方式,但却可以给我们一些对析构和构造函数的新的认识,那就是析构和构造函数仅仅是一个函数而已,它远没有我们大多数人想的特殊。

58.  A a;

59.  a.m_a = 3;  

60.  cout << "A3 " << a.m_a << endl;  

61.  a.A::A();  

62.  cout << "A() " << a.m_a << endl;   

63.  a.~A();

64.  cout << "A~ " << a.m_a << endl;

输出如下:


显然,当我们把构造和析构函数当成普通函数来调用,它们确实像普通函数一样,进行着某种数据操作而已。

讲到这里,我们就要问,就算你是对的,但这有什么用,首先我得承认,这样使用构造函数的意义确实不大,因为在多态为王的C++体系里,这么生硬的调用构造函数并没有明显的意义,但是析构函数是支持多态的,这种随时对析构函数的调用,意义就很大了。除了上面实现了不碰触内存释放而进行的析构调用以外——它几乎就是STL的洗碗机,大部分STL的清洗工作就是在显式调用析构函数,还有就是那个既生瑜何生亮快被埋没了的Placement new操作,也就是在一片固定的内存上定位性的new出一个对象,这样就不用申请新的内存,而只需要不断在固定内存上不断构造出对象,然后你手动调用析构函数,这似乎是为了在内存有限的地方使用的,但硬件的发展和它本身使用不方便的缘故,它的存在并没有得到预期的使用,逐渐成为一个仅在技术讨论的有限帖子里被提及的东西,这是后话,不表。

(注:文章精髓由我的好朋友武总所写,我按照我的思路进行了总结,文章提及的小朋友自然就是我了,文章原文:http://user.qzone.qq.com/304830578/2

0 0
原创粉丝点击