技术开发 频道

C++智能指针漫谈(上)

        【IT168 技术】由于C++缺乏语言级别的GC(Garbage Collection)支持,资源生命周期管理一直是一个棘手的问题。系统地解决这一问题的方法有两种:

  • 使用GC。但GC会带来额外的负担:内存占用率过高,这点在Java身上可以得到验证。在这不讨论GC,现代程序语言的GC管理是一个复杂的话题。

  • 使用引用计数。引用计数也可以算是一种朴素的GC。但引用计数存在循环引用和多线程之间同步的问题。

  万丈高楼平地起。在讨论引用计数之前,先看看简单的智能指针。智能指针的目的是帮助管理和释放资源(不仅仅是管理内存)。智能指针能够工作,其基本原理是C++对象的构造和析构,编译器会在超出对象作用域时,调用其析构函数。同时C++的new和delete与C的malloc和free不同,它包含了对象的构造和析构。

  智能指针托管对象,它有一种不同的方式来管理拥有权。一种是自动转移所有权。它只是提供单一引用的方案:被管理的对象在某一时刻只存在一个引用。这个方案的典型实现就是STL的auto_ptr。一种是不允许转移所有权。这种智能指针不可复制(noncopyable)的智能指针,这个方案的典型实现时Boost的scoped_ptr。一种是复制所有权,在你拷贝它时,总会复制一个其所指的对象,这种方法在许多常量性的值上存在,比如Java的String。还有一种是采用引用计数,它会追踪所指对象的智能指针总数,总数为0时释放对象。

  但我们的应用往往需要提供多引用才能满足需求。这就需要加入引用计数来,在增加引用时,计数器+1,在减少引用时,计数器-1。+1和-1却让人痛苦了很久。C++的引用计数可以分为两类:

  • 侵入式。要求资源对象本身维护引用计数,同时提供增加和减少引用计数的管理接口。侵入式方案一般会提供配套的侵入式引用计数智能指针。这样设计的优势在于可以减少内存使用量,智能指针本身只需要存放对象地址即可。

  • 非侵入式。非侵入式的引用计数管理对资源对象没有任何要求,而是完全借助非侵入式引用计数智能指针在资源对象外部维护独立的引用计数。

  对于基于引用计数的智能指针,有两个设计抉择:是否支持多线程,是否解决循环引用问题。一个通用性最高的设计应该是支持多线程,而放弃解决循环引用。当然更多的设计细节也需要去权衡,比如:

  • ……

  • 是否允许隐式调用构造函数?

  • 是否提供隐式类型转换?

  • 是否支持Bool运算?

  • 如何定义多态语境下的智能指针行为?

  • 后面列出的两个问题很困难!

  RAII

  RAII(Recourse Acquisition Is Initialization)是一个通用的设计方法。在C++之父Bjarne Stroustrup老大的巨著《The C++ Programming Language》第14章第4节资源管理介绍了“资源申请即初始化”。 利用局部对象管理资源的技术通常被说成是“资源申请即初始化”。 这种技术依赖于构造函数和析构函数的特性,以及它们与异常机制的关系。

  智能指针是利用局部对象管理资源的一种典型技术。当进入代码块时,临时对象在栈上分配,将在new分配的堆对象与栈对象生命周期关联起来。在退出代码块时,退栈的时机正是堆对象结束生命周期的时机。从而实现对资源的自动管理和维护。

  智能指针是有值语义的。何为值语义?值语义是指对象可以被拷贝和赋值。指针不具有值语义,对它赋值会存在内存泄露和多重删除的危险。

${PageNumber}

        右值引用

  C++的处理临时对象一直是一个被诟病的问题。右值就是等号右边的值,经常会存在临时对象的拷贝。

1 std::vector<int> v = readFile();

       v的初始化依赖于函数的返回值。

