http://www.vckbase.com/document/viewdoc/?id=847
编译:死猫
校对:Wang Tianxing
1 引言
我相信大家很了解,创建、复制和销毁临时对象是C++编译器最爱的户内运动。不幸的是,这些行为会降低C++程序的性能。确实,临时对象通常被视为C++程序低效的第一因素[1]。
下面的代码是正确的:
vector < string > ReadFile();
vector < string > vec = ReadFile();
或者
string s1, s2, s3;
//...
s1 = s2 + s3;
但是,如果关心效率,则需要限制类似代码的使用。ReadFile()和operator+创建的临时对象分别被复制然后再废弃。这是一种浪费!
为了解决这个问题,需要一些不太优雅的约定。例如,可以按照引用传递函数参数:
void ReadFile(vector < string > & dest);
vector < string > dest;
ReadFile(dest);
这相当令人讨厌。更糟的是,运算符没有这个选择,所以如果想高效的处理大对象,程序员必须限制创建临时对象的运算符的使用:
string s1, s2, s3;
//...
s1 = s2;
s1 += s3;
这种难缠的手法通常减缓了设计大程序的大团队的工作效率,这种强加的持续不断的烦恼扼杀了编写代码的乐趣而且增加了代码数量。难道从函数返回值,使用运算符传递临时对象,这样做是错误的吗?
一个正式的基于语言的解决方案的提议已经递交给了标准化委员会[2]。Usenet上早已引发了大讨论,本文也因此在其中被反复讨论过了。
本文展示了如何解决C++存在的不必要的复制问题的方法。没有百分之百让人满意地解决方案,但是一个干净的程度是可以达到的。让我们一步一步的来创建一个强有力的框架,来帮助我们从程序中消除不需要的临时对象的复制。这个解决方案不是百分之百透明的,但是它消除了所有的不需要的复制,而且封装后足以提供一个可靠的替代品,直到多年以后,一个干净的、基于语言的标准化的实现出现。
2 临时对象和“转移构造函数”(Move Constructor)
在和临时对象斗争了一段时间之后,我们意识到在大多数情况下,完全消除临时对象是不切实际的。大多数时候,关键是消除临时对象的复制而不是临时对象本身。下面详细的讨论一下这个问题。
大多数具有昂贵的复制开销的数据结构将它们的数据以指针或者句柄的形式储存。典型的例子包括,字符串(String)类型储存大小(size)和字符指针(char*),矩阵(Matrix)类型储存一组整数维数和数据存储区指针(double*),文件(File)类型储存一个文件句柄(handle)。
如你所见,复制字符串、矩阵或者文件的开销不是来自于复制实际的数据成员,而是来自于指针或者句柄指向的数据的复制。
因此,对于消除复制的目的来说,检测临时对象是一个好方法。残酷点说就是,既然一个对象死定了,我们完全可以趁着它还新鲜,把它用作器官捐献者。
顺便说一下什么是临时对象?这里给出一个非正式的定义:
当且仅当离开一段上下文(context)时在对象上执行的仅有的操作是析构函数时,一个对象被看成是临时的。这里上下文可能是一个表达式,也可能是一个语句范围,例如函数体。
C++标准没有定义临时对象,但是它假定临时对象是匿名的,例如函数的返回值。按照我们的更一般化的定义,在函数中定义的命名的栈分配的变量也是临时的。稍后为了便于讨论我们使用这个一般化的定义。
考虑这个String类的实现(仅作为示例):
class String
{
char* data_;
size_t length_;
public:
~String()
{
delete[] data_;
}
String(const String& rhs)
: data_(new char[rhs.length_]), length_(rhs.length_)
{
std::copy(rhs.data_, rhs.data_ + length_, data_);
}
String& operator=(const String&);
//...
};
这里复制的成本主要由data_的复制组成,也就是分配新的内存并复制。如果可以探测到rhs实际上是临时的就好了。考虑下面的C++伪代码:
class String
{
//...同前...
String(temporary String& rhs)
: data_(rhs.data_), length_(rhs.length_)
{
//复位源字符串使它可以被销毁
//因为临时对象的析构函数仍然要执行
rhs.data_ =0;
}
//...
}
这个我们虚构的重载构造函数String(temporary String&)在创建一个String临时对象(按照前面的定义)时调用。然后,这个构造函数执行了一个rhs对象转移的构造过程,只是简单的复制指针而不是复制指针指向的内存块。最后,“转移构造函数”复位源指针rhs.data_(恢复为空指针)。使用这个方法,当临时对象被销毁时,delete[]会无害的应用在空指针上[译注:C++保证删除空指针是安全的]。
一个重要的细节是“转移构造”后rhs.length_没有被清0。按照教条主义的观点,这是不正确的,因为data_==0而length_!=0,所以字符串被破坏了。但是,这里有一个很站得住脚的理由,因为rhs的状态没有必要是完整的,只要它可以被安全而正确的销毁就行了。这是因为会被应用在rhs上唯一一个操作就是析构函数,而不是其他的。所以只要rhs可以被安全的销毁,而不用去看是否像一个合法的字符串。
“转移构造函数”对于消除不需要的临时对象复制是一个良好的解决方案。我们只有一个小问题,C++语言中没有temporary关键字。
还应该注意到临时对象的探测不会帮助所有的类。有时,所有的数据直接存储在容器中。考虑:
class FixedMatrix
{
double data_[256][256];
public:
//...操作...
};
对这样一个类,实际上复制成本在于逐字节的复制sizeof(FixedMatrix)个字节,而探测临时对象并没有帮助[译注:因为数组不是指针,不能直接交换地址]。
3 过去的解决方案
不必要的复制是C++社区长期存在的问题。有两个努力方向齐头并进,其一是从编码和库编写的角度,另一个是语言定义和编译器编写层面。
语言/编译器观点方面,有返回值优化(Return Value Optimization, RVO)。RVO被C++语言定义所允许[3][译注:但是不是强制性的,而是实现定义的]。基本上,编译器假定通过拷贝构造函数(Copy Constructor)复制返回值。
确切地说,基于这样的假定,因此编译器可以消除不必要的复制。例如,考虑:
vector< String > ReadFile()
{
vector< String > result;
//...填充result...
return result;
}
vector< String > vec=ReadFile();
聪明的编译器可以将vec的地址作为一个隐藏的参数传递给ReadFile而把result创建在那个地址上。所以上面的源代码生成的代码看起来像这样:
void ReadFile(void* __dest)
{
//使用placement new在dest地址创建vector
vector< String >& result=
*new(__dest) vector< String >;
//...填充result...
}
//假设有合适的字节对齐
char __buf[sizeof(vector< String >)];
ReadFile(__buf);
vector< String >& vec=
*reinterpret_cast < vector< String >* >(__buf);
RVO有不同的风格,但要旨是相同的:编译器消除了一次拷贝构造函数的调用,通过简单的在最终目的地上构造函数返回值。
不幸的是,RVO的实现不像看上那样容易。考虑ReadFile稍稍修改后的版本:
vector< String > ReadFile()
{
if (error) return vector< String >();
if (anotherError)
{
vector< String > dumb;
dumb.push_back("This file is in error.");
return dumb;
}
vector< String > result;
//...填充result...
return result;
}
******************************************************
Wang Tianxing校注:
这个例子并不是很有说服力。里面的三个对象的作用域互不相交,因此还是比较容易使用 RVO 的。难以运用RVO的是这种情况:
vector< String > ReadFile()
{
vector< String > dumb;
dumb.push_back( "This file is in error." );
vector< String > result;
// ... 填充 result ...
return error ? dumb : result;
}
******************************************************
现在有不止一个局部变量需要被映射到最后的结果上,他们有好几个。有些是命名的(dumb/result),而另一些是无名的临时对象。无需多说,面对这样的局面,大量优化器会投降并且服从保守的和缺乏效率的方法。
即使想写不导致混淆RVO实现的“直线条”的代码,也会因为听到每个编译器或者编译器版本都有自己探测和应用RVO的规则而失望。一些RVO应用仅仅针对返回无名临时对象的函数,这是最简单的RVO形式。最复杂的RVO应用之一是函数返回值是一个命名的结果,叫做命名返回值优化(Named RVO或NRVO)。
本质上,写程序时要指望可移植的RVO,就要依赖于你的代码的精确写法(在很难定义的“精确”意义下),依赖于月亮的圆缺,依赖于你的鞋的尺码。
但是,别忙,还有很多种情况下RVO无法避免临时对象的拷贝。编译器时常不能应用RVO,即使它很想。考虑稍稍改变后的 ReadFile() 的调用:
vector vec;
vec=ReadFile();
这个改变看上去完全没有恶意,但是却导致了巨大的差异。现在不再调用拷贝构造函数而调用赋值运算符(assignment operator),这是令一个不同的脱缰野马。除非编译器优化技巧完全像是在使用魔法,现在真的可以和RVO吻别了:vector<T>::operator=(const vector<T>&)期望一个vector的常量引用,所以ReadFile会返回一个临时对象,绑定到一个常量引用,复制到vec,然后被废弃。不必要的临时对象又来了!
在编码方面,一个长期被推荐的技术是COW(按需复制,copy-on-write)[4],这是一个基于引用计数的技巧。
COW有几个优点,其中之一是探测和消除了不必要的复制。例如,函数返回时,返回的对象的引用计数是1。然后复制的时候,引用计数增加到2。最后,销毁临时对象的时候,引用计数回到1,引用指向的目的地仅仅是数据的所有者。实际上没有复制动作发生。
不幸的是,引用计数在多线程安全性方面有大量的缺陷,增加自己的开销和大量隐藏的陷阱[4]。COW是如此之笨拙,因此,虽然它有很多优点,最近的STL实现都没有为std::string使用引用计数,尽管实际上std::string的接口有目的设计为支持引用计数!
已经开发了几个实现“不可复制”对象的办法,auto_ptr是最精炼的一个。auto_ptr是容易正确使用的,但是不幸的是,刚好也容易不正确的使用。本文的讨论的解决方法扩充了定义auto_ptr中使用的技术。
4 Mojo
Mojo(联合对象转移,Move of Joint Objects)是一项编码技术,又是一个消除不必要的临时对象复制的小框架。Mojo通过辨别临时对象和合法的“非临时”的对象而得以工作。
4.1 传递函数参数
Mojo引发了一个有趣的分析,即函数参数传递约定的调查。Mojo之前的一般建议是:
[规则1]如果函数试图改变参数(也就是作为副作用),则把参数作为非常量对象的指针或者引用传递。例如:
void Transmogrify(Widget& toChange);
void Increment(int* pToBump);
[规则2]如果函数不修改它的参数而且参数是基本数据类型,则按照值传递参数。例如:
double Cube(double value);
[规则3]否则,参数是用户自定义类型(或者模板的类型参数)而且一定不变,则作为常量引用传递参数。例如:
String& String::operator=(const String& rhs);
template< class T > vector< T >::push_back(const T&);
第三条规则试图避免意外的大对象的复制。然而,有时第三条规则强制不必要的复制进行而不是阻止它的发生。考虑下面的Connect函数:
void Canonicalize(String& url);
void ResolveRedirections(String& url);
void Connect(const String& url)
{
String finalUrl=url;
Canonicalize(finalUrl);
ResolveRedirections(finalUrl);
//...使用finalUrl...
}
Connect函数获得一个常量引用的参数,并快速的创建一个副本。然后进一步处理副本。
这个函数展示了一个影响效率的常量引用的参数使用。Connect的函数声明暗示了:“我不需要一个副本,一个常量引用就足够了”,而函数体实际上却创建了一个副本。所以假如现在这样写:
String MakeUrl();
//...
Connect(MakeUrl());
可以预料MakeUrl()会返回一个临时对象,他将被复制然后销毁,也就是令人畏惧的不需要的复制模式。对一个优化复制的编译器来说,不得不作非常困难的工作,其一是访问Connect函数的定义(这对于分离编译模块来说很困难),其二是解析Connect函数的定义并进一步理解它,其三是改变Connect函数的行为以使临时对象和finalUrl融合。
假如现在将Connect函数改写如下:
void Connect(String url) //注意按值传递
{
Canonicalize(url);
ResolveRedirections(url);
//... 使用 url ...
}
从Connect的调用者的观点来看,绝对没有什么区别:虽然改变了语法接口,但是语义接口仍然是相同的。对编译器来说,语法的改变使所有事物都发生了改变。现在编译器有更多的余地关心url临时对象了。例如,在上面提到的例子中:
Connect(MakeUrl());
编译器不一定要真的聪明到将MakeUrl返回的临时对象和Connect函数需要的常量融合。如果那么做,确实会更加困难。最终,MakeUrl的真正结果会被改变而且在Connect函数中使用。使用常量引用参数的版本会使编译器窒息,阻止它实行任何优化,而使用传值参数的版本和编译器顺畅的合作。
这个新版本的不利之处在于,现在调用Connect也许生成了更多的机器码。考虑:
String someUrl=...;
Connect(someUrl);
在这种情况下,第一个版本简单的传递someUrl的引用[译注:从非常量到常量是标准转型]。第二个版本会创建一个someUrl的副本,调用Connect,然后销毁那个副本。随着调用Connect的静态数量的增长,代码大小的开销同时增长。另一方面,例如Connect(MakeUrl())这样的调用会引入临时对象,在第二个版本中又刚好生成更少的代码。在多数情况下,大小差异好像不会导致问题产生[译注:在某些小内存应用中则是一个问题,例如嵌入式应用环境]。
所以我们给出了一套不同的推荐规则:
- [规则1]如果函数内部总是制作参数的副本,按值传递。
- [规则2]如果函数从来不复制参数,按常量引用传递。
- [规则3]如果函数有时复制参数,而且关心效率,则按照Mojo协议。
现在只留下开发Mojo协议了,不管它是什么。
主要的想法是重载同样的函数(例如Connect),目的是辨别临时的和非临时的值。后者也称为左值(lvalue),因为历史原因,左值因为可以出现在赋值运算符的左边而得名。
现在开始重载Connect,第一个想法是定义Connect(const String&)来捕捉常量对象。然而这是错误的,因为这个声明“吞吃”了所有的String对象,不管是左值(lvalue)或者临时对象[译注:前面提到过,非常量可以隐式转型为常量,这是标准转型动作]。所以第一个好主意是不要声明接受常量引用的参数,因为它像一个黑洞一样,吞噬所有的对象。
第二个尝试是定义Connect(String&)试图捕获非常量的左值。这工作良好,特别是常量值和无名的临时对象不能被这个重载版本接受,这是一个好的起点。现在我们只剩下在常量对象和非常量临时对象之间作出区分了。
为了达到这个目的,我们采取了一种技术,定义两个替身类型[译注:原文是type sugar,嘿嘿,如果你愿意,可以叫他类型砂糖,如果你喜欢吃糖的话。]ConstantString和TemporaryString,并且定义了从String对象到这些对象转型运算符:
class String;
//常量String的替身类型
struct ConstantString
{
const String* obj_;
};
//临时String的替身类型
struct TemporaryString : public ConstantString {};
class String
{
public:
//...构造函数,析构函数,运算符,等等......
operator ConstantString() const
{
ConstantString result;
result.obj_ = this;
return result;
}
operator TemporaryString()
{
TemporaryString result;
result.obj_ = this;
return result;
}
};
现在定义下面三个重载版本:
//绑定非常量临时对象
void Connect(TemporaryString);
//绑定所有的常量对象(左值和临时对象)
void Connect(ConstantString);
//绑定非常量左值
void Connect(String& str)
{
//调用另一个重载版本
Connect(ConstantString(str));
}
常量String对象被Connect(ConstantString)吸收。没有其他绑定可以工作,另两个仅仅被非常量String对象调用。
临时对象不能调用Connect(String&)。然而它们可以调用Connect(TemporaryString)或者Connect(ConstantString),前者必然被选中而不发生歧义。原因是因为TemporaryString从ConstantString派生而来,一个应该注意的诡计。
考虑一下ConstantString和TemporaryString都是独立的类型。那么,当要求复制一个临时对象时,编译器将同等的对待operator TemporaryY()/Y(TemporarY)或者operator ConstantY() const/Y(ConstantY)。
为什么是同等的?因为就选择成员函数来说,非常量到常量转型是“无摩擦的”。
因而,需要告诉编译器更多的选择第一个而不是第二个。那就是继承在这里的作用。现在编译器说:“好吧,我猜我要经过ConstantString或者TemporaryString...,但是等等,派生类TemporaryString是更好的匹配!”
这里的规则是从重载候选中选择函数时,匹配的派生类被视作比匹配的基类更好。
[译注]
我对上述代码稍作修改,从std::string派生了String,并在此基础上按照Mojo的方式修改,结果在gcc3.2编译器下的确如作者指出的行为一般无二。这条重载的决议规则很少在C++书籍中提到,Wang Tianxing从烟波浩淼的标准文本中找出了这条规则:
13.3.3.2 Ranking implicit conversion sequences [over.rank]
4 [...]
-- If class B is derived directly or indirectly
from class A and class C is derived directly
or indirectly from B,
[...]
-- binding of an expression of type C to a
object of type B is better than binding
an expression of type C to a object
of object A,
上面这些标准中的条款,是从隐式转型的转换等级中节选出来的,大致的意思是说,如果C继承B,而B继承A,那么类型为C的表达式绑定到B的对象比到A的对象更好,这是上面叙述的技术的标准依据。此外,类似的引用和指针的绑定也适用于此规则,这里省略了这些条款。
最后一个有趣的花样是,继承不需要必须是public的。存取规则和重载规则是不冲突的。
让我们看看Connect如何工作的例子:
String s1("http://moderncppdesign.com");
// 调用Connect(String&)
Connect(s1);
// 调用operator TemporaryString()
// 接下来调用Connect(TemporaryString)
Conncet(String("http://moderncppdesign.com"));
const String s4("http://moderncppdesign.com");
// 调用operator ConstantString() const
// 接下来调用Connect(ConstantString)
Connect(s4);
如你所见,我们达到了期望的主要目标:在临时对象和所有其他对象之间制造了差别。这就是Mojo的要旨。
还有一些不太显眼的问题,大多数我们要一一解决。
首先是减少代码重复:Connect(String&)和Connect(ConstantString)基本上作相同的事情。上面的代码通过第一个重载函数调用第二个重载函数解决了这个问题。
让我们面对第二个问题,为每个需要mojo的类型写两个小类听上去不是很吸引人,所以让我们开始制作一些更具一般性的东西更便于使用。我们定义了一个mojo名字空间,并放入两个泛型的Constant和Temporary类:
namespace mojo
{
template < class T >
class constant
{
const T* data_;
public:
explicit constant(const T& obj) : data_(&obj)
{
}
const T& get() const
{
return *data_;
}
};
template < class T >
class temporary : private constant< T >
{
public:
explicit temporary(T& obj) : contant< T >( obj)
{
}
T& get() const
{
return const_cast< T& >(constant< T >::get());
}
};
}
让我们再定义一个基类mojo::enabled,它包括了两个运算符:
template < class T > struct enabled //在mojo名字空间中
{
operator temporary< T >()
{
return temporary< T >(static_cast< T& >(*this));
}
operator constant< T >() const
{
return constant< T >(static_cast< const T& >(*this));
}
protected:
enabled() {} //只能被派生
~enabled() {} //只能被派生
};
使用这个“脚手架”,将一个类“mojo化”的任务可以想象会变得更简单:
class String : public mojo::enabled< String >
{
//...构造函数,析构函数,运算符,等等...
public:
String(mojo::temporary< String > tmp)
{
String& rhs = tmp.get();
//...执行rhs到*this的析构性复制...
}
};
这就是传递函数参数的Mojo协议。
通常,一切工作良好,你得到了一个好的设计品。不错,那些意外的情况都控制在一个很小的范围内,这使他们更有价值。
用Mojo设计我们可以很容易检测到一个类是否支持Mojo。只需要简单的写:
namespace mojo
{
template < class T >
struct traits
{
enum
{
enabled = Loki::SuperSubclassStrict< enabled< T >, T >::value
};
};
};
Loki提供了探测一个类型是否从另一个类派生的机制。[5]
现在可以发现一个任意的类型X是按照Mojo协议设计的,只要通过mojo::traits<X>::enabled即可确定。这个检测机制对泛型编程是很重要的,很快我们就会看到它的作用。
4.2 函数返回值优化
现在我们可以正确的传递参数,让我们看看如何将Mojo扩展到函数返回值优化。这次的目的又是具有可移植性的效率改善,即100%的消除不需要的复制而不依赖于特定的返回值优化(RVO)实现。
让我们先看看通常的建议怎么说。出于好意,一些作者也推荐返回值的使用规则[7]:
- [规则4]当函数返回用户定义的对象的值的时候,返回一个常量值。例如:
const String operator+(const String& lhs,const String& rhs);
规则4的潜台词是使用户定义的运算符更加接近于内建的运算符可以禁止错误的表达式的功能,就好像想是if (s1+s2==s3)的时候笔误成了if (s1+s2=s3)。如果operator+返回一个常量值,这个特定的BUG将会在编译期间被检测到[译注:返回内建数据类型的值隐含地总是常量的,而用户定义类型则需要显式的用常量限定符指出]。然而,其他的作者[6]推荐不要返回常量值。
冷静的看,任何返回值都是短暂的,它是刚刚被创建就要很快消失的短命鬼。那么,为什么要强迫运算符的使用者获得一个常量值呢?从这个观点看,常量的临时对象看上去就象是自相矛盾的,既是不变的,又是临时的。从实践的观点看,常量对象强迫复制。
现在假定我们同意,如果效率是重要的,最好是避免返回值是常量,那么我们如何使编译器确信将函数的结果转移到目的地,而不是复制他呢?
当复制一个类型为T的对象时,拷贝构造函数被调用。按照下面的设置,我们刚好可以提供这样一个拷贝构造函数实现这个目标。
class String : public mojo :: enabled < string >
{
//...
public:
String( String& );
String( mojo :: temporary < String > );
String( mojo :: constant < String > );
};
这是一个很好的设计,除了一个小细节--它不能工作。
因为拷贝构造函数和其他的函数不完全相同,特别是,对一个类型X来说,在需要X(const X&)的地方定义X(X&),下面的代码将无法工作:
void FunctionTakingX(const X&);
FunctionTakingX(X()); // 错误!不能发现X(const X&)
[译注]
Wang Tianxing在gcc3.2, bcc5.5.1, icl7.0环境下测试结果表明都不会发生错误,并进而查阅了标准,发现Andrei是正确的,如果一定说要有什么错误的话,他没有指出这是实现定义的。
8.5.3 References
5 [...]
— If the initializer expression is an rvalue, with T2 a class type,
and “cv1 T1” is reference-compatible with “cv2 T2,” the reference
is bound in one of the following ways (the choice is implementation-
defined):
— The reference is bound to the object represented by the rvalue
(see 3.10) or to a sub-object within that object.
— A temporary of type “cv1 T2” [sic] is created, and a
constructor is called to copy the entire rvalue object into the
temporary. The reference is bound to the temporary or to a
sub-object within the temporary.93)
The constructor that would be used to make the copy shall be
callable whether or not the copy is actually done.
93) Clearly, if the reference initialization being processed is one
for the first argument of a copy constructor call, an implementation
must eventually choose the first alternative (binding without
copying) to avoid infinite recursion.
我引用了这段标准文本,有兴趣的读者可以自行研究它的含义。
这严重的限制了X,所以我们被迫实现String(const String&)构造函数。现在如果你允许我引用本文的话,在前面我曾经说过:“所以第一个好主意是不要声明一个函数接受常量引用,因为它像一个黑洞一样吞噬所有的对象。”
鱼与熊掌不可兼得,不是吗?
很清楚,拷贝构造函数需要特别的处理。这里的想法是创建一个新的类型fnresult,那就是为String对象提供一个“转移器(mover)”。下面是需要执行的步骤:
- 前面返回类型为T的值的函数现在将返回fnresult<T>。为了使这个变化对对调用者透明,fnresult必须可以被隐式的转型为T。
- 然后为fnresult建立转移语义:无论何时一个fnresult<T>对象被复制,里面包含的T被转移。
- 类似运算符的常量性和临时性,在mojo::enabled类中为fnresult提供一个转型运算符。
- 一个mojo化的类(如前例中的String)定义了一个构造函数String( mojo :: fnresult < String > )完成转移。
这个fnresult的定义看起来就像:
namespace mojo
{
template < class T >
class fnresult : public T
{
public:
fnresult ( const fnresult& rhs )
: T ( temporary < T > ( const_cast < fnresult& > ( rhs ) ) )
{
}
explicit fnresult ( T& rhs ) : T ( temporary < T > ( rhs ) )
{
}
};
}
因为fnresult<T>从T继承而来,第一步值得注意,即fnresult<T>转型为T,然后第二个值得注意的就是复制fnresult<T>对象的时候,隐含着它的T子对象(subobject)强制转型为temporary<T>。
正如前面提到的,我们增加一个转型允许返回一个fnresult,最后的版本看起来是这样的:
template < class T > struct enabled
{
operator temporary < T > ( )
{
return temporary < T > ( static_cast < T& > ( *this ) );
}
operator constant < T > ( ) const
{
return constant < T > ( static_cast < const T& > ( *this ) );
}
operator fnresult < T > ( )
{
return fnresult < T > ( static_cast < T& > ( *this ) );
}
protected:
enabled ( ) { } // intended to be derived from
~enabled ( ) { } // intended to be derived from
};
最后是String的定义:
class String : public mojo :: enabled < String >
{
//...
public:
// COPY rhs
String ( const String& rhs );
// MOVE tmp.get() into *this
String ( mojo :: temporary < String > tmp );
// MOVE res into *this
String ( mojo :: fnresult < String > res );
};
现在考虑下面的函数:
mojo :: fnresult < String > MakeString()
{
String result;
//?..
return result;
}
//...
String dest(MakeString());
在MakeString的return语句和dest的定义之间的路径是:
result -> String :: operator fnresult < String > () -> fnresult < String > (const fnresult < String >& ) -> String :: String ( fnresult < String > )
使用RVO的编译器可以消除调用链中fnresult<String>(const fnresult<String>&)的调用。然而,更重要的是没有函数执行真正的复制,它们都被定义为结果的实际内容平滑的转移到dest。也就是说没有涉及内存分配和复制。
现在,正如所见,有两个,最多三个转移操作。当然,在一定条件和一定类型的情况下,一次复制比三次转移可能更好。还有一个重要的区别,复制也许会失败(抛出异常),而转移永远不会失败。
5 扩展
好的,我们使Mojo工作了,而且对于单独的类相当好。现在怎样将Mojo扩展到组合对象,它们也许包含大量其他的对象,而且他们中的一些已经是mojo化的。
这个任务就是将转移构造函数从类传递到成员。考虑下面的例子,内嵌类String在类Widget中:
class Widget : public mojo::enabled < Widget >
{
String name_;
public:
Widget(mojo::temporary< Widget > src) // source is a temporary
: name_(mojo::as_temporary(src.get().name_))
{
Widget& rhs = src.get();
//... use rhs to perform a destructive copy ...
}
Widget(mojo::constant< Widget > src) // source is a const
: name_(src.get().name_) // 译注:这里原文name_(src.name_)显然有误
{
Widget& rhs = src;
//... use rhs to perform a destructive copy ...
}
};
在转移构造函数中的name_的初始化使用了一个重要的Mojo辅助函数:
namespace mojo
{
template < class T >
struct traits
{
enum { enabled =
Loki::SuperSubclassStrict< enabled< T >, T >::value };
typedef typename
Loki::Select< enabled,temporary< T >,T& >::Result temporary;
};
template < class T >
inline typename traits< T >::temporary as_temporary(T& src)
{
typedef typename traits< T >::temporary temp;
return temp(src);
}
}
as_temporary做的所有事情就是根据一个左值创建一个临时对象。使用这个方法,类成员的转移构造函数被目标对象所调用。
如果String是mojo化的,Widget得到他的优点;如果不是,一个直接的复制被执行。换句话说,如果String是mojo::enabled<String>的一个派生类,那么as_temporary返回一个mojo::temporary<String>。否则,as_temproary(String& src)是一个简单的函数,带一个String&的参数并返回同样的String&。
6 应用:auto_ptr的亲戚和mojo化的容器
考虑一个mojo_ptr类,它通过使拷贝构造函数私有而禁止它们:
class mojo_ptr : public mojo::enable< mojo_ptr >
{
mojo_ptr(const mojo_ptr&); // const sources are NOT accepted
public:
// source is a temporary
mojo_ptr(mojo::temporary< mojo_ptr > src)
{
mojo_ptr& rhs = src.get();
//... use rhs to perform a destructive copy ...
}
// source is a function's result
mojo_ptr(mojo::fnresult< mojo_ptr > src)
{
mojo_ptr& rhs = src.get();
//... use rhs to perform a destructive copy ...
}
//..
};
这个类有一个有趣的行为。你不能复制这个类的常量对象。你也不能复制这个类的左值。但是你可以复制这个类的临时对象(使用转移语义),而且你可以显式的移动一个对象到另外的对象:
mojo_ptr ptr1;
mojo_ptr ptr2 = mojo::as_temporary(ptr1);
这本身并没有什么大不了的,如果 auto_ptr 里让 auto_ptr(auto_ptr&)私有,也可以做到这一点。有趣的地方不是mojo_ptr本身,而是如何使用as_temporary。你可以建立高效的容器,储存“经典”的类型、一般的mojo化的类型以及和mojo_ptr类似的类型。所有这样的一个容器当他需要转移元素时,必须使用as_temporary。对于“经典”类型,as_temporary是一个什么都不做的等效函数,对于mojo_ptr,as_temporary是一个提供平滑转移机制的函数书。move()以及uninitialized_move()的函数模板(参见所附代码,译注:代码请到原版链接处寻找)也唾手可得。
使用标准术语,mojo_ptr既不是可以复制的,也不是可以赋值的。然而,mojo_ptr可以看作是一种新类型,叫做“可转移的”。这是一个重要的新的分类,也许可以用于锁(lock)、文件(file)和其他的不可复制的句柄(handle)。
如果你曾经希望一个拥有元素的类似于 vector< auto_ptr<Widget> > 的容器,而且有安全、清楚的语义,现在你得到了,而且还有其他功能。另外,当包含一个拷贝昂贵的类型时,如vector< vector<string> >,mojo化的vector“更能适应元素个数增减的需要”。
7 结论
mojo是一种技术,也是一个紧凑的小框架,用于消除不必要的临时对象的复制。mojo的工作方式是检测临时对象并且通过函数重载操纵他们而不是简单的作为左值。这样做的结果是,获得临时对象的函数执行一个破坏性的复制,只要确信其他代码不再使用这个临时对象即可。
如果客户代码按照一套简单的规则传递函数参数和返回值,可以应用mojo。
mojo定义了一个单独的机制来消除函数返回时的复制。
额外的机制和类型转换使mojo对于客户代码不是100%的透明,然而对于基于库的解决方案来说集成度是相当好的。说得好听一点,mojo将作为一个健壮的替代品,直到一个更健壮的、基于语言特性的被标准化并实现。
8 致谢
原文的致谢略,译文得到了Wang Tianxing的热情帮助,除了帮助我审核了若干技术细节之外,还指出了不少打字错误,以及若干英语中的谚语。
9 参考文献
[1] Dov Bulka and David Mayhew. Efficient C++: Performance Programming Techniques, (Addison-Wesley, 1999).
[2] Howard E. Hinnant, Peter Dimov, and Dave Abrahams. "A Proposal to Add Move Semantics Support to the C++ Language," ISO/IEC JTC1/SC22/WG21 — C++, document number N1377=02-0035, September 2002, <http://anubis.dkuug.dk/jtc1/sc22/wg21/docs/papers/2002/n1377.htm>.
[3] "Programming Languages — C++," International Standard ISO/IEC 14882, Section 12.2.
[4] Herb Sutter. More Exceptional C++ (Addison-Wesley, 2002).
[5] Andrei Alexandrescu. Modern C++ Design (Addison-Wesley, 2001).
[6] John Lakos. Large-Scale C++ Software Design (Addison-Wesley, 1996), Section 9.1.9.
[7] Herb Sutter. Exceptional C++ (Addison-Wesley, 2000).
作者简介
Andrei Alexandrescu是一位华盛顿大学西雅图分校的博士生,广受赞誉的《Modern C++ Design》(中译本现代C++设计正在译制中)一书的作者。可以通过电子邮件andrei@metalanguage.com联系。Andrei还是一个C++课程的有号召力的讲师。
译者的话
作为第一次编译技术文章,而且选择的是C++中自己相对比较陌生的主题,并且本文讲述的内容是具有前瞻性的,而不是见诸于现有资料和文献的重新整理。因此在翻译过程中,有些细节译者本人也没有完全理解,因此难免出现不少差错,欢迎大家来到newsfan的C++新闻组讨论。