琪露诺都能看懂的右值引用介绍

RsCb

2024-10-19 02:54:33

Tech. & Eng.

首先声明,右值引用在信息学竞赛中毫无用处。各位 OIer 可以把此文当作课外科普。

C++11 中引入了新的语法:右值引用。相信很多人跟我一样,即使看了它的描述,对它有什么用也完全不清楚。因此,本文将对它的根本功能进行介绍。

前置知识

左值和右值

在 C 和 C++11 之前,值被分为左值右值。顾名思义,可以在等号左边的值叫左值,只能在等号右边的值叫右值。左值一般是变量、引用等,它有实际的地址,可以进行取地址操作。右值则是字面量等临时值,它没有实际地址,它的存在是转瞬即逝的。

int a;
a = 42;

如上,a 是一个左值,可以被赋值。而 42 是一个右值,我们无法写出 42 = 42 这样的赋值表达式,我们也找不到 a = 4242 对应的实际地址。

int foo();
int &bar();
bar() = foo();

如上,foo 返回 intfoo() 是右值,而 bar 返回的是一个 int 引用,因此 bar() 是左值,它可以被赋值。

const int a = 42;

如上,a 虽然无法被赋值,但它拥有实际的地址,因此它是一个左值,只不过是不可修改的左值。

栈空间与堆空间

在程序运行时,数据储存在两种地方:栈空间堆空间

栈空间就是函数调用堆栈时使用的空间。当函数一连串被调用时,函数需要的数据会依次堆叠在内存空间里,函数进入时数据堆入,离开时数据释放,和栈是一样的。栈空间的大小是确定的,程序在编译时就会为函数中声明的的所有变量预留好空间。

由于一个函数的栈空间大小永远是确定的,因此为函数中的变量开辟空间十分迅速。不过,栈空间的大小是有限制的,如果我们调用太多函数,同时函数中定义了太多变量,就会喜闻乐见地爆栈。因此,尽量不要在函数内部声明过量数据。

堆空间则是一片十分自由的空间。在函数中,我们可以用 new 开辟任意多的空间。因此,我们会用堆空间来储存大量数据或是长度不确定的数据。不过,在堆空间中开辟空间的速度略逊于栈空间。同时,在堆空间开辟的数据需要及时手动回收,否则如果指向这片空间的指针已经找不到了,那么这片空间就喜闻乐见地内存泄漏了。泄露的空间会永远留在内存里无法被清理。

请参考以下例子:

// 编译时确定 foo 分配的栈空间大小:需要分配参数 b,声明的变量 c 和 d,返回值以及一些其它信息。
int foo(int b) {
    int c[42]; // c 中所有元素储存在栈空间。
    int *d = new int[42]; // 分配的内存储存在堆空间,指针 d 储存在栈空间。
    delete[] d; // 及时处理避免泄露。
    return b;
}

浅拷贝和深拷贝

假设我们有以下数据结构:

struct numbers {
    int len, *arr;
    numbers(): len(0), arr(nullptr) {}
    numbers(int _len): len(_len), arr(new int[_len]) {}
};

这个结构体代表一个数组。由于数组大小不确定,因此数组的实际内容会在堆空间分配。实际存储在结构体的只有指向它的指针。

假如我们有一个 numbers 变量 a,我们要将它的值传递给另一个 numbers 变量 b,我们该怎么做?

方法一。我们可以将 a 中的两个属性: arr 指针和 len 传递给 b。如果我们没有重构赋值运算符,那么 b = a 默认就会这么操作。但是这么做之后,ba 实际上拥有的是同一片内存区域。我们修改 a 拥有的内容的话,b 也会被修改,ab 就会相互影响。如果我们了解 Rust 所有权的话,就会知道这严重违背了『每个值都被且仅被一个变量所有』的原则。在简单情况下,这好像没什么问题,但在复杂的场景下,例如由多人开发,如果一个值被不止一个变量拥有,在其中一个变量操作时值可能会遭到无法预料的修改,从而导致错误。

方法二。我们考虑基本类型的值的传递:

int a, b;
a = 42;
b = a;

