用反汇编分析c++RVO开启和关闭时的底层原理以及C++prvalue,xvalue和lvalue的相关知识

前言

本篇文章主要讲述C++prvalue,xvalue和lvalue的相关知识,会用到部分intel式和ATT式汇编的知识。我会在文章末尾给出测试代码的反汇编代码以及右值引用(Rvalue references)官方文档 😃。

三五法则

三五法则:有析构就应该有拷贝构造函数和拷贝赋值运算法(3)。
c++11下,一个类还可以有移动构造函数和移动赋值运算符(3+2)。
三五法则时候一般情况,如果不想某个函数被普通或者友元使用可以将其定义为=delete或者在private里面声明但不定义,具体参考C++ primer p449。
移动函数的出现提高了类内存转让的效率,而支承这一技术的基础就是prvalue,xvalue,lvalue。
(xvalue和prvalue统称为rvalue,lvalue和xvalue统称为glvalue。只需要记住上面说的三种就可以,这两个统称可以不用记)。
这三种值的出现场合和特点在后文详细说明。

未开启RVO优化与xvalue和prvalue的关系

关闭RVO的方法:
eg:clang++ -g -fno-elide-constructors /home/dengye/test/test.cpp -o /home/dengye/test/test
先设计一个三五法则的类,再分析关闭和开启RVO时程序的堆栈分布图。
废话不多说开整!

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class B
{
public:
B(int val=0):bVal(val){}
// B(const B& tmp):bVal(tmp.bVal){}
~B(){}
int bVal;
};

class Foo
{
public:
Foo(int tmp = 0):ptrb(new B(tmp))
{
std::cout<<"构造函数"<<std::endl;
}
Foo(const Foo& src):ptrb(new B(*(src.ptrb)))//会继续调用B的拷贝构造函数
{
std::cout<<"拷贝构造函数"<<std::endl;
}
Foo& operator=(const Foo& src)
{
std::cout<<"拷贝赋值函数"<<std::endl;
if (this == &src)
return *this;//防止自己给自己赋值。
delete ptrb;//先把自己的这块内存干掉
ptrb = new B(*(src.ptrb));
return *this;
}
Foo(Foo&& src) noexcept :ptrb(src.ptrb)//noexcept:通知标准库我们这个移动构造函数不抛出任何异常(如果编译器确认函数不会抛出异常,它就能执行某些特殊的优化操作,而这些操作不适合用于可能出错的代码),例如我们希望在vector重新分配内存这类情况下对我们自定义类型的对象进行移动而不是拷贝,就必须显式地告诉标准库我们的移动构造函数可以安全使用。《c++primer》p474,690。
{
std::cout<<"移动构造函数"<<std::endl;
src.ptrb = nullptr;//记得将原来指向堆内存的指针置空.
}
Foo& operator=(Foo&& src) noexcept
{
std::cout<<"移动赋值函数"<<std::endl;
if (this == &src)
return *this;
delete ptrb;
ptrb = src.ptrb;//这里不需要在new,直接从src那里拿来
src.ptrb = nullptr;//记得置空
return *this;
}
~Foo()
{
std::cout<<"析构函数"<<std::endl;
delete ptrb;//删一个空指针没任何反应
}
B *ptrb;
};
Foo RVO_test(int val)
{
Foo foo(val);
return foo;
}
int main()
{
Foo fooRvo = RVO_test(100);
return 0;
}

在分析之前我先把该程序的内存分布图画出来帮大家理解,见下图:

像这种random offset的出现,就是为了避免溢出攻击。画这张图要用到nm命令(寻找代码段,bss段和data段的起始地址),gdb的vmmap命令(寻找堆栈的起始和结束地址)。灰色部分表示这些范围在进程虚拟地址空间中不可用,也就是说,没有为这些区域创建页表(page table)。

反汇编配合堆栈图分析流程

友情提醒:请配合附件中的反汇编代码进行分析。


到达这一步以前发生的事情有:main函数开辟出了0x20字节的空间,将rbp-0x18的地址(临时对象prvalue)给了rdi,然后将0x64给了esi,接着调用了RVO_test(int)函数,还没push rbp。



