一、什么是左值和右值?
在掌握右值引用前,必须先知道什么是右值,既然有右值,那么肯定有左值。当我们在赋值的时候a=b,能够被放到=号左边的值即为左值,反则为右值。
那么什么值可以作为左值呢?显然变量肯定是可以作为左值的。什么值不能作为左值呢?显然常数、表达式、函数返回值等,是不能作为左值的,也就是右值。显然作为左值的都是可以长期保存起来的,对应是保存在内存中;但常数、表达式、函数返回值等都是临时值,这些值都保存在寄存器中。可以总结:
- 左值:可以长时间保存,可以存在于=左边的值,可以取地址;
- 右值:临时值,不能存在于=左边的值,不可以取地址。
int a = 9, b = 8;
a = 8, b = 0; // a,b为左值
// a + 4 = 5; // 错误 a + 4为右值,a + 4为一个临时对象
// -a = 4; // 错误 -a为右值,为一个临时对象
(a) = 5; // 正确 (a)为左值,返回的是a
++a = 3; // 正确 ++a为左值,可以理解为a先+1,然后返回a本身,也即整个表达是a
// a++ = 3; // 错误 a++为右值,可以理解为int tmp = a;a = a +1;return tmp;返回的是一个临时变量
二、什么是左值引用和右值引用?
对左值的引用即为左值引用,对右值的引用即为右值引用。需要注意得是引用是对值得别名,所以不会产生副本,同时引用本身也是变量,所以也是左值。如下:
int &t = a; // a为左值,所以可以赋给左值引用
// int &t1 = 3; // 错误 3为一个临时值,为右值,不能赋给左值引用
// int &&t = a; // 错误 a为左值,不能赋给右值引用
int &&t = 3; // 可以
int &&t = -a; // 可以
// int &t = -a; // 不可以
// int &&t1 = t; // 不可以,t本身是左值
三、右值引用作用
右值引用是C++11新特性,之所以引入右值引用,是为了提高效率。如下面所示:
class A
{
public:
A(size_t N):m_p(new char[N])
{
}
A(const A & a)
{
if (this != &a)
{
delete[]m_p;
m_p = new char[strlen(m_p) + 1];
memcpy(m_p, a.m_p, strlen(m_p) + 1);
}
}
~A()
{
delete []m_p;
}
private:
char *m_p = nullptr;
};
A createA(size_t N)
{
return A(100);
}
void func(A a)
{
//
}
int main()
{
func(createA(100));
system("pause");
return 0;
}
这里会导致大量得调用A得构造函数,不考虑编译优化,原本执行如下:
- createA(100),执行A(100)调用A(size_t)构造函数一次;
- 退出createA,临时构造得A(100),释放调用析构函数一次;
- 赋给返回值会调用一次拷贝构造函数一次;
- 返回值传入func中形参会调用拷贝构造函数一次;
- func运行完成后形参释放,调用A析构函数一次;
- 返回值使用完成释放,调用A析构函数一次;
从上面可以看出有大量得构造、析构调用 ,但是我们做的工作无非就是临时构造一个A(100)给func使用而已。那么可否将临时A(100)始终一份给到func使用呢?答案就是右值引用。如下:
class A
{
public:
A(size_t N):m_p(new char[N])
{
}
~A()
{
delete []m_p;
}
private:
char *m_p = nullptr;
};
A&& createA(size_t N)
{
return (A&&)A(100);
}
void func(A&& a)
{
//
}
int main()
{
func(createA(100));
system("pause");
return 0;
}
我们将临时A(100)强制转换为了右值引用,同时func形参也是右值引用,也就是将临时对象延长到了func中,中间避免了其他构造和析构调用,提高了效率。
注意到我们将A得拷贝构造函数去掉了,因为已经用不到。如果原版写法,去掉拷贝构造函数会崩溃,因为会自动调用默认拷贝构造函数,是浅拷贝,中间临时对象会提前删除公共内存,后面对象再次释放是就会重复删除内存导致崩溃。
四、将左值转换为右值(std::move)
右值引用只能绑定右值,那是否可以将右值引用绑定到左值呢?可以,一个很简单的写法就是强制转换,如下:
int main()
{
int a = 3;
int &&t = (int &&)a;
t = 9;
cout << a << endl; // a = 9
system("pause");
return 0;
}
其实,C++11提供了更为优雅的转换函数std::move,std::move(a)无论a是左值还是右值都将转换为右值。如下:
int main()
{
int a = 3;
int &&t = std::move(a);
int &&t2 = std::move(3);
system("pause");
return 0;
}
五、std::move实现原理(强制转换)
可以打开std::move源码,如下:
template<class _Ty> inline
constexpr typename remove_reference<_Ty>::type&&
move(_Ty&& _Arg) _NOEXCEPT
{ // forward _Arg as movable
return (static_cast<typename remove_reference<_Ty>::type&&>(_Arg));
}
可以发现,move是将传入的_Arg值,强制转换为了typename remove_reference<_Ty>::type&&类型,那么typename remove_reference<_Ty>::type是什么呢?接着往下看。
template<class _Ty>
struct remove_reference
{ // remove reference
typedef _Ty type;
};
template<class _Ty>
struct remove_reference<_Ty&>
{ // remove reference
typedef _Ty type;
};
template<class _Ty>
struct remove_reference<_Ty&&>
{ // remove rvalue reference
typedef _Ty type;
};
原来,remove_reference是一个类模板。第一个type类型为传入类型本身;第二个类模板形参是左值引用,type为去掉的引用类型;第三个类模板形参为右值引用,type为去掉引用的类型。从本身字面意思也可知道remove_reference的作用就是去掉引用得到原本的类型。
我们再次回到move。move返回的是typename remove_reference<_Ty>::type&&,原本类型的右值引用。至此move作用就非常清晰了:就是将传入值强制转换为值原类型的右值引用。
六、通用引用及其条件
如果你足够仔细的话,你会发现move形参为_Ty&&,形式上是右值引用,那为什么传入左值不会发生错误呢?这涉及到通用引用,所谓通用引用就是根据接受值类型可以自行推导是左值引用还是右值引用。
对于形如:
template<typename T>
void print(T &&) {}
如果传入参数是左值则会被推导为print(int&),如果参数是右值则推导为print(int&&),除了模板外auto &&也有相同效果,如下:
template<typename T>
void print(T &&) {}
int main()
{
int a = 9;
print(a); // print(int&)
print(8); // print(int&)
auto &&t = 3; // int &&
auto &&t2 = a; // int &
system("pause");
return 0;
}
那是不是所有模板函数形参T&&都是通用引用呢?答案是否定的。条件是:如果声明变量或参数具有T&&
某种推导类型的类型 T
,则该变量或参数为通用引用,否则就是右值引用(无法传入左值)。
也就是传入的参数在编译时需要推导,如果不需要推导,则不是通用引用。如下:
template<typename T>
class B
{
public:
void print(T &&) {}
};
int main()
{
B<int> b;
b.print(3); // 为右值引用
system("pause");
return 0;
}
因为在编译print之前print中的参数已经由B<int> b确定了,所以在print编译时无需推导,故B中的T&&为右值引用。如果改为如下:
template<typename T>
class B
{
public:
template<typename Arg>
void print(Arg &&) {}
};
int main()
{
B<int> b;
b.print(3); // 为右值引用
system("pause");
return 0;
}
因为print时函数模板形参和类模板形参类型时独立的,故在编译print时是需要推导的,故Arg&&为通用引用。同理,下面的int&&也不是通用引用,因为类型已经确定。
template<typename T>
class B
{
public:
void print(int &&) {}
};
int main()
{
B<char> b;
int t = 0;
// b.print(t); // 出错,print(int&&)为右值引用
system("pause");
return 0;
}
通用引用的形式必须是T&&,添加其他修饰都不是通用引用,如下:
template<typename T>
void print(const T&&) // 右值引用
{
}
template<typename T>
void print(std::vector<T>&& params) // 右值引用
{
}
七、引用折叠
上面我们知道通用引用虽然形式上是右值引用,但是却可以接受左值,这是怎么实现的呢?这就是引用折叠。
有如下代码:
template<typename T>
void print(T&& t)
{
}
int main()
{
int a = 9;
print(a);
print(9);
system("pause");
return 0;
}
print(a)时,因为a为左值,会被推导成print(int& &&t)形式,int& &&t 会被折叠为int &,所以最终形式为print(int &)。(左值被推导为左值引用)
print(9)时,为9为右值,所以被推导为print(int&& &&)形式,而int&& &&会被折叠为int&&,所以最终形式为print(int&&)。(右值被推导为右值引用)
当将引用本身
引用类型只有两种,所以折叠形式就是4中,为:T& &,T& &&,T&& &,T&& &&。引用折叠规则概况为两种:
- T&& &&折叠为T&&;
- 其他折叠为T&.
下面使用typedef验证引用折叠,如下:
template<typename T>
class B
{
public:
typedef T& type;
typedef T&& type2;
};
int main()
{
int a = 9;
B<int>::type t = a; // type->int&
B<int&>::type t2 = a; // type->int& &折叠为int&
B<int&&>::type t3 = a; // type->int&& &折叠为int&
B<int>::type2 t4 = 3; // type2->int&&
B<int&>::type2 t5 = a; // type2->int& &&折叠为int&
B<int&&>::type2 t6 = 3; // type2->int&& &&折叠为int&&
system("pause");
return 0;
}
八、完美转发及其意义
通用引用既可以接受左值也可以接受右值,但是通用引用本身是左值。如果在函数模板中继续传递该值给其他函数,势必会改变该值的属性,即都为左值引用。如下:
template<typename T>
void _print(T &&t)
{
cout << (std::is_lvalue_reference<decltype(t)>::value ? "lvalue" : "rvalue") << endl;
}
template<typename T>
void print(T&& t)
{
_print(t);
}
int main()
{
int a = 3;
print(a); // lvalue
print(3); // lvalue
system("pause");
return 0;
}
本来3为右值,传递到_print之后变成了左值。整个属性是被print改变的。那么可否有一种方式以原属性传递呢?答案是std::foward,被称为完美转发。如下代码:
template<typename T>
void _print(T &&t)
{
cout << (std::is_lvalue_reference<decltype(t)>::value ? "lvalue" : "rvalue") << endl;
}
template<typename T>
void print(T&& t)
{
_print(std::forward<T>(t));
}
int main()
{
int a = 3;
print(a); // lvalue
print(3); // rvalue
system("pause");
return 0;
}
在print中传递给_print的值为std::foward<T>(t),传递给_print,接受的值属性和之前保持一致。所谓完美转发就是不改变值原本属性进行转发。
完美转发有什么意义呢?某个功能对左值和右值处理情况不一致,如果将左值和右值引用当作同一种情况使用,可能会会有性能损失。假设有如下代码:
class A
{
public:
A() {}
A(int a) { m_pa = new int(a); }
A(const A &a)
{
if (this != &a)
{
delete m_pa;
m_pa = new int(*a.m_pa);
}
}
~A() { delete m_pa; }
int *m_pa = nullptr;
};
A * _makeA(A &a)
{
return new A(a);
}
A * _makeA(A &&a)
{
A *pa = new A;
pa->m_pa = a.m_pa;
a.m_pa = nullptr;
return pa;
}
template<typename T>
auto makeA(T&& t)
{
return _makeA(t);
}
int main()
{
A a(3);
auto t = makeA(a);
auto t2 = makeA(A(4));
system("pause");
return 0;
}
该代码作用是根据已有的A对象创建新的A对象指针。因为左值具有延时性,所以根据左值创建A指针时是将对象中m_pa进行了深拷贝;根据临时对象创建A指针时,由于临时对象由于已经创建好了m_pa,没有必要再创建将临时对象的m_pa进行深拷贝,只需要将临时对象的m_pa给到新创建的A即可,同时将临时对象的m_pa指向nullptr。这样可以提高性能。
但是通过makeA间接传递给_makeA之后,都调用了_makeA(A&),也就是对m_pa进行了深拷贝,这与原本make(A(4))想调用_makeA(A&&)不一致。
如果我们能在makeA中完美转发t,那么就可以达到要求,这就是std::foward的意义。
九、std::foward原理
先看下std::foward的实现代码,如下:
template<class _Ty> inline
constexpr _Ty&& forward(
typename remove_reference<_Ty>::type& _Arg) _NOEXCEPT
{ // forward an lvalue as either an lvalue or an rvalue
return (static_cast<_Ty&&>(_Arg));
}
template<class _Ty> inline
constexpr _Ty&& forward(
typename remove_reference<_Ty>::type&& _Arg) _NOEXCEPT
{ // forward an rvalue as an rvalue
static_assert(!is_lvalue_reference<_Ty>::value, "bad forward call");
return (static_cast<_Ty&&>(_Arg));
}
我们简化为_foward版本(方便看) ,如下:
template<typename T>
T&& _forward(typename remove_reference<T>::type& t)
{
return static_cast<T&&>(t);
}
template<typename T>
T&& _forward(typename remove_reference<T>::type&& t)
{
return static_cast<T&&>(t);
}
foward给出了两个版本,一个接收左值,一个接收右值。现在我们使用_foward,如下:
void _print(int &t)
{
cout << "lvalue" << endl;
}
void _print(int &&t)
{
cout << "rvalue" << endl;
}
template<typename T>
void print(T &&t)
{
_print(_forward<T>(t));
}
这里有两个疑问:1)如果我们调用print(3),_foward会执行哪个版本?2)如果我们调用print(a),因为_foward中强制转换为了T&&, 是否都调用_print(int&&)版本?
问题1:无论print的参数是左值还是右值,传递给print后t都为左值,所以_foward会执行左值版本,即第一个版本。
问题2:在_foward都将t强制转换为了T&&,按照道理来说,应该都会执行_print(int&&),不信可以执行:
int a = 3;
_print((int&&)(a));
代码,发现确实是调用了_print(int&&)。既然这样_foward为什么还能做到完美转发呢?显然通过_foward(a)之后的调用的肯定是_print(int&)版本。在_foward中使用static_cast<T&&>(或者T&&)和int&&结果不一样呢?原因是引用折叠。具有推导类型的T&&转换会进行引用折叠。而int&&类型是确定的,不能进行折叠。
如果调用print(3),传递给_foward的t为&&类型,然后T&& &&将折叠为T&&,故最终会调用_print(int&&)版本;如果调用print(a),传递给_foward的t为&类型然后T&& &将折叠为T&,所以最终调用_print(int&)版本。
参考:
https://covariant.cn/2020/02/25/uniref-in-cpp/
https://avdancedu.com/a39d51f9/