以上代码中,b = a 这句话将 a 的值复制给了 b,之后 ab 就不存在联系了。类比过来,如果我们把一个 numbers 中,它的的两个属性以及它所拥有的内存区域看成一个整体的话,当我们将 numbers 变量 a 传递给 b 时,显然我们应该将 a 中的全部东西复制给 b,也就是说,我们要把 a 所拥有的内存区域也复制一份给 b。显然,面对规模较小的数据,可以使用这种方法。但如果数据规模太大,每次都将所有数据拷贝一遍会带来很大的性能开销。

以上两个方法,一个只复制结构体属性(也就是存储在栈空间的内容),一个既复制属性,也复制拥有的内存(也就是存储在栈空间和堆空间的内容)。前者叫做浅拷贝,后者则叫做深拷贝

直接浅拷贝往往会造成所有权的混乱,而深拷贝在面对大量数据时会产生大量开销。当我们既面对大量数据想要避免复制,又想拥有清晰的所有权时,就需要使用优化过的浅拷贝。

不过在此之前,我们先了解一下深拷贝的实现。

值语义

numbers 的深拷贝可以这样写:

// 给 b 增加一个 const 修饰符,以代表我们不会修改 b。
void copy(numbers &a, const numbers &b) {
    if(&a == &b) return; // 小心自己赋值给自己的情况。
    delete[] a.arr; // 由于 a 之前可能已经有一个值,因此先处理掉原先的值防止内存泄漏。
    a.len = b.len;
    a.arr = new int[a.len];
    for(int i=0; i<a.len; i++)
        a.arr[i] = b.arr[i];
}

不过每次赋值都调用 copy 太麻烦。如果我们默认 numbers 的赋值是深拷贝,可以直接重载它赋值相关的函数,这样就能简化语法:

struct numbers {
    int len, *arr;
    numbers(): len(0), arr(nullptr) {}
    numbers(int _len): len(_len), arr(new int[_len]) {}
    // 复制构造函数
    numbers(const numbers &from): len(from.len), arr(new int[from.len]) {
        for(int i=0; i<len; i++)
            arr[i] = from.arr[i];
    }
    // 复制赋值运算符
    numbers &operator=(const numbers &from) {
        if(this == &from) return *this;
        delete[] arr;
        len = from.len;
        arr = new int[len];
        for(int i=0; i<len; i++)
            arr[i] = from.arr[i];
        return *this;
    }
};

以上代码中,复制构造函数在变量初始化时的赋值,以及作为函数参数时给形参的赋值中被调用:

void foo(numbers nums);
numbers a;
numbers b = a; // 调用复制构造函数。
foo(a); // 调用复制构造函数。

由于是初始化,因此自己事先没有存储其它值,不需要释放自己原先引用的空间。

复制赋值运算符重载了赋值运算符 =,在使用等号赋值时被调用:

numbers a, b;
b = a; // 调用复制赋值运算符。

我们将这样实现深拷贝功能的赋值语义成称为值语义

右值引用和移动语义

有缺陷的移动语义

上文已经提到,假如数据量很大,深拷贝就会有很大的开销。例如:

void foo(numbers nums);
numbers a(1000000);
foo(a);

如果使用复制构造函数来给 foo 的形参赋值的话,将会造成很大的开销。我们希望仅进行浅拷贝。但前文也说了,这样会造成所有权的混乱。我们怎么解决这样的问题呢?

在 Rust 中对此问题有两种解决方法:所有权的转移和借用。同样以 a 赋值给 b 为例:

借用就是将 a 的引用借给 b,必须在 b 生命周期结束后才能继续使用 a。有借有还,确保变量被换回来后不会再被修改。C++ 做不到这一点,因为它没有生命周期的检查。一旦获得了引用,想留到什么时候就留到什么时候,别人管不了你,十分自由。

转移则是直接把 a 送给 b。将 a 浅拷贝给 b 之后,b 获得了所有权,然后放弃 a 的所有权,a 变量变为无效。此过程中,所有权发生了转移,但数据仍然只让一个变量拥有。管不好别人可以管好自己,C++ 可以实现转移操作。

