【C++ Primer】查缺补漏(二)左值和右值、左值引用和右值引用、万能引用和完美转发
文章目录
一、左值和右值
- 左值(l-value)可以出现在赋值语句的左边或者右边,比如变量。被用做左值时,用的是对象的身份(在内存中的位置),是取得到地址的表达式。
- 右值(r-value)只能出现在赋值语句的右边,比如常量。当一个对象被用作右值的时候,用的是对象的值(内容),是取不到地址的表达式。
int *p1 = &a;//success,可以取到a的地址 int *p2 = &&a;//fail,&a是右值,取不到地址 int *p3 = &p1;//success,p1是一个左值
- 内置解引用运算符、下标运算符、迭代器解引用运算符、string和vector的下标运算符的求值结果都是左值
- 如果表达式的求值结果是左值,decltype作用于该表达式(不是变量)得到个引用类型。例如,对于int*p:
- 因为解引用运算符生成左值,所有decltype(*p)的结果是 int&
- 因为取地址运算符生成右值,所以decltype(&p)的结果是 int **
下面再展示其他一些小栗子:(《现代c++语言核心特性解析》)
int x = 1;
int get_val()
{
return x;
}
void set_val(int val)
{
x = val;
}
int main()
{
x++;
++x;
int y = get_val();
set_val(6);
}
在上面的代码中,x++和++x虽然都是自增操作,但是却分为不同的左右值。其中x++是右值,因为在后置++操作中编译器首先会生成一份x值的临时复制,然后才对x递增,最后返回临时复制内容。而++x则不同,它是直接对x递增后马上返回其自身,所以++x是一个左值。如果对它们实施取地址操作,就会发现++x的取地址操作可以编译成功,而对x++取地址则会报错。但是从直觉上来说,&x++看起来更像是会编译成功的一方:
int *p = &x++; // 编译失败
int *q = &++x; // 编译成功
接着来看上一份代码中的get_val函数,该函数返回了一个全局变量x,虽然很明显变量x是一个左值,但是它经过函数返回以后变成了一个右值。原因和x++类似,在函数返回的时候编译器并不会返回x本身,而是返回x的临时复制,所以int * p = &get_val();也会编译失败。对于set_val函数,该函数接受一个参数并且将参数的值赋值到x中。在main函数中set_val(6);实参6是一个右值,但是进入函数之后形参val却变成了一个左值,我们可以对val使用取地址符,并且不会引起任何问题:
void set_val(int val)
{
int *p = &val;
x = val;
}
最后需要强调的是,通常字面量都是一个右值,除字符串字面量以外:
int x = 1;
set_val(6);
auto p = &"hello world";
这一点非常容易被忽略,因为经验告诉我们上面的代码中前两行的1和6都是右值,因为不存在&1和&6的语法,这会让我们想当然地认为"hello world"也是一个右值,毕竟&"hello world"的语法也很少看到。但是这段代码是可以编译成功的,其实原因仔细想来也很简单,编译器会将字符串字面量存储到程序的数据段中,程序加载的时候也会为其开辟内存空间,所以我们可以使用取地址符&来获取字符串字面量的内存地址。
二、左值引用和右值引用
- 左值引用:传统的C++中引用被称为左值引用
- 右值引用:C++11中增加了右值引用,右值引用关联到右值时,右值被存储到特定位置,右值引用指向该特定位置,也就是说,右值虽然无法获取地址,但是右值引用是可以获取地址的,该地址表示临时对象的存储位置
左值引用的缺陷
class X {
public:
X() {}
X(const X&) {}
X& operator = (const X&) { return *this; }
};
X make_x()
{
return X();
}
int main()
{
X x1;
X x2(x1);
X x3(make_x());
x3 = make_x();
}
以上代码可以通过编译,但是如果这里将类X的复制构造函数和复制赋值函数形参类型的常量性删除,则X x3(make_x());和x3 =make_x();这两句代码会编译报错,因为非常量左值引用无法绑定到make_x()产生的右值。常量左值引用可以绑定右值是一条非常棒的特性,但是它也存在一个很大的缺点——常量性。一旦使用了常量左值引用,就表示我们无法在函数内修改该对象的内容(强制类型转换除外)。所以需要另外一个特性来帮助我们完成这项工作,它就是右值引用。
右值引用
- 新标准引入右值引用以支持移动操作。
- 通过
&&
获得右值引用。 - 只能绑定到一个将要销毁的对象。
- 常规引用可以称之为左值引用。
- 左值持久,右值短暂。
int i = 0;
int &j = i; // 左值引用
int &&k = 11; // 右值引用
move函数:
int &&rr2 = std::move(rr1);
move
告诉编译器,我们有一个左值,但我希望像右值一样处理它。- 调用
move
意味着:除了对rr1
赋值或者销毁它外,我们将不再使用它。
右值引用的优化空间
(摘自《现代c++语言核心特性解析》)
# include <iostream>
class X {
public:
X() { std::cout << "X ctor" << std::endl; }
X(const X&x) { std::cout << "X copy ctor" << std::endl; }
~X() { std::cout << "X dtor" << std::endl; }
void show() { std::cout << "show X" << std::endl; }
};
X make_x()
{
X x1;
return x1;
}
int main()
{
X &&x2 = make_x();
x2.show();
}
在理解这段代码之前,让我们想一下如果将X &&x2 = make_x()这句代码替换为X x2 = make_x()会发生几次构造。在没有进行任何优化的情况下应该是3次构造,首先make_x函数中x1会默认构造一次,然后return x1会使用复制构造产生临时对象,接着X x2 = make_x()会使用复制构造将临时对象复制到x2,最后临时对象被销毁。
以上流程在使用了右值引用以后发生了微妙的变化,让我们编译运行这段代码。请注意,用GCC编译以上代码需要加上命令行参数-fno-elide-constructors用于关闭函数返回值优化(RVO)。因为GCC的RVO优化会减少复制构造函数的调用,不利于语言特性实验:
X ctor
X copy ctor
X dtor
show X
X dtor
从运行结果可以看出上面的代码只发生了两次构造。第一次是make_x函数中x1的默认构造,第二次是return x1引发的复制构造。不同的是,由于x2是一个右值引用,引用的对象是函数make_x返回的临时对象,因此该临时对象的生命周期得到延长,所以我们可以在X &&x2 =make_x()语句结束后继续调用show函数而不会发生任何问题。对性能敏感的读者应该注意到了,延长临时对象生命周期并不是这里右值引用的最终目标,其真实目标应该是减少对象复制,提升程序性能。
三、移动构造函数和移动赋值运算符
- 移动构造函数:
- 第一个参数是该类类型的一个引用,关键是,这个引用参数是一个右值引用。
StrVec::StrVec(StrVec &&s) noexcept{}
- 不分配任何新内存,只是接管给定的内存。
- 移动赋值运算符:
StrVec& StrVec::operator=(StrVec && rhs) noexcept{}
- 移动右值,拷贝左值。
- 如果没有移动构造函数,右值也被拷贝。
- 更新三/五法则:如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。
- 移动迭代器:
make_move_iterator
函数讲一个普通迭代器转换为一个移动迭代器。
- 建议:小心地使用移动操作,以获得性能提升。
四、万能引用和完美转发
- 万能引用:
T&&
,辅助模板编程,这样左值和右值的参数都可以接收; - 完美转发:
std::forward
,可以保留参数的左值和右值属性,因为后续使用该参数可能还需要这个属性;
万能引用
void foo(int &&i) {} // i为右值引用
template<class T>
void bar(T &&t) {} // t为万能引用
int get_val() { return 5; }
int &&x = get_val(); // x为右值引用
auto &&y = get_val(); // y为万能引用
万能引用能如此灵活地引用对象,实际上是因为在C++11中添加了一套引用叠加推导的规则——引用折叠。在这套规则中规定了在不同的引用类型互相作用的情况下应该如何推导出最终类型。c++引用折叠
完美转发
示例:
#include <iostream>
#include <utility> // 包含std::forward
void process(int& i) {
std::cout << "处理左值: " << i << std::endl;
}
void process(int&& i) {
std::cout << "处理右值: " << i << std::endl;
}
// 完美转发的函数模板
template<typename T>
void logAndProcess(T&& param) {
// 调用process函数,同时保持param的左值/右值特性
process(std::forward<T>(param));
}
int main() {
int a = 5;
logAndProcess(a); // a是左值,将调用处理左值的重载
logAndProcess(10); // 10是右值,将调用处理右值的重载
return 0;
}
在这个示例中,logAndProcess
函数模板通过std::forward
实现了完美转发。无论传递给logAndProcess
的是左值还是右值,std::forward
都能确保参数以其原始值类别传递给process
函数。
输出:
处理左值: 5
处理右值: 10