泛型:转移构造函数

泛型<编程>:转移构造函数

Andrei Alexandrescu

    我想你们知道得很清楚,创建,拷贝,并摧毁临时对象是你的C++编译器爱做的事,临时对象在内部被创建,不幸的是这极大影响到了C++程序的执行效率。实际上,临时对象是C++程序中最大的影响效率因素。

    这样的代码看上去不错:

 

vector ReadFile();

vector vec = ReadFile();

 

或:

 

string s1, s2, s3;

s1 = s2 + s3;

 

    如果你需要效率,你就不要用这样的代码。ReadFile()和operator+创造的临时对象分别被拷到目标对象,然后被丢弃——多么浪费!

    为了解决这个问题,你需要遵循不那么美观的规范。例如,你应该把目标对象作为函数的一个参数传进去。

 

void ReadFile(vector& dest);

vector dest;

ReadFile(dest);

   

    这真麻烦。更糟的是,操作符函数不给你这个选择,所以如果你要高效处理大对象,你必须限制自己不用会建立临时对象的操作符:

 

string s1, s2, s3;

s1 = s2;

s1 += s3;

 

    这些笨拙的常用法经常在大项目组的大程序中蔓延,不断带来麻烦,影响了写代码的乐趣,并增加了代码行数。如果我们能从函数中返回值,使用操作符,并自由传递临时对象,却不必担心会有大量的创建/拷贝/摧毁动作带来的时间上的浪费,这该多好。

    这可能吗?

    实际上这已经不是“这该多好”这样的梦想了。整个C++社区都要求解决这个多余的拷贝问题。对这个问题存在着广泛的兴趣。已经有了一个正式的草案,已经被提交到标准委员会了[2]。该草案采用基于语言的解决方案。这方面的讨论在Usenet中到处都是,你现在在读的这篇文章已经被热烈地讨论过了。

    本文告诉你怎样解决这个存在于C++中的不必要的拷贝问题。没有100%合适的解决方法,但可以达到很大程度上的改善。我们会一步一步地建立一个完整的强大的框架,来帮你消除你程序中多余的拷贝临时对象。这个解决方法不是100%透明的,但它的确去掉了所有不必要拷贝,并提供足够的封装来作为一个可靠的替代品,直到几年后,一个更干净的,基于语言的方法被定为标准并被实现。

 

临时对象和“转移构造函数(Move Constructor)”

    在和临时对象搏斗了一阵后,人们意识到去除真正的临时对象在大多数情况下不是真正的问题所在。很多时候,问题在于消除不必要的临时对象拷贝。请让我来详细解释一下。

    多数“昂贵拷贝”的数据结构以指针或句柄形式存储它们的数据。典型的例子是:一个String类型存放一个大小和一个char*,一个Matrix类型存放一些维度的整数和一个double*,或一个File类型存放一个句柄。

    正如你看到的,拷贝String,Matrix或File的花销并不来于拷贝实际的数据成员,而来自于复制被指针或句柄所引用的数据。

    知道这些后我们的目标是消除拷贝,一个好方法是检查一个临时对象。反正那个对象要被摧毁,我们可以乘它还可以利用的时候利用它。

    但怎样才算是一个临时对象?我们来提出一个有争议的定义:

在一个环境中,当且仅当直到一个对象退出环境时只有析构函数是唯一操作它的函数,这个对象被认为是临时的

    这个环境可以是一个表达式或一个域(比如函数体)

    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时这个假想的重载构造函数String(temporary String&)起作用。然后,通过简单地拷贝指针(用不着复制它所指向的内存块),构造函数把rhs转移到被创建的对象。最后但非常重要的,转移构造函数复位源指针rhs.data_。这样,当临时对象被摧毁时,delete[]会无害地作用于一个空指针上。

    一个重要的细节是转移构造完后rhs.length_不设为零。这从理论上说是不正确的(我们得到一个无效的String,它的data == 0,length_!= 0),但这样做有一个好的理由。rhs的最终状态不必是一致的。这是因为对rhs要执行的唯一操作是析构函数——没有其他的了。所以只要rhs满足能被摧毁的条件,它根本没必要被认为是一个合法的字符串。

    转移构造函数是个消除不必要的拷贝临时对象的好方法。我们只有一个小问题——C++语言中没有temporary关键字。

    (应该指出的是检查临时对象不是对所有类都有用。有时候,所有数据都直接存放到容器里,比如:

 

class FixedMatrix

{

    double data_[256][256];

public:

...操作函数...

};

 

    对这样一个类,真的拷贝所有sizeof(FixedMatrix)个字节是个昂贵的操作,而且检查临时对象没什么用处)

 

