auto_ptr到底能不能作为容器的元素?

  

【摘要】对C++语言本身来说,它并不在乎用户把什么类型的对象作为STL容器的元素,因为模板类型参数在理论上可以为任何类型。比如说STL容器仅支持“值”语义而不支持“引用(&)”语义,并非因为模板类型参数不能为引用,而是因为如果容器元素为引用类型,就会出现“引用的引用”、“引用的指针”等C++语言不支持的语法和语义。智能指针是一种模拟原始指针行为的对象,因此理论上也可以作为容器的元素,就象原始指针可以作为容器元素一样。但是智能指针毕竟是一种特殊的对象,它们在原始指针共享实值对象的基础能力上增加了自动销毁实值对象的能力,如果将它作为容器的元素,可能导致容器之间共享元素对象实值,这不仅不符合STL容器的概念和“值”语义,也会存在安全隐患,同时也会存在许多应用上的限制,特别是象STL中的auto_ptr这样的智能指针。本文深入地阐述了auto_ptr这种较简单的智能指针“可以”或者“不可以”作为容器元素的根本原因,以及它作为容器元素会存在的限制和带来的问题,最后说明auto_ptr存在的真正意义、正确的使用方法以及它的替代品——带有引用计数能力的智能指针,当容器之间需要共享元素对象时,或者程序中存在大量的指针传递而担心资源泄漏时,这样的智能指针就特别有用。

【关键字】auto_ptr  容器  智能指针

 

一、引言

Scott Meyers在《More Effective C++[3]一书中对智能指针及其相关问题(构造、析构、复制、提领、测试以及类型转换等)作了深入的分析,其中也提到“STLauto_ptr这种在复制时会把对实值对象的拥有权转交出去的智能指针不宜作为STL容器的元素”,而且在他的《Effective STL[4]Item 8中明确指出了这一点。Nicolai M.Josuttis的《The C++ Standard Library[5]中有一节专门针对auto_ptr的阐述也指出“auto_ptr不满足STL标准容器对元素的最基本要求”。但是他们都是从容器的需求、语义以及应用的安全性来阐述,而没有从语言的静态类型安全性和auto_ptr的实现方案角度深入地分析其原因,因此有些读者看了之后可能仍然不明就里:它是如何不满足容器需求的?它是如何违反C++的静态类型安全性从而避免误用的?

我们知道,可以作为STL容器的元素的数据类型一般来说需要满足下列条件:

1)可默认构造的(Default Constructible),也即具有publicdefault constructor,不论是用户显式定义的还是编译器自动合成的。但是用户定义的带参数的constructor(包括copy constructor)会抑制编译器合成default constructor。实际上并非任何情况下任何一种容器都强制要求其元素类型满足这一要求,特别是关联式容器,因为只有序列式容器的某些成员函数才可能明确地或隐含地使用元素类型的默认构造函数,如果你不使用这样的成员函数,编译器就不需要元素类型的默认构造函数;

2)可拷贝构造(Copy Constructible)和拷贝赋值(Copy Assignable)的,即具有publiccopy constructorcopy assignment operator,不论是编译器自动合成的还是用户显式定义的。其它版本的operator=()重载并不会抑制编译器合成copy assignment operator,如果你没有显式定义它的话。这个条件可归结为:元素必须是可拷贝的(Copyable),但实际上拷贝赋值的要求也不是强制的,原因和默认构造函数类似;

3)具有publicdestructor,不论是编译器自动合成的还是用户显式定义的;

4)对于关联式容器,要求其元素必须是可比的(Comparable)。

auto_ptr满足上述条件吗?至少满足前三条,因此至少可以作为序列式容器的元素;如果为auto_ptr定义了比较运算符的话,应该还可以把它作为关联式容器的元素。

但是auto_ptr的特点是接管和转移拥有权,而不是像原始指针那样可以共享实值对象,即:auto_ptr在初始化时接管实值对象和拥有权,而在拷贝时(拷贝构造和拷贝赋值)会交出实值对象及其拥有权。因此,auto_ptr对象和它的拷贝绝对不会共享实值对象,任何两个auto_ptr也不应该共享同一个实值对象。这就是说,auto_ptr对象和它的拷贝并不相同。然而根据STL容器“值”语义的要求,可拷贝构造意味着一个对象必须和它的拷贝相同(标准中的正式定义比这稍复杂一些)。同样,可赋值意味着把一个对象赋值给另一个同类型对象将产生两个相同的对象。显然,auto_ptr不能满足这一要求,似乎与上面的结论矛盾!

