RValue右值引用详解

RValue 右值引用详解

1. 右值Rvalue简介

在标准C++03中只定义了左值(lvaue)和右值(rvalue)两种表达式(expression)分类。

lvalue是一个表达式e出现在赋值的左侧或者右侧
rvalue是一个表达式只能出现在赋值的右侧

int a = 42;    // 表达式42, 1024是右值
int b = 1024;

a = b;         // 表达式 a, b 均为左值
b = a;
a = a * b;

int c = a * b; // 表达式 a * b 为右值
a * b = 2;     // 错误!右值不能出现在赋值左侧

另一种不太严格的理解是

lvalue表示了一块内存地址,可以用&对其取地址
rvalue不能用&取地址

int i = 42;   // i是左值
int *p = &i;

int& foo();   // foo()是左值
foo() = 42;
int* p_foo = &foo();

int bar();
int j = bar();        // bar() 是右值
int* p_bar = &bar();  // 错误!右值无法取地址

而在C++11标准中则扩展为了5中表达式类型,即lvalue, xvalue, glvalue, rvalue以及prvalue。当然作为一个正常人类,可以安全的忽略下面的定义。

他们的关系总结如下图。

expression_tree
逐个解释如下:
lvalue: 左值,出现在赋值表达式左边的值。
xvalue(eXpiring value): 在接近生命周期结束的对象。例如一个返回右值引用的函数的返回值,比如std::move(x)返回值,数组的元素a[n],非静态类成员a.m等;
glvalue(generalized lvalue): 是一个lvalue或者xvalue;
rvalue: 出现在赋值表达式右边的对象,是一个xvalue临时变量,或者一个没有关联对象的值,比如常数42或者上面的bar()函数返回值,a * b返回值等。
prvalue: 是一个rvalue,但是不是xvalue。比如常数42,或者上面的bar()函数返回值。

2. 右值引用

右值引用解决了两个问题

  1. 移动语义(move semantics)
  2. 完美转发(perfect forwarding)

本文主要讨论移动语义的内容,下一篇文章中将对完美转发进行讨论。

2.1 左值的困难

看一个左值赋值会遇到的困难:

void lvalue() {
    T s = s1 + s2;
    std::cout << s << std::endl;
}

在上面的操作中

  1. 创建一个临时变量来存储s1+s2的结果
  2. 把临时变量的值赋值到s
  3. 析构s变量
  4. 析构这个临时变量

可以看到s1+s2的结果使用了一个临时变量,并进行了一次赋值。
而实际功能只是把临时变量输出,并没有后续的继续计算。
能不能直接操作s1+s2的这个临时变量即这个右值呢?

注意上面的操作分析是启用g++的-fno-elide-constructors标志下分析的,否则编译器会直接进行RVO(Return Value Optimization)优化,来避免构造析构和复制操作。

2.2 右值引用的引入

引入右值引用来困难的解决:

class T;
T& ;  // 表示类型T的左值引用
T&&;  // 表示类型T的右值引用(c++11)

void foo(T& value);        // 接受左值引用
void foo(T const& value);  // 接收const左值引用
void foo(T&& value);       // 接收右值引用

具体的函数调用那个版本的赋值算子根据下面规则来决定:

  • 若存在void foo(T&),不存在 void foo(T&&),则foo()只能接受左值作为参数。
  • 若存在void foo(T const&),不存在 void foo(T&&),则foo()能接受左值或者右值。
  • 若存在 void foo(T&&),不存在任何左值版本,则foo()只能接受右值。
T x;        // x 是左值
T foobar(); // foobar() 是右值

foo(x);         // 调用const左值版本
foo(foobar());  // 调用右值版本

引入右值引用类型可以让编译器在编译时期就决定使用那个版本的函数。
采用右值引用版本的函数可以从右值对象中窃取(Steal)资源,而避免赋值资源。

注意,当右值传入接收右值的函数后,该函数退出时右值引用对象生命结束,函数会析构传入的对象

void rvalue() {
    T&& s_rref = s1 + s2;
    std::cout << s_rref << std::endl;
}

通过这样的语法,即可以表达为s_rref为右值s1+s2的引用,即临时变量的引用,避免了显示声明为左值来存储结果的代价。

2.3 移动语义

c++11中采用函数std::move()来强制转换左值引用为右值引用。

