左值和右值,移动语义与完美转发

什么是左值,什么是右值?

左值:具有生命周期的,具有名称的值,即为它有一个具体的内存空间。

右值:没有生命周期,也成为将亡值。不指向稳定内存地址的匿名值。

基于上述特性,我们也可以用取地址符号判断,能够取到地址的是左值,不能取到的是右值。

左值与右值的理解

从字面理解,无非是表达式等号左边的值为左值,表达式右边的值为右值。

int x = 1;
int y = 2;
int z = x + y;

以上述代码为例子,x是左值,1是右值;y是左值,2是右值。

z是左值,x+y的结果是右值。

但是简单地用表达式左右来区分左右值仍然过于武断,让我们考虑下面的例子

int a = 1;
int b = a;

在line1 a显然是左值,但是line2中a却变成了右值。故而以表达式区分是有矛盾的。所以我们还是应该理解其内在含义。

在c++中 lvalue 一般表示指向一个具体地址(具体内存)的具有名称的值(具名对象)。它有一个相对稳定的内存地址,并且有一段较长的生命周期。而右值则是不指向稳定内存地址的匿名值(不具名对象)。以上述代码为例子,因为&a和&b都能够取到地址,所以a和b都是左值。

让我们考虑以下代码

#include <iostream>

using namespace std;

int x = 1;

int get_val()
{
    return x;
}

void set_val(int val)
{
    x = val;
}

int main() {
    x++; /* rvalue */
    ++x; /* lvalue */

    int *q = &++x; /* success */
    int *p = &x++; /* fail */
    int *r = &get_val(); /* fail */
    
    int y = get_val();
    set_val(6);
    auto p = &"this is a lvalue";
}

在main函数中++x和x++虽然都是对x做自增操作,但是实际上++x是对x自增后马上返回其自身,而x++是先生成x的临时复制然后才对x递增,最后返回临时复制内容。

所以我们看上述取地址操作,x++无法取到地址,而++x可以。

int *q = &++x; /* success */

int *p = &x++; /* fail */

编译器告警

test1.cpp:20:20: error: lvalue required as unary ‘&’ operand

20 | int p = &x++; / fail */

我们再看get_val这个函数,它返回的是左值还是右值呢?它虽然返回了一个全局变量x,但是实际上返回的是x的临时复制,故而是一个右值。所以 int *r = &get_val() 也会失败。

在main函数 set_val中实参6是一个右值,但是进入函数之后形参val却是一个左值。懂得函数压栈行为的同学可能知道val是会被压入寄存器,所以此时若对其取地址取到的是寄存器地址。

前置++和后置++的实现

/* 前置++ */
Integer& operator++() {
    ++value;
    return *this;
}

/* 后置++ */
Integer operator++(int) {
    Integer tmp = *this;
    ++value;
    return tmp;
}

什么是左值引用?什么是右值引用?

概念

左值引用

左值引用:左值引用是指向左值的引用。左值引用只能用单个符号&表示。常量左值引用可以绑定左值,也可以绑定右值,非常量左值引用只能绑定左值.

这样说起来有点绕,我们来看一个例子

int &x1 = 1; // compile err
const int &x2 = 11; // compile succ

编译错误:

error: cannot bind non-const lvalue reference of type ‘int&’ to an rvalue of type ‘int’

29 | int &x1 = 1; // compile err

int &x1 是非常量左值引用,1是右值,非常量左值引用只能绑定左值。1是右值,所以此时报错

const int &x2 是常量左值引用,可以绑定左值和右值。

那么这个特性有什么作用呢?在函数形参列表中具有重大意义,比如在复制构造函数和复制运算符函数中,通常情况下我们实现这两个函数的形参都是一个常量左值引用。

考虑以下代码:

class X {
public:
    X() {}
    X(const X&) {}
    X& operator = (const X&) { return *this; }
};

X make_x()
{
    return X();
}

int main()
{
    X x1;
    X x2(x1);
    X x3(make_x());
    x3 = make_x();
}

以上代码可以编译通过,但是让我们测试一下,

如果去除复制构造函数中的const,则 X x3(make_x()); 和 x3 = make_x(); 会报错,因为非常量左值引用无法绑定到X make_x() 产生的右值。

但是它也存在一个很大的缺点——常量性。一旦使用了常量左值引用,就表示我们无法在函数内修改该对象的内容(强制类型转换 除外)。所以需要另外一个特性来帮助我们完成这项工作,它就是右值引用