我们将实现转移操作的赋值语义成称为移动语义。首先,我们模仿写值语义的方式写出移动语义的构造函数和赋值运算符:

struct numbers {
    int len, *arr;
    numbers(): len(0), arr(nullptr) {}
    numbers(int _len): len(_len), arr(new int[_len]) {}
    numbers(numbers &from): len(from.len), arr(from.arr) {
        from.len = 0;
        from.arr = nullptr; // 将原来的变量无效化,取消其所有权。
    }
    numbers &operator=(numbers &from) {
        if(this == &from) return *this;
        delete[] arr;
        len = from.len;
        arr = from.arr;
        from.len = 0;
        from.arr = nullptr;
        return *this;
    }
};

这样实现的话有两个缺点:值语义和移动语义无法同时存在;同时,虽然移动后我们将移动前的变量无效化,但我们并没有显式地告知,如果不知情的用户在移动后又尝试访问原变量的话,就会产生问题。

于是,C++11 中,出现了一个新功能,以在语法层面对移动语义进行完善。它就是本文的主角——右值引用,以及与它搭配使用的 std::move

右值引用

右值引用type &&)和引用的唯一区别在于它必须绑定右值。它的绑定如下:

int &&a = 42;

如果尝试绑定左值,无法通过编译:

int a;
int &&b = a; // C/C++(1768):无法将右值引用绑定到左值。

在绑定后,右值引用就和一般的引用没有区别了。也就是说,右值引用是一个左值。

int &&a = 42;
int *b = &a; // 编译通过。
a = 0; // 编译通过。

不过,声明一个右值引用并将它绑定给一个字面量并没有什么意义:

int &&a = 42;

有些地方说这么做可以延长临时值 42 的生命周期。不过,这么做与直接声明一个变量没有太大区别:

int a = 42;

右值引用的真正作用在于确保引用的值在离开该引用的作用域之后就失效了,不再被使用,因此可以对引用的内容为所欲为:

void bad(std::string &&s) {
    s = "f**k you";
}

在该函数中,s 是一个右值引用,也就是说绑定 s 时(也就是传参时)只能用一个右值。

std::string good_word = "thank you";
bad(good_word); // C/C++(1768):无法将右值引用绑定到左值。
printf("%s\n", good_word.c_str());

以上代码会被禁止,因为只能向 bad 传递右值。这样确保了 s 引用的值在函数结束时必须失效。否则,如果像以上代码一样,这个值在之后仍然被使用,那么将会导致严重的后果,例如程序会向用户展示带有攻击性的语言。

std::move

继续以 bad 函数为例:

void bad(std::string &&s) {
    s = "f**k you";
}

我们并不能直接将一个左值传递给 bad,因为传的参数的值必须要确保在函数执行后不再被使用。那么如果我们必须要传一个左值,但我们保证传递之后就不再使用它了,该怎么做呢?答案是 std::move

std::move 将一个左值转换为右值:

int a;
std::move(a) = 42; // C/C++(137):表达式必须是可修改的左值。
&std::move(a); // C/C++(158):表达式必须为左值或函数指示符

由于是右值,以上操作都无法通过编译。

不过,虽然名字叫 move,但实际上,std::move 不会做任何事,只是在语法层面将左值变为右值。

int a = 42;
int *p0 = &a;
std::move(a);
std::move(a);
std::move(a);
assert(a == 42);
int *p1 = &a;
assert(p0 == p1);

由于 std::move 什么也不做,因此即使调用 100 遍,a 的值和地址都不会发生变化。它只是让编译器认为这是一个右值,从而可以将该值绑定给右值引用。

不过,正常人不会像以上代码一样无缘无故调用 std::move,而是会将这个右值绑定给一个右值引用。比如上面 bad 函数的例子,我们可以用 std::move 将一个变量变为右值,这样就能调用 bad 函数了:

std::string good_word = "thank you";
bad(std::move(good_word));

但是绑定给右值引用后就需要遵守之前约定:不再使用原来的变量。这就是为什么这个函数叫 move,因为它将所有权转移走了。

不过,这个约定只是君子协定,假如你在 std::move 后继续访问原变量,编译器并不会阻止你。但是这么做需要自己承担后果。