例如

void foo(T&& value) {
    T lcopy(value);            // value是左值,调用左值复制构造函数 T(T const&)
    T rcopy(std::move(value)); // std::move(value)是右值,调用右值构造函数 T(T&&)
}

在例如,使用右值版的交换函数swap函数

template<typename T>
void swap(T& lhs, T& rhs) {
    T tmp(std::move(lhs));
    lhs = std::move(rhs);
    rhs = std::move(tmp);
}

从原理上,g++9版本的std::move()实现在include/c++/9/bits/move.h

/**
 *  @brief  Convert a value to an rvalue.
 *  @param  __t  A thing of arbitrary type.
 *  @return The parameter cast to an rvalue-reference to allow moving it.
*/
template<typename _Tp>
    constexpr typename std::remove_reference<_Tp>::type&&
    move(_Tp&& __t) noexcept
    { return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }

所以std::move单纯的是一种静态类型转换,并不是真实物理内存地址上的移动操作。类型转换只是告诉编译器,这个左值在后面的操作中可以不再使用,请用为右值。

这样做的优点有

  1. 使用右值提升性能,减少复制。在标准库中使用右值大幅提升了STL性能。如std::sort()算法。
  2. 表示所有权的转移。有些类型只能有移动语义,而不能有复制语义。比如std::unique_ptr

注意,声明为右值引用的表达式可以是左值也可以是右值。判断的依据是,如果表达式有名字(if-it-has-a-name rule),则为左值,否则为右值

从这个简单的盘踞角度看。std::move()的返回值没有名字,因此把左值转换为了右值。

特别的,在继承类的右值复制构造函数中,也需要用std::move调用右值版的基类复制构造函数。

class Derived: public Base {
public:
    Derived(Dervied&& rhs) : Base(std::move(rhs)) {
        /* constructor details */ 
    }
};

2.4 函数返回值优化

实现一个函数需要返回值,可以返回右值吗?答案是不需要。因为编译器会进行RVO(return value optimization)

T foo() {
    T value;
    return value;  // 不要返回std::move(value)!
}

编译器会在需要位置直接构造并且放置函数返回值,这比move好的多。因此不需要返回一个右值。

3 应用算例

那么,自定义类型类型T应该如何定义,才能带来这样的便利呢?

来看一个具体的例子。现在定义一个数组类RArray来管理一个大量数据的资源。

3.1 RArray类型定义

static std::size_t g_id = 0;
template<typename T>
struct RArray {
    RArray()
        : data_(nullptr)
        , size_(0)
        , id_(g_id++)
    { 
        fmt::print("Construct Empty RArray id = {}\n", id_); 
    }

    RArray(RArray const& data)
        : data_(new T[data.size_])
        , size_(data.size_)
        , id_(g_id++)
    { 
        fmt::print("Construct RArray from lvalue id = {}\n", id_); 
        std::copy(data.data_, data.data_ + size_, data_);
    }

    RArray(RArray&& data) noexcept
        : data_(data.data_)
        , size_(data.size_)
        , id_(g_id++)
    { 
        fmt::print("Construct RArray from rvalue id = {}\n", id_); 
        data.data_ = nullptr;
        data.size_ = 0;
    }

    RArray(const T* array) 
        : id_(g_id++)
    {
        fmt::print("Construct RArray from array id = {}\n", id_); 
        size_ = std::strlen(array);
        data_ = new T[size_];
        std::copy(array, array + size_, data_);
    }

    ~RArray() { 
        if(data_ != nullptr) {
            fmt::print("Destruct Full RArray id = {}\n", id_); 
            delete [] data_;
            data_ = nullptr;
            size_ = 0;
        } else {
            fmt::print("Destruct Empty RArray id = {}\n", id_); 
        }
    }
    
    RArray& operator=(RArray const& rhs) {
        fmt::print("Copy RArray from lvalue\n"); 
        if (this == &rhs) 
            return *this;
        if(data_ != nullptr) {
            delete [] data_;
        }
        size_ = rhs.size_;
        data_ = new T[size_];
        std::copy(rhs.data_, rhs.data_ + size_, data_);
        return *this;
    }

    RArray& operator=(RArray&& rhs) noexcept {
        fmt::print("Copy RArray from rvalue\n"); 
        if (this == &rhs) 
            return *this;
        data_ = rhs.data_;
        size_ = rhs.size_;
        rhs.data_ = nullptr;
        rhs.size_ = 0;
        return *this;
    }

