Learning to be Giant.

移动语义(move semantic)和完美转发(perfect forward)

|

新标准重新定义了lvalue和rvalue,并允许函数依照这两种不同的类型进行重载。通过对于右值(rvalue)的重新定义,语言实现了移动语义(move semantic)和完美转发(perfect forwarding),通过这种方法,C++实现了在保留原有的语法并不改动已存在的代码的基础上提升代码性能的目的。作为一个C++0x标准的初学者,理解这些概念其实还有有一定的困难的,加上网上能够找得到的中文资源有比较的少,少有的资源写的也都不是那么的通俗易懂,多少有点晦涩,这也为学习设置了一定的障碍。同样作为初学者,我就花了不少时间研究这些概念,终于算是有所体悟。这里就把我粗浅的理解记录于此,希望能够给后来接触这些内容的同侪们以帮助,也供我日后参考。谬误之处在所难免,还望不吝赐教。

移动语义解决了什么问题

我们先看一段代码。

class Test {
    int * arr{nullptr};
public:
    Test():arr(new int[5000]{1,2,3,4}) { 
    	cout << "default constructor" << endl;
    }
    Test(const Test & t) {
        cout << "copy constructor" << endl;
        if (arr == nullptr) arr = new int[5000];
        memcpy(arr, t.arr, 5000*sizeof(int));
    }
    ~Test(){
        cout << "destructor" << endl;
        delete [] arr;
    }
};

这是一段常见的类的定义。在其中我们定义了一个int类型数组arr,它一共有5000个元素。考虑到我们可以使用一个已有的Test对象来初始化一个新的Test对象,我们实现了复制构造函数(copy constructor)。

接下来,我们考虑一个这样的应用场景:

int main() {
	Test reusable;
	// do something to reusable
	Test duplicated(reusable);
	// do something to reusable
}

我们创建了一个reusable变量并对其做了某一些操作,之后我们使用这个更改过的reusable变量初始化一个duplicated变量,在对其进行初始化之后,我们依然需要对reusable做其他的操作。在这个情境下,reusableduplicated变量各自有自己的用处,没有谁是为谁附带产生的。所以我们看到,在这个情境下,我们的复制构造函数是合情合理的。

现在我们考虑另外一个场景:

Test createTest() {
    return Test();
}

int main() {
    Test t(createTest());
}

在这个场景当中,我们需要使用一个工厂函数来构造Test的实例。那么在这个场景下,我们的复制构造函数被调用了2次。这两次调用相当于复制了10000个元素,是一个不小的开销。可是我们的这个开销有意义吗?我们知道,在工厂函数当中建立的Test实例在函数返回时就会被析构,而用于返回值的Test的临时实例也会在将值赋给main函数当中的t之后被析构。也就是说,这两个临时对象事实上并没有什么意义。由于构造他们而产生的复制的开销其实完全没有必要(事实上,编译器一般会对这种情况进行(N)RVO,但不见得每次都能很好的优化)。所以我们就在考虑,有没有可能我们可以将在工厂函数当中构造的成员变量的那块内存“偷”过来,而不是重新开辟一块内存,然后再将之前的内容复制过来呢?

移动语义(move semantic)

铛铛铛铛!移动语义登场了!移动语义就是为了解决上面的这种问题而产生的。通过移动语义,我们可以在没有必要的时候避免复制。那么在接下来,我们就重点来谈一谈移动构造函数(move constructor)。相信到这里你已经意识到了,移动构造函数的出现就是为了解决复制构造函数的这个弊病。所以,其实移动构造函数应该和复制构造函数实现差不多的功能。那么,它也应该是一种构造函数的重载(好废的废话……)。所以,我们可以想象出来,其实移动构造函数大概就会是这个样子:

Test(<KEYWORD> t):arr(t.arr){t.arr = nullptr;}

这里解释一下,通过移动构造函数,事实上我们是做了一个浅拷贝(shallow copy)。至于要将之前的指针置为空的原因在于,我们的类会在析构的时候delete掉我们的数组。那么我们浅拷贝出来的这个对象的成员变量(arr指针)就变成了一个悬挂指针(dangling pointer)。