std::string good_word = "thank you";
bad(std::move(good_word));
printf("%s\n", good_word.c_str());

以上代码虽然能通过编译,但由于调用 std::move 后继续访问原变量,导致攻击性的话语被打印出来了。显然,问题出在编写以上这段 std::move 后继续访问原变量的代码的人,而不是编写 bad 函数的人——后者已经用右值引用明确说明了变量传给 bad 后就不能使用了。

总结:右值引用可以标注一个参数在离开函数作用域后就废弃了。一般来说,我们只能传右值给右值引用。当我们要传左值,需要用 std::move 将左值转换为右值,并遵守之后不再使用这个值的约定。

亡值

此节可跳过,不影响理解。

如果 a 是一个左值,我们说 std::move(a) 是一个右值,似乎有些不太对劲。因为 std::move(a) 本质上就是 a,它拥有一个实际的地址。不过,它在语法上和右值又没有区别:它不能被赋值和取地址,可以绑定到右值引用。

我们发现,它在语法上是右值,但在实际上又好像是一个左值,传统的分类方法似乎有些瑕疵。于是,C++11 中修改了分类方法,值被分为左值,纯右值和亡值。原先的左值还是左值,右值是纯右值,被 std::move 后的左值被称为亡值。

左值和亡值统称泛左值:它们有实际地址。

亡值和纯右值统称右值:它们可以绑定给右值引用。

于是,值的分类问题被解决了,可喜可贺。

移动语义

有了右值引用,我们可以更好地实现之前的移动语义:

struct numbers {
    int len, *arr;
    numbers(): len(0), arr(nullptr) {}
    numbers(int _len): len(_len), arr(new int[_len]) {}
    // 移动构造函数
    numbers(numbers &&from): len(from.len), arr(from.arr) {
        from.len = 0;
        from.arr = nullptr;
    }
    // 移动赋值运算符
    numbers &operator=(numbers &&from) {
        if(this == &from) return *this;
        delete[] arr;
        len = from.len;
        arr = from.arr;
        from.len = 0;
        from.arr = nullptr;
        return *this;
    }
};

将移动前的对象声明为右值引用,标明了该对象赋值后即失效,这正好达成了我们想要的目的。

更不错的是,我们可以让移动和复制语义同时存在:

struct numbers {
    int len, *arr;
    numbers(): len(0), arr(nullptr) {}
    numbers(int _len): len(_len), arr(new int[_len]) {}
    // 复制构造函数
    numbers(const numbers &from): len(from.len), arr(new int[from.len]) {
        for(int i=0; i<len; i++)
            arr[i] = from.arr[i];
    }
    // 复制赋值运算符
    numbers &operator=(const numbers &from) {
        if(this == &from) return *this;
        delete[] arr;
        len = from.len;
        arr = new int[len];
        for(int i=0; i<len; i++)
            arr[i] = from.arr[i];
        return *this;
    }
    // 移动构造函数
    numbers(numbers &&from): len(from.len), arr(from.arr) {
        from.len = 0;
        from.arr = nullptr;
    }
    // 移动赋值运算符
    numbers &operator=(numbers &&from) {
        if(this == &from) return *this;
        delete[] arr;
        len = from.len;
        arr = from.arr;
        from.len = 0;
        from.arr = nullptr;
        return *this;
    }
};

对于一个左值,使用 std::move 后赋值会调用移动语义,不使用的话就会调用复制语义:

void foo(numbers nums);
numbers a;
numbers b = a; // 调用复制构造函数。
numbers c = std::move(a); // 调用移动构造函数。
// a 已经失效了。我们假定这之后 a 被重新赋值。
foo(a); // 调用复制构造函数。
foo(std::move(a)); // 调用移动构造函数。
// 我们假定这之后 a 又被重新赋值。
numbers c;
c = a; // 调用复制赋值运算符。
c = std::move(a); // 调用移动赋值运算符。

以上代码中的的 std::move 就更有 move 的含义了。

numbers a;
numbers &&b = std::move(a);