    RArray operator+(RArray const& rhs) {
        fmt::print("call operator+()\n"); 
        RArray sum;
        sum.size_ = size_ + rhs.size_;
        sum.data_ = new T[sum.size_];
        std::copy(    data_,     data_ +     size_, sum.data_        );
        std::copy(rhs.data_, rhs.data_ + rhs.size_, sum.data_ + size_);
        return sum;
    }

    RArray& operator+=(RArray const& rhs) {
        fmt::print("call operator+=()\n"); 
        T* p_sum = new T[size_ + rhs.size_];
        std::copy(    data_,     data_ +     size_, p_sum);
        std::copy(rhs.data_, rhs.data_ + rhs.size_, p_sum + size_);
        if(data_ != nullptr) {
            delete [] data_;
        }
        data_ = p_sum;
        size_ += rhs.size_;
        return *this;
    }

    friend std::ostream& operator<<(std::ostream& os, RArray const& array) {
        for(std::size_t i = 0; i < array.size_; ++i) {
            os << array.data_[i];
        }
        return os;
    }
    
    void fill(T const& value) {
        std::fill(data_, data_ + size_, value);
    }

    T* data_;
    std::size_t size_;
    std::size_t id_;
};

using RString = RArray<char>;
  1. 移动语义当然可以在任意位置使用,但是主要作用发挥在赋值算子中,即定义两个版本的
T& T::operator=(T const& rhs);
T& T::operator=(T&& rhs);

其中右值引用版本直接偷窃了把rhs的数据指针给了this,并析构了rhs对象。

  1. 接受右值的构造函数和赋值函数最好声明为noexcept,否则可能会因为签名类型不匹配而映射到lvalue版函数。

3.2 左值和右值调用对比

下面是测试用例以及对应的输出结果:

void copy_constructor_lvalue() {
    fmt::print("copy by lvalue\n");
    RString c("3.1415926");
    /*
    Construct RArray from array id = 3
    */
    RString s(c);
    /*
    Construct RArray from lvalue id = 4
    Destruct Full RArray id = 4
    Destruct Full RArray id = 3  // note full data is destructed
    */
}
void copy_constructor_rvalue() {
    fmt::print("copy by rvalue\n");
    RString c("3.1415926");
    /*
    Construct RArray from array id = 6
    */
    RString s(std::move(c));
    /*
    Construct RArray from array id = 5
    Construct RArray from rvalue id = 6
    Destruct Full RArray id = 6
    Destruct Empty RArray id = 5  // note empty data is destructed, copy is avoid
    */
}
void string_toy_lvalue() {
    fmt::print("Perform +\n");
    RString s = s1 + s2;                // the result of s1 + s2 is an RString object
    /*
    Construct Empty RArray id = 7       // variable `sum` inside operator+() function
    Construct RArray from rvalue id = 8 // temporary varible, return of operator+()
    Destruct Empty RArray id = 7        // out of operator+() scope, destruct `sum`
    Construct RArray from rvalue id = 9 // construct `s`
    Destruct Empty RArray id = 8        // destruct temporary variable
    */
    fmt::print("Perform +=\n");
    s += s3;                            // change the string
    fmt::print("print\n");
    std::cout << s << std::endl;
    /*
    Hello world, my friend
    */
}

void string_toy_rvalue() {
    fmt::print("Perform +\n");
    RString&& s_rref = s1 + s2;          // the result of s1 + s2 is an rvalue
    /*
    Construct Empty RArray id = 10       // variable `sum` inside inside operator+() function
    Construct RArray from rvalue id = 11 // for temporary varible, return of operator+()
    Destruct Empty RArray id = 10        // out of operator+() scope, destruct `sum`
    */
    fmt::print("Perform +=\n");
    s_rref += s3;                         // change the temporary string
    fmt::print("print\n");
    std::cout << s_rref << std::endl;
    /*
    Hello world, my friend
    */
}

4. 性能测试

我们测试相同函数功能分为左值和右值版本的性能表现。

4.1 测试程序

std::vector<RString> g_data;

