GotW#25 auto_ptr
难度:8/10
问题
考虑下面的代码:那些是好的,那些是安全的,那些是合法的, 那些是非法的?
auto_ptr<T> source() { return new T(1); }
void sink( auto_ptr<T> pt ) { }
void f() {
auto_ptr<T> a( source() );
sink( source() );
sink( auto_ptr<T>( new T(1) ) );
vector< auto_ptr<T> > v;
v.push_back( new T(3) );
v.push_back( new T(4) );
v.push_back( new T(1) );
v.push_back( a );
v.push_back( new T(2) );
sort( v.begin(), v.end() );
cout << a->Value();
}
class C {
public: /*...*/
protected: /*...*/
private:
auto_ptr<CImpl> pimpl_;
};
答案
考虑下面的代码:那些是好的,那些是安全的,那些是合法的, 那些是非法的?
标准更新:这周〖这篇GotW发布的那一周〗,在WG21/J16于Morristown NJ USA的会议上,C++语言的最终草案投票表决通过。我们期待在下次会议上(Nice, March 1998)它是否能通过并成为一个ISO正式标准。
在Jersey的会议上,auto_ptr被精化以满足委员会的要求。这期专刊纵览最终版本的auto_ptr,以了解怎样和为什么被修改得更安全和很易用,以及怎样更好地使用它。
概要:
1.所有对auto_ptr的合法使用工作得和以前一样,除了你不能使用(必然反引用)空auto_ptr。
2.对auto_ptr的危险的滥用成为非法。
感谢:感谢Bill Gibbons、Greg Colvin、Steve Rumsby和其他精心修改auto_ptr的人员。尤其是Greg多年来一直从事auto_ptr及相关类的修改工作以满足各方面的意见和要求,并应该得到公众对此工作的赞誉。
背景
auto_ptr的最初动机是使得下面的代码更安全:
void f() {
T* pt( new T );
/*...more code...*/
delete pt;
}
如果f()从没有执行delete语句(因为过早的return或者是在函数体内部抛出了异常),动态分配的对象将没有被delete,一个典型的内存泄漏。
使其安全的一个简单方法是用一个“灵巧”的类指针对象包容这个指针,并在其析构时自动删除此指针:
void f() {
auto_ptr<T> pt( new T );
/*...more code...*/
} // cool: pt's dtor is called as it goes out of
// scope, and the allocated object is deleted
现在,这个代码将不再泄漏T对象,无论是函数正常结束还是因为异常,因为pt的析构函数总在退栈过程中被调用。类似地,auto_ptr可以被用来安全地包容指针〖注意:关于安全的一个重要细节没有在本次提及,它见于GotW #62和《Exceptional C++》〗:
// file c.h
class C {
public:
C();
/*...*/
private:
auto_ptr<CImpl> pimpl_;
};
// file c.cpp
C::C() : pimpl_( new CImpl ) { }
现在,析构函数不再需要删除pimpl_指针,因为auto_ptr将自动处理它。我们将在结束时再次讨论这个例子。
源和移交
这就是它的出发点,但它干得更好。基于Greg Colvin的工作和经验,人们注意到如果定义了auto_ptr的拷贝函数,对其在函数间进行传递(作为参数或者返回值)非常有用。
这就是草案第二稿(Dec 1996)中的auto_ptr的实际工作方式,auto_ptr的拷贝行为将所有权从源移交到目标。在拷贝后,只有目标auto_ptr“拥有”这个指针并在适当的时候删除它,而源auto_ptr仍然包容同样的对象但并不“拥有”它于是也不删除它(否则会有二次删除问题)。你仍然可以使用这个指针同时通过拥有它或不拥有它的atuo_ptr对象。
例如:
void f() {
auto_ptr<T> pt1( new T );
auto_ptr<T> pt2;
pt2 = pt1; // now pt2 owns the pointer, and
// pt1 does not
pt1->DoSomething(); // ok (before last week)
pt2->DoSomething(); // ok
} // as we go out of scope, pt2's dtor deletes the
// pointer, but pt1's does nothing
让我们看第一部分代码:(注1)
auto_ptr<T> source() { return new T(1); }
void sink( auto_ptr<T> pt ) { }
结论
| Before NJ After NJ
| Legal? Yes Yes
| Safe? Yes Yes
这正是在Taligent人们所关注的:
l source()分配了一个新对象并用一个完全安全的方法将它返回给调用者,让调用者获得了指针的所有权。即使是调用者忽略了返回值(当然,你从不写忽略返回值的代码,对吧?),分配的对象仍然被安全地删除。
参见GotW #21,它证明了为什么这是一个重要的习惯用法,因为通过auto_ptr包容它而返回有时是唯一的让函数强异常安全方法。
l sink()接受一个值传递的auto_ptr,并接管它的所有权。当sink()结束时,删除动作被执行(假设sink()没有将所有权传递给别人)。因为sink()函数在函数体中没有做任何事,调用“sink(a);”相当于写为“a.release();”。
下面的代码显示了source()和sink()的行为:
void f() {
auto_ptr<T> a( source() );
结论
| Before NJ After NJ
| Legal? Yes Yes
| Safe? Yes Yes
此处,f()接管了从source()返回的指针的所有权,并(忽略f()代码后面部分的一些问题)在自动变量a超处生存范围时自动删除它。这很好,并显示了auto_ptr的值传递是可以工作的。
sink( source() );
结论
| Before NJ After NJ
| Legal? Yes Yes
| Safe? Yes Yes
由于这儿的source()和sink()的定义过于简单(比如,为空),这只不过是变了形的“delete new T(1);”。所以,它真的有用吗?好吧,假设source是个非空的厂函数而sink()是个非空的消费函数,那么,是的,它有很大的意义并突然有规律地出现在真实世界的程序中。
sink( auto_ptr<T>( new T(1) ) );
结论
| Before NJ After NJ
| Legal? Yes Yes
| Safe? Yes Yes
再次地,一个变形的“delete new T(1)”,并且当sink()是个非空的消费函数并接管了所指对象的所有权时,它是一个有用的习惯用法。
不能做的事情,以及为什么不能做
“那么,”你说道,“这太好了,明确地支持auto_ptr的拷贝是个好事。”是的,但它也使得你在最不期望的时候陷入水深火热之中,这也就是为什么反对草案2中的auto_ptr的形式的原因。这里有一个根本问题,我用黑体突出出来:
对于auto_ptr,拷贝不是对等的。
它指出了在范型中使用auto_ptr时的重要影响:必须明白拷贝是不相等的。例如:
vector< auto_ptr<T> > v;
结论
| Before NJ After NJ
| Legal? Yes No
| Safe? No No
这是第一个问题,也是委员会想修正的事之一。简而言之,即使编译器甚至没有提示warning,将auto_ptr放入容器也是不安全的。因为我们没有办法警告包容器:拷贝auto_ptr的语意是不同寻常的(传递了所有权,并改变了右侧对象的状态)。实际上,就我所知的绝大部分编译器都放过了这个代码,并甚至作为例子出现在它们的文档中。然而,它实际上是不安全的(并且,现在也是不合法的)。
问题是auto_ptr不满足放入容器的对象在类型上的要求,因为auto_ptr的拷贝并不相等。首先,并没有说vector必须对所包容的对象建一个“额外”的拷贝。当然,通常你期望vector不要做这个拷贝(因为它不是必须的,并且是低效的,并且基于竞争,卖方也不喜欢提供一个没必要的低效的库),但这并不一定如此,你也不能依赖它。
继续,因为开始发生错误:
v.push_back( new T(3) );
v.push_back( new T(4) );
v.push_back( new T(1) );
v.push_back( a );
(插一句:注意拷贝a到vector意味着'a'对象不在拥有它所携带的指针。过会儿再讲它。)
v.push_back( new T(2) );
sort( v.begin(), v.end() );
结论
| Before NJ After NJ
| Legal? Yes No
| Safe? No No
这实在是个恶梦,这也是委员会要求修改的另外一个原因(草案2中的auto_ptr的形式被表决No的主要原因)。当你调用将拷贝元素的范型函数(如sort())时,这写函数必须假设拷贝是相等的。例如,sort内部至少要保留一个“当前”元素的拷贝,而如果你想让它能工作于auto_ptr时,它必须有一个当前auto_ptr对象的拷贝(因此接管了它的所有权并放在一个临时auto_ptr对象中),并继续完成余下的工作(包括获得更多的现在不再拥有所有权的auto_ptr对象的拷贝以作为当前值),当排序完成时,临时对象被销毁,于是问题来了:序列中至少一个auto_ptr(作为当前值的拷贝的那个)不再拥有它包容的指针的所有权,实际上,这个指针已经被delete了!
草案2中的auto_ptr的问题是它没有提供保护--没有warning,什么都没有--以应付这样的错误代码。委员会要求auto_ptr或者去掉不同寻常的拷贝语义或者使得这些危险的代码无法编译,以便编译器能阻止你做危险的事情如构造一个auto_ptr的vector或试图对它进行排序。
挖掘不再拥有所有权的auto_ptr
// (after having copied a to another auto_ptr)
cout << a->Value();
}
结论
| Before NJ After NJ
| Legal? Yes No
| Safe? (Yes) No
(我们假设a已经被拷贝了,但其指针没有被vector或sort删除。)在草案2下,它很正常,因为虽然不再拥有所有权,auto_ptr仍然留有一份拷贝,只不过不在自己超出生存范围时对其调用delete,因为它知道它不再拥有所有权的。
然而,现在,拷贝一个auto_ptr不但传递所有权,并且还将源auto_ptr置NULL。这特别阻止了任何人通过没有所有权的auto_ptr做任何事情。根据最终规则,这样做是非法的,其结果未定义(在大部分系统下通常是内核dump。)
简而言之:
void f() {
auto_ptr<T> pt1( new T );
auto_ptr<T> pt2( pt1 );
pt1->Value(); // using a non-owning auto_ptr...
// this used to be legal, but is
// now an error
pt2->Value(); // ok
}
这是对auto_ptr的常见用法的最终版本。
封装指针成员
class C {
public: /*...*/
protected: /*...*/
private:
auto_ptr<CImpl> pimpl_;
};
〖注意:关于安全的一个重要细节没有在本次提及,它见于GotW #62和《Exceptional C++》〗
结论
| Before NJ After NJ
| Legal? Yes Yes
| Safe? Yes Yes
auto_ptr以前是,现在仍然是对指针成员的有效封装。它工作得非常象我们在开始处的“背景”一段中提起的例子,只是用不需要在C的析构函数中应付执行清理工作的麻烦替换了不需要在函数结束时执行清理工作的麻烦。
这儿仍然有一个警告,当然...就如同你仍然使用的是普通指针成员一样,你必须处理拷贝构造函数和赋值函数(哪怕是用申明它们为私有而不定义它们的方法来禁止它们),因为默认版本的行为是错误的。
最后的新闻:“cont auto_ptr”的习惯用法
现在我们进入一个更深层次,你将发现一个有趣的技巧。在其它好处之外,精修后的auto_ptr也使得拷贝const auto_ptr非法。例如:
const auto_ptr<T> pt1( new T );
// making pt1 const guarantees that pt1 can
// never be copied to another auto_ptr, and
// so is guaranteed to never lose ownership
auto_ptr<T> pt2( pt1 ); // illegal
auto_ptr<T> pt3;
pt3 = pt1; // illegal
这个“const auto_ptr”的习惯用法可能成为常用技巧之一,而现在你可以说你知道它了。
我希望你喜欢这期的专刊,以献给C++标准的ISO最终稿〖November 1997〗。
注1.在最初的问题中,我忘了没有一个T*到auto_ptr<T>的转换函数,因为构造函数是“explicit”的。后面的代码修正了这一点。