右值引用

右值引用:右值引用是指向右值的引用。右值引用使用两个符号&&表示,即&&。右值引用的主要作用是支持移动语义完美转发。通过移动语义,我们可以避免不必要的操作,提高性能。

如下示例代码:

int main() {
    int i = 0;
    int &j = i; /* lvalue ref */
    int &&k = 10; /* rvalue ref */
}

这里的k就是一个右值引用,如果k去引用i的话会引起编译错误。

右值引用的特点是可以延长右值的生命周期,看如下示例

#include <iostream>

class X {
public:
    X() { std::cout << "X ctor" << std::endl; }
    X(const X&x) { std::cout << "X copy ctor" << std::endl; }
    ~X() { std::cout << "X dtor" << std::endl; }
    void show() { std::cout << "show X" << std::endl; }
};

X make_x()
{
    X x1;
    return x1;
}

int main()
{
    int i = 0;
    int &j = i; /* lvalue ref */
    int &&k = 10; /* rvalue ref */
    X &&x2 = make_x();
    x2.show();
}

编译:

g++ rvalue_ref.cpp -g -fno-elide-constructors

  • -fno-elide-constructors: 关闭函数返回值优化(RVO),rvo优化会减少复制构造函数的调用。

测试结果:

X ctor

X copy ctor

X dtor

show X

X dtor

如果将X &&x2 = make_x()这句代码替换为X x2 = make_x()会发生几次构造。在 没有进行任何优化的情况下应该是3次构造,首先make_x函数中x1会 默认构造一次,然后return x1会使用复制构造产生临时对象,接着 X x2 = make_x()会使用复制构造将临时对象复制到x2,最后临时对象被销毁。

而如果使用了右值引用,则最后“make_x()会使用复制构造” 这一步可以省略,只会发生两次构造。可以是减少对象复制,提升程序性能。

右值引用的优化以及移动语义

我们知道右值在很多情况下都是一个临时对象,即“将亡值”。当右值被使用过后程序会马上销毁对象并释放内存,这可能的导致性能问题(即频繁地开辟和释放内存)。

右值引用的性能优化空间

让我们来看下面的代码:

#include <iostream>
#include <cstring>

class BigMemoryPool
{
public:
    static const int PoolSize = 4096;
    BigMemoryPool() : pool_(new char[PoolSize]) {}
    ~BigMemoryPool()
    {
        if (pool_ != nullptr)
        {
            delete[] pool_;
        }
    }

    BigMemoryPool(const BigMemoryPool &other) : pool_(new char[PoolSize])
    {
        static int cnt = 1;
        std::cout << "copy big memory pool. cnt is " << cnt++ << std::endl;
        memcpy(pool_, other.pool_, PoolSize);
    }

private:
    char *pool_;
};

BigMemoryPool get_pool(const BigMemoryPool &pool)
{
    return pool;
}

BigMemoryPool make_pool()
{
    BigMemoryPool pool;
    return get_pool(pool);
}

int main()
{
    BigMemoryPool pool = make_pool();
}

以上代码的主要作用是构造内存池,那么在使用C11标准且不开启ROV的情况下,上述代码进行了几次copy?

关键步骤分析

  1. make_pool() 函数内
    1. 创建局部对象 pool(调用默认构造函数,无复制)。
    2. 调用 get_pool(pool),参数为 const BigMemoryPool&(引用传递,无复制)。
  2. get_pool() 函数内
    1. 返回 pool 时,由于函数返回类型是 BigMemoryPool(值返回),需要从引用生成临时对象。此处触发 1 次复制构造函数(输出 cnt=1)。
  3. make_pool() 的返回值
    1. return get_pool(pool) 时,若编译器启用返回值优化(RVO),会直接将 get_pool 的返回值构造到 make_pool 的返回位置,避免额外复制。若未开启ROV,则此处触发 1 次复制构造函数(输出 cnt=2)。
  4. main() 中初始化 pool
    1. BigMemoryPool pool = make_pool(); 触发 C++17 的强制复制省略(Mandatory Copy Elision),无额外复制。若使用C11标准,此处触发 1 次复制构造函数(输出 cnt=3)。

测试结果

编译:g++ MempoolMultiCopy.cpp -g -fno-elide-constructors -std=c++11

