【翻译】VC10中的C++0x新特性:右值引用(rvalue references) (1)

零度の冰翻译,原文地址在此,转载请注明出处

本系列的第一部分讲述了lambda表达式auto关键字static_assert

本文将描述右值引用,和随它而来的两个新概念:Move语义(move semantics)完美转发(perfect forwarding)。这篇文章会很长,因为我会详细解释右值引用是如何工作的。最开始的时候你可能会觉得有点乱,因为很少有C++98/03程序员熟悉左值(lvalue)右值(rvalue)区分的。

但毋须退缩,因为使用右值引用很简单。在你自己的代码中,不管是实现move语义还是完美转发,都可以归结为下面我将要描述的简单模式。并且,学习右值引用是非常值得的,因为move语义可以产生数量级上的性能提升,完美转发使得编写高度通用的代码非常的容易。

C++98/03中的左值和右值

为了理解C++0x中的右值引用,必须首先理解C++98/03中的左值和右值。(译注:按照俺的理解,左值就是能够放在=左边被赋值的东东,右值就是不能放在=左边而只能放在=右边的东东……也不知道对也不对)

术语“左值”和“右值”挺乱的,因为它们的历史就比较乱…… 这些概念最初来源与C,后在C++上弄得比较复杂。为了节约时间,我跳过去它们的历史不说,而直接阐述它们在C++98/03中是怎么工作的。

左值是超越单个表达式而持续存在的。例如,obj,*ptr,ptr[index],++x,它们都属于左值。

右值是临时对象,它们只存活在单个的表达式中,并在该表达式结束后被销毁。例如1729,x + y,std::string("meow")和x++,都是右值。

(译注:lvalue->左值,const lvalue->常量左值,rvalue->右值,const rvalue->常量右值,下文视情况使用中文或英文)

注意 x++ 和 ++x 的区别:如果我们声明 int x = 0,则x是左值,并且语句声明了一个持久的对象。++x 也是一个左值,它修改并且返回了这个持久对象x;但是,x++却是右值,因为它把持久对象x拷贝了一份,修改了x的值,然后返回的是修改之前的拷贝,这份拷贝是临时性质的。x++ 和 ++x 都递增了x,但++x递增返回自增后的x本身,x++返回的是x自增前的一份拷贝,这份拷贝是临时性的。这就是为什么++x是左值,而x++是右值。判断左值和右值不用管这个语句干了什么,而只看该语句命名了什么(是持久性的对象还是临时对象)。

如果你想对建立起判断左右值的直觉,另一种快速的方法就是看“取它的地址是否合法?”如果能取地址,那就是左值,否则就是右值。例如:&obj,&*ptr,&ptr[index]和&++x都是可以的,但是&1729,&(x + y),&std::string("meow")和&x++都是不合法的。为什么可以这样判断呢?因为取地址运算符要求操作对象是左值(C++03 5.3 1/2)。为什么会有这种要求呢?因为取一个持久对象的地址是对的,但是取临时对象的地址是危险的,因为临时对象将很快被销毁。

前面的例子忽略了运算符重载,调用重载的运算符也是一种函数调用。“函数调用是一个左值,当且仅当其返回值是一个引用。”(C++03 5.2.2/10)。因此,给定vector v(10, 1729);,v[0]是一个左值,因为operator [] ()返回一个 int& (并且&v[0]是有效的)。给出string s("foo");和string t("bar");,s + t是右值因为operator + ()返回string类型(并且&(s + t)是非法的)。

左值和右值都可以是变量或者常量,例如:

string one("cute");
const string two("fluffy");
string three() { return "kittens"; }
const string four() { return "are an essential part of a healthy diet"; }
one;     // modifiable lvalue
two;     // const lvalue
three(); // modifiable rvalue
four(); // const rvalue