以前的方法

    不必要的拷贝是C++社区里的老问题了。有两个解决方向,一个从写代码/库方向入手,另一个从语言定义/编译器方向入手。

    从语言/编译器角度看,我们有“返回值优化,”缩写为RVO。RVO是C++语言定义[3]所明确允许的。基本上,对所有的函数,你的C++编译器可以通过一个函数来确认它是否能够做RVO。这个函数就是拷贝构造函数,并且前提条件是编译器认为拷贝构造函数做拷贝。

    正因为编译器那样认为,它才能够去除不必要的拷贝。比如:

 

vector ReadFile()

{

    vector result;

    ...填充result...

    return result;

}

vector vec = ReadFile();

 

    一个聪明的编译器可以传入vec的地址作为Readfile一个隐藏的参数并就在那个地址上创建result。所以从上面源代码所产生的代码会象这样:

 

void ReadFile(void* __dest)

{

    //在地址dest上使用placement new

//来创建一个vector

vector& result =

    *new(__dest) vector;

    ...填充result...

}

//假设已经正确对齐了

char __buf[sizeof(vector)]

ReadFile(__buf);

vector& vec =

    reinterpret_cast*>(__buf);

 

    RVO有几种不同形式,但要点是一样的,编译器通过简单地在最终目标对象上构造函数返回值来消除对拷贝构造函数的调用。

    不幸的是,实现RVO不象看上去那么容易。假设ReadFile做一个小小改变:

 

vector ReadFile()

{

    if (error) return vector();

    if (anotherError)

    {

        vector dumb;

        dumb.push_back(This file is in error.);

        return dumb;

    }

    vector result;

    ...填充result...

}  

   

    现在不是一个本地变量需要映射到最终结果,而是三个。有些是命名的(dumb,result)有些是无名的临时对象。不用说,面对这样的局面,许多优化器都会放弃,转而使用保守且效率较低的方法。

    即使你准备写“明显的”代码而不至于使RVO的实现为难,你也会失望地获知每个编译器,并且经常是每个编译器版本,有它自己检测和实施RVO的标准。一些仅仅对函数返回无名临时对象(RVO最简单的形式)时使用RVO。更复杂的编译器则当函数有命名返回结果时也使用RVO(所谓的命名RVO,或NRVO)。

    基本上,当写代码时,你可以认为RVO是否能通用地在你代码中被使用取决于你怎样正确写代码(这个“正确”的定义非常不确定),月亮的盈亏,和你脚趾头的大小。

    但是等等,还有更糟的。经常是编译器即使非常想做RVO但仍然做不了。试想这个对ReadFile()调用的轻微改变:

 

vector vec;

vec = ReadFile();

   

    尽管这个改变看上去无害,但情况发生巨大的改变。我们现在调用赋值操作符函数代替了拷贝构造函数,这是个不同的大怪兽。除非你编译器的优化技巧达到魔术般的境界,现在你可以和你的RVO吻别了:vector::operator=(const vector&)需要一个vector的const引用,所以ReadFile()返回的临时对象被绑定到const引用上,拷贝到vec,然后被丢弃。不必要的临时对象对象再次出现!

    从编码的角度看,有一个长期以来一直被推荐的技术是COW(copy-on-write写时拷贝)[4],这是一个基于引用记数的技术。

    COW有几个优点,一个是它检查并消除不必要的拷贝。比如,当一个函数返回,返回对象的引用记数为1。然后你拷贝它,这增加它的引用记数到1。最后,你摧毁临时对象,引用记数回到1,在这个点上目标对象是数据的唯一拥有者。没有做过实际的拷贝。

    很不幸,引用记数在线程安全上同时有许多缺点:它带来其本身的开销,和许多隐藏的问题[4]。COW太难用了,这样,尽管它存在优点,现在的STL实现不在std::string中使用引用记数,尽管事实上std::string的接口本来被设计成用来支持引用记数!。

    几个“不复制”对象的常用法已经被开发出来,其中auto_ptr是最好的一个。auto_ptr容易被正确使用,但,不幸的是,同样也容易被误用。本文讨论的方法扩展了在auto_ptr中使用的技术。

 