copy big memory pool. cnt is 1

copy big memory pool. cnt is 2

copy big memory pool. cnt is 3

移动语义和完美转发

如果我们能够消除复制临时对象带来的性能开销,岂不美哉?这就是移动语义

让我们修改BigMemoryPool类:

    BigMemoryPool(BigMemoryPool &&other)
    {
        std::cout << "move big memory pool" << std::endl;
        pool_ = other.pool_;
        other.pool_ = nullptr;
    }

我们新增了一个构造函数,它的形参是一个右值引用类型,称为移动构造函数

简单来说,移动语义是指针的替换

编译及输出这段代码,结果如下:

可以看到后两次的构造函数变成了移动构造函数,因为这两次操作中源对象都是右值,对于右值编译器会优先选用移动构造函数去构造对象。

除了移动构造函数外,移动复制运算符也能完成移动的操作。

继续在类中新增移动赋值运算符函数:

    BigMemoryPool &operator=(BigMemoryPool &&other)
    {
        std::cout << "move operator(=) big memory pool." << std::endl;
        if (this != &other)
        {
            if (pool_ != nullptr)
            {
                delete[] pool_;
            }
            pool_ = other.pool_;
            other.pool_ = nullptr;
        }
        return *this;
    }
    ...
    
int main()
{
    BigMemoryPool my_pool;
    my_pool = make_pool();
}

这段代码编译运行的结果是:

可以看到编译器对源对象是右值的情况会优先调用移动赋值运算符函数,如果该函数不存在,则调用复制赋值运算符函数。

将左值转化为右值

因为右值引用只能绑定一个右值,如果尝试绑定,左值会导致编译错误。

int i = 0;
int &&k = i; //compile fail

但是在C11标准后可以在不创建临时值的情况下显示地将左值通过<static_cast>转化为将亡值。

由于将亡值属于右值,所以可以被右值绑定。

int i = 0;
int &&k = static_cast<int&&> i;

这存在的意义是什么呢?

正确的使用场景是在一个右值被转化为左值后需要再次转化为右值

最典型的例子是一个右值作为实参传入到函数中,因为无论函数的实参是左值还是右值,函数的形参都是左值。即使这个形参看上去是一个右值引用,如:

void move_pool(BigMemoryPool &&pool)
{
    std::cout << "call move_pool" << std::endl;
    BigMemoryPool my_pool(pool);
}

int main()
{
    move_pool(make_pool());
}

编译运行如下:

同时,为了让my_pool调用移动构造函数进行构造,需要将形参pool转为右值。

void move_pool(BigMemoryPool &&pool)
{
    std::cout << "call move_pool" << std::endl;
    BigMemoryPool my_pool(static_cast<BigMemoryPool&&>(pool));
}

在cpp的标准库中提供了函数模版std::move帮助我们将左值转化为右值。这个函数内部也是用static_cast做转化,只是由于它是用模版实现的,可以自动推导类型。所以以后使用std::move而不是static_cast.

void move_pool(BigMemoryPool &&pool)
{
    std::cout << "call move_pool" << std::endl;
    BigMemoryPool my_pool(std::move(pool));
}

万能引用和折叠引用

我们之前提到过常量左值引用既可以引用左值又可以引用右值,是一个几乎万能的引用,但是可惜的是由于其常量属性,导致使用范围受限。其实在C++11标准中提供了一个”万能”引用。

void foo(int &&i) {} //i是右值引用

template<class T>
void bar(T &&t) {} //t是万能引用

int get_val()
{
    return 1;
}

int main()
{
    int &&x = get_val(); //右值引用
    auto &&y = get_val(); //万能引用
}

万能引用实际上是发生了类型推导,如果源对象是左值,则会推导出左值引用,如果源对象是右值,则会推导出右值引用。

万能引用能够灵活地应用对象,是因为有引用折叠。

万能引用的形式必须是T&&或者auto&&,也就是说它们必须在初始化的时候被直接推导出来,如果在推导中出现中间过程,则不是一个万能引用。如:

void foo(std::vector &&t) {}
int main()
{    
    std::vector v{ 1,2,3 };
    foo(v);                                // 编译错误  
}

因为foo的形参(t)并不是一个万能引用,而是一个右值引用。因为foo的形参类型是std::vector<T>&& 而不是 T&&.

完美转发

万能引用的用途是搭配完美转发

