首页 右值引用
文章
取消

右值引用

按 C++ 标准的发展来梳理出右值引用的概念。但会先复习一下引用的概念。

引用

先复习一下 C++ 中引用的概念:

  • A reference defines an alternative name for an object
    • 引用就是另外一个已经存在的对象的名字的别名
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int ival = 1024;

// refVal refers to (is another name for) ival
int &refVal = ival;

// 错: a reference must be initialized
int &refVal2;

// assigns 2 to the object to which refVal refers, i.e., to ival
refVal = 2;

// same as ii = ival
int ii = refVal;

// refVal3 实际上引用的是 ival(refVal 所指向的对象)
int &refVal3 = refVal;

// 实际上把 refVal 所指向的对象 ival 的值赋值给 i
int i = refVal;

引用在其初始化的时候被绑定到它对应的「初始对象」上;而且引用一旦被绑定就不能再被绑定到其他对象上

得到 3 个推论:

  • 把某个值赋值给引用,实际上是赋值给引用指向的真正的对象
  • 当从引用获取一个值的时候,实际上获取的的引用指向的真正的对象
  • 当使用引用来初始化时,实际上是用引用指向的真正的对象来做初始化

const 和 引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const int ci = 1024;
// ok: both reference and underlying object are const 
const int &r1 = ci;

// r1 是 ci 的别名,所以 ci 是 const 的话,也不能通过 r1 修改其值

// 错: r1 is a reference to const
r1 = 42;

// 企图用非 const 的引用来修改 const 的对象也是不行的
// 错: non const reference to a const object
int &r2 = ci;

// 但反过来是可以的
// we can bind a const int& to a plain object
// 不过这样做的话,虽然不能通过 r1 来修改对象;但是目标对象 i 本身还是可以修改的
int i = 42;
const int &r1 = i;

// const & 可以引用一个 rvalue(重要)
const int &r3 = i * 42;

引用作为函数参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void f(int a) {
    a = a + 5;
    std::cout << a << std::endl;
}

int b = 5;
// 不用引用作为参数的话,是按值传参。相当于做了一次 int a = b
f(a);

void fr(int &c) {
    c = c + 5;
    std::cout << c << std::endl;
}

// 使用引用作为参数的话,实现了按参数传参的效果。相当于做了一次 int &c = d
// 在函数内部,引用 c 指向的是对象 d,修改 c 就是修改 d
int d = 6;
fr(d);

再看一个 const & 作为参数的例子:

1
2
3
4
5
// compare the length of two strings
// 这样做的话,不会把值 copy 到函数内部,提升效率;同时又不会修改入参指向的对象的值(const &)
bool isShorter(const string &s1, const string &s2) {
    return s1.size() < s2.size();
}

引用作为返回值

返回值是引用,就是说当函数的调用者得到这个「返回值」以后,就拿到了指向某个「对象」的引用。这样的话:

  • 既然拿到的是引用,在返回时没有发生「值」的 copy,效率高
  • 另外,如果这个引用不是 const 的话,函数的调用者,可以通过这个引用修改该引用指向的「对象」

例如:和 C 语言不同(C 语言的函数调用不能是「左值」),如果返回值是引用的话,该函数的调用可以是「左值」。参考下面例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
char &get_val(std::string &str, std::string::size_type ix) {
  // get_val assumes the given index is valid
  return str[ix]; 
}

int main(void) {
  std::string t("a value");
  std::cout << t << std::endl;
  // changes t[0] to A
  get_val(t, 0) = 'A';
  std::cout << t << std::endl;

  return 0;
}

但注意不要返回临时变量的引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 问题: 返回一个指向局部对象的引用

Matrix& add( Matrix &m1, Matrix &m2 ) {
    Matrix result;

    if ( m1.isZero() )
        return m2;
    
    if ( m2.isZero() )
        return m1;

    // 将两个 Matrix 对象的内容相加  
    // 喔! 返回之后 结果指向一个有问题的位置
    return result;
}

操作符重载函数