Type& 与lvalue绑定(并且可以被用于观察和修改它们)。它不能绑定到const lvalue上,因为这违反了常量约束。它不能绑定rvalues上,因为这样可能是极端危险的。意外地修改临时对象,只会导致临时对象和刚刚的修改操作一起消失,会导致一些难以发现的讨厌BUG,因此C++禁止这么干。(我应该提到VC有一个邪恶的扩展允许你这么干,但是如果你通过/W4选项编译,编译器会报警。)还有,它也不能绑定到const rvalue上,因为这样可能会导致双倍的错误(译注:1、非常量引用类型引用常量,2、左值引用引用右值)(细心的读者可能已经注意到了我这里没有谈到模板参数类型推导)

const Type& 可以绑定到任何东西上:lvalue, rvalue, const lvalue, const rvalue(并且可用于观察它们的变化)。

一个引用类型是具名的,因此,一个绑定到右值的引用本身,是左值。(因为只有常量引用才能绑定到右值上,因此它会是一个const lvalue)这个有点拗口,而且会导致一些比较大的问题,因此我来进一步说明一下。给定一个函数签名void observe(const string& str),即便调用observe()时传入像上面的three()这样的右值当作参数,在此函数内部str也是一个const lvalue,其地址是可以取到的,并且在函数返回前都可用。你也可以调用observe("purr"),这将导致一个临时的string对象被构造,并且将str绑定到上面。three()、four()的返回值也是右值,但是在observe函数内部,str就是一个名字,因此它是左值。就像我上面强调的,“左值性或者是右值性是针对于表达式来说的,并不是针对于对象。”当然了,因为str可以被绑定到一个会在未来被销毁的临时对象上,所以保存其地址,在observe()返回后使用是不对的。

你曾经将一个右值绑定到一个const Type&上面并且取过它的地址吗?是的,你肯定这么干过!当你重载一个赋值运算符Foo& operator=(const Foo& other),里面会有自己给自己赋值的检测 if(this != &other){ copy stuff; } return *this;,这样,当你从临时对象赋值时,上述情况就发生了,例如Foo make_foo(); Foo f; f = make_foo();。

这时候,有同学会问了,“我不能将Type&与rvalue绑定,我也不能指派个啥东西与rvalue关联,那我还能修改右值吗?const rvalue和rvalue有啥不同?”这是一个非常好的问题!在C++98/03中,const rvalue和rvalue有轻微的不同:可以在rvalue的对象上调用其非常量成员函数,而const rvalue属性的对象上则不能调用。在C++0x中,答案戏剧性地变化了,导致了move语义的出现。

祝贺你!你现在已经具有了“左值/右值感官”:看到一个表达式就能够辨认出这是左值还是右值的能力。结合const属性,你可以精确的分析给出声明void mutate(string& ref)和上面的变量定义,mutate(one)是OK的,mutate(two), mutate(three()), mutate(four()), mutate("purr")都是非法的。所有的observe(one), observe(three()), observe(four())和observe("purr")都是有效的。如果你是一名C++98/03程序员,你以前是“本能的直觉”告诉你上面哪些调用是非法的,哪些是有效的。但现在,你有了“左值/右值感官”,它可以精确的告诉你为什么mutate(three())通不过编译(因为three()是一个右值,Type&不能绑定到右值上)。这有用吗?对于研究语法的人,很有用,但对于普通的程序员,就没啥大用处了。毕竟,你在没有掌握太多左右值细节的情况下,得到了这项能力。但注意,相对于C++98/03,C++0x需要更加强大的左右值判断能力,你现在具有了这项牛B能力,让我们继续下去吧!

对象复制问题

C++98/03将强大的抽象能力和强大的执行效率完美结合在了一起,但它有一个问题:它过度的依赖于对象的复制了。对象都是具有值语义的,因此复制一个对象不会影响源对象,其副本也是独立的。值语义很棒, 但它有时候会导致像string, vector这样的庞大对象的不必要的复制。有些情况下返回值优化 Return Value Optimization (RVO) 和 具名返回值优化 Named Return Value Optimization (NRVO)可以减轻这个问题,但是它们也没有完全消除所有不必要的复制行为。

最不必要的复制行为就是去复制那些即将销毁的对象。你会在复制了一夜表单后立马将原来的那份扔了吗?这太浪费了吧,你应该保留原有的那一份,而不是复制一份再扔掉原来的。这里有一个我称之为“杀手级”的例子,来源于标准委员会(N1377)。假设你有一坨string:

string s0("my mother told me that");
string s1("cute");
string s2("fluffy");
string s3("kittens");
string s4("are an essential part of a healthy diet");

然后你把他们连接起来:

string dest = s0 + " " + s1 + " " + s2 + " " + s3 + " " + s4;

这句话效率如何?(我们不是担心这个特例的效率问题,它也只运行几微秒而已;我们担心的是整个语言的属于上面例子这一类的效率问题)

每次调用operator + () 都返回一个string的临时对象。上面一共调用了8次operator + (),因此出现了8个临时的string。每次构造临时对象时,其构造函数都会动态分配一些内存然后拷贝之前连接过的所有字符。接着,其析构函数被调用,内存被释放。

实际上,每次字符串的连接都会把之前连接过的字符串的所有字符复制一遍,因此,随着连接次数的增多,复杂度成平方级增长。啊!这真是极度的浪费啊!我们如何避免这种情况的发生呢?

问题出在operator + ()上面,它接受两个const string&或者一个const string&和一个const char*作为参数,它不能判断出接受的参数到底是左值还是右值,因此它就每次创建并且返回一个临时的string。为什么知道左值或是右值很重要呢?

当我们看s0 + " "时,创建一个临时对象是完全必要的。s0是左值,它命名了一个持久性的对象,因此我们不能修改它。但是当我们看(s0 + " ") + s1时,我们应该直接将s1的内容增加进(s0 + " ")创建的临时对象中,而不是创建一个新的临时对象,再把第一个临时对象扔掉。这就是move语义的关键:因为(s0 + " ")是个右值,一个代表临时对象的表达式,整个程序中再没有其它地方观察这个变量了。如果我们能够检测到这是个右值,我们就能任意修改它,而不影响任何其它的地方。operator + ()并不想修改它的参数,但是如果参数是可修改的右值,修改了也无妨吧?使用这种方法,每次调用operator + ()都在那个首次创建的临时string上扩展字符,这完全消掉了不必要的内存管理和复制,带给我们线性的复杂度,哦耶!

技术层面上讲,C++0x中,每次调用operator + () 仍然返回一个独立的临时变量。但是,(s0 + " ") + s1中,第二个+返回的临时变量窃取了第一个+创建的临时变量的内存,并且将s1的内容接在窃取的那段内存后面(可能会导致重新申请更大的内存)。“窃取”还包括指针的修改:第二个临时变量拷贝走第一个临时变量的内存,并将其内存指针设为NULL。当第一个临时变量释放内存时,指针为空,因此它的析构函数啥也不用干。

通常,检查可修改的右值的能力使你成为了“资源小偷”。如果被引用的右值包含任何资源(例如内存),你就可以窃取它的资源而不必拷贝它,因为它马上就会被销毁了。通过窃取右值上面的资源构造对象或者是赋值,通常称之为“move”,可move的对象具有“move语义”。

这在很多地方都很有用,例如vector的重新分配。当一个vector需要更大的容量时,重新分配内存后,它需要将数据对象从老的内存块中移动到新的内存块,而调用它们的拷贝构造函数可能代价很昂贵(一个vector<string>,每个string的拷贝都需要分配内存,拷贝整个字符串)。但是等一等!在旧的内存块中的对象是马上要被销毁的,所以我们可以移动它们,而不是拷贝。在这种情况下,在旧的内存块中的元素是持久性存储的,而像old_ptr[index]这样引用这个元素的表达式是左值。如果把它们看做是右值就好了,这样就允许我们移动它们,消灭掉了拷贝构造函数的调用。(说“我想将这个左值看做右值”等价于说“我知道这是个左值,代表了一个持久性的对象,但我不在乎了,因为我即将销毁它了,或者给它重新复制了,等等。所以如果你能窃取它的资源,那就这么干吧!”)

C++0x的右值引用给了我们检测右值和窃取右值资源的能力,这使move语义成为现实。右值引用也允许我们将左值看做是右值而实施move语义。现在,让我们看看右值语义如何工作吧。