到达这一步以前发生的事情有:RVO_test(int)函数开辟了0x30字节的空间,rdi寄存器存着临时对象prvalue的地址,然后将其赋给了rbp-0x8,rbp-0x20,rbp-0x28,又将0x64赋给了rbp-0xc(形参val),最后在临时对象foo的地址上进行了构造。



到达这一步以前发生的事情有:编译器将rbp-0x18(局部对象foo)的内容移动到了临时对象的里面。此时局部对象的指针数据成员指向空,在RVO_test(int)函数返回时,会析构这个局部对象,而delete一个空指针没任何反应。最后RVO_test(int)函数将rbp-0x28地址上的内容赋给rax作为返回值。



到达这一步以前发生的事情有:RVO_test(int)函数回收了0x30字节的空间,pop rbp时会将保存的main函数的rbp地址取出来放到rbp栈基址寄存器里面,同时rsp会+8,指向要跳转的代码段。最后一步没执行,不难看出ret是让rip指令寄存器指向rsp的内容,即跳转到<main+29>。



到达这一步以前发生的事情有:临时对象的内容被移动赋值函数转移到了main函数的局部对象fooRvo。然后临时对象就被析构了,接着main函数将返回值0赋给rbp-0x4所指的空间,最后fooRvo对象也被析构。程序随之结束。

小结

无RVO返回优化,编译器会生成一个prvalue或者xvalue的临时对象(Temporary materialization)(《深度探索c++对象模型》p267),取决于prvalue是否出现在丢弃值表达式(Discarded-value expressions)里。
像这样写Foo fooRvo = RVO_test(100);RVO_test(100)产生的临时对象就是prvalue,这种prvalue被称为"有一个结果对象"(such prvalue is said to have a result object)。
而这样写Foo fooRvo;fooRvo = RVO_test(100);RVO_test(100)产生的临时对象就是xvalue,原因就是prvalue出现在了丢弃值表达式里。这种表达式包括了任何完整表达式语句(Such expressions include the full expression of any expression statement)。
然后临时对象的地址会被传入到RVO_test(int)函数中,该函数中的局部对象foo在返回时触发移动构造函数将foo的内存转让给临时对象。为什么会触发移动构造函数呢?归功于return语句,一个拷贝操作发生在return语句时,会被忽略或者被对待成rvalue,目的在于选择一个重载后的构造函数(Note:A copy operation associated with a return statement may be elided or considered as an rvalue for the purpose of overload resolution in selecting a constructor (12.8). —end note,本句子出自c++标准手册文档-N2118的6.6.3 - The return statement)。
由于右值引用可以接受一个rvalue,这个被return转化后的rvalue选择了移动构造函数。至此临时对象被构造出来,RVO_test(int)函数的局部对象foo被析构。而临时对象prvalue,由于赋值运算符的存在,会去调用移动构造函数,(细心的小伙伴肯定要问为啥不是调用重载后的移动赋值函数,很简单!赋值运算符在定义处表现出来的形式为构造而不是拷贝)。待main函数中的fooRvo对象被构造出来后,临时对象也被析构。最后fooRvo对象被析构。

这是clang编译器下运行完代码的结果:
这是运行完代码的结果

注意:MSVC编译有接住这个民间概念,也就说Foo fooRvo = RVO_test(100);这条语句中,RVO_test产生的临时对象会被fooRvo对象接住,从而不执行对临时对象的析构。或者说fooRvo的空间和临时对象的空间合二为一了。我平时写代码都是用的clang编译器,以前刚开始学c++是在MSVC所以这里就稍微提及一下MSVC的不同之处。

这是MSVC编译器下运行完代码的结果:

开启RVO优化

代码还是用上面的代码


上面已经带着大家分析过一遍了,这里就直接跳到最后看结果。
对比关闭RVO来说,RVO_test(int)函数的局部对象就是直接构造到fooRvo对象里面去了,中间一个移动构造都没有。通过分析反汇编代码不难发现,它为了优化返回局部对象,直接让fooRvo对象的内存接管了局部对象foo的,可以说是合二为一了。但是有一个问题,局部对象foo一定要执行析构函数呀!别急,它这里妙手回春一手,将一字节的标志位放在了RVO_test(int)函数的栈帧中,然后通过判断标志位跳过对局部对象foo的析构函数执行。这玩意儿有点像加了if的goto 😃。

