C++ 右值引用

2019-12-20 language c/cpp

在 C++11 的新特性中增加了所谓的右值引用的支持,其主要目的是为了解决两个问题:A) 临时对象非必要的拷贝操作;B) 在模板函数中如何按照参数的实际类型进行转发。

同时,和右值引用相关的概念比较多,包括了纯右值、将亡值、Universal References、引用折叠、移动语义、Move语义和完美转发等等,这里简单介绍。

引用介绍

在介绍之前,先看下 C++ 中最原始引用变量的定义。引用变量可以看做是另外一个变量的别名,可以通过引用来访问和修改原变量,而这,严格来说是左值引用。

引用与指针类似,很多地方通过引用替换指针会更容易阅读和维护,当然,两者使用方式和场景略有区别:

  • 不存在空引用,在声明时必须初始化,需要指向一块合法内存;指针可以为空,允许后续指定。
  • 一旦指定引用对象,不能修改;指针可以随时指向另外的对象。

如下是一个简单示例。

#include <iostream>

int main(void)
{
    int var;
    int &ref = var;
    int *ptr;

    /* ERROR: declared as reference but not initialized
    int &ref;
    ref = var;
    */
    var = 10;
    ptr = &var;
    std::cout << "Value " << var << ", Reference " << ref << ", Pointer " << *ptr << std::endl;
}

其输出为。

Value 10, Reference 10, Pointer 10

除了对变量的使用,最常见的是在函数参数以及函数返回值中使用。

函数参数

传递参数时包含了值传递、指针传递、引用传递三种方式,其中值传递会将原变量复制一份,会导致函数中的修改无法影响到原变量,后两者则可以影响,可以根据场景使用。

如下是一个简单的交换值的示例。

#include <iostream>

void swap(int &x, int &y)
{
    int tmp;

    tmp = x;
    x = y;
    y = tmp;
}

int main(void)
{
    int x = 1, y = 2;

    std::cout << "Before: x=" << x << ", y=" << y << std::endl;
    swap(x, y);
    std::cout << "After : x=" << x << ", y=" << y << std::endl;
}

该函数的执行结果为。

Before: x=1, y=2
After : x=2, y=1

可以看到两个值已经交换,如果将函数定义为 void swap(int x, int y) ,实际上是不会交换的。

返回值

当引用作为返回值时,类似于返回一个隐式指针,这样,除了获取该元素外,甚至可以将其放到赋值语句的左边,也就是所谓的左值。

#include <iostream>

int vals[] = {1, 2, 3, 4, 5};

int &GetValue(int idx)
{
    return vals[idx];
}

int main(void)
{
    std::cout << "vals[1]=" << GetValue(1) << std::endl;

    std::cout << "Before:";
    for (int i = 0; i < 5; i++)
            std::cout << " vals[" << i << "]=" << vals[i];
    std::cout << std::endl;

    GetValue(1) = 4;
    GetValue(3) = 8;

    std::cout << "After :";
    for (int i = 0; i < 5; i++)
            std::cout << " vals[" << i << "]=" << vals[i];
    std::cout << std::endl;
}

其输出结果如下。

vals[1]=2
Before: vals[0]=1 vals[1]=2 vals[2]=3 vals[3]=4 vals[4]=5
After : vals[0]=1 vals[1]=4 vals[2]=3 vals[3]=8 vals[4]=5

也就是,通过左值完成了原数组元素的修改。

另外,需要注意,当返回一个引用时,需要注意被引用对象的作用域,返回一个局部变量的引用是不合法的,但允许返回一个对静态变量的引用。

#include <iostream>

int &FooBar(void)
{
    /* warning: reference to local variable ‘bar’ returned [-Wreturn-local-addr]
    int bar = 10;
    return bar;
    */
    static int foo = 10;
    return foo;
}

int main(void)
{
    std::cout << "FooBar " << FooBar() << std::endl;
}

返回局部变量时会有一个 warning 信息,如果栈被销毁后被覆盖,那么指向的值就会被修改,所以,严格来说不能这么使用。

左值 VS. 右值

最开始实际很少会区分所谓的左值(lvalue)、右值(rvalue),只是在 C/C++11 版本之后,在做一些性能优化时引入了一些新的概念,也同时导致了这两者也很容易混淆。

首先,可以这么简单定义 (后面会补充细节),左值是可标识的内存位置,可以通过 & 符号取地址;而右值定义用的是排除法,一个表达式,不是左值就是右值。

示例

例如,当执行 int i = foobar(); 语句时,通过函数获取一个整形值,但实际上返回了两种类型:A) 左值 i 会一直存在;B) 临时值,在表达式结束时被销毁,这个也就是右值。左值可以执行 &i 取其地址,但右值无法执行 &foobar() 获得地址。

另外一个示例是 int a = b + c; ,其中 a 就是左值,可以通过 &a 获取其地址,而表达 b + c 是右值,在赋值给某个具体的变量前,不能直接通过变量名获取,例如 &(b + c) 会直接报错。

另外,还有个区别,左值可以出现在赋值操作的左边或者右边,而右值只能出现在右边。

如下是一些常见的示例。

#include <iostream>

int global = 10;

int GetRValue(void)
{
    return global;
}

int &GetLValue(void)
{
    return global;
}

int main(void)
{
    int var;
    int *ptr;

    var = 8;    // OK var is a l-value, and 8 is a r-value
    ptr = &var; // OK both ptr and var are l-value
    //8 = var;    // ERROR r-value on the left
    //(var + 1) = 8; // ERROR r-value on the left

    ptr = &GetLValue(); // OK return a l-value
    GetLValue() = 20;   // OK return a l-value
    //ptr = &GetRValue(); // ERROR return a r-value
    //GetRValue() = 20;   // ERROR return a r-value
}