那么问题究竟出在哪里呢?

 

二、copy constructorcopy assignment operator的形式

在揭开auto_ptr的神秘面纱之前需要了解copy constructorcopy assignment operator的几种合法形式。任何一个类都允许两种形式的copy constructor[1]C代表任何一个类):

C(const C& copy);

C(C& copy);

同样,copy assignment operator也允许类似的两种形式(返回值类型视实际需要可改变):

C& operator=(const C& copy);

C& operator=(C& copy);

实际上,由于copy assignment operator为普通的运算符重载成员函数,因此还可以为下列形式:

C& operator=(C copy);

这两个函数具体是什么形式,取决于用户的定义或者该类的成员对象及其基类具有什么样的copy constructorcopy assignment operator。比如,如果基类的copy constructor为第一种形式,那么编译器自动为派生类合成的copy constructor也为第一种形式;相反为第二种形式。Copy assignment operator亦类似。具体细节可参考[8]

这两种形式的区别就在于参数有无修饰符const:如果有const修饰,则该函数体不能修改实参对象(即拷贝源),也不能调用其non-const成员函数;如果没有const修饰,则该函数可以修改实参对象,也可以调用其non-const成员函数。

从语言的角度讲,任何对象都可以放到容器中(只要不是引用,因为STL容器不支持“引用”语义),只是某些类型的对象会存在安全隐患或者其容器会受到很大的应用限制。如果要防止用户把一些不适宜的对象放入容器中,就要求对象的设计和实现者使用一些语言支持的但不常用的特征。也就是说,要能够在编译阶段就阻止这种具有潜在危险性的行为。常用的方法就是迫使其违反C++静态类型安全规则。

下面我们来看一看auto_ptr到底是如何通过迫使其违反C++静态类型安全规则而在编译时阻止将其作为容器元素的。

三、auto_ptr源码分析

其实auto_ptr的拥有权管理非常简单。根据上一节的阐述,可以使用两种方案来实现auto_ptr。下面是拷贝构造函数和拷贝赋值函数采用non-const参数的一个实现版本:

template<class T>

class auto_ptr

{

private:

T      *m_ptr;            // 原始指针

public:

explicit auto_ptr(T *p = 0) throw()        // explicit constructor

: m_ptr(p){ }                          // *p必须是运行时创建的对象

 

auto_ptr(auto_ptr& other) throw()         // 非常规copy constructor

: m_ptr(other.release()){ }           // 转让拥有权,修改了实参对象

 

#ifdef _SUPPORT_MEMBER_TEMPLATES_

template<class U>

auto_ptr(auto_ptr<U>& other) throw()

: m_ptr(other.release()){ }           // 转让拥有权,修改了实参对象

#endif

    

auto_ptr& operator=(auto_ptr& other) throw() // 非常规assignment

{

if (&other != this) {

delete m_ptr;                     // 释放实值对象

m_ptr = other.release();          // 交出拥有权,修改了实参对象

}

return (*this);

}

    

#ifdef _SUPPORT_MEMBER_TEMPLATES_

template<class U>

auto_ptr& operator=(auto_ptr<U>& other) throw()

{

if (other.get() != this->get()) {

delete m_ptr;                   // 释放实值对象

m_ptr = other.release();        // 交出拥有权,修改了实参对象

}

return (*this);

}

#endif

        

            // 从析构函数看,m_ptr必须指向动态创建的对象

~auto_ptr(){ delete m_ptr; }           // destructor,“delete 0”没有任何问题!

T& operator*() const throw(){ return *m_ptr; }

T* operator->() const throw(){ return m_ptr; }

T* get() const throw(){ return m_ptr; }

 

T* release() throw(){

T *temp = m_ptr;

m_ptr = 0;          // 必要!修改成员,释放拥有权

return temp;

}

void reset(T *p = 0) throw(){

if (p != m_ptr) {

delete m_ptr;

m_ptr = p;

}

}

bool owns() const{ return (m_ptr != 0); }

…      // 这里省略了一些无关紧要的东西

};

如你所见,该auto_ptr实现版本的copy constructorcopy assignment operator的参数类型都是non-const的,因为这两个函数都会修改实参对象的数据成员,即调用其release方法(non-const方法)释放其对实值对象的拥有权,并把实值对象的指针置为0。如果参数类型为const的,那么这种修改就不可能直接进行。所以,一旦用一个auto_ptr对象去构造另一个auto_ptr对象,或者把一个auto_ptr对象赋值给另一个auto_ptr对象,你就不能再使用原来的那个auto_ptr对象了,因为反引用NULL指针会导致运行时异常,除非你让它重新接管一个新的实值对象。