右值引用:初始化

C++0x引入了一个新的引用类型,右值引用:Type&&和const Type&&。当前的C++0x工作草案,N2798 8.3.2/2中说:“通过&声明的引用叫做左值引用,通过&&声明的引用叫做右值引用。左值引用和右值引用是不同的类型。除非明确地指出,它们在语义上是等效的,通常被称为引用。”这意味着你需要学习它们的不同之处。

相对于左值引用,右值引用在初始化和重载时有不同的行为。区别在于它们倾向于绑定什么类型的对象(初始化),什么类型的对象倾向于优先绑定到它们(重载)。让我们先看看初始化:

  • 我们已经知道了Type&倾向于绑定lvalue。其它的都不行(const lvalue,rvalue,const rvalue)。
  • 我们已经知道了const Type&倾向于绑定所有类型。
  • Type&&倾向于绑定lvalue和rvalue,但不能绑定const lvalue和const rvalue(这违反了常量约束)。
  • const Type&&倾向于绑定所有类型。

这些规则看着挺神秘,但它们都是从下面两条派生出来的:

  • 遵守常量约束,变量引用不能绑定与常量。
  • 避免修改临时变量,阻止左值引用绑定到右值上。

如果你更喜欢看编译器给出的错误信息,下面就是一例:

C:/Temp>type initialization.cpp
#include <string>
using namespace std;

string modifiable_rvalue() {
    return "cute";
}

const string const_rvalue() {
    return "fluffy";
}

int main() {
    string modifiable_lvalue("kittens");
    const string const_lvalue("hungry hungry zombies");

    string& a = modifiable_lvalue;          // Line 16
    string& b = const_lvalue;               // Line 17 - ERROR
    string& c = modifiable_rvalue();        // Line 18 - ERROR
    string& d = const_rvalue();             // Line 19 - ERROR

    const string& e = modifiable_lvalue;    // Line 21
    const string& f = const_lvalue;         // Line 22
    const string& g = modifiable_rvalue(); // Line 23
    const string& h = const_rvalue();       // Line 24

    string&& i = modifiable_lvalue;         // Line 26
    string&& j = const_lvalue;              // Line 27 - ERROR
    string&& k = modifiable_rvalue();       // Line 28
    string&& l = const_rvalue();            // Line 29 - ERROR

    const string&& m = modifiable_lvalue;   // Line 31
    const string&& n = const_lvalue;        // Line 32
    const string&& o = modifiable_rvalue(); // Line 33
    const string&& p = const_rvalue();      // Line 34
}

C:/Temp>cl /EHsc /nologo /W4 /WX initialization.cpp
initialization.cpp
initialization.cpp(17) : error C2440: 'initializing' : cannot convert from 'const std::string' to 'std::string &'
        Conversion loses qualifiers
initialization.cpp(18) : warning C4239: nonstandard extension used : 'initializing' : conversion from 'std::string' to 'std::string &'
        A non-const reference may only be bound to an lvalue
initialization.cpp(19) : error C2440: 'initializing' : cannot convert from 'const std::string' to 'std::string &'
        Conversion loses qualifiers
initialization.cpp(27) : error C2440: 'initializing' : cannot convert from 'const std::string' to 'std::string &&'
        Conversion loses qualifiers
initialization.cpp(29) : error C2440: 'initializing' : cannot convert from 'const std::string' to 'std::string &&'
        Conversion loses qualifiers

右值绑定到右值引用可以被用来修改临时变量。

即使左值引用和右值引用在初始化时行为是类似的(只有18行和28行有不同),它们在重载中表现得却很不同。

右值引用:重载判定

你已经对参数为变量和常量左值引用的函数重载很熟悉了。在C++0x中,函数可以用常量或非常量右值引用重载。给出一个一元函数的所有四种重载形式,你应该发现了,每个表达式都倾向于绑定到与它对应的引用上:

C:/Temp>type four_overloads.cpp
#include <iostream>
#include <ostream>
#include <string>
using namespace std;