void use_lvalue(std::size_t n_loop, std::size_t len) {
    for(std::size_t i = 0; i < n_loop; ++i){
        RString big(len, 'W');
        g_data.push_back(big);
    }
}
void use_rvalue(std::size_t n_loop, std::size_t len) {
    for(std::size_t i = 0; i < n_loop; ++i){
        RString big(len, 'W');
        g_data.push_back(std::move(big));
    }
}

驱动程序如下


int main() {

    std::size_t len = 4 * MB;
    std::size_t repeat = 1 * (1<<10);

    for(int i = 0; i < 10; ++i) {
        fmt::print("Loop = {}\n", i);
        g_data.clear();
        g_data.reserve(4);
        run_with_cost_print(use_lvalue, "use_lvalue", repeat, len) ;

        g_data.clear();
        g_data.reserve(4);
        run_with_cost_print(use_rvalue, "use_rvalue", repeat, len) ;
    }

    fmt::print("Done\n");

    return 0;
}

编译程序命令:

g++ -O3 rvalue_perf.cpp -lfmt -o rvalue_perf && ./rvalue_perf 

4.2 实验方法

衡量表现时应该注意考虑:

  1. 应注意回复g_data的初始状态,保证插入数组的状态相同;
  2. 剔除开始冷数据的影响,剔除前几次循环的数据,取后面表现稳定的数据;
  3. 原始RArray中的fmt::print()输出需要关闭。

4.3 实验结果

实验运行的环境为
CPU: Intel® Core™ i9-10900K CPU @ 3.70GHz
Memory: 64GB
OS: Ubuntu 20.04 x64
CC: g++ (Ubuntu 9.3.0-17ubuntu1~20.04)

首先研究随着插入个数的增加,cost的效果。
将不同的元素大小固定,变化插入个数,

为了进一步量化cost和插入元素个数的关系,两边取对数。
loglog_len_lcost
loglog_len_rcost
lvalue的结构,cost对len回归的结果是:

size/bytebar2
1-4.0357141.0190490.999922
4-3.9454611.0134140.999639
8-3.9759151.0155630.999694
64-4.3046581.0564770.999251
1024-3.7327661.1275110.998782
4096-2.8383511.1687690.998362

rvalue下cost对len回归的结果是:

size/bytebar2
1-5.1597921.0589860.999505
4-4.6140661.0147150.996637
8-5.0146561.0452990.999565
64-5.6160821.1250620.998748
1024-4.1206651.1454140.998827
4096-3.7171471.2137810.995649

综合来看回归斜率接近于1,解释力度 R 2 R^2 R2均高于99%。可以验证不论左值还是右值,耗时均是插入个数的现行函数。
随着元素大小的上升,斜率逐渐升高,可解释力度变小。

接下来,对于固定插入个数len = 32768,来研究插入元素的大小size对cost的影响
loglog_size_cost
最后,来看一下对插入不同size的元素下的左值和右值的加速比。
size_acc_ratio

4.4 结果分析

  1. 随着插入个数的增加,可以认为总成本cost是插入个数len的线性函数;
  2. 插入的元素越大,插入需要的时间越长。
  3. 插入大元素的len对耗时的可解释力度变低,说明有其他因素的影响。猜测是vector的growth效用。
  4. 插入的元素32B以内,加速效果明显,大约2倍;
  5. 插入元素大于1MB后,加速效果大幅降低,大约1.1倍。

这种提升下降是因为申请内存成本已经远高于了移动内存带来的效益,内存分配作为时间消耗的主导因素,此时左值和右值的效果逐渐接近,最终稳定在10%的提升,这部分就是纯移动内存的时间。

5. 总结讨论

主要回顾了右值引用引入的场景。右值的定义,使用方法,移动语义等相关概念。

给出了一个具体的算例,并进行了性能测试。
下一步工作可以进一步分解cost的总用时,分为分配时间和移动时间来单独观察,应该能看到使用右值的贡献在移动时间上体现出来。

6. 参考文献

  1. https://stackoverflow.com/questions/3601602/what-are-rvalues-lvalues-xvalues-glvalues-and-prvalues
  2. http://thbecker.net/articles/rvalue_references/section_01.html
  3. https://www.internalpointers.com/post/c-rvalue-references-and-move-semantics-beginners
  4. https://gcc.gnu.org/gcc-9/
  5. https://en.cppreference.com/w/cpp/language/value_category
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值