好了,现在的问题变成了,这个<KEYWORD>究竟是什么?编译器如何自动判断到底应该调用复制构造函数(我突然想起来这个东西的翻译貌似应该是拷贝构造函数,但是既然都已经写了这么多了,我就不改了)还是移动构造函数呢?

左值(lvalue)、右值(rvalue)、左值引用(lvalue reference)和右值引用(rvalue reference)

左值和右值

为了回答上面的这个问题,我们首先需要明确左值和右值的概念。C++定义了与C不相同的左值和右值的判断方法,不过说起来非常简单:凡是真正的存在内存当中,而不是寄存器当中的值就是左值,其余的都是右值。其实更通俗一点的说法就是:凡是取地址(&)操作可以成功的都是左值,其余都是右值。现在相信大家都已经知道左值和右值的关系了。我们来看几个例子:

// lvalues:
int i = 42;
i = 43; // ok, i is an lvalue 
int* p = &i; // ok, i is an lvalue 
int& foo();
foo() = 42; // ok, foo() is an lvalue
int* p1 = &foo(); // ok, foo() is an lvalue
// rvalues: 
int foobar(); 
int j = 0;
j = foobar(); // ok, foobar() is an rvalue
int k = j + 2; // ok, j+2 is an rvalue
int* p2 = &foobar(); // error, cannot take the address of an rvalue 
j = 42; // ok, 42 is an rvalue

那么,函数是不是就只可以作为右值呢?其实不是。考虑一个我们司空见惯的例子:

vector<int> vec = {1,2,3,4,5};
vec[1] = 99; // overloaded operator[]

我们看到,其实operator[]是一个函数,其返回值依然可以作为左值。

左值引用和右值引用

好了,在明确了左值和右值的关系之后,左值引用而右值引用的概念也就显而易见了。对于左值的引用就是左值引用,而对于右值的引用就是右值引用。虽然这么说,但是其实这个概念还并不是那么好理解。

事实上,不好理解的原因是我们之前从来没有真正的去区分过这两个概念,因为我们曾经将左值引用直接称为“引用”。也就是说,我们曾经一直用的int&事实上是对于int类型左值的引用。而对于右值呢?在新标准当中我们使用int&&来表示。我们不妨看看几个例子:

void foo(const int & i) { cout << "const int & " << i << endl; }
void foo(int & i) { cout << "int & " << i << endl; }
void foo(int && i) { cout << "int && " << i << endl; }
void foo(const int && i) { cout << "const int && " << i << endl; } // 这是个奇葩,我一会说

我们在以往使用的时候大多会使用第一种形式。其实,第一种形式是一种神奇的形式,因为const int &既可以绑定左值,也可以绑定右值。所以在没有后面三个重载函数的情况下,我们调用一下语句:

int i = 2;
foo(i);
foo(2);

他们的输出都是const int & 2。而如果在只有第二个函数而没有其他函数的时候,第三条语句是违法的。在只有第三个函数没有其它函数的时候,第二条语句是违法的。所以我们总结一下:const reference可以绑定所有的值,而其他类型的引用只能绑定自己类型的值。在这四种函数都存在的情况下,每一种函数都会绑定与自己最接近的那个值。也就是说,在四个函数都存在的情况下,当我们再次运行上面的这段代码,输出的结果就将变成:

int & 2
int && 2

所以,当我们运行下面的语句:

foo(i);
foo(j);
foo(2);
foo([]()->const int && {return 2;}());

我们得到的结果将会是:

int & 2
const int & 2
int && 2
const int && 2

这里解释一下第四个。第四条语句编译的时候会有Warning,提示”Returning reference to local temporary object”。想想也确实是这么个事情,不过它让我通过了,而且结果没错误。我觉得这个是不靠谱的。不过其实仔细考虑一下,常量右值引用其实不太能想出什么应用场景。所以个人认为,这只是贯彻C++标准当中”不应当组织程序员拿起枪射自己的脚“的精神,到不一定有什么实际意义,所以这个就不要纠结了。

相信现在大家已经能够对于左值、右值、左值引用和右值引用有一个准确的认识了。

回到之前的问题

现在我们可以知道上面那个Test类当中的神奇的<KEYWORD>到底是什么了。其实他就是Test &&。由于左值和右值是两种不同的类型,所以可以依照这个类型进行重载。所以我们的Test类就变成了这样:

class Test {
    int * arr{nullptr};
public:
    Test():arr(new int[5000]{1,2,3,4}) { 
    	cout << "default constructor" << endl;
    }
    Test(const Test & t) {
        cout << "copy constructor" << endl;
        if (arr == nullptr) arr = new int[5000];
        memcpy(arr, t.arr, 5000*sizeof(int));
    }
    Test(Test && t): arr(t.arr) {
        cout << "move constructor" << endl;
        t.arr = nullptr;
    }
    ~Test(){
        cout << "destructor" << endl;
        delete [] arr;
    }
};

所以,当我们再次考虑下面这个应用场景的时候:

Test createTest() {
    return Test();
}

int main() {
    Test t(createTest());
}

我们会发现,打印的结果变成了:

default constructor
move constructor
destructor
move constructor
destructor
destructor

也就是说,我们的Test实例在工厂函数当中被使用默认构造函数(default constructor)构造一次之后,调用的全部都是移动构造函数,因为我们发现其实所有的这些值都是右值。这极大地节省了开支。

这里有一个编译器的trick。gcc是一个丧心病狂的编译器,他会强制进行(N)RVO。如果你不做任何设置直接用GCC编译运行上面的代码,你将看到的是:

default constructor

这个时候不要怀疑我上面说的东西有问题或者你写错了。请直接在gcc后面添加编译参数-fno-elide-constructors。所以整个的编译语句应该是:

g++ -std=c++11 -fno-elide-constructors test.cpp # for instance

移动语义再多说几句

现在我们再来看看一开始那个reusable的例子。

int main() {
	Test reusable;
	// do something to reusable
	Test duplicated(reusable);
	// do something to reusable
}

如果现在我们不想复制reusable了,我们也想在构造duplicated的时候使用转移构造函数,那么应该怎么做呢?新标准给我们提供了一个解决方案:

	Test duplicated(std::move(reusable));

这个std::move()的作用是将左值转换为右值。不过这里要注意的一点是,如果我们在这里使用了move的话,那么后面我们就不能再对reusable进行操作了。因为转移构造函数已经将reusable的成员变量arr指针置为空了。

讲解完了转移构造函数,其实转移赋值语句(move assignment)与之同理,各位就自己研究一下吧。由于STL已经默认对所有的代码进行了右值引用的改写,所以现在当你运行你之前写过的代码时,你不需要做任何的更改,就会发现似乎更快了一些。

进一步探讨左值和右值

我们来考虑下面的情景:

void doWork(TYPE&& param) {
	// ops and expressions using std::move(param)
}

这个代码是从Scott Meyers的演讲当中摘取的。现在的问题是:** param是右值吗? **答案是:不!param是一个左值。

这里牵扯到一个概念,即事实上左值和右值与类型是没有关系的,即int既可以是左值,也可以是右值。区别左值和右值的唯一方法就是其定义,即能否取到地址。在这里,我们明显可以对param进行取地址操作,所以它是一个左值。也就是说,但凡有名字的“右值”,其实都是左值。这也就是为什么上面的代码当中鼓励大家对所有的变量使用std::move()转成右值的原因。

完美转发(perfect forward)又是在做什么

我们依然考虑一个例子:

template <typename T>
void func(T t) {
    cout << "in func" << endl;
}

template <typename T>
void relay(T&& t) {
    cout << "in relay" << endl;
    func(t);
}

int main() {
    relay(Test());
}

在这个例子当中,我们的期待是,我们在main当中调用relayTest的临时对象作为一个右值传入relay,在relay当中又被转发给了func,那这时候转发给func的参数t也应当是一个右值。也就是说,我们希望:relay的参数是右值的时候,func的参数也是右值;当relay的参数是左值的时候,func的参数也是左值

那么现在我们来运行一下这个程序,我们会看到,结果与我们预想的似乎并不相同:

default constructor
in relay
copy constructor
in func
destructor
destructor