简单来说,常见的如常量 (如上的 8)、临时值 (通过寄存器而非地址保存数据,如上的 var + 1) 都会被看做右值,临时值也包括了函数返回的临时值。

上述的 GetLValue() 返回左值对一些重载运算符很有用,例如常见的 [] 操作符重载,可以用来实现访问的查找操作,例如 std::map 中的使用:

std::map<int, float> amap;
amap[10] = 3.1415926;

之所以可以赋值给 amap[10] ,就是因为 std::map::operator[] 重载后返回的是一个可赋值的引用。

通用引用

当右值引用和模板结合之后,此时的 T&& 就不一定表示右值引用了,可能是左值也可能是右值,这取决于入参。

#include <iostream>

template<typename T>
void dump(T &&data) {
    std::cout << data << std::endl;
}

int main() {
    dump(10);  // 这是一个右值
    int val = 10;
    dump(val); // 这是一个左值
}

正常是无法将左值赋值给右值引用的,这里却是可以的。实际上,这里的 && 是一个未定义的引用类型,被称为 universal references,必须被初始化才行,是左值还是右值取决于初始化的值,如果被左值初始化那么就是左值引用,反之亦然。

注意,只有当发生自动类型推断时,其 && 才是通用引用,例如上述的函数模板,以及 auto 关键字等。

如下是更多的示例。

template<typename T>
void foobar(T &&param); // T类型需要推导,所以 && 是通用引用

template<typename T>
class Test {
  Test(Test &&rhs);    // Test特定类型,不需要类型推导,所以&&表示右值引用  
};
void foobar(Test &&param); // 同上,右值引用

template<typename T>
void foobar(std::vector<T>&& param); // 调用前的vector<T>已经确定,所以该函数无需类型推断,是右值引用

template<typename T>
void foobar(const T&& param); // 右值引用

也就是说,通用引用仅发生在 T&& 这一场景下,任何一点附加条件都会使之失效。

引用折叠

所以,上述关键要看 T 被推导成什么类型,如果推导为 string 那么 T&& 就是 string&& 对应了右值引用;如果推导为 string& 那就是 string& && 类型。这就对应了 C++11 中的引用折叠规则:

  • 右值引用叠加到右值引用上仍然使一个右值引用。
  • 其它引用类型之间的叠加都将变成左值引用。

那么上面的 string& && 折叠后就是 string& 类型,也就是左值引用。

#include <string>
#include <iostream>
#include <type_traits>

template<typename T>
void foobar(T &&param) {
    if (std::is_same<std::string, T>::value)
        std::cout << "string" << std::endl;
    else if (std::is_same<std::string &, T>::value)
        std::cout << "string&" << std::endl;
    else if (std::is_same<std::string &&, T>::value)
        std::cout << "string&&" << std::endl;
    else if (std::is_same<int, T>::value)
        std::cout << "int" << std::endl;
    else if (std::is_same<int &, T>::value)
        std::cout << "int&" << std::endl;
    else if (std::is_same<int &&, T>::value)
        std::cout << "int&&" << std::endl;
    else
        std::cout << "unkown" << std::endl;
}

int main() {
    int x = 1;
    foobar(1); // 参数是右值,T->int->int&& 右值引用
    foobar(x); // 参数是左值,T->int&->int& && 左值引用
    int &&a = 2;
    foobar(a); // 虽然a是右值引用,但其本身还是左值,所以T推导成了int&

    std::string str = "hello";
    foobar(str);             // 参数是左值,T推导成了string&
    foobar(std::string("hello")); // 参数是右值,T推导成了string
    foobar(std::move(str));  // 参数是右值,T推导成了string
}

简单来说,传递左值进去就是左值引用,传递右值进去就是右值引用,这就是其名字的由来,下面的完美转发就是利用了这个特性。

完美转发

转发就是通过一个函数将参数继续转交给另一个函数进行处理,原参数可能是右值或者左值,如果还能继续保持参数的原有特征,那么它就是完美的,就是所谓的完美转发。

#include <string>
#include <iostream>

void process(std::string &str) {
    std::cout << "string& " << str << std::endl;
}

void process(std::string &&str) {
    std::cout << "string&& " << str << std::endl;
}

void forward(std::string &&str) {
    std::cout << "forward " << str << std::endl;
    process(str);
}

int main() {
    std::string a = "Hello";
    forward(std::move(a)); // 右值因为有了名字,最后转发的实际上是左值
    //forward(a); // 错误,右值不接受左值
}

上述就是不完美的转发,将一个右值经过转发后变成了左值,解决办法是将 forward() 函数修改为如下,利用标准库中的模板函数解决。

void forward(std::string &&str) {
	std::cout << "forward " << str << std::endl;
	process(std::forward<std::string>(str));
}

不过这里还是无法处理左值的,此时就需要借助通用引用类型和 std::forward() 函数模板共同实现,如下是一个简单示例。

#include <string>
#include <iostream>

void process(std::string &str) {
    std::cout << "string& " << str << std::endl;
}

void process(const std::string &str) {
    std::cout << "const string& " << str << std::endl;
}

void process(std::string &&str) {
    std::cout << "string&& " << str << std::endl;
}

void process(const std::string &&str) {
    std::cout << "const string&& " << str << std::endl;
}

template<typename T>
void forward(T &&str) {
    process(std::forward<T>(str));
}

int main() {
    std::string a = "Hello";
    const std::string b = "World";
    forward(a);
    forward(std::move(a));
    forward(b);
    forward(std::move(b));
}

其它

数组

使用 std::vector 时一般会通过 push_back() 函数添加,如果实现了移动构造函数,那么可以通过 emplace_back() 赋值,这样可以有效减少数据的复制,尤其是大对象。