Mojo

   Mojo(Move of Joint Objects接合对象的转移)是一个编码技术和小的框架,它的目的是消除不必要临时对象拷贝。Mojo通过识别临时对象和合法“非临时”对象来工作。

 

传参数到函数

    首先做一个有趣的分析,对传参数到函数的规则进行详细回顾。Mojo之前的一般建议是这样的。

1、如果函数需要改变参数来作为一个附带效果,参数为指向非const对象的引用/指针,例如:

 

void Transmogrify(Widget& toChange);

void Increment(int* pToBump);

 

2、如果函数不改变参数并且参数是基本类型,就传值。例如:

 

double Cube(double value);

 

3、此外,参数为(或可能为,当你定义一个模板的时候)一个用户定义类型并且是不能更改的,所以通过const引用传参,例如:

   

    String& String::operator=(const String& rhs);

    Template vector::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...

}

   

    Conncet带一个指向const的引用作为参数并且,马上,创建一个它的拷贝。然后函数进一步操作这个拷贝。

    这个函数展示了const是如何成为效率的障碍。Connect的声明说:“我不需要一个拷贝,一个对const的引用足够了”——但函数实体实际上的确建立了一个拷贝。所以如果你现在写:

 

String MakeUrl();

Connect(MakeUrl());

 

    然后你可以认为MakeUrl()返回一个临时对象,它被拷贝然后摧毁:可怕的不必要拷贝模式。对一个要优化掉这个拷贝的编译器来说,必须做的艰巨工作是{1}进入Connect的定义部分(不同的编译模块会带来困难),{2}分析Connect的定义,建立对函数定义的理解,然后{3}改变Connect的行为,这样临时对象和finalUrl熔为一体。

    假设你象下面那样改变Connect:

 

void Connect(String url)  //注意,传值调用

{

    Canonicalize(url);

    ResolveRedirections(url);

    ...使用finalUrl...

}

 

    从Conncet的调用者的角度看完全没有区别,尽管你改变了语法接口,语义接口是一样的。对编译器来说,这个语法的改变有完全不同的结果。现在编译器有更大的余地来处理url临时对象。比如,在上例中:

 

Connect(MakeUrl());

 

编译器要把MakeUrl返回的临时对象融合进Connect需要的临时对象并不困难。事实上,做不到这个才真的困难!最终,MakeUrl的最后结果会被改变并在Connect里使用。前个版本让编译器很郁闷,阻止它执行任何优化。这个版本顺畅地和编译器协同工作。

    这个新的设定的缺点是现在调用Connect可能产生更多的机器代码。比如:

 

String someUrl = ...;

Connect(someUrl);

   

    在这个例子中,头一个版本会简单地传一个someUrl的引用。第二个版本会创建一个someUrl的拷贝,调用Connect,然后摧毁拷贝。这种代码尺寸的开销随着对Connect固定调用次数增加而增加。从另一个方面说,会产生临时对象的调用比如Connect(MakeUrl())可以在第二个版本中产生更少代码。至少,代码大小的区别不太可能会成为一个问题。

 

所以我们有了一个不同的建议表:

3.1.如果函数总是在内部对其参数做拷贝,传值给它。

3.2.如果函数决不对其参数做拷贝,传const引用给它。