void meow(string& s) {
    cout << "meow(string&): " << s << endl;
}

void meow(const string& s) {
    cout << "meow(const string&): " << s << endl;
}

void meow(string&& s) {
    cout << "meow(string&&): " << s << endl;
}

void meow(const string&& s) {
    cout << "meow(const string&&): " << s << endl;
}

string strange() {
    return "strange()";
}

const string charm() {
    return "charm()";
}

int main() {
    string up("up");
    const string down("down");

    meow(up);
    meow(down);
    meow(strange());
    meow(charm());
}

C:/Temp>cl /EHsc /nologo /W4 four_overloads.cpp
four_overloads.cpp

C:/Temp>four_overloads
meow(string&): up
meow(const string&): down
meow(string&&): strange()
meow(const string&&): charm()

在实践中,对Type&,const Type&,Type&&,const Type&&的重载不是非常的实用。一个更加有趣的重载是集合const Type&和Type&&:

C:/Temp>type two_overloads.cpp
#include <iostream>
#include <ostream>
#include <string>
using namespace std;

void purr(const string& s) {
    cout << "purr(const string&): " << s << endl;
}

void purr(string&& s) {
    cout << "purr(string&&): " << s << endl;
}

string strange() {
    return "strange()";
}

const string charm() {
    return "charm()";
}

int main() {
    string up("up");
    const string down("down");

    purr(up);
    purr(down);
    purr(strange());
    purr(charm());
}

C:/Temp>cl /EHsc /nologo /W4 two_overloads.cpp
two_overloads.cpp

C:/Temp>two_overloads
purr(const string&): up
purr(const string&): down
purr(string&&): strange()
purr(const string&): charm()

为什么会是这种结果呢?下面是绑定规则:

  • 上面初始化相关的规则具有否决权。
  • 左值非常强烈的倾向于绑定到左值引用上,右值非常强烈的倾向于绑定到右值引用上。
  • 变量表达式倾向于绑定到非常量引用上,但倾向性稍弱。

(对于“否决”,我指的是决定匹配的候选函数时,被断定为不可行的函数将马上被排除在外)让我们根据规则判断一下:

  • 对于purr(up),purr(const string&)和purr(string&&)都没有被初始化规则否决掉。up是一个左值,因此它强烈地想绑定到左值引用purr(const string&)上。up是可变的,所以它比较弱地倾向于绑定到可变引用purr(string&&)上。比较强烈的倾向purr(const string&)胜出。
  • 对于purr(down),初始化规则根据常量约束否定掉了purr(string&&),因此purr(const string&)胜出。
  • 对于purr(strange()),purr(const string&)和purr(string&&)都没有被初始化规则否决掉。strange()是一个右值,因此它强烈地倾向于绑定到右值引用purr(string&&)上。strange()是可变的,因此它比较弱地倾向于绑定到非常量引用purr(string&&)上。强烈地倾向purr(string&&)胜出。
  • 对于purr(charm()),初始化规则根据常量约束否决掉了purr(string&&),因此purr(const string&)胜出。

值得注意的是,当你重载const Type&和Type&&时,变量右值绑定到了Type&&上,其它的都绑定到了const Type&上。因此这组重载非常适合move语义。

重要提示:函数按值返回时(而不是返回引用),它应该返回Type(就像strange()那样),而不是返回const Type(像charm()那样)。因为后者几乎没有任何作用(除了禁止非常量成员函数的调用),还妨碍了move语义的优化。

move语义:模型

这里有一个简单的class,remote_integer,它存储了一个指针,指向一个动态分配的int。它的默认构造函数、一元的构造函数、拷贝构造函数、重载赋值运算符和析构函数你都应该比较熟悉了。我又给它增加了move构造函数、move重载赋值运算符,它们被#ifdef MOVABLE条件编译,我用它来演示有这两个函数和没有他们时分别发生了什么,真正的代码不会这么干。

C:/Temp>type remote.cpp
#include <stddef.h>
#include <iostream>
#include <ostream>
using namespace std;

class remote_integer {
public:
    remote_integer() {
        cout << "Default constructor." << endl;

        m_p = NULL;
    }

