当前位置导航:炫浪网>>网络学院>>编程开发>>C++教程>>Visual C++教程

VC10中的C++0x特性 Part 2 :右值引用

    简介

    这一系列文章介绍Microsoft Visual Studio 2010 中支持的C++ 0x特性,目前有三部分。

    Part 1:介绍了Lambdas, 赋予新意义的auto,以及 static_assert;

    Part 2:介绍了右值引用(Rvalue References);

   Part 3:介绍了表达式类型(decltype)。

    今天我要讲的是 rvalue references (右值引用),它能实现两件不同的事情: move 语意和完美转发。刚开始会觉得它们难以理解,因为需要区分 lvalues 和 rvalues ,而只有极少数 C++98/03 程序员对此非常熟悉。这篇文章会很长,因为我打算极其详尽地解释 rvalue references 的运作机制。

    不用害怕,使用 ravlue references 是很容易的,比听起来要容易得多。要在你的代码中实现 move semantics 或 perfect forwarding 只需遵循简单的模式,后文我会对此作演示的。学习如何使用 rvalue references 是绝对值得的,因为 move semantics 能带来巨大的性能提升,而 perfect forwarding 让高度泛型代码的编写变得非常容易。

    C++ 98/03 中的 lvalues 和 rvalues

    要理解C++ 0x中的 rvalue references,你得先理解 C++ 98/03 中的 lvalues 与 rvalues.

    术语 “lvalues” 和 “rvalues” 是很容易被搞混的,因为它们的历史渊源也是混淆。(顺带一提,它们的发音是 ‘L values“ 和  ”R values“, 尽管它们都写成一个单词)。这两个概念起初来自 C,后来在 C++ 中被加以发挥。为节省时间,我跳过了有关它们的历史,比如为什么它们被称作 ”lvalues“ 和 ”rvalues“,我将直接讲它们在 C++ 98/03 中是如何运作的。(好吧,这不是什么大秘密: ”L“ 代表 ”left“,”R“ 代表 ”right“。它们的含义一直在演化而名字却没变,现在已经”名“不副”实“了。与其帮你上一整堂历史课,不如随意地把它们当作像”上夸克“和”下夸克“之类的名字,也不会有什么损失。)

    C++ 03 标准 3.10/1 节上说: “每一个表达式要么是一个 lvalue ,要么就是一个 rvalue .” 应该谨记 lvalue 跟 rvalue 是针对表达式而言的,而不是对象。

    lvalue 是指那些单一表达式结束之后依然存在的持久对象。例如: obj,*ptr, prt[index], ++x 都是 lvalue.

    rvalue 是指那些表达式结束时(在分号处)就不复存在了的临时对象。例如: 1729 , x + y , std::string("meow") , 和 x++ 都是 rvalue.

    注意 ++x 和 x++ 的区别。当我们写 int x = 0; 时, x 是一个 lvalue,因为它代表一个持久对象。 表达式 ++x 也是一个 lvalue,它修改了 x 的值,但还是代表原来那个持久对象。然而,表达式 x++ 却是一个 rvalue,它只是拷贝一份持久对象的初值,再修改持久对象的值,最后返回那份拷贝,那份拷贝是临时对象。 ++x 和 x++ 都递增了 x,但 ++x 返回持久对象本身,而 x++ 返回临时拷贝。这就是为什么 ++x 之所以是一个 lvalue,而 x++ 是一个 rvalue. lvalue 与 rvalue 之分不在于表达式做了什么,而在于表达式代表了什么(持久对象或临时产物)。

    另一个培养判断一个表达式是不是 lvalue 的直觉感的方法就是自问一下“我能不能对表达式取址?”,如果能够,那就是一个 lvalue;如果不能,那就是 一个 rvalue. 例如:&obj , &*ptr , &ptr[index] , 和 &++x 都是合法的(即使其中一些例子很蠢),而 &1729 , &(x + y) , &std::string("meow") , 和 &x++ 是不合法的。为什么这个方法凑效?因为取址操作要求它的“操作数必须是一个 lvalue”(见 C++ 03 5.3.1/2)。为什么要有那样的规定?因为对一个持久对象取址是没问题的,但对一个临时对象取址是极端危险的,因为临时对象很快就会被销毁(译注:就像你有一个指向某个对象的指针,那个对象被释放了,但你还在使用那个指针,鬼知道这时候指针指向的是什么东西)。

    前面的例子不考虑操作符重载的情况,它只是普通的函数调用语义。“一个函数调用是一个 lvalue 当且仅当它返回一个引用”(见 C++ 03 5.2.2/10)。因此,给定语句 vercor<int> v(10, 1729); , v[0] 是一个 lvalue,因为操作符 []() 返回 int& (且 &v[0] 是合法可用的); 而给定语句 string s("foo");和 string t("bar");,s + t 是一个rvalue,因为操作符 +() 返回 string(而 &(s + t) 也不合法)。

    lvalue 和 rvalue 两者都有非常量(modifiable,也就是说non-const)与常量(const )之分。举例来说:

 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,因为那将违背 const 正确性;也不能把它绑定到非常量 rvalue,这样做极端危险,你用这个引用来修改临时对象,但临时对象早就不存在了,这将导致难以捕捉而令人讨厌的 bug,因此 C++ 明智地禁止这这么做。(我要补充一句:VC 有一个邪恶的扩展允许这么蛮干,但如果你编译的时候加上参数 /W4 ,编译器通常会提示警告"邪恶的扩展被激活了“)。也不能把它绑定到 const ravlue,因为那会是双倍的糟糕。(细心的读者应该注意到了我在这里并没有谈及模板参数推导)。

    const Type& 可以绑定到: 非常量 lvalues, const lvalues,非常量 rvalues 以及 const values.(然后你就可以用这个引用来观察它们)

    引用是具名的,因此一个绑定到 rvalue 的引用,它本身是一个 lvalue(没错!是 L)。(因为只有 const 引用可以绑定到 rvalue,所以它是一个 const lvalue)。这让人费解,(不弄清楚的话)到后面会更难以理解,因此我将进一步解释。给定函数 void observe(const string& str), 在 observe()'s 的实现中, str 是一个 const lvalue,在 observe() 返回之前可以对它取址并使用那个地址。这一点即使我们通过传一个 rvalue 参数来调用 observe()也是成立的 ,就像上面的 three() 和 four()。也可以调用 observe("purr"),它构建一个临时 string 并将 str 绑定到那个临时 string.three() 和 foure() 的返回对象是不具名的,因此他们是 rvalue,但是在 observe()中,str 是具名的,所以它是一个 lvalue.正如前面我说的“ lvalue 跟 rvalue 是针对表达式而言的,而不是对象”。当然,因为 str 可以被绑定到一个很快会被销毁的临时对象,所以在 observe() 返回之后我们就不应该在任何地方保存这个临时对象的地址。

    你有没有对一个绑定到 rvalue 的 const 引用取址过么?当然,你有过!每当你写一个带自赋值检查的拷贝赋值操作符: Foo& operator=(const Foo& other), if( this != &other) { copy struff;}; 或从一个临时变量来拷贝赋值,像: Foo make_foo(); Foo f; f = make_foo(); 的时候,你就做了这样的事情。

    这个时候,你可能会问“那么非常量 rvalues 跟 const rvalues 有什么不同呢?我不能将 Type& 绑定到非常量 rvalue 上,也不能通过赋值等操作来修改 rvalue,那我真的可以修改它们?” 问的很好!在 C++ 98/03 中,这两者存在一些细微的差异: non-constrvalues 可以调用 non-const 成员函数。 C++ 不希望你意外地修改临时对象,但直接在non-const rvalues上调用 non-const 成员函数,这样做是很明显的,所以这是被允许的。在 C++ 0x中,答案有了显著的变化,它能用来实现 move 语意。

    恭喜!你已经具备了我所谓的“lvalue/rvalue 观”,这样你就能够一眼就判断出一个表达式到底是 lvalue 还是 rvalue.再加上你原来对 const 的认识,你就能完全理解为什么给定语句 void mutate(string& ref) 以及前面的变量定义, mutate(one) 是合法的,而 mutate(two), mutate(three()), mutate(four()), mutate("purr") 都是不合法的。如果你是 C++ 98/03 程序员,你已经可以分辨出这些调用中的哪些是合法的,哪些是不合法的;是你的“本能直觉”,而不是你的编译器,告诉你 mutate(three()) 是假冒的。你对 lvalue/rvalue 的新认识让你明确地理解为什么 three() 是一个 rvalue,也知道为什么非常量引用不能绑定到右值。知道这些有用么?对语言律师而言,有用,但对普通程序员来说并不见得。毕竟,你如果不理解关于 lvalues 和 rvalues 一切就要领悟这个还隔得远呢。但是重点来了:与 C++ 98/03 相比, C++ 0x 中的 lvalue 和 rvalue 有着更广泛更强劲的含义(尤其是判断表达式是否是 modifiable / const 的 lvalue/rvalue,并据此做些处理)。要有效地使用 C++ 0x,你也需具备对 lvalue/rvalue 的理解。现在万事具备,我们能继续前行了。

    拷贝的问题

    C++ 98/03 将不可思议的高度抽象和不可思议的高效执行结合到了一起,但有个问题:它过度滥用拷贝。对行为像 int 那样有着值语意的对象而言,源对象的拷贝是独立存在的,并不会影响源对象。值语意很好,除了在会导致冗余拷贝之外,像拷贝 strings,vectors 等重型对象那样的情况。(“重型”意味着“昂贵的拷贝开销”;有着100万个元素的 vector 是重型的)。返回值优化(RVO) 和命名返回值优化(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;

    这样做的效率如何?(我们不用为这个特殊的例子而担忧,它的执行只要几微秒;我们担忧它的一般化情况,在语言层面上的情况)。

    每次调用操作符 +() 就会返回一个临时 string.上面调用了 8 次操作符 +(),因而产生了 8 个临时 string. 每一个临时 string,在构造过程中分配动态内存,再拷贝所有已连接的字符,最后在析构过程中释放分配的动态内存。(你听说过短串优化技术么,为了避免动态内存的分配与释放,VC是这么干的,在这个被我精心挑选的有着合适长度的 s0 面前短串优化技术也无能为力,即使执行了这样的优化,也无法避免拷贝操作。如果你还听说过写时拷贝优化(Copy - On - Write),忘了它吧,在这里也不适用,并且在多线程环境下这种优化会恶化问题,因此标准库实现根本就不再做这个优化了)。

    事实上,因为每一个串接操作都会拷贝所有已经串接好的字符,所以那个复杂度是字符串长度的平方了。哎呀!这太浪费了!这点确实让 C++ 尴尬。事情怎么会搞成这样呢?有没有改善的办法?

    问题是这样的,operator+()接受两个参数,一个是 const string&,另一个是 const string& 或 const char * (还有其他重载版本,但在这里我们没有用到),但 operator+() 无法分辨出你塞给它的是 lvalue 还是 rvalue 参数,所以它只好总是创建一个临时 string,并返回这个临时 string. 为什么这跟 vavlue/rvalue 有关系?

    当我们要计算 s0 + " " 的值时,很明显这里有必要创建一个新的临时 string. s0 是一个 lvalue,它已经命名了一个持久对象,因此我们不能修改它。(有人注意到了!) .如果要计算 (s0 + “ ”) + s1 的值,我们可以简单地将 s1 的内容追加到第一个临时 string 上,而不用创建第二个临时 string 再把第一个丢弃掉。这就是 move 语意背后的核心观念: 因为 s0 + " " 是一个 rvalue ,只有那个在整个程序中唯一能够觉察到临时对象存在的表达式可以引用临时对象。如果我们能检测到表达式是一个非常量 rvalue,我们就可以任意修改临时对象,而不会有人发现。 操作符 +() 本不应该修改它的参数,但如果其参数是非常量 rvalue,谁在乎?照这种方法,每次调用操作符 +() 都把字符追加到唯一的临时对象上,这样就彻底省掉了不必要的动态内存管理和冗余的拷贝操作,呈现出线性复杂度。耶!

    从技术上讲,在 C++ 0x 中,每次调用操作符 +() 还是会返回一个单独的临时 string. 然而,第二个临时 string (产生自 (s0 + “ ”) + s1 )可以通过“窃取”第一个临时 string (产生自 s0 + " "  )的内存而被构造出来,然后再把 s1 的内容追加到那块内存后面(这将会引发一个普通的重分配操作)。“窃取”是通过指针的操作实现的:第二个临时 string 会先拷贝第一个临时 string 的内部指针,然后再清空这个指针。第一个临时 string 最后被销毁(在分号那地方)时,它的指针已经置为 null 了,因此它的析构函数什么也不会做(译注:也就是说不会释放它的内存,这部分内存现在是第二个临时 string 在使用了)。

    通常,如果能够检测到非常量 rvalue,你就能够做些“资源窃取”的优化。如果非常量 rvalue 所引用的那些对象持有任何资源(如内存),你就能窃取它们的资源而不用拷贝它们,反正它们很快就会被销毁掉。通过窃取非常量 rvalue 持有的资源来构建或赋值的手法通常被称作 “moving”,可移动对象拥有 “move 语意”。

    在大多数情况下这相当有用,比如 vector 的重新分配。当一个 vector 需要更多空间(如 push_back() 时)和进行重分配操作时,它需要从旧的内存块中拷贝元素到新的内存块中去。这些拷贝构造调用的开销很大。(对 vector<string> 来说,需要拷贝每一个 string 元素,这涉及动态内存分配)。但是等一等!旧内存块中的那些元素很快会被销毁掉的呀,所以我们可以挪动这些元素,而不用拷贝它们。在这种情形下,旧内存块中的元素依然存在于内存中,用来访问它们的表达式,如 old_ptr[index],还是 lvalue.在重分配过程中,我们想用非常量 rvalue 表达式来引用旧内存块中的元素。假定它们是非常量 rvalue,那我们就能够移动它们,从而省去拷贝构造开销。(说“我想假定这个 lvalue 是一个非常量 rvalue ”等同于说“我知道这是一个 lvalue,它指向一个持久对象,但我不关心随后会对这个 lvalue 进行怎样的操作,或销毁它,或给它赋值,或进行任意操作。因此如果你能从它那里窃取资源的话,尽管行动吧”)

    C++0x 的 rvalue 引用概念给与我们检测非常量 rvalue 并从中窃取资源的能力,这让我能够实现 move 语意。rvalue 引用也让我们能够通过把 lvalue 伪装成非常量 rvalue 而随意触发 move 语意。现在,我们来看看 rvalue 引用是如何工作的!

    ravlue 引用:初始化

    C++0x 引进了一种新的引用,ravlue 引用,其语法是 Type&& 和 const Type&& .目前 C++0x 草案 N2798 8.3.2/2 上说:“用 & 声明的引用类型被称作 lvalue 引用,而用 && 声明的引用类型被称作 rvalue 引用。lvalue 引用与 rvalue 引用是截然不同的类型。除非特别注明,两者在语意上是相当的并且一般都被称作引用。”这意味着对 C++98/03 中引用(即现在的 lvalue 引用)的直觉印象可以延伸用于 rvalue 引用;你只需要学习这两者的不同之处。

    (说明:我选择把 Type& 读作 “Type ref”,Type&& 读作 "Type ref ref".它们的全称分别是 “lvalue reference to Type” 和 "rvalue reference to Type",就像 “cosnt pointer to int” 被写成 “int * const”,而被读作 “int star const”一样。)

    两者有什么区别?与 lvalue 引用相比, rvalue 引用在初始化与重载决议时表现出不同的行为。两者的区别在于它们会优先绑定到什么东西上(初始化时)和什么东西会优先绑定到它们身上(重载决议时)。首先让我们来看看初始化:

    我们已经明白为何非常量 lvalue 引用( Type& ) 只能绑定到非常量 lvalue 上,而其他的一概不能(如 const lvalues,非常量 rvalues,const rvalues)

    · 我们已经明白为何 const lvalue 引用( const Type& ) 能绑定到任何东西上。

    · 非常量 rvalue ( Type&& ) 能够绑定到非常量 lvalue 以及非常量 rvalue 上,而不能绑定到 const lvalues 和 const rvalues (这会违背 const 正确性)

    · const rvalue 引用( const Type&& ) 能够绑定到任何东西上。

    这些规则听起来可能有些神秘,但是他们来源于两条简单的规则:

    · 遵守 const 正确性,所以你不能把非常量引用绑定到常量上。

    · 避免意外修改临时对象,所以你不能把非常量 lvalue 引用绑定到非常量 rvalue 上来。

    如果你更喜欢阅读编译器错误信息,而不是阅读文字描述,下面是一个示例:

     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


    非常量 rvalue 引用绑定到非常量 rvalue 是没问题的;要领就是它们可以被用来修改临时对象。

    虽然 lvalue 引用和 rvalue 引用在初始化时有着相似的行为(只有第 18 和 28 行不同),但在重载决议的时候它们的区别就很显著了。

共5页 首页 上一页 1 2 3 4 5 下一页 尾页 跳转到
相关内容
赞助商链接