3.3.如果函数有时候对其参数做拷贝并且效率对你很重要,遵循Mojo原则

 

    不管怎样,现在剩下的唯一事情就是定义“Mojo原则”,

    基本思路是重载同一个函数(比如Connect),目的是区分临时和非临时值。(由于历史原因,后者也被认为是“左值(lvalues)”:说白了,左值可以放在赋值操作中的左边)

    在开始重载Connect时,一个可能想法是定义Connect(const String&)来捕捉“真正的”不变对象。然而,这会是个错误,因为这个声明会“吃掉”所有String对象——不管是左值还是临时值。所以第一个好的想法是不声明一个接受const引用的函数,因为它会象黑洞一样吞掉所有对象。

    第二个尝试是定义Connect(String&),目的为了捕捉所有非const的左值。这样做很好,特别是const值和无名临时对象不会被这个重载函数“吃掉”——一个不错的开头。现在我们只需要分辨const对象和非const临时对象。

    为了达到目的,我们用的手段是定义两个“类型辅助”类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;

    }

};

 

    这样现在String定义了两个转换操作符。它们之间一个显著不同是TemporaryString函数不作用于const String对象。

    现在假如你定义下面三个重载函数:

 

//绑定到非const临时对象

void Connect(TemporaryString);

//绑定到所有const对象(左值非左值)

void Connect(ConstantString);

//绑定到非const左值

void Connect(String& str)

{

    //仅仅转到另一个重载函数

    Connect(ConstantString(str));

}

 

    现在说工作原理。常量String对象被Connect(ConstantString)“吸引”,没有其他绑定会工作;另两个只对非const String起作用。

    临时对象不能传入Connect(String&)。但它们能够传入Connect(TemporaryString)或Connect(ConstantString),而且前一个重载必须被无二义地选中。这是为什么TemporaryString从ConstantString继承的原因,这是一个值得注意的技巧。

    想一想,如果ConstantString和TemporaryString是完全无关的类型,那么,当拷贝一个临时对象时,编译器有同样理由调用任何一个:

 

operator TemporaryY() à Y(TemporaryY)

 

或:

 

operator ConstantY() const à Y(ConstantY)

 

    为什么有同样理由?因为在选择有关成员函数时对象从const转换到非const是“无摩擦”的。

    于是,需要做的就是给编译器更多“理由”去选择第一个而不是第二个。这正是继承的用武之地。现在编译器说:“好,我想我可以用ConstantString或TemporaryString,但是等一下,继承类TemporaryString是个更好的匹配!”

    这里的规则是当从一堆重载函数中选择一个时,匹配继承类比匹配基类更加适合。[译注1]

    最后,一个有趣的变化是——继承不必一定是公有的。访问权限规则和重载规则互不干涉。

    我们来看Connect如何用于一个实例:

 