1 std::vector<int> readFile(){  
2
3      std::vector<int> retv;  
4
5      … // fill retv  
6
7      return retv;  
8
9 }

        函数返回一个临时对象,为了初始化v,需要将std::vector都拷贝一遍,存在很大的性能开销。幸运的是编译器一遍都有ROV(Return Value Optimization)。它会检测初始化是利用临时对象调用拷贝构造函数,由于临时对象在初始化语句之后便被释放,为了减少拷贝,直接使用该临时对象来构造新对象。

  但是ROV能做的非常有限。如果函数有多个返回出口,或者不是调用拷贝构造函数,而是拷贝赋值函数,编译器将会放弃ROV优化。多重拷贝的问题依然存在。

  右值在返回时,其类型被认定为const reference,它与普通的常量引用夹杂在一起,如何区分它们呢?到C++0x之前,标准是没有办法的。如果我们写两个构造函数:

1 X(X const&);
2 X(X&);

         它是存在问题的。X const&会把non-const临时对象一并收纳,而X&是一个non-const引用,它只能接受non-const左值。

  定义两种语义:

  • Copy语义。Copy语义是将源对象拷贝一份到目标对象,不影响源对象。

  • Move语义。Move语义是将源对象搬迁的目标对象,源对象被清空,存在所有权的转移。std::auto_ptr就是Move语义的智能指针。Move语义的实现只需要修改引用(指针地址)即可,不需要重新构造新的对象,开销非常小。

  常量性和左值右值可以通过下面的矩阵来看:

 ConstNon-const
L-valueConst l-valueNon-const l-value
R-valueConst r-valueNon-const r-value

  Non-const引用只能绑定到其中的一个组合,即non-const lvalue。只有non-const rvalue才是可以move的。现在的问题是如何设计重载函数来搞定const lvalue和const rvalue,使得最后只留下non-const rvalue。根据C++03,non-const rvalue只能绑定到const引用。但如果我们用const引用的话,就会越俎代庖把const左右值一并接受了。那除了用const引用,难道还有什么办法来接受一个non-const rvalue吗?在std::auto_ptr一节中,我们讨论一种方法来找出Non-const r-value,这种绕行的方法复杂,非常不优雅。

  T&作为函数参数,如果参数是左值,那么不管是const左值还是non-const左值,f都能正确转发,因为对于const左值,T将会被推导为const U(U为参数的实际类型)。并且,对于const右值,f也能正确转发(因为const引用能够绑定到右值)。只有对non-const右值不能完美转发(因为这时T&会被推导为non-const引用,而后者不能绑定到右值)。const T&作为函数的参数,可以完美转发non-const右值(non-const 右值绑定到const引用)。但const T&不能正确转发non-const左值。(这里的完美传递表示参数传递的过程中,保持参数的左值/右值、const/non-const属性不变)。

1 template<typename T>  
2 void f(T& t)  
3 {  
4      g(t);  
5 }  
6
7 template<typename T>  
8 void f(const T& t)  
9 {  
10      g(t);  
11 }

        我们来分析一下上面代码对左值/右值和const/non-const属性的影响。

  • 对于non-const左值,重载决议会选中T&,因为绑定到non-const引用显然优于绑定到const引用(const T&)。

  • 对于const左值,重载决议会选中const T&,因为显然这是个更specialized的版本。

  • 对于non-const右值,T&根本就行不通,因此显然选中const T&。

  • 对于const右值,选中const T&,原因同第二种情况。

  可见,这种方案完全保留了参数的左右值和const/non-const属性。但这种方法在多参数时,存在组合爆炸。问题的关键在于:对于non-const右值来说,模板参数推导机制不能正确地根据其右值属性确定T&的类型(non-const/const属性已经能够被正确推导出来,T&会被编译器推导为左值引用)。

  幸运的是在C++0x之后,C++标准开始支持右值引用,实现Copy和Move语义非常简单明了:

1 X(X const& o); // copy constructor
2 X(X&& o); // move constructor

        右值引用的特点是优先绑定到右值。其语法是&&(注意,不读作“引用的引用”,读作“右值引用”)。左值以及const右值都被绑定到了第一个重载版本。剩下的Non-const右值被绑定到第二个重载版本。

  引入右值引用后,模板参数推导规则为:

  • 如果实参是左值,那么T就被推导为U&(其中U为实参的类型),于是T&& = U& &&,而U& &&则退化为U&(理解为:左值引用的右值引用仍然是左值引用)。

  • 如果实参是右值,那么T就被推导为U,于是T&& = U&&(右值引用)。

0
相关文章