    explicit remote_integer(const int n) {
        cout << "Unary constructor." << endl;

        m_p = new int(n);
    }

    remote_integer(const remote_integer& other) {
        cout << "Copy constructor." << endl;

        if (other.m_p) {
            m_p = new int(*other.m_p);
        } else {
            m_p = NULL;
        }
    }

#ifdef MOVABLE
    remote_integer(remote_integer&& other) {
        cout << "MOVE CONSTRUCTOR." << endl;

        m_p = other.m_p;
        other.m_p = NULL;
    }
#endif // #ifdef MOVABLE

    remote_integer& operator=(const remote_integer& other) {
        cout << "Copy assignment operator." << endl;

        if (this != &other) {
            delete m_p;

            if (other.m_p) {
                m_p = new int(*other.m_p);
            } else {
                m_p = NULL;
            }
        }

        return *this;
    }

#ifdef MOVABLE
    remote_integer& operator=(remote_integer&& other) {
        cout << "MOVE ASSIGNMENT OPERATOR." << endl;

        if (this != &other) {
            delete m_p;

            m_p = other.m_p;
            other.m_p = NULL;
        }

        return *this;
    }
#endif // #ifdef MOVABLE

    ~remote_integer() {
        cout << "Destructor." << endl;

        delete m_p;
    }

    int get() const {
        return m_p ? *m_p : 0;
    }

private:
    int * m_p;
};

remote_integer square(const remote_integer& r) {
    const int i = r.get();

    return remote_integer(i * i);
}

int main() {
    remote_integer a(8);

    cout << a.get() << endl;

    remote_integer b(10);

    cout << b.get() << endl;

    b = square(a);

    cout << b.get() << endl;
}

C:/Temp>cl /EHsc /nologo /W4 remote.cpp
remote.cpp

C:/Temp>remote
Unary constructor.
8
Unary constructor.
10
Unary constructor.
Copy assignment operator.
Destructor.
64
Destructor.
Destructor.

C:/Temp>cl /EHsc /nologo /W4 /DMOVABLE remote.cpp
remote.cpp

C:/Temp>remote
Unary constructor.
8
Unary constructor.
10
Unary constructor.
MOVE ASSIGNMENT OPERATOR.
Destructor.
64
Destructor.
Destructor.

这里面有几点需要注意:

  • 拷贝和move构造函数是重载的,拷贝和move赋值运算符也是重载的。我们已经看到了const Type&和Type&&函数重载时发生的情况。这就是为什么当move赋值运算符可用时,b = square(a)自动的选择了它。
  • move拷贝构造函数和move赋值运算符简单的从别的地方窃取内存,而不是动态的申请内存。当窃取别人的内存时,我们直接拷贝了他的内存指针,并且将其置NULL。当那个对象释放时,其析构函数不会重复释放内存。
  • move拷贝构造函数和move赋值运算符都需要做自赋值检测。因为像int这样的类型可以无害地做x = x,所以用户自定义类型也应该支持自赋值。自赋值通常不会发生在手写的程序代码中,但经常发生在一些算法中,例如std::sort()。在C++0x中,像std::sort()这样的算法可以移动对象,而不是拷贝它们。这时候,潜在的自赋值就存在了。

这时,你会问move语义是否影响到了自动生成的(“隐式声明的”)构造函数和赋值运算符?

  • 编译器从来不自动生成move构造函数和move赋值运算符。
  • 包括拷贝构造函数move构造函数,用户自定义的任何构造函数都会阻止编译器生成默认的构造函数。
  • 用户自定义的拷贝构造函数会阻止生成隐式的拷贝构造函数,但自定义的move拷贝构造函数不会阻止生成隐式的拷贝构造函数。
  • 同样,用户自定义的move赋值运算符不会阻止生成隐式的赋值运算符。

基本上,自动生成规则不会被move语义影响,除了move构造函数的声明,声明任何构造函数都会阻止编译器生成默认构造函数。

未完,下转【翻译】VC10中的C++0x新特性:右值引用(rvalue references) (2)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值