这个版本的auto_ptr就不能作为任何容器的元素,如果你这样做了,在编译阶段就会检查出错误,即违反了C++的静态类型安全规则。比如:

std::list< std::auto_ptr<int> >  la;     // auto_ptr列表

std::auto_ptr<int> p1(new int(1));

std::auto_ptr<int> p2(new int(2));

std::auto_ptr<int> p3(new int(3));

la.push_back(p1);                        // compiling-error!

la.push_back(p2);                        // compiling-error!

la.push_back(p3);                        // compiling-error!

 

set<auto_ptr<int> > sa;                  // auto_ptr集合:假设为auto_ptr定义了operator<

sa.insert(p1);                           // compiling-error!

sa.insert(p2);                           // compiling-error!

sa.insert(p3);                           // compiling-error!

STL容器管理元素的方法是动态创建元素的拷贝,并负责管理这些动态分配的资源,即值的深拷贝语义(deep copy),具体由一个可定制的memory allocator来负责,不过这不是我们讨论的重点,因此忽略。可以想象std::list<T>::push_back方法的实际动作如下:

template<typename T>

void list<T>::push_back(const T& x)

{

T *p = operator new(sizeof(T));  // 分配内存空间

new (p) T(x);                    // placement new,调用T的copy constructor

……                             // 将p交给容器管理,调整容器大小

}

由于auto_ptrcopy constructor被显式地定义为接受non-const&,因此上述函数实现就需要将一个const T& x转换为non-const&,显然是违反静态类型安全规则的。STL容器不能使用强制类型转换来帮你达到此目的,否则它本身就不是类型安全的了。

其它追加元素的方法如insert的某些版本也是一样的道理。

上述auto_ptr通过采用非常规copy constructorcopy assignment operator使“企图将auto_ptr对象作为STL容器元素”的行为在编译阶段就被检测出来,从而避免了潜在的危险。

然而如果auto_ptr采用常规copy constructorcopy assignment operator形式,编译器就无能为力了,因为它们不违反C++静态类型安全规则。P.J.Plauger版本(MS VC++采用的实现)的auto_ptr就是一个例子!

P.J.Plauger版本的auto_ptr确实可以作为容器的元素,这并不是因为它没有修改拷贝源的拥有权,而是它的release函数虽然是const member function,却在修改拥有权时使用了const_cast强制类型转换。因为在一个const member function里面,编译器把当前对象看成是一个const对象(即this的类型为const auto_ptr<T> * const),调用copy constructor时通过强制类型转换就可以修改实参对象的拥有权属性,尽管它是const &传递。

下面是拷贝构造函数和拷贝赋值函数采用const参数的一个实现版本:

template<class T>

class auto_ptr

{

private:

T      *m_ptr;            // 原始指针

public:

explicit auto_ptr(T *p = 0) throw()        // explicit constructor

: m_ptr(p){ }                          // *p必须是运行时创建的对象

 

auto_ptr(const auto_ptr& other) throw()   // 常规copy constructor

: m_ptr(other.release()){ }           // 转让拥有权,修改了实参对象

 

#ifdef _SUPPORT_MEMBER_TEMPLATES_

template<class U>

auto_ptr(const auto_ptr<U>& other) throw()

: m_ptr(other.release()){ }           // 转让拥有权,修改了实参对象

#endif

    

auto_ptr& operator=(const auto_ptr& other) throw() // 常规assignment

{

if (&other != this) {

delete m_ptr;                     // 释放实值对象

m_ptr = other.release();          // 交出拥有权,修改了实参对象

}

return (*this);

}

    

#ifdef _SUPPORT_MEMBER_TEMPLATES_

template<class U>

auto_ptr& operator=(const auto_ptr<U>& other) throw()

{

if (other.get() != this->get()) {

delete m_ptr;                   // 释放实值对象

m_ptr = other.release();        // 交出拥有权,修改了实参对象

}

return (*this);

}

#endif

 