赋值号的「操作符重载函数」必须是成员函数:

  • 赋值号的左边是当前对象(this 参数指向的对象)
  • 赋值号的右边是「操作符重载函数」的入参
  • 「操作符重载函数」的返回值一般来说都是赋值号左边对象的引用
1
2
3
4
5
6
7
8
9
10
11
class Foo {

public:
    // assignment operator
    // 1) 入参使用 const Foo& 不会改变赋值号右边的对象
    // 2) Assignment operators ordinarily should return a reference to their left-hand operand.
    //    这是因为当 a = b = c 这种连续赋值操作的时候返回值是「左边对象的引用」效率高一点。
    //    但只是 a = b 这种赋值操作时,无所谓是「左边对象的引用」还是直接返回「对象本身」
    Foo& operator=(const Foo& v);

};

下面是一个调用的例子:

1
2
3
4
5
6
Foo f;
Foo g;

// 这里的赋值会调用「赋值操作符重载函数」。
// 注意:就这个赋值操作来说,并不会使用到「赋值操作符重载函数」的返回值(虽然一般建议「赋值操作符重载函数」的返回值是一个「左边对象的引用」)
g = f;

一个例子:

1
2
3
4
5
6
7
// equivalent to the synthesized copy-assignment operator
Sales_data& Sales_data::operator=(const Sales_data &rhs) {
    bookNo = rhs.bookNo; // calls the string::operator=
    units_sold = rhs.units_sold; // uses the built-in int assignment
    revenue = rhs.revenue; // uses the built-in double assignment
    return *this; // return a reference to this object
}

最后,先简单提一下左右值(这里先不考虑后来引入的 xvalue 的概念):

Every expression in C++ is either an rvalue (pronounced “are-value”) or an lvalue (pronounced “ell-value”)。

可以粗略的理解成,当使用一个对象的值的时候,是把这个对象当作 rvalue 来使用;而当把一个对象用作一个 identity (可以根据 identity 来按在内存的位置来使用对象)的时候,是把这个对象当作 lvalue 来使用:

  • lvalue:An expression that yields an object or function. A non const lvalue that denotes an object may be the left-hand operand of assignment.
    • lvalue 多被用于持有某种状态
  • rvalue:Expression that yields a value but not the associated location, if any, of that value.
    • rvalue 一般就是「字面量」或者「临时对象」

引用来说,在 C++ 11 之前,引用只可以引用 lvalue,否则必须是 const & 才能引用 rvalue:

1
2
3
4
5
6
7
int i = 42;

// 引用一个 lvalue
int &r = i;

// const & 可以引用一个 rvalue(重要)
const int &r3 = i * 42;

C++98

C++98 为了解决「临时对象拷贝」效率的问题,对「const 引用」指向的临时对象的析构时机做了规定(临时对象的析构可以延迟)。

参考下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void f(std::vector<int> v) {
    for (int n: v) {
        std::cout << n << endl;
    }
}

