IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    Modern C++ 学习笔记 -- 左值与右值

    Timothy发表于 2023-09-08 14:08:16
    love 0

    左值(lvalues)与右值(rvalues)的概念

    左值和右值是Modern C++中引入的新概念。简而言之:

    • 左值位于等号左边,我们可以对左值进行取地址操作。
    • 右值位于等号右边,本质上是一个数值,即 literal constant,我们没法对它进行取地址操作。
    1
    int x = 999; // x 是左值, 999是右值

    我们,可以把左值大致想象为一个容器,而容器中存放的就是右值,如果没有这个容器,其中的右值将会失效。对于以下的程序,我们在编译的时候将会得到类似的错误:

    1
    error: lvalue required as left operand of assignment

    1
    2
    int x;
    123 = x;

    很显然,等号左边需要的是一个左值,而123作为一个 literal constant 类型,是没有办法充当左值的。同样,我们也没法对一个右值进行取地址的操作:

    1
    2
    int *x;
    x = &123; //无法对右值取地址

    编译器报错:

    1
    error: lvalue required as unary '&' operand

    左值到右值的隐式转换

    左值在很多情况下有可能被转换为右值,比如在C++中的 - 运算符,它将两个右值作为参数,并将计算结果作为右值返回。

    1
    2
    3
    int x = 10;
    int y = 5;
    int z = x - y;

    在上面的程序片段中,我们明显看到x, y本身是左值,但是却以右值的身份参与了减法运算。这是如何做到的呢?答案是编译器对左值做了隐式的转换,将x和y转换成为了右值。C++中其他的乘法,除法和加法运算也是同样如此。

    如果左值能被转换成右值,那么右值本身能被转换成左值吗?答案是 否定 的.

    左值引用与右值引用

    C++中引入引用的概念,是为了在程序中方便的通过引用修改原变量的值,并且,在调用方法传参的过程中,传递引用可以避免拷贝。在通常情况下,左值引用只能指向左值,而右值引用只能指向右值。听起来比较废话,但是也有特殊的情况。

    左值引用

    1
    2
    3
    int x = 10;
    int& ref_x = x;
    ref_x++;

    在上面的示例程序中,我们定义了一个左值x,然后赋值10。随后定义了一个引用,指向x。因此,ref_x成为x的引用,它就叫做左值引用。通过操作ref_x,我们就可以改变x的值。

    如果我们把上面的程序简化为:

    1
    int& ref_x = 10;

    在编译的时候,我们会得到类似的错误:

    1
    cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'

    显然,左值引用只能指向一个左值,而不能指向一个右值。不错从错误信息中,我们方法可以得出另外一种写法:

    1
    const int& ref_x = 10;

    根据编译器的规则,我们被允许通过定义一个const类型的左值来指向右值。不过既然这个左值被定义成了const,没有办法修改指向的值。

    右值引用

    C++中的右值引用以&&表示。通过右值引用,可以修改其指向的右值。

    1
    2
    int&& ref_x = 10; //定义右值引用
    ref_x--; //通过右值引用修改其指向的右值

    如果我们尝试将右值引用指向一个左值:

    1
    2
    int x = 10;
    int&& ref_x = x;

    编译器也会抛出类似的错误,告诉我们不能把一个右值引用指向左值。

    1
    error: cannot bind rvalue reference of type 'int&&' to lvalue of type 'int'

    左右值引用的本质

    通过一个简单的示例程序,我们就能知道左值引用和有值引用的本质。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    void increase(int&& input) {
    input++;
    }

    int main() {
    int x = 10;

    int& ref_a = &x;
    int&& ref_b = std::move(x);

    increase(x); // 编译错误,不能传入左值
    increase(ref_a); // 编译错误,不能传入 左值引用
    increase(ref_b); // 编译错误,右值引用本身是一个左值

    increase(std::move(a)); // 编译通过
    increase(std::move(ref_a)); // 编译通过
    increase(std::move(ref_b)); // 编译通过

    increase(7); //编译通过,7是右值

    return 0;
    }

    从上面的代码示例中,我们可以看出右值引用 ref_b 本身也是一个左值,在调用 increase 的时候,需要通过 std::move 转换为右值,编译器才不会报错。

    通过以上的例子,我们可以总结出如下的规律:

    • 左右值引用的引入,都是为了避免拷贝。
    • 左值引用通常指向左值,通过添加 const 关键字约束也能指向右值,不过无法对右值进行修改。
    • 右值引用本质上也是一个左值,右值引用通常情况下指向右值,不过通过 std::move 等形式也可以指向左值。

    右值引用与移动语义

    在前面的例子中,我们已经涉及到了 std::move 这样的操作。右值引用配合 std::move 通常能实现移动语义,从而实现避免拷贝,提升程序性能。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    #include <iostream>
    #include <vector>

    int main() {
    std::vector<std::string> list;

    std::string str_a = "Hello";
    std::string str_b = "World";

    list.push_back(str_a);
    list.push_back(std::move(str_b));

    std::cout << "str_a: " << str_a << std::endl;
    std::cout << "str_b: " << str_b << std::endl;
    std::cout << "list[0]: " << list[0] << std::endl;
    std::cout << "list[1]: " << list[1] << std::endl;
    return 0;
    }

    如果运行上面的示例程序,我们会得到这样的程序输出:

    1
    2
    3
    4
    str_a: Hello
    str_b:
    list[0]: Hello
    list[1]: World

    很明显,在str_a被添加到vector的时候,并没有涉及到移动语义,所以str_a的值被拷贝到了vector中。而在把str_b添加到vector的过程中,由于用到了std::move,所以str_b的值被移动到了vector中。之后再输出vector的值的时候,可以看到其中已经包含了str_a和str_b的值。但是str_b本身的值已经被偷走了。

    需要注意的是,std::move本身的名字比较有迷惑性,其实它在这里的工作只是把str_b从左值转换成右值,而不会做实际上移动资源的操作。如果我们把添加str_b的代码替换成:

    1
    list.push_back(static_cast<std::string&&>(str_b));

    会达到一样的效果。而真正的秘密在于 std::vector 提供的两种不同的重载方法:

    1
    2
    void push_back( const T& value );
    void push_back( T&& value );

    第一个重载方法接受的是左值引用,当传入 str_a 的时候,由于 const 关键字的限制,它的值会被拷贝一份,并放入到vector中,而 str_a 本身的值并不受影响。而第二个重载方法接受的是一个右值引用,push_back方法会把其值放入vector中,并转移 str_b 对字符串值 World 的所有权。这样,当我们再输出它的值的时候,发现已经为空了。

    完美转发(std::forward)

    完美转发(Perfect Forwarding),转发的意义在于当一个方法把其参数传递给另外一个方法的时候,不光转发参数本身,还包括其属性(左值引用保持左值引用,右值引用保持右值引用)。

    std::forward 实际上也是做类型的转换,不同的是 std::move 只把左值转换为右值,std::forward 能转换为左值或右值。

    std::forward<T>(arg) 中,如果类型 T 是左值,参数 arg 将被转换为左值,否则 arg 将被转换为右值。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    #include <iostream>
    #include <utility>

    void target_func(int& arg) { std::cout << "lvalue reference" << std::endl; }

    void target_func(int&& arg) { std::cout << "rvalue reference" << std::endl; }

    template <typename T> void forward(T&& arg) {
    target_func(std::forward<T>(arg));
    }

    int main() {
    forward(5);
    int x = 5;
    forward(x);
    return 0;
    }

    在以上的示例程序中,forward 使用一个Universal Reference类型接受一个参数,并且通过 std::forward 讲参数转发给 target_func。由于此方法有两个重载,分别接受左值引用和右值引用。在我们分别传入右值 5 和左值 x 的时候,我们发现 forward 这个方法都能准确无误的把参数转发给对应的重载方法。因此,程序的输出分别是:

    1
    2
    rvalue reference
    lvalue reference

    参考文章

    1. Understanding the meaning of lvalues and rvalues in C++
    2. Cpp Reference
    3. Perfect Forwarding


沪ICP备19023445号-2号
友情链接