            // 从析构函数看,m_ptr必须指向动态创建的对象

~auto_ptr(){ delete m_ptr; }           // destructor,“delete 0”没有任何问题!

T& operator*() const throw(){ return *m_ptr; }

T* operator->() const throw(){ return m_ptr; }

T* get() const throw(){ return m_ptr; }

 

T* release() const throw(){

T *temp = m_ptr;

((auto_ptr<T>*)this)->m_ptr = 0;     // 必要!修改成员,释放拥有权

return temp;

}

void reset(T *p = 0) throw(){

if (p != m_ptr) {

delete m_ptr;

m_ptr = p;

}

}

bool owns() const{ return (m_ptr != 0); }

…      // 这里省略了一些无关紧要的东西

};

一旦如此实现,auto_ptr容器就可以顺利通过编译并可能正确执行。例如:

int main()

{

 typedef std::list<std::auto_ptr<int> >  IntPtrList;

IntPtrList  la;  // or:std::vector<std::auto_ptr<int> >  va;

std::auto_ptr<int> p1(new int(1));

std::auto_ptr<int> p2(new int(2));

std::auto_ptr<int> p3(new int(3));

std::auto_ptr<int> p4(new int(4));

std::auto_ptr<int> p5(new int(5));

la.push_back(p1);                   // ok! 转交所有权

la.push_back(p2);                   // ok! 转交所有权

la.push_back(p3);                   // ok! 转交所有权

la.push_back(p4);                   // ok! 转交所有权

la.push_back(p5);                   // ok! 转交所有权

// 不能再使用p1、p2、p3、p4、p5

for (IntPtrList::const_iterator first = la.begin(),

      last = la.end(); first != last; ++first)

std::cerr << **first << ‘\t’;

// 不能再使用p1、p2、p3、p4、p5

return 0;

} // la析构的时候会自动调用每一个auto_ptr元素的析构函数,从而保证释放动态分配的内存

 

输出:

1   2   3   4   5

但是把auto_ptr作为容器元素毕竟是一个危险的动作,而且这样的容器在使用时会受到很大的限制。如果上面的程序接着使用la,比如创建它的拷贝,调整它的大小,甚至对它排序,那么la就可能遭到破坏,它的所有元素会变成无效指针,或者里面夹杂了无效指针,甚至有可能丢失一些元素,而你却没有意识到。例如,假设为auto_ptr定义了泛型比较运算符:

int main()

{

 typedef std::vector<std::auto_ptr<int> >  IntPtrVector;

IntPtrVector  va;

std::auto_ptr<int> p1(new int(1));

std::auto_ptr<int> p2(new int(2));

std::auto_ptr<int> p3(new int(3));

std::auto_ptr<int> p4(new int(4));

std::auto_ptr<int> p5(new int(5));

va.push_back(p1);

va.push_back(p2);

va.push_back(p3);

va.push_back(p4);

va.push_back(p5);

 

 (注意:以下操作并非放在一起进行,仅是示范)

 IntPtrVector vb = va;    // va丧失对所有实值对象的拥有权,元素成为NULL指针

 vb.resize(10);           // 新增的元素都为NULL指针

 std::sort(vb.begin(), vb.end());      // 可能会使其中某些元素成为NULL指针

 std::auto_ptr<int> t = vb.front();    // 改变了容器元素

 std::auto_ptr<int> r = vb[3];         // 改变了容器元素

 

 std::list<std::auto_ptr<int> >  la;

 std::copy(vb.begin(), vb.end(), std::back_inserter(la)); // copy改变了拷贝源

 

return 0;

}

Scott Meyers在《Effective STL[4]Item 8中详细地分析了对auto_ptr容器进行排序时可能会导致的问题。但是在MSVC++环境下经测试,并没有出现书中所描述的悲惨结果,而是结果正确。主要的原因在于C++标准并没有要求std::sort等泛型算法的实现必须采用某一种方法,而是只规定了它们的接口、功能和应该达到的性能要求(容器也是如此)。因此,不同的STL实现可能采取不同的方法,比如有的sort实现采用快速排序法,而有的采用插入式排序法,等等。不同的排序方法在遭遇auto_ptr这样的容器时可能就会产生不同的结果。

P.J.Plauger版本在这方面的防范能力确实不如SGI版本做得好!不过没关系,STL的源代码都是公开的,你可以比较不同的实现甚至修改它们,使之更安全、更适合你的应用。

四、auto_ptr对象作为容器元素的危险性

应该说,从应用的方便性和安全角度出发,容器应该要求其元素对象的拷贝与原对象相同或者等价,但auto_ptr显然不满足这一条。auto_ptr作为容器元素的危险性主要表现在如下几个方面:

1)将auto_ptr对象插入容器中之后企图继续使用它,比如通过它调用实值对象的成员函数,然而此时它指向的实值对象已经交给容器中的某一个元素对象了;

2)就象auto_ptr和它的拷贝并不相同一样,auto_ptr容器和它的拷贝也不一样;如果对某些成员函数的返回结果使用不当的话,可能无意中会产生不期望的结果。因此其应用受到很大限制,必须小心应付;

3)某些算法将无法用于这样的容器。比如sort等会修改区间的算法,因为它们的实现调用元素对象的copy constructorcopy assignment operator,可能会释放掉某些元素对实值对象的拥有权;就连本来不会修改源区间的算法如copy,如果应用于auto_ptr容器,也可能修改源区间;还有的算法比如find等要求元素对象提供比较能力,如果auto_ptr不是可比的,那也不能用于auto_tr容器;

4)不可移植。目前有些STL实现比如SGI版本可以在编译阶段阻止这种行为,但是某些STL实现仍然允许这样做。

鉴于此,无论你使用的STL平台是否允许auto_ptr容器,你都不应该这样做。

然而许多其它的功能强大的智能指针,比如使用了引用计数的智能指针,作为容器的元素时不会存在上述问题,但是auto_ptr不是这样的智能指针。关于智能指针的更详细阐述还可参考Andrei Alexandrescu的《Modern C++ Design[6]一书第7章。

可见,智能指针“可以”还是“不可以”作为容器的元素并非绝对的,不仅与STL的实现有关,而且与STL容器的需求和安全性以及容器的语义有关。

五、auto_ptr的正确用法

既然auto_ptr在复制或赋值时会使原来的auto_ptr失效,那么我们只要防止其复制和赋值行为的发生就可以了。比如在传递auto_ptr对象时使用const &const *传递而不是值传递。例如:

void func(const auto_ptr<int>& pInt)

{

cout << *pInt << endl;

}

int main()

{

auto_ptr<int> a(new int(100));

func(a);

}

但即使这样,如果遇到象P.J.Plauger那样实现的auto_ptr,还是不能保证在函数内部不会出现对它的拷贝或者赋值。

再就是不要用静态创建的对象来初始化auto_ptr。例如:

int main()

{

int x(100);

auto_ptr<int> a(&x);

}// 这里调用delete删除本地对象,错误!

由于auto_ptr是对象化的智能指针,具有自动释放资源的能力,因此它真正有价值的用途是在发生异常时避免资源泄漏。比如,如果不使用auto_ptr,则下列代码在发生异常的情况下不得不多次手工释放资源:

class A{ … };

void func()

{

A *pA = new A;

try

{

…                // using *pA

}

catch(…)

{

delete pA;       // 发生异常时要显式释放

throw;

}

delete pA;           // 函数退出时还要显式释放

}

现在有了auto_ptr,我们就可以这么做:

class A{ … };

void func()

{

auto_ptr<A> pA(new A);

…                // using *pA

}

这是因为C++有一个保证:本地对象在函数退出时总是会被销毁,而不论函数以何种方式退出。也就是说,不管是在发生异常的情况下函数退出,还是函数的正常退出,堆栈都要展开,每一个本地对象的析构函数都会被依次调用。关于资源泄漏和智能指针的相关话题,请参考[3]的第9101128等条款,其中有极详细和精彩的论述。

如果想防止无意中修改auto_ptr对实值对象的拥有权,可以使用const auto_ptr,这样的auto_ptr只能使用引用或指针传递,不能值传递,也不能赋值和拷贝构造。例如:

class A{ … };

void func()

{

const auto_ptr<A> p1(new A);

…                               // using *pA

auto_ptr<A> p2(p1);              // error!

auto_ptr<A> p3;

p3 = p1;                         // error!

}

关于auto_ptr的运用技巧可参考[5]的相关章节。

 

【参考资料】

[1]STL implementation, SGI co., 2000.

[2]STL implementation, P.J.Plauger, 1995.

[3]More Effective C++, Scott Meyers, 1998.

[4]Effective STL, Scott Meyers, 2001.

[5]The C++ Standard Library, Nicolai M.Josuttis, 1999.

[6]Modern C++ Design, Andrei Alexandrescu, 2001.

[7]Generic Programming and the STL, H.Austern, 1999.

[8]Inside the C++ Object Model, Stanley B.Lippman, 1996.



[1] 注意:C(C copy); 并非一个递归函数,它是非法的,因为它是一个悖论。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值