int main(void) {
    std::vector<int> v{1, 2, 3};

    // 这里的函数 f 的入参是一个「临时对象」,
    // 然后 f 会对该「临时对象」做 copy,copy 之后,原先的「临时对象」会被析构
    // 「临时对象」被 copy 到函数内部的变量 v 上,然后当函数执行结束时,函数内部的变量 v 又会被析构掉
    f(std::vector<int> {4, 5, 6};
}

在上面的例子中,当调用函数 f 时,会发生 2 次对 vector 的构造,2 次对 vector 对象的析构。

既然按之前提到的 const & 可以引用到普通的「非 const 左值」,也可以引用到右值(所谓的「万能引用」):

1
2
3
4
5
6
7
8
// we can bind a const int& to a plain object
// 不过这样做的话,虽然不能通过 r1 来修改对象;但是目标对象 i 本身还是可以修改的
int i = 42;
const int &r1 = i;

// const & 可以引用一个 rvalue
const int &r3 = i * 42;

为了解决这个效率问题,C++98 对「const 引用」(也就是「万能引用」)指向的临时对象的析构时机做了规定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 函数 f 的入参是一个 const &
void f(std::vector<int> const &v) {
    for (int n: v) {
        std::cout << n << endl;
    }
}

int main(void) {
    std::vector<int> v{1, 2, 3};

    // const & 作为函数入参,可以是一个「非临时对象」
    f(v);
    // const & 作为函数入参,也可以是一个「临时对象」。
    // 把「临时对象」作为地址传入给函数 f
    // 然后由「编译器」来保证对 const &,当 f 结束以后(分号以后),才对「临时对象」做析构。而在函数 f 调用过程中,「临时对象」一直有效
    f(std::vector<int> {4, 5, 6};
}

同时,为了解决「临时对象返回值」失效的问题,C++98 又规定了「临时对象返回值」会到该对象的作用域结束时才会对这个对象做析构。参考下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct S
{
    ~S() {std::cout << "destruct it" << std::endl;}
}

S f()
{
    S s;
    return s;
}

int main(void)
{
    {
        // 针对函数 f 的返回值使用 const& 可以解决「临时对象返回值」的效率问题
        // 这样函数 f 内部返回的「临时对象」可以在返回以后继续使用
        // 但注意,C++98 规定这里的 s(const &)不会在这个「分号」后被析构
        // 而是会到 s 的作用域结束后才会被析构
        const S &s = f();
        std::cout << "inner scope" << std::endl;
    }
    std::cout << "outer scope" << std::endl;
}

对 C++98 左右值的总结:

通过对「右值」对象做 const &,使得「右值临时对象」可以被延迟析构,从而可以复用这个已经构建好的「临时对象」,提升效率

C++11

C++98 虽然通过规定 const & 可以延迟对「右值」临时对象的延迟析构来提升效率,但最后还是会被析构。

考虑另外一种可以提升效率的场景:既然这个临时对象已经构造好了,拥有了一些分配好的资源;如果开发者希望该对象被析构以后,能够继续复用这个对象已经拥有的分配好的资源,从而提升效率?

所以 C++11 更进一步,引入了「移动」相关的概念,提供给开发者来解决这个问题。

右值引用

在 C++ 11 之前,引用只可以引用 lvalue,否则必须是 const & 才能引用 rvalue:

1
2
3
4
5
6
7
int i = 42;

// 引用一个 lvalue
int &r = i;

// const & 可以引用一个 rvalue
const int &r3 = i * 42;

C++ 11 引入了一种新的引用:右值引用

右值引用绑定到某个对象,就说明这个对象是一个「临时对象」,其含义是:

  • 右值引用指向的对象是「临时的」,也就是随后就会被析构
  • 不存在其他的方式来使用这个「临时对象」
  • 右值引用不能被绑定到 lvalue
1
2
// 报错:右值引用不能被绑定到 lvalue
int &&rr = i;

所以,右值引用其实就是一种标志:被右值引用指向的对象可以被移动出来复用,避免不必要的拷贝,提升效率。

移动赋值

C++ 11 之前「重载赋值操作符」的话,是「拷贝赋值」,也就是会把「赋值号」右边的「对象」拷贝给「赋值号」左边的对象。

在有的场景,这种拷贝其实是一种浪费:「赋值号」右边的「对象」其实未来不再会被使用了,实际上赋值的时候可以采用移动的方式,把未来不会再被使用的「对象」的资源移动给「赋值号」左边的「对象」。

先看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct S
{
    // 重载赋值操作符
    S &operator=(const S &s) {
        std::cout << "const &" << std::endl;
        // 实现赋值逻辑(略)
        return *this;
    }
}

int main(void)
{
    S s;
    S ss;

    // 下面 2 个赋值都会使用重载的赋值操作,同时都会使用 const & 作为入参(C++98)
    ss = s;

    // 1)先是 S() 为返回值构造一个临时对象
    // 2)然后会匹配到参数为 const S & 的「赋值操作符重载函数」(const & 是「万能引用」),入参直接就是 S() 中的临时对象(因为按 C++ 98 的规定,因为是 const S &,返回的时候不会析构这个临时对象)
    // 3)「赋值操作符重载函数」之后,才会析构 S() 中构造出来的临时对象
    ss = S();
}

上面这个例子中,按 C++98 的规定,当「赋值操作符重载函数」完成之后,S() 中构造出来的临时对象会被析构掉。但如果开发人员为了提升效率,希望这个临时对象被析构以后,其拥有的资源可以继续被复用(比如可以通过 ss 这个 identifier 继续使用)。就需要用到 C++11 右值引用相关规定了:

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
struct S
{
    // 重载赋值操作符
    S &operator=(const S &s) {
        std::cout << "const &" << std::endl;
        // 实现赋值逻辑(略)
        return *this;
    }

    // C++11 标准规定了「右值引用」:&&;标明这个赋值操作「右边」的对象是一个可以被复用的「临时对象」,把直接把该对象的资源移动给赋值操作「左右」的对象,从而提升效率。
    S &operator=(S &&s) {
        std::cout << "&&" << std::endl;

        if (this != &s) {
            free(); // free existing
            elements = s.elements; // take over resources elements froms s
            s.elements = nullptr;
        }

        return *this;
    }
}

int main(void)
{
    S ss;

    // C++11 的编译器能判断出 S() 是一个未来不会被使用的「临时对象」,所以会优先使用 && 为入参的赋值操作,而开发人员已经把这个赋值操作实现成了移动资源,而不是拷贝资源,从而实现了效率的提升
    ss = S();
}

总结

  • 「开发者」负责实现多个版本的函数,分别满足对资源的「拷贝」和「移动」
    • 「拷贝」版本的入参是:普通的「引用」、或「const 引用」
    • 「移动」版本的入参是「右值引用」
  • 如果「开发者」没有提供「右值引用」的版本,只提供了「const 引用」的版本,「编译器」多半会匹配到「const 引用」的版本(万能引用)
  • 如果「开发者」提供了「右值引用」的版本,那么「编译器」能够根据当前入参是左值还是右值,判断出当前需要使用那个版本的函数:
    • 如果入参是「左值」,就会匹配「拷贝」版本的函数
    • 如果入参是「右值」,就会匹配到「移动」版本的函数

另外,如果当前值是「左值」,「编译器」就不会匹配到入参是「右值引用」的「移动」版本。但如果此时「开发者」希望能匹配到入参是「右值引用」的「移动」版本,可以通过使用 std::move 显示的指定调用「右值引用」的版本:

1
2
3
4
5
6
7
8
S s;
S ss;

// 略

// 通过使用 std::move 显示的指定调用「右值引用」的版本
ss = std::move(s);

C++ 新标准中也提出了 xvalue 的概念。

这种新的 value 的分类,个人理解其实是为了迎合前面章节总结出的 move 概念,而抽象出来定义。所以放到附录里面,看看就好了,和前面章节的内容并不冲突:

For all the values, there were only two independent properties:

  • has identity” – i.e. an address, a pointer, the user can determine whether two copies are identical, etc. (defined as glvalue)
  • can be moved from” – i.e. we are allowed to leave to source of a “copy” in some indeterminate, but valid state. (defined as rvalue)

There are four possible composition:

  • iM: has identity and cannot be moved from (defined as lvalue)
  • im: has identity and can be moved from (defined as xvalue)
  • Im: does not have identity and can be moved from (defined as prvalue)
  • IM: doesn’t have identity and cannot be moved (C++ 中目前没有这种)
1
2
3
4
5
      expression
      /        \
    glvalue   rvalue
    /     \   /    \
lvalue   xvalue   prvalue
  • prvalue,纯右值。典型的 prvalue 有:纯数学值(整数 7,单个字符 a),、引用(&a)、有返回值的函数调用(T f()
  • xvalue,临时对象。即 expiring value(即将消亡的 value),也就是前面章节提到的可以被 move 的 value。xvalue 从哪里来的?比如:
    • prvalue 在某些语句中会被实体化(例如前面章节中的例子:S s = func()),从而创建出一个「临时对象」,也就产出了一个 xvalue
本文由作者按照 CC BY 4.0 进行授权