String sl(http://moderncppdesign.com");

//调用Connect(String&)

Conncet(s1);

//调用operator TemporaryString()之后

//调用Connect(TemporaryString)

Connect(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 constant //针对常量对象的辅助类型

    {

        const T* data_;

    public:

        explicit constant(const T& obj) : data_(&obj)

        {

        }

        const T& get() const

        {

            return *data_;

        }

    };

 

    template

    //针对临时对象的辅助类型

    class temporary : private constant

    {

    public:

        explicit temporary(T& obj) : constant(obj)

        {

        }

        T& get() const

        {

            return const_cast(constant::get());

        }

    };

}

 

    我们同时定义一个基类mojo::enabled,它定义了两个操作:

 

template struct enabled //在mojo命名空间内

{

    operator temporary()

    {

        return temporary(static_cast(*this));

    }

    operator constant{} const

{

return constant(static_cast(*this));

}

protected:

    enabled() {}  //为了能被继承

    ~enabled() {}  //为了能被继承

};

 

    有了这个框架,“mojo化”一个类的任务变得简单得多:

 

class String : public mojo::enabled

{

    ...构造函数,析构函数,其他你定义的函数...

public:

    String(mojo::temporary tmp)

    {

        String& rhs = tmp.get();

        ...对rhs执行破坏性拷贝到*this...

    }

};

 

    这就是传递参数给函数的Mojo规则。

    有时候,所有东西看上去都运转良好,你设计了一个还没真正使用过的出色作品,实际上,这种好势头会得到进一步加强,而这使得这一切更有价值。

    Mojo的设计正是这样,我们还能够轻松知道一个类是否支持Mojo,只要这样写:

 

namespace mojo

{

    template

    struct traits

    {

        enum { enabled =

            Loki::SuperSubclassStrict, T>::value };

    };

}

 

    Loki有现成的机制,能够检查一个类型是否从另一个继承[5]。

    现在你能通过用mojo::traits::enabled来检查任意类型X是否被设计成支持Mojo。这个检查机制在泛型代码中非常重要,我们会很快看到这一点。

 

优化函数返回值

    现在传参数没问题了,我们来看怎样把Mojo扩展成能优化函数返回值。再一次,目标是通用的优化——100%消除不必要拷贝,不依赖于任何一个特定的RVO实现。

    我们首先看一般建议怎么说。出于很好的理由,一些作者还推荐const化的返回值[7]。继续旧规则:

4、当一个函数返回用户定义对象的值时,返回一个const值。例如:

 

const String operator+(const String& lhs,

    const String& rhs)

 

    规则4之下蕴含的思想是,通过禁止错误的表达式,比如本来要写成if(s1 + s2 ==s3)却错误写成if(s1 + s2 = s3),来让用户定义的操作行为和内建操作非常类似。如果operator+返回一个const值,这种特定的错误就会在编译时被发现。然而,另外一些作者[6]建议不返回const值。

    从哲学观点说,一个返回值是昙花一现的,是一个生出来就很快消失的蜉蝣。那么,为什么要迫使operator+的客户接受一个常量值?这只蝴蝶“常存”在何处?从这个角度看,const和临时值看上去互相矛盾。从实践上说,const临时值强制拷贝到目的对象。

    假设我们现在同意如果效率很重要,最好是在返回值前不加const,我们怎样让编译器把函数结果转移到目的对象而不是拷贝它呢?

    当拷贝一个类型T的对象时,拷贝构造函数被调用。拷贝构造函数是个和其他函数差不多的函数,看上去我们可以只要使用上面的方法,引出下面框架设定:

 

class String : public mojo::enabled

{

    ……

public:

    String(String&);

    String(mojo::temporary);

    String(mojo::constant);

};

   

    这是非常好的设定,除了一个很小的细节——它不能工作。

    记得当我说:“拷贝构造函数是个象其他任何函数一样的函数”吗?好吧,我说谎了。拷贝构造函数是个特殊函数,它的运行方式让人讨厌。如果对应一个类型X你定义X(X&)而不是X(const X&),那么下面代码将不能运行:

 

void FunctionTakingX(const X&);

FunctionTakingX(X());    //错误!

    //无法找到X(const X&)

   

    这极大削弱了X,所以我们必须有String(const String&)构造函数。现在请你允许我引用本文中的话,在某处我说:“这样第一个好想法是不申明一个接受const引用的函数,因为它象个黑洞一样吞噬所有对象。”

    你可以说这是“自相矛盾”吗?

    很明显,拷贝构造函数需要区别对待。这里的解决办法是创建一个新类,fnresult,它作为String对象的“搬运工”。下面是我们需要做的步骤:

1、  定义fnresult,让一个本来返回T的函数现在返回fnresult。为了让这个改变对调用者透明,fnresult必须能被装换为T。

2、  建立fnresult的转移语义规则,任何时候fnresult对象被拷贝,包含在内的T被转移。

3、  类似于operator constant和temporary,在类mojo::enabled提供一个转换到fnresult的转换操作符函数

4、  一个mojo化类(比如我们例子中的String)定义一个构造函数String(mojo::fnresult)来完成转移

 

fnresult定义如下:

 

namespace mojo

{

    template

    class fnresult : public T

    {

    public:

      //如果没有人会真的创建一个

        //const fnresult对象,

        //下面的转换(cast)就是合法的

    fnresult(const fnresult& rhs)

         :T(temporary(const_cast(rhs)))

     {

     }

    explicit fnresult(T& rhs) : T(temporary

     {

     }

};

}

 

    因为fnresult从T继承,步骤1,从一个fnresult转换到一个T已经完成。然后,拷贝一个fnresult对象时通过强制转换到temporary来转移它的子对象T,这样步骤2完成。

    如同前面,我们在enabled里增加一个返回fnresult的转换操作符函数。Enabled的最终版本如下:

 

template struct enabled

{

    operator temporary()

    {

        return temporary(static_cast(*this));

    }

    operator constant() const

    {

        return constant(static_cast(*this));

    }

    operator fnresult()

    {

        return fnresult(static_cast(*this));

    }

protected:

    enabled() {}  //为了能被继承

    ~enabled() {}  //为了能被继承

};

 

    最后,String定义步骤4提到的构造函数。下面是带所有构造函数的String:

 

class String : public mojo::enabled

{

    ……

public:

    //拷贝rhs

    String(const String& rhs);

    //转移tmp.get()到*this

    String(mojo::temporary tmp);

    //转移res到*this

    String(mojo::fnresult res);

};

 

现在考虑下面函数:

 

mojo::fnresult MakeString()

{

    String result;

    ?...

    return result;

}

...

String dest(MakeString());

 

    从MakeString的return语句到dest的路径是:resultàString::operator fnresult()àfnresult(const fnresult&)àString::String(fnresult)

    一个使用RVO的编译器能够消除其中的fnresult(const fnresult&)调用。但最重要的是其中没有函数执行了真的拷贝,它们被定义为能够把result中的内容平滑地转移到dest。没有内存分配也没有内存拷贝。

    现在正如你看到的,有两个,最多三个转移操作。有可能对于某种类型和在某种条件下,一个拷贝比三个转移要好(速度上的)。但是其中有一个重要区别:拷贝可能失败(抛出一个意外),但转移永远不会。

 

扩充

    好的,我们让Mojo开始工作了,而且对单独的类工作得很好,现在来扩充Mojo,支持包含许多其他对象的复合对象,一些“其他对象”同时也是mojo化的。

    这个任务是从一个转移构造函数“往下传”到它的成员。比如,把上面的String类植入一个Widget类:

 

class Widget : public mojo::enabled

{

    String name_;

Public:

    Widget(mojo::temporary src)        //源为一个临时对象

        : name_(mojo::as_temporary(src.get().name_))

    {

        Widget& rhs = src.get();

        ...使用rhs来执行破坏拷贝...

    }

    Widget(mojo::fnresult src)    //源是一个函数返回结果

        : name_(mojo::as_temporary(src.name_))

    {

        Widget& rhs = src;

        ...使用rhs来执行破坏拷贝...

    }

};

 

    破坏拷贝函数中对name_的初始化用到一个重要的Mojo辅助函数:

 

namespace mojo

{

    template

    struct traits

    {

        enum {enabled =

            Loki::SuperSubclassStrict, T>::value };

        typedef typename

            Loki::Select<

            enabled,

            temporary,

            T&>::Result

        temporary;

    };

    template

    inline typename traits::temporary as_temporary(T& src)

    {

        typedef typename traits::temporary temp;

        return temp(src);

    }

}

 

    所有as_temporary做的是从一个左值强制创建一个临时对象。这样,member_的转移构造函数就被调用来创建目标对象。

    如果String是mojo化的,Widget会从中受益,如果不是,一个直接拷贝会被执行。换句话说,如果String是mojo::enabled的子类,as_temporary就返回一个mojo::temporary。否则,as_temporary(String& src)是个接受一个String&参数返回同样的String&的恒等函数。

    我们得益于另一个Loki特性:Select::Result是T还是U取决于布尔条件是真还是假[5]。

 

应用:auto_ptr的表兄弟及Mojo容器

    假设有一个mojo_ptr类,通过申明一些构造函数为私有来禁止它们:

 

class mojo_ptr : public mojo::enable

{

    mojo_ptr    //const源对象不被接受

public:

    //源为临时对象

    mojo_ptr(mojo::temporary src)

    {

        mojo_ptr& rhs = src.get ();

        ...使用rhs来执行破坏拷贝...

    }

    //源是一个函数返回结果

    mojo_ptr(mojo::fnresult src)

    {

        mojo_ptr& rhs = src.get();

        ...使用rhs来执行破坏拷贝...

    }

    ?..

};

 

这个类有一个有趣的行为。你不能拷贝这个类的const对象。你也不能拷贝这个类的左值!但你可以拷贝(用转移语义)临时对象,并且你可以通过这样写来显式转移一个对象到另一个。

 

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既不是“可拷贝copyable”也不是“可赋值assignable”。但是,mojo_ptr可以被看作是一个新类型种类的的一部分,这个新类型种类叫做“可转移moveable”。这是一个重要的新种类,它还应该包括锁,文件,和其他不能复制的句柄。

     结果?如果你曾经希望有一个“特有的容器”类似于有安全,清晰的语义的vector >,那么你已经得到了一个,还附带特殊功能。同时,mojo化的vector当包含拷贝昂贵的类型,比如vector >时,也运行良好。

 

结论

    mojo是一项技术也是一个简洁的框架,它的用途是消除非必要的临时对象拷贝。mojo通过检查临时对象,将它们引入一个不同于接受左值的重载函数。这样,接受临时对象的函数可以确信没有其他代码会使用该临时对象,从而对它进行破坏拷贝。

    mojo适用于当客户代码遵循一系列简单的函数传参数及返回值的规则。

    mojo定义有一个专门机制来消除函数返回时的拷贝。

    额外的机制和乏味的类型操作使mojo对用户不是100%透明,但是,作为一个基于库的解决方案,它的整合能力非常好。Mojo作为一个强壮的替代品,在各种应用中都是最好的选择,直到有一个更好的,基于语言本身的特性被立为标准和被实现。

 

致谢

    mojo被认真地检查过并经历了一个短暂,但充实的初始阶段。

    David Abrahams对转移构造函数有突出贡献。Rani Sharoni指出一些微妙的问题。Peter Dimov在设计早期指出一个关键问题,这使得mojo从头开始。

    Gary Powell为使mojo能用于继承做了很多工作,Eveny Karpov使用模板函数极大地简化了代码。我希望我们能够在下篇文章中讨论这些改进。

    感谢Howard Hinnant,Peter Dimov,和Dave Abrahams提出将转移构造函数加入语言的提案。

    全世界许许多多热心的志愿者对本文做出评论。感谢你们所有人!我要特别指出的是Walter E. Brown, David Brownell, Marshall Cline, Peter Dimov, Mirek Fidler, Daniel Frey, Dirk Gerrits, Fredrik Hedman, Craig Henderson, Howard Hinnant, Kevin S. Van Horn, Robin Hu, Grzegorz Jakacki, Sorin Jianu, Jozef Kosoru, Rich Liebling, Ray Lischner, Eric Niebler, Gary Powell, William Roeder, Maciej Sinilo, Dan Small, Alf P. Steinbach, Tommy Svensson, David Vandevoorde, Ivan Vecerina, Gerhard Wesp, and Yujie Wu.

   

后记:Mojo正变得越来越棒

    自从mojo发表以来,或者更精确地说,发表在互联网以来,它受到了普遍的关注。C++社区毫无疑问在寻找怎样去除传值语义,这很好。

    我收到来自读者的无数带有建议和改进意见的信件。大多数为Mojo增加了使用上的便利,比如为mojo::temporary增加operator->,或以其他方式做出改进。

    Dave Abrahams发给我的代码纠正了在mojo::uninitialized_move中的一个打字错误和一个异常安全问题(你知道Dave的工作,所以可以打赌他是第一个纠正异常安全问题的人)。Mojo::uninitialized_move的原始版本是这样的:

 

Template

Iter2 uninitialized_move(Iter1 begin, Iter1 end, Iter2 dest)

{

    for (; begin != end; ++begin, ++dest)

    {

        new(*dest) T(as_temporary(*begin));

    }

    return dest;

}

 

首先,new(*dest)必须改成new(&*dest)。其次,有一个异常安全的问题,如果T的构造函数的某处抛出异常,程序会创建一些它无法跟踪的对象,这使程序进入非常糟糕的状态。

    Dave发来的正确版本是:

 

Template

Iter2 uninitialized_move(Iter1 begin, Iter1 end, Iter2 dest)

{

    typedef typename std::iterator_traits::value_type T;

    Iter2 built = dest;

    try

    {

        for (; begin != end; ++begin, ++built)

        {

            new (&*built) T(as_temporary(*begin));

        }

    }

    catch ()

    {

        for (; dest != built; ++dest)

        {

            dest->~T();

        }

        throw;

    }

    return built;

}

 

    正如你所见的,修改后的代码通过拷贝dest到一个新的迭代器built来跟踪对象的创建。然后在整个创建对象循环中使用built。如果任何意外发生,uninitialized_move漂亮地在退出前把地板扫干净,这样函数要么成功创建所有对象,要么失败,什么对象也不创建。

    一个新实现中的微妙问题使它不再支持将iter2作为输出迭代器(output iterators)。毫无疑问输出迭代器不允许你保留它的拷贝(Iter2 built = dest;)并且在以后使用它们——你必须一下子做完所有事。

    如果你想一想,这个要求其实很合理。输出迭代器就象纸上用墨水写出的字,象网络数据包流向线路,或者象歌唱家唱出的动人音符。你不能够取消这些动作。旧版本做的迭代是“做过就忘”的。新版本做得更仔细,如果产生错误,取消任何做过的动作。作为必要的代价,新的更仔细的版本不支持输出迭代器。如果你象我一样,你肯定会喜欢这些不管理论还是实践都能解释得通的情况。

    感谢Dave!

    最后,我长期的笔友和正在崛起的高手Rani Sharoni(他使Loki兼容于Microsoft Visual C++.NET)写信给我说Mojo的第一个实现[8]:(这个版本比较简单但Peter Dimov发现有很多问题)可能实际上是正确的。以下是相关链接[9,10]。我们能看到事物是如何发展的,就象一个聪明的起诉人在一桩著名的诉讼中找到新的,没人想得到的证据。最近一次Rani写给我的信是这样的:

    首先,我同意你的话(尽管我认为我不是一个高手)。

实际上我有点失望,因为某些相关人物(比如Steve Adamczyk)没有回我的帖子,但在291条auto_ptr的注释的确让我相信你原来的实现根据现在的标准是合法的,就象其他一些auto_ptr技巧一样。

    不管怎样,好消息是Mojo可以用而且如果满足它的设计要求它还用的很好。此外,除了Sharoni上面提到的因素外,Mojo看上去是100%消除不必要拷贝的最紧凑的框架。许多人开始使用Mojo。可能不用多久我们就可以看到Mojo大范围应用的报告和性能数据。顺便说一句,如果你有这方面的数据,只要是有效的,发出来。

 

参考书目及注解

[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, .

[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).

[8]

[9] Usenet posting by Rani Sharoni, .

[10] Standard C++ Defect Report, .

[译注1]细心的读者可能注意到两个转换函数是否加const最终影响到了重载函数的选择。也就是说const和继承同时决定了调用哪个重载函数。至于怎样决定,是一个非常复杂的调用过程。好玩的是Andrei同学本人在看到下面讨论前也不知道原理何在:http://groups.google.com/groups?hl=en&lr=&ie=UTF-8&safe=off&frame=right&th=959852371d21285f&seekm=au2ihh%243rps5%241%40ID-14036.news.dfncis.de#link1 ,遗憾的是可能大多数国内读者无法访问google group(如果你找到某个代理服务器能够上google group,请千万千万写信告诉我,谢谢)其中主要规则是这样的,假设有:

class A {};

class B : public A {};

 

void f(A);

void f(B);

 

class C {

public:

operator A() const { return A(); };

operator B() { return B(); };

};

  

void foo()

{

C c;

    f(c);  // (1)

}

编译器有两个选择:f(A)和f(B),如何调用f(B)很明显,通过CàB转换,如何调用f(A)有一点复杂。存在有两个转换路径CàC constàA和CàBàA,首先做的比较是不包括用户定义转换的路径,也就是:CàC const和CàC,显然后者较优,所以调用f(A)需要CàBàA,由于CàB是其子序列,所以选择f(B)。如果B不是A子类,或者去除operator A()的const,或者加到operator B()上,编译器会给出调用二义的错误。我们很容易推论出为什么。

 

源代码下载

ftp://ftp.cuj.com/pub/2003/2102/alexandr.zip

Andrei Alexandrescu 是位于西雅图的华盛顿大学的博士,也是受到好评的《Modern C++ Design》一书的作者。可以通过www.moderncppdesign.com. 来联系他。Andrei同时也是C++研讨会 ().的一名有号召力的讲师。

 

你可能感兴趣的:(泛型:转移构造函数)