需要注意,以上代码并不会调用移动构造函数,因为它不是在赋值,而是在绑定一个右值引用。它和绑定一个普通引用一样,只会生成一个指针,没有值被移动。

void foo(numbers &&nums);
numbers a;
foo(std::move(a));

以上代码同理。它和上一个例子中的 foo 的区别在于,这个 foo 接收的是一个右值引用,传参时进行绑定操作,而前一个 foo 接收的是一个值,传参时进行赋值操作。

numbers a = numbers(42);
foo(numbers(42));

以上两句均不会调用移动构造函数。它直接在目标区域(变量 a 对应的栈空间和 foo 的参数对应的栈空间)声明变量并调用构造函数 numbers(int),值不会从其它区域移动过来。从常理来说这个过程也显然不应该调用两个不同的构造函数。

numbers a(42);
a = numbers(42);

以上代码则会调用移动赋值运算符。假如不调用的话显然会内存泄漏。

因此,移动构造函数和移动赋值运算符何时调用的规律非常简单:当原对象存在实际地址或被赋值对象已存储值,这些必须进行移动操作的情况下,才会调用移动构造函数和移动赋值运算符。

右值引用的引入,使得我们能够更加自如地使用移动语义和值语义。

完美转发

前文已经将右值引用介绍完毕了。本章稍微进行一些拓展。

万能引用

以下函数中,T && 是万能引用:

template<typename T>
void foo(T &&a);

我们知道,无论左值引用还是右值引用都是引用。所谓万能引用,就是能根据传入的参数是左值还是右值自动决定使用左值引用还是右值引用。

int a;
foo(a); // foo 的形参是左值引用。
foo(42); // foo 的形参是右值引用。

这样一来,无论左值还是右值在 foo 中统统变成了引用,本来需要写两个重载,现在只要写一个就行了。

不过可惜的是,只有 T && 中的 T 是函数的模板参数时,T && 才是万能引用。

template<typename T>
struct bar{
    template<typename U>
    void foo(T &&a, std::vector<U> &&b);
}

以上代码中,ab 都不是万能引用,而是平平无奇的右值引用。

不过,为什么只有模板参数才能拥有万能引用呢?其实,左值引用和右值引用归根结底还是不同的两个类型,而普通的函数参数只能指定一个明确类型。正如一个普通的不重载的函数的参数做不到既能是 int 又能是 float,它的参数也做不到既能是左值引用又能是右值引用。又正如我们将 intfloat 等类型全部抽象成 T 一样,只有使用模板,我们才能将左值引用和右值引用全部抽象成 T &&

引用折叠

引用折叠是另一种理解万能引用的方法。

C++ 中,多重引用是禁止的:

int a;
int &b = a;
int & &c = b; // C/C++(249):不允许使用对引用的引用

而如果像上文中的 foo

template<typename T>
void foo(T &&a);

如果传入的实参是 int 的左值,T 被推导为 int &,右值则被推导为 int。假如 T 被推导为 int &,那么 foo 的参数类型不就变成了 int & &&,也就是多重引用了吗?

对此,C++ 编译器有引用折叠的办法,具体如下:

因此,int & && 被折叠成 int &

根据此原理,万能引用得以实现。只有传入右值时,参数被推导为右值引用。

完美转发

不同的类型很好区分,但左值引用和右值引用几乎没有区别。

void foo(int &a);
void foo(int &&a);
template<typename T>
void bar(T &&a) {
    foo(a);
}

以上代码中,由于左值引用和右值引用都是引用,也就是左值,因此无论给 bar 传左值还是右值,它都只会调用 foo(int &) 的重载。

我们想要在给 bar 传左值时它给 foo 传左值,传右值时给 foo 传右值,这样就实现了完美转发

std::forward 帮助我们实现这一点。给 std::forward 左值引用时,它返回左值,右值引用则返回右值。

于是可以修改为如下代码:

void foo(int &a);
void foo(int a);
template<typename T>
void bar(T &&a) {
    foo(std::forward<T>(a));
}

即实现完美转发。

总结

上面写那么多写不动了,最后简短一点结束。希望这篇文章可以帮到你。祝愿你能度过美好的每一天。