这是clang编译器下运行完代码的结果:

有关临时对象的认识可以参考《深度探索c++对象模型》p267

xvalue,prvalue和lvalue

这里笔者就帮大家把坑填上,相信大家对上面测试代码分析过后会对prvalue和xvalue有个初步的印象,即函数返回时可能会涉及到它们。
对于这三种值的概述:
每一个c++的表达式(作用在运算对象上的重载操作符,字面常量,一个变量名,"a = b"(an assignment expression),etc.)都会有两个独立的属性:一种类型,一个值的类别。每个表达式都有一些非引用类型,并且每个表达式都恰好属于三个主要值类别中的一个:prvalue、xvalue和lvalue。这三种值没有一个确切的定义,或者说你要废很大功夫去定义它们,亦或者描述个大概即可。
下面我们来看这三种值类型的概要定义以及特性(完整请参考Value categories):
(补充一句,检测表达式的值类别的相关代码,我放在了附录中,感兴趣的读者可以自行验证感到疑惑的地方)

lvalue

所谓左值,从历史的角度来考虑,它可以出现在一个赋值表达式的左边。资源可以被重用(resources can be reused)。有空间地址。

  • 一般有名字,除了 unamed lvalue(*ptr是一个没有名字的lvalue,一个返回值为lvalue reference的函数调用表达式也是没名字的lvalue),如果有单独的一个identifier 来表示它,它一定是 lvalue
  • 可以用 & 符号取其地址,除了bit field(The type of a bit field can only be integral or enumeration type.
  • 一个函数或者重载操作符的返回值为lvalue reference
    例如 std::getline(std::cin, str) or std::cout << 1 or str1 = str2 or ++iter
  • 其生命周期为其所在的 scope
  • 一些内置操作符表达式
    内置前自增自减,解引用,内置下标表达式(a[n]是一个lvalue的情况下),类类型的内置成员访问表达式(除了访问枚举数据成员和非静态成员函数,还有一些特例请参考Primary categories),逗号表达式(最右边的操作数为lvalue的情况下),三元条件表达式(第二个和第三个操作数为lvalue的情况下)
  • 将表达式强制转换为左值引用类型和右值引用函数署名类型(The signature of a function
    例如 static_cast<int&>(x)
    例如 static_cast<void (&&)(int)>(x)
  • 在所有 literal 中,只有 string literal 是 lvalue:cout << &"dy" << endl;(其他 literal 是 rvalue:cout << &'d' << endl; 非法)

lvalue的特性:

  • 可以通过取址运算符获取其地址,&++i[1] and &std::endl像这样的表达式都是合法的
  • 可修改的左值可用作内置赋值和内置复合赋值运算符的左操作数
  • 可以用来初始化左值引用,这个左值引用将是lvalue的别名。
  • lvalue可以被隐式地转换为prvalue就像lvalue-to-rvalue, array-to-pointer, or function-to-pointer的隐式转换。(int ival1 = 1;int ival2 = -ival1;,这里减号可以lvalue隐式转换成prvalue)
  • 可以是多态的(Polymorphic),它所对应的动态类型和静态类型可以不一样,例如:一个指向子类的父类指针
  • 可以是不完整类型,只要表达式允许。例如:由前置声明但未定义的类类型

xvalue

一个将要到期的值(生命周期被延长),不能出现在一个赋值表达式的左边,资源可以被重用的对象,有空间地址。

  • 一个函数调用或者重载操作符函数表达式的返回值为rvalue reference
    例如 std::move(x)
  • 将表达式的类型强制转换为右值引用类型。
    例如(int&&)astatic_cast<int&&>(a)
  • 内置下标表达式(a[n]为一个xvalue的情况下)。
  • 类类型的内置成员访问表达式。
    例如a.m是一个 xvalue,a是xvalue,m是非引用类型的非静态数据成员。
    以及 a.*mp是一个 xvalue。a是xvalue,mp 叫做 pointer to data member(《c++primer》p739)。
  • 三元条件表达式(第二个和第三个操作数为xvalue的情况下)
  • 指定临时对象的任何表达式(除了prvalue initializes an object的情况)(since C++17)

xvalue的特性:

  • 可以被绑定到右值引用和const左值引用,同时生命周期被延长到这个引用的作用域结束
  • xvalue可以具有多态性(Polymorphic
  • 非类类型的xvalue可以被const和volatile修饰(cv type qualifiers
  • xvalue可以被隐式地转换为prvalue就像lvalue-to-rvalue, array-to-pointer, or function-to-pointer的隐式转换(int ival = 1;if((int&&)ival){putchar('Y');}xvalue被隐式转换成了prvalue)
  • 可以是不完整类型
  • 不能被取地址(通过内置取地址运算符)
  • 不能是内置赋值和内置复合赋值运算符的左操作数
  • 当作用在函数参数时,有两个函数重载都可用,一个是右值引用参数,另一个是const左值引用参数,一个xrvalue会绑定到参数为右值引用的重载函数上(因此,拷贝和移动构造函数都存在时,一个xrvalue会去调用移动构造而不是拷贝构造,同理作用于拷贝和移动赋值运算符)

prvalue

一个将要消亡的值(生命周期马上结束),资源不可以被重用(想要被重用必须转化成xvalue)的对象。一部分有空间地址但马上就会被回收(空间地址可以被后面当前栈内的新数据覆盖掉),一部分没有空间地址。

  • 所有 literals:bool literal (例如true),integer literal (例如 42) 等等(无空间地址)。
  • 实质上的函数调用:(return non-reference)(有空间地址)
    f()function call

    a.f()static or non-static member function
    A() 包括各种构造函数
    str1 + str2 重载 operator 也相当于函数调用
    functor() (对应 operator() 重载)
    []{}()lambda (对应 operator() 重载)
  • 一些内置操作符产生的运算结果(无空间地址):
    a++ 自增自减
    a+b 加减乘除
    a&b 位运算
    a&&b 逻辑运算
    a<b 关系运算
    &a 取地址
    a, b comma expression (如果b 是 rvalue)
    (int)a 或 static_cast< int(a) 强制类型转换
    a ? b : c(第二个和第三个操作数为rvalue的情况下)
  • enumerator 枚举值(无空间地址):
    enum { yes, no }; 中的 yes, no
    a.mp-m,其中 m 是 member enumerator
  • 普通成员函数本身(The signature of a function)(无空间地址)
    a.mp-ma.*pmp-*pm,其中 m 和 pm 都对应普通成员函数(non-static)
  • this 指针(有空间地址)
    被当做参数传进去放在栈帧上。

prvalue的特性:

  • 不会是多态的
  • 非类非数组的prvalue不能被cv-qualified修饰,除非它为了被绑定到一个cv-qualified的引用类型上而转换化成xvalue (since C++17)
    例如 :const int& i = 1;(注意:一个函数调用或者强制类型转换表达式会导致生成一个非类cv-qualified类型,但是cv-qualifier通常会立即被剥离。)
  • prvalue不能有不完整的类型(使用decltype指示符时也不能有,除了void)
  • prvalue不能有抽象类类型或者an array thereof(不太懂官方要表达的意思,所以就以英文方式放在这里供大家参考)☹️
  • 不能被取地址(通过内置取地址运算符)
  • 不能是内置赋值和内置复合赋值运算符的左操作数
  • 当作用在函数参数时,有两个函数重载都可用,一个是右值引用参数,另一个是const左值引用参数,一个prvalue会绑定到参数为右值引用的重载函数上(因此,拷贝和移动构造函数都存在时,一个prvalue会去调用移动构造而不是拷贝构造,同理作用于拷贝和移动赋值运算符)

Temporary materialization

经过对xvalue,prvalue和lvalue对认识后,咱们来聊聊临时对象,除了xvalue可以被称为临时对象,prvalue在某些情况下也可以被称为临时对象。从一个任何完整类型T的prvalue转换为相同类型T的xvalue的过程被称为临时物化(Temporary materialization)。该转换将prvalue作为其结果对象求值,从而将prvalue初始化为T类型的临时对象,并生成一个表示临时对象的xvalue(since C++17)。如果T是一个类或者类类型的数组,它必须具有可访问且未删除的析构函数。

Temporary materialization 会发生在下列情况中:(since C++17)

  • 绑定作用于prvalue时
    int&& c = 1;const int& c = 1;c绑定的是一个xvalue
  • 在一个prvalue类上使用成员访问运算符时
    A().m;pvalue A() 自动变成一个 xvlaue
    A().*mp; pvalue A() 自动变成一个 xvlaue
  • prvalue数组转变成一个指针或被绑定时,或者用下标访问一个prvalue数组
    1.prvalue数组被绑定时
    int (&& a)[2] = (int[]){1, 2};
    2.prvalue数组转变成一个指针时
    [](int* iptr){std::cout<<*iptr;}((int[]){1,2});
    对于一个对象或者一个表达式,如果可以对其使用调用运算符,则称它为可调用对象(《c++primer》p345)
    3.用下标访问一个prvalue数组时
    ((int[]){1, 2})[0];
  • 当从带括号的列表初始化初始化std::initializer_list<T类型的对象时;
    《c++primer》p197
  • typid作用于一个prvalue时
    《c++primer》p732
  • sizeof作用于一个prvalue时
  • 当一个prvalue出现在丢弃值表达式(Discarded-value expressions) 上文研究开启RVO时讲过。
  • 注意:当从相同类型的prvalue初始化一个对象时(通过直接初始化或复制初始化)不会发生临时物化:这样的对象是直接从初始化器初始化的。这确保了“拷贝副本的省略(RVO的工作机制)”。(上文研究开启RVO时讲过,prvalue可以用来初始化一个对象,这种prvalue被称为"有一个结果对象"(such prvalue is said to have a result object))

重要的事情说三遍!
从一个任何完整类型T的prvalue转换为相同类型T的xvalue的过程被称为临时物化
从一个任何完整类型T的prvalue转换为相同类型T的xvalue的过程被称为临时物化
从一个任何完整类型T的prvalue转换为相同类型T的xvalue的过程被称为临时物化

说下我们平时口头说的左值右值(特别是面试),根据Value categories文档的描述,xvalue即既包含部分左值的特性(非类类型的xvalue可以被const和volatile修饰)又包含部分右值的特性(可以被右值引用绑定)。但是在程序中xvalue因为临时物化这个过程的存在,它表现得更像右值。所以我们口头说右值的时候通常吧xvalue归类到右值里面去。

引用折叠(万能引用,引用塌缩)

上面说过移动操作的出现提高了类内存转让的效率,而支承这一技术的基础就是prvalue,xvalue,lvalue。但右值引用才是移动操作能实现的根本原因。这里咱们好好讲一讲右值引用的相关知识引用折叠(关于右值引用的基础知识请参看《c++primer》p471这里不多赘述)。
引用折叠的两个规则被称为C++语言在正常绑定规则之外定义的两个例外规则,允许这种绑定。而这两个例外规则是move这种标准模板库设施正确工作的基础。
先介绍一下它的两个规则:
- 规则一:对于一个给定类型T,当我们将一个左值传给模板函数的右值引用参数时,编译器推断模板类型参数T为左值引用类型(T&),例如对于int类型的左值时,推断Tint&;当我们将一个右值传进去时,T被推断出来的类型为T,例如对于int类型的右值时,推断Tint
- 规则二:如果我们间接创建了一个引用的引用,则这些引用形成了引用折叠。正常情况下,不能直接创建引用的引用,但是可以间接创建(如类型别名和模板参数)。大部分情况下,引用的引用会折叠为普通的左值引用(T& &、T& &&、 T&& &都会折叠成类型T&),右值引用和右值引用,则折叠成右值引用(T&& &&折叠成T&&)。《c++primer》p608

测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <boost/type_index.hpp>
template<typename T>
void testfunc(T&& tmp) {

std::cout << "--------------------------------begin----------------" << std::endl;
using boost::typeindex::type_id_with_cvr;
std::cout << "T=" << type_id_with_cvr<T>().pretty_name() << std::endl; //显示T的类型
std::cout << "tmprv=" << type_id_with_cvr<decltype(tmp)>().pretty_name() << std::endl; //显示tmprv的类型
std::cout << "--------------------------------end------------------" << std::endl;
}
class testStruct {
int i = 0;
};
int main()
{
testStruct i,j;
testStruct&& pi = testStruct{};
testStruct& ri = i;

//pi的是左值,T被推断为 testStruct&,引用折叠后的类型为testStruct&
testfunc(pi);
//ri是左值,T被推断为 testStruct&,引用折叠后的类型为testStruct&
testfunc(ri);

//std::move(i)返回的是一个xvalue(一般将其归类到右值里面)
//显示指定T为testStruct&&,引用折叠后的类型为testStruct&&
testfunc<testStruct&&>(std::move(i));
//testStruct{}构造函数返回的是一个prvalue(右值)
//显示指定T为testStruct&&,引用折叠后的类型为testStruct&&
testfunc<testStruct&&>(testStruct{});

//不显示指定T为testStruct&&时,根据万能引用第一个规则可知,当我们将一个右值传进去时T被推断出来的类型为T,即testStruct
//此时没有发生引用折叠,decltype(tmp)结果为testStruct&&。
testfunc(std::move(j));
testfunc(testStruct{});
return 0;
}

对上面两个规则熟练后,我们来分析一下标准库move函数:

虽然不能直接将一个右值引用绑定到一个左值上,但可以用move获得一个绑定到左值上的xvalue。由于move本质上可以接受任何类型的实参,因此我们不会惊讶于他一个函数模板。

std::move是如何定义的

1
2
3
4
5
6
template<typename T>
constexpr typename std::remove_reference<T>::type&&
move(T&& t) noexcept
{
return static_cast<typename std::remove_reference<T>::type&&>(t);
}
在返回类型和类型转换中也要用到typename(《c++primer》p593)。std::remove_reference<T>::type的用处就是去除类型的引用(《c++primer》p605)。static_cast静态类型转换(《c++primer》p145)。

std::move是如何工作的
这段代码很短,但其中有些微妙之处。首先,move的函数参数T&&是一个指向模板类型参数的右值引用。通过引用折叠,此参数可以与任何类型的实参匹配。特别是,我们既可以传递给move一个左值,也可以传递给它一个右值:

1
2
3
string s1 ( "hi! "),s2;
s2 = std::move (string ( "bye!") );//正确:从一个右值移动数据
s2 = std::move(s1);//正确:但在赋值之后,s1的值是不确定的(内容被移动赋值运算符移动过)。
在第一个赋值中,传递给move的实参是string的构造函数的右值结果。在std::move(string("bye!"))中:
1. 推断出的T的类型为string。
2. 因此remove_reference用string进行实例化。
3. remove_reference的type成员是string。
4. move的返回类型是string&&。
5. move的函数参数t的类型为string&&。

因此,这个调用实例化move,即函数string&& move(string &&t)
函数体返回static_cast<string&&>(t)t的类型已经是string&&,于是类型转换什么都不做。因此,此调用的结果就是它所接受的右值引用(返回值为右值引用类型有临时对象产生)。
现在考虑第二个赋值,它调用了std::move ()。在此调用中,传递给move的实参是一个左值。这样:
1. 推断出的T的类型为string& (string 的引用,而非普通string)。
2. 因此,remove reference用string&进行实例化。
3. remove reference<string&>的type成员是string。
4. move的返回类型仍是string&& 。
5. move的函数参数t实例化为string& &&,会折叠为string&。

因此,这个调用实例化move<string&>,即string&& move(string &t)
这正是我们所寻求的—我们希望将一个右值引用绑定到一个左值。这个实例的函数体返回static_cast<string&&>(t)。在此情况下,t的类型为string&,cast将其转换为string&&(强转伴随临时对象xvalue的产生其空间地址与std::move()函数产生的临时对象xvalue的空间地址相同)。

将右值引用绑定到一个被强转成xvalue的左值,这一特性被称为截断。(《c++primer》p612)

std::move()函数总结

说白了std::move()函数就是将一个prvalue或者lvalue转变成xvalue的过程,这样不管是prvalue还是lvalue都可以被右值引用接受。

接下来咱们来分析标准库forward函数:

在分析forward源码之前,咱们先来看看forward完成了一个什么样的功能。
我们知道某些函数需要将其一个或多个实参连同类型不变地转发给其他函数。在此情况下,我们需要保持被转发实参的所有性质,包括实参类型是否是const的以及实参是左值还是右值。
作为一个例子,我们将编写一个函数,它接受一个可调用表达式和两个额外实参。我们的函数将调用给定的可调用对象,将两个额外参数逆序传递给它。下面是我们的翻转函数的模样:

1
2
3
4
5
6
7
8
9
10
11
//接受一个可调用对象和另外两个参数的模板
//对“翻转”的参数调用给定的可调用对象
template <typename F, typename T1,typename T2>
void flip2(F f,T1 &&t1,T2 &&t2)
{
f (t2,t1);
}
void f(int v1,int &v2)
{
std::cout << v1 << " " << ++v2 << std::endl;
}
通过将一个函数参数定义为一个指向模板类型参数的右值引用,我们可以保持其对应实参的所有类型信息。而使用引用参数(无论是左值还是右值)使得我们可以保持const属性,因为在引用类型中的const是底层的。如果我们将函数参数定义为T1&&T2&&, 通过引用折叠,就可以保持翻转后实参的左值/右值属性(对应实参的const属性和左值/右值属性将得到保持):
例如,如果我们调用flip2(f,j,42),将传递给参数t1一个左值j。但是,在flip2中,推断出的T1的类型为int&,这意味着t1的类型会折叠为int&。由于是引用类型,t1被绑定到j上。当flip2调用f时,f中的引用参数v2被绑定到t1,也就是被绑定到j。当f递增v2时,它也同时改变了j的值。

现在,咱们不用可调用对象f,用g

1
2
3
4
5
6
7
8
9
10
11
//接受一个可调用对象和另外两个参数的模板
//对“翻转”的参数调用给定的可调用对象
template <typename F, typename T1,typename T2>
void flip2(F f,T1 &&t1,T2 &&t2)
{
g(t2,t1);
}
void g(int &&i, int& j)
{
std::cout << i << " " << j << std::endl;
}
如果我们试图通过flip2调用g,则参数t2将被传递给g的右值引用参数。即使我们传递一个右值给flip2:
1
flip2(g, i,42);//错误:不能从一个左值实例化int&&
传递给g的将是flip2中名为t2的参数。函数参数与其他任何变量一样,都是左值表达式。因此,flip2中对 g的调用将传递给g的右值引用参数一个左值。

在调用中使用std::forward保持类型信息

我们可以使用一个名为forward 的新标准库设施来传递flip2的参数,它能保持原始实参的类型。类似move,forward定义在头文件utility中。与move不同,forward必须通过显式模板实参来调用。forward返回该显式实参类型的右值引用。即,forward<T>的返回类型是T&&
通常情况下,我们使用forward传递那些定义为模板类型参数的右值引用的函数参数。通过其返回类型上的引用折叠,forward可以保持给定实参的左值/右值属性(forward可以保持实参类型的所有细节)。
使用forward,我们可以再次重写翻转函数:

1
2
3
4
5
6
7
8
9
template <typename F,typename T1,typename T2>
void flip(F f,T1 &&t1,T2 &&t2)
{
g(std::forward<T2>(t2),std::forward<T1>(t1)) ;
}
void g(int &&i, int& j)
{
std::cout << i << " " << j << std::endl;
}
如果我们调用flip(g, i,42)i将以int&类型传递给g42将以int&&类型的xvalue传递给g。(primer没说清楚,单纯因为42是int&&类型是无法传给可调用对象g。lvalue可以是int&&类型,xvalue也可以是int&&类型,但是lvalue不能传给右值引用,而xvalue可以。)

现在咱们知道forward是干什么的了。废话不多说直接分析它的源码,看看它如何保持实参类型的所有细节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename _Tp>
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type& _t) noexcept
{
return static_cast<_Tp&&>(_t);
}
template<typename _Tp>
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type&& _t) noexcept
{
static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument"
" substituting _Tp is an lvalue reference type");
return static_cast<_Tp&&>(_t);
}
std::forward有两个版本的重载,一个用于左值一个用于右值。不过咱们一般用的是第一个版本,因为用在形参上,形参不管怎么样都是个左值。那我们就只分析第一个版本的情况:
1
2
3
4
5
6
7
8
9
template <typename F,typename T1,typename T2>
void flip(F f,T1 &&t1,T2 &&t2)
{
g(std::forward<T2>(t2),std::forward<T1>(t1));
}
void g(int &&i, int& j)
{
std::cout << i << " " << j << std::endl;
}
就拿上面这个例子来说,如果我们调用flip(g, i,42)
T2将被推导成intt2的类型为int&&。调用std::forward<T2>(t2)时,forward函数将被实例化成:
1
2
3
4
5
constexpr int&&
forward(int& _t) noexcept
{
return static_cast<int&&>(_t);
}
_t的类型为int&,return时_t被强制转换成int&&,此时强转产生的xvalue和forward函数由返回类型int&&而产生的xvalue在同一空间地址。xvalue可以被函数g的形参 i所接受,完成flip实参42的类型保持。

T1将被推导成int&t1的类型被折叠成int&。调用std::forward<T1>(t1)时,forward函数将被实例化成:

1
2
3
4
5
constexpr int&
forward(int& _t) noexcept
{
return static_cast<int&>(_t);
}
返回类型折叠成int&t1传入flip中是左值引用传入,被forward转换后也是左值引用。完成类型保持。

std::forward总结

不难看出forward函数跟move函数的工作原理都一样,用的引用折叠技术。

  • forward将g函数的右值引用类型实参从左值转换成右值,保持rvalue能被int&&接收的性质(可调用对象的形参类型为int&&,接受实参值的类型为rvalue)。
  • 而g函数的左值引用类型实参,进入forward是左值引用进入,函数返回也是左值引用传出,没变化。
  • 最后g函数的形参为int类型时,接受实参值的类型为lvalue或rvalue。forward不管怎么转换,它都可以接受。

这就是所谓的类型保持(可能叙述的不是特别详细,毕竟过程有点复杂,只是起一个抛砖引玉的作用,大家可以下去自己捣鼓捣鼓)。

最后咱们来看下move和forword的不同之处。

std::move函数的源码:

1
2
3
4
5
6
template<typename T>
constexpr typename std::remove_reference<T>::type&&
move(T&& t) noexcept
{
return static_cast<typename std::remove_reference<T>::type&&>(t);
}
std::forward函数的源码:
1
2
3
4
5
6
template<typename _Tp>
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type& _t) noexcept
{
return static_cast<_Tp&&>(_t);
}
不难发现,move函数的std::remove_reference<T>::type,放在返回类型和static_cast<>中,而且后面都是直接接上两个&&。他这样写的目的在于,不管t接受的是一个右值还是一个左值,返回出来值的类型永远都是右值。永远能被int&&接受。
而forward函数的std::remove_reference<T>::type,放在形参_t上,而且返回类型和static_cast<>都用了引用折叠技术,值得注意的一点是move函数不需要显示指定模板的类型,而forward函数需要显示指定实参类型。forward函数的形参一定是引用传入,而且返回类型和static_cast<>可以根据实参类型动态改变。forward函数的实参类型为int&&,则返回类型为int&&,返回出去的就是一个xvalue。forward函数的实参类型为int&,则返回类型也为int&,返回出去的和传入进来的都是实参的引用。

附件

注意:下面的文件下载都是免费的,0积分下载,别被VIP字样吓到了蛤 🤗
右值引用相关文档
测试代码的反汇编代码

附录

摘取自《c++ template 2nd》附录中的内容,可以用来检查表达式的值类别( value category of the expression:prvalue,xvalue或lvalue)。

1
2
3
4
5
6
7
8
#define ISXVALUE(x) \
std::is_rvalue_reference<decltype((x))>::value
#define ISLVALUE(x) \
std::is_lvalue_reference<decltype((x))>::value
#define CHEECK_VALUE_CATOGORIES(x) \
if(ISXVALUE(x)) printf("%s is a xvalue\n",#x);\
else if(ISLVALUE(x)) printf("%s is a lvalue\n",#x);\
else printf("%s is a prvalue\n",#x);


用反汇编分析c++RVO开启和关闭时的底层原理以及C++prvalue,xvalue和lvalue的相关知识
https://howl144.github.io/2022/01/28/00004. 用反汇编分析c++RVO开启和关闭时的底层原理以及C++prvalue,xvalue和lvalue的相关知识/
Author
Deng Ye
Posted on
January 28, 2022
Licensed under