我们看到,在relay当中转发的时候,调用了复制构造函数,也就是说编译器认为这个参数t并不是一个右值,而是左值。这个的原因已经在上一节将结果了,因为它有一个名字。那么如果我们想要实现我们所说的,如果传进来的参数是一个左值,则将它作为左值转发给下一个函数;如果它是右值,则将其作为右值转发给下一个函数,我们应该怎么做呢?

这时,我们需要std::forward<T>()。与std::move()相区别的是,move()会无条件的将一个参数转换成右值,而forward()则会保留参数的左右值类型。所以我们的代码应该是这样:

template <typename T>
void func(T t) {
    cout << "in func " << endl;
}

template <typename T>
void relay(T&& t) {
    cout << "in relay " << endl;
    func(std::forward<T>(t));
}

现在运行的结果就成为了:

default constructor
in relay
move constructor
in func
destructor
destructor

而如果我们的调用方法变成:

int main() {
    Test t;
    relay(t);
}

那么输出就会变成:

default constructor
in relay
copy constructor
in func
destructor
destructor

完美地实现了我们所要的转发效果。

通用引用(universal reference)

现在一定有同学感到奇怪了,既然我刚才讲的完美转发就是怎么传进来怎么传给别人,那么也就是说在后面这个例子当中我们传进来的这个参数t竟然是一个左值!可是我们的参数表里不是写着T&&,要求接受一个右值吗?其实不是这样的。这里就牵扯到一个新的概念,叫做通用引用。

通用引用(universal reference)是Scott Meyers在C++ and Beyond 2012演讲中自创的一个词,用来特指一种引用的类型。构成通用引用有两个条件:

  1. 必须满足T&&这种形式
  2. 类型T必须是通过推断得到的

所以,在我们完美转发这个部分的例子当中,我们所使用的这种引用,其实是通用引用,而不是所谓的单纯的右值引用。因为我们的函数是模板函数,T的类型是推断出来的,而不是指定的。那么相应的,如果有一段这样的代码:

template <typename T>
class TestClass {
	public:
		void func(T&& t) {} //这个T&&是不是一个通用引用呢
}

上面的这个T是不是通用引用呢?答案是不是。因为当这个类初始化的时候这个T就已经被确定了,不需要推断。

所以,可以构成通用引用的有如下几种可能:

  1. 函数模板参数(function template parameters)

    cpp template <typename T> void f(T&& param);

  2. auto声明(auto declaration)

    cpp auto && var = ...;

  3. typedef声明(typedef declaration)
  4. decltype声明(decltype declaration)

那么,这个通用引用与其他的引用有什么区别呢?其实最重要的一点就是引用类型合成(Reference Collapsing Rules)。规则很简单:

  1. T& & => T&
  2. T&& & => T&
  3. T& && => T&
  4. T&& && => T&&

简单一点说,就是传进来的如果是左值引用那就是左值引用,如果是右值引用那就是右值引用。但是注意,这个合成规则用户是不允许使用的,只有编译器才能够使用这种合成规则。这就是为什么上面的通用引用当中有一条要求是类型必须可以自动推导。这个合成规则其实就是类型推倒的规则之一。

这样,我们就可以知道为什么Scott Meyers在演讲中建议大家在通用引用的情境下,尽可能使用forward()了,因为这样可以在不改变语义的情况下提升性能。

template <typename T>
void doWork(T && param) {
	// ops and expressions using std::forward<T>(param)
}

后记

C++0x通过引入许多新的语言特性来实现了语言性能的提升,使得本来就博大精深的一门语言变得更加的难以学习。但是一旦了解,就会被语言精妙的设计所折服。参考资料中给出了更多的关于左值、右值、左值引用、右值引用、移动语义和完美转发的例子。我自己实在是没有精力看完所有的这些资料了,各位有兴趣的话可以参阅。

参考资料

  1. http://thbecker.net/articles/rvalue_references/section_01.html#section_01
  2. http://blog.csdn.net/pongba/article/details/1697636
  3. http://channel9.msdn.com/Shows/Going+Deep/Cpp-and-Beyond-2012-Scott-Meyers-Universal-References-in-Cpp11
  4. https://isocpp.org/blog/2012/11/universal-references-in-c11-scott-meyers
  5. https://onedrive.live.com/view.aspx?resid=F1B8FF18A2AEC5C5!1062

Comments