在介绍完美转发之前,我们先看一个常规的转发函数模版。

#include <iostream>
#include <string>

template<class T>
void show_type(T t)
{
    std::cout << typeid(t).name() << std::endl;
}

template<class T>
void normal_forwarding(T t)
{
    show_type(t);
}

template<class T>
void ref_forwarding(T &t)
{
    show_type(t);
}

template<class T>
void const_ref_forwarding(const T &t)
{
    show_type(t);
}

template<class T>
void perfect_forwarding(T &&t)
{
    show_type(std::forward<T>(t));
}

std::string get_string()
{
    return "hello";
}

int main()
{
    std::string s = "hello world";
    normal_forwarding(s);
    perfect_forwarding(s);
    //normal_forwarding(get_string());
}

以上normal_forwarding是一个常规的转发模版函数,实际上进行的是值的传递,效率低下。那么我们可以怎么做去提高效率?一种方法是把他改成引用传递,如ref_forwarding, 但是这样修改的问题在于,如果传入的是一个右值,会有编译错误。

但是没关系,我们之前有说过,常量左值引用,既可以绑定左值,又可以绑定右值。所以我们可以使用常量左值引用去引用右值。但是这也有问题,那就是虽然它可以“完美”地转发字符串,但是如果在后续的函数中需要修改字符串,则会编译错误。

那么万能引用就能解决上述的问题。perfect_forwarding.万能引用能够根据值的类型自动推导形参,如果实参是左值,则形参被推导为左值引用,如果实参是右值,则形参被推导为右值引用。

std::forward<T>(t) 的本质也是static_cast,只是和std::move的方式一样隐藏了细节。

std::move 和 std::forward的区别

std::move 一定会将实参转化为一个右值引用,并且使用std::move不需要指定模版实参,模版实参是推导出来的。

std::forward 会根据左值和右值的实际情况进行转发,在使用的时候需要指定模版参数。

Question

  1. ++x返回的是左值还是右值?x++呢?字符串字面量呢?

先说结论:++x返回的是左值,x++返回的是右值。字符串字面量通常是右值,除了字符串字面量之外。

++x是对x自增后马上返回其自身,而x++是先生成x的临时复制然后才对x递增,最后返回临时复制内容。

字符串字面量通常是右值,除了字符串字面量之外:

以下代码可以被编译成功。

auto p = &”this is a lvalue”;

让我们想想这是为什么?因为编译器会将字符串字面量存储到程序的数据段中,程序加载 的时候也会为其开辟内存空间,所以我们可以使用取地址符&来获取 字符串字面量的内存地址。

  1. 什么是左值引用,什么是右值引用?

先说结论:左值引用:左值引用是指向左值的引用。左值引用只能用单个符号&表示。常量左值引用可以绑定左值,也可以绑定右值,非常量左值引用只能绑定左值.

右值引用:右值引用:右值引用是指向右值的引用。右值引用使用两个符号&&表示,即&&。右值引用的主要作用是支持移动语义完美转发。通过移动语义,我们可以避免不必要的操作,提高性能。

  1. 右值引用如何提高性能?

先说结论:右值引用通过避免不必要的拷贝来提升性能。

举两个例子:

  1. 我们可以使用右值引用来接收一个函数返回的临时对象,这时候局部变量的声明期会被延长,右值引用会直接使用局部对象的内存,避免了使用这个局部变量创建一个临时对象所带来的不必要的开销。
  2. 在使用一个临时对象构建一个对象时,我们可以通过移动构造函数,使我们在构造新对象时,将一个资源从一个临时对象(右值)”移动”到新对象,而不是创建新对象的拷贝。这样可以避免不必要的拷贝操作。
  1. 介绍一下RVO?

RVO(Return Value Optimization)是一种编译器优化技术,用于消除不必要的临时对象拷贝,从而提高代码的性能。

RVO的基本思想是:在函数调用栈上直接构造返回值,而不是先构造一个局部对象,然后再拷贝到调用者的栈空间。这样可以减少临时对象的创建和销毁。

  1. 移动构造函数可能有什么问题?

在移动构造函数中,如果一个对象的资源移动到另一个对象时发生了异常,也就是说对象的一部分转义了但是另一部分没有,则会有移动不完整的现象发生,这种是未定义行为。所以编写移动构造函数时要保证不抛出异常。

参考资料

  1. 现代C++语言核心特性解析
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