C++学习笔记:右值引用,移动和转发

右值引用是一种新的C++语法,是C++11标准对C++语言增添的新特性,基于右值引用引申出的2种C++编程技巧,分别为移动语义和完美转发。

  • 「移动语义」:将内存的所有权从一个对象转移到另外一个对象,高效的移动用来替换效率低下的复制,对象的移动语义需要实现移动构造函数(move constructor)和移动赋值运算符(move assignment operator)。

  • 「完美转发」:定义一个函数模板,该函数模板可以接收任意类型参数,然后将参数转发给其它目标函数,且保证目标函数接受的参数其类型与传递给模板函数的类型相同。

右值引用

右值引用可以从字面意思上理解,指的是以引用传递(而非值传递)的方式使用C++右值。关于引用,上一章节我们已经讲过。

左值的英文简写为“lvalue”,右值的英文简写为“rvalue”。很多人认为它们分别是"left value"、“right value” 的缩写,其实不然。lvalue 是“loactor value”的缩写,可意为存储在内存中、有明确存储地址(可寻址)的数据,而 rvalue 译为 “read value”,指的是那些可以提供数据值的数据(不一定可以寻址,例如存储于寄存器中的数据)。

我们知道,右值往往是没有名称的,想要使用它只能借助引用的方式。这就产生一个问题,实际开发中我们可能需要对右值进行修改(实现移动语义时就需要),显然左值引用的方式是行不通的(常量左值引用可以接收右值,但无法修改)。

右值引用的基本语法type &&引用名 = 右值表达式;右值引用的“&&”中间不可以有空格。右值引用一定不能被左值所初始化,只能用右值初始化:

int x = 20;    // 左值
int&& rrx1 = x;   // 非法:右值引用无法被左值初始化
const int&& rrx2 = x;  // 非法:右值引用无法被左值初始化
int&& a = 10;  // 合法

右值引用还可以对右值进行修改。例如:

int&& a = 10;
a = 100;
cout << a << endl;  // output 100

另外值得一提的是,C++ 语法上是支持定义常量右值引用的,例如:

const int&& a = 10;//编译器不会报错

但这种定义出来的右值引用并无实际用处。一方面,右值引用主要用于移动语义和完美转发,其中前者需要有修改右值的权限;其次,常量右值引用的作用就是引用一个不可修改的右值,这项工作完全可以交给常量左值引用完成。

右值引用还可以用于函数参数:

// 接收左值
void fun(int& lref) {
    cout << "l-value reference\n";
}
// 接收右值
void fun(int&& rref) {
    cout << "r-value reference\n";
}

int main() {
    int x = 10;
    fun(x);   // output: l-value reference
    fun(10);  // output: r-value reference
}

可以看到,函数参数要区分开右值引用与左值引用,这是两个不同的重载版本。还有,如果定义了下面的函数:

void fun(const int& clref) {
    cout << "l-value const reference\n";
}

其实它不仅可以接收左值,而且可以接收右值。

移动语义

「移动语义的“移动”,意味着把某对象持有的资源或内容转移给另一个对象。」 在转移资源后,被移动的对象处于“有效但未定义的状态”(valid but unspecified state)。因此,如果要移动一个左值对象,通常在它生命周期结束时移动,因为之后不会再有代码操作它;或者把它设为一个周知的状态,比如一个vector,给它赋值,或者调用vector的clear()成员函数,以明确其状态。

移动语义需要通过std::move方法实现,std::move就是无条件地把它的参数转换成一个右值,实际上,std::move并不能移动任何东西,它唯一的功能是将一个左值强制转化为右值引用,继而可以通过右值引用使用该值,以用于移动语义。从实现上讲,std::move基本等同于一个类型转换:static_cast(lvalue);

  1. C++标准库使用比如vector::push_back 等这类函数时,会对参数的对象进行复制,连数据也会复制.这就会造成对象内存的额外创建, 本来原意是想把参数push_back进去就行了,通过std::move,可以避免不必要的拷贝操作。

  2. std::move是将对象的状态或者所有权从一个对象转移到另一个对象,只是转移,没有内存的搬迁或者内存拷贝所以可以提高利用效率,改善性能。

  3. 对指针类型的标准库对象并不需要这么做。

我们看下下面的关于std::move的实现代码:

template 
typename remove_reference::type&& move(T&& t)
{
    return static_cast::type&&>(t);
}

std::move实现,首先,通过右值引用传递模板实现,利用引用折叠原理将右值经过T&&传递类型保持不变还是右值,而左值经过T&&变为普通的左值引用,以保证模板可以传递任意实参,且保持类型不变。然后我们通过static_cast<>进行强制类型转换返回T&&右值引用,而static_cast之所以能使用类型转换,是通过remove_refrence::type模板移除T&&,T&的引用,获取具体类型T。关于引用折叠如下:

公式一)T& &、T&& &、T& &&都折叠成T&,用于处理左值

公式二)T&& &&折叠为T&&,用于处理右值

「实现移动语义」

我们下面简单实现一个移动语义以便更了解它:

class my_vector
{
    int* data_;
    size_t size_;
    size_t capacity_;

public:
    // 复制构造函数
    my_vector(const my_vector& oth) :
        size_(oth.size_), 
        capacity_(oth.capacity_)
    {
        data_ = static_cast(malloc(sizeof(int) * size_));
        for (size_t i = 0; i < size_; ++i) {
            data_[i] = oth.data_[i];
        }
    }

    // 移动构造函数
    // std::exchange(obj, new_val)的作用是把返回obj的旧值,并把new_val赋值给obj
    my_vector(my_vector&& oth) :
        data_(std::exchange(oth.data_, nullptr)),
        size_(std::exchange(oth.size_, 0)), 
        capacity_(std::exchange(oth.capacity_, 0))
    {}    
};

上面这段代码应该很好理解,复制构造函数就是简单分配一块新内存,然后就是粗暴的拷贝;而移动构造函数相对就复杂很多了,std::exchange()的作用就是把返回obj的值,然后把new_value值赋给obj,意思简单,就是把obj值替换掉,然后返回obj原值。切记,移动后一定需要将原对象致’空’,否则后面两个对象同时操作同一块内存,造成内存异常。

template< class T, class U = T >
T exchange( T& obj, U&& new_value );

「右值引用是C++语法层面表示移动语义的机制」。以上的例子说明,为了利用右值,在常见的左值引用外,C++标准又定义了右值引用,不经过转型时,只有右值能被绑定到右值引用而std::move()可以把任意类型转为右值引用;然后基于参数类型不同进行函数重载的机制,程序员就可以针对右值引用编写重载函数,从而达到利用右值的目的。以下通过代码继续讲解这一点:

class Foo
{
public:
    std::string member;
    // 按值传递
    Foo(std:;string m): member(m) {}
    // Copy member-按常量左值引用传递.
    Foo(const std::string& m): member(m) {}
    // Move member-按右值引用传递.
    Foo(std::string&& m): member(std::move(m)) {}
};

看到上面Foo类构造函数的三个重载,可以更清楚地知道,右值引用也是一个类型!根据参数类型不同,函数的行为可以有所差别;而当参数类型为右值引用时,函数移动它的资源,即为“移动语义”。

左值与右值是C++中表达式的属性,在C++11中,每个表达式有两个属性:类型(type,除去引用特性,用于类型检查)和值类型(value category,用于语法检查,比如一个表达式结果是否能被赋值)。值类型中就有2个基本类型:lvaluervalue

「++i是左值,i++是右值」。前者,对i加1后再赋给i,最终的返回值就是i,所以,++i的结果是具名的,名字就是i;而对于i++而言,是先对i进行一次拷贝,将得到的副本作为返回结果,然后再对i加1,由于i++的结果是对i加1前i的一份拷贝,所以它是不具名的。这样,我们再来思考一下上面的代码段:

  • 为什么参数为右值时,还要再std::move()一回?如上文所言,区分左右值的标准之一就是变量是否有名字。m虽然是个右值引用(type),但它有名字,因此仍然是左值(value category);如果要移动它,就得调用std::move()把它转换为右值引用。

  • 可以写const T&&吗?这个引用类型确实是合乎语法的,但是语义上存在矛盾:移动会修改对象,const修饰符又禁止了修改。因此,如果真的传入一个const T&&add(T&& thing),从而失去移动语义。这里可以看到,右值引用是可以绑定到左值引用的。对于已有复制构造函数,但没有重载移动构造函数的类,传递右值引用作为参数会回退为调用复制构造函数(const T& 可以接收右值引用的)。

「总结」

对象的移动语义的实现是依靠移动构造函数和移动赋值操作符。但是前提是传入参数必须是右值引用,但是有时候需要将一个左值也进行移动语义(因为你已经知道这个左值后面不再使用),那么就必须提供一个机制来将左值转化为右值。在C++中,std::move就是专为此而生:

vector v1{1, 2, 3, 4};
vector v2 = v1;             // 此时调用复制构造函数,v2是v1的副本
vector v3 = std::move(v1);  // 此时调用移动构造函数,v3与v1交换:v1为空,v3为{1, 2, 3, 4}

可以说,移动语义是为提高性能的一种手段,而std::move是实现这种手段的方法。

完美转发

文章最前面已经提到,完美转发实现了参数在传递过程中保持其值属性的功能,即若是左值,则传递之后仍然是左值,若是右值,则传递之后仍然是右值。C++11标准为 C++ 引入了右值引用和移动语义,因此很多场景中是否实现完美转发,直接决定了该参数的传递过程使用的是拷贝语义(调用拷贝构造函数)还是移动语义(调用移动构造函数)。

我们看下面的例子,应该就理解上面这句话的含义了:

#include 

template
void print(T& t) {
    std::cout << "左值" << std::endl;
}

template
void print(T&& t) {
    std::cout << "右值" << std::endl;
}

template
void wrapper(T&& v) {
    print(v);
    print(std::forward(v));
    print(std::move(v));
}

int main(int argc, char * argv[]) {
    wrapper(1);
    std::cout << "--------------" << std::endl;
    int x = 1;
    wrapper(x);
    return 0;
}

上面两个print模板函数,一个接收左值,另一个接收右值,在wrapper函数中三次调用print函数分别传入不同的参数,这样可以更直观的看出forward与move的区别,上面的执行结果:

左值
右值
右值
--------------
左值
左值
右值

从上面调用的的结果我们可以看到,传入的1虽然是右值,但经过函数传参之后它变成了左值(在内存中分配了空间);而第二行由于使用了std::forward函数,所以不会改变它的右值属性,因此会调用参数为右值引用的print模板函数;第三行,因为std::move会将传入的参数强制转成右值,所以结果一定是右值。

再来看看第二组结果。因为x变量是左值,所以第一行一定是左值;第二行使用forward处理,它依然会让其保持左值,所以第二也是左值;最后一行使用move函数,因此一定是右值。

通过上面的例子我想你应该已经清楚forward的作用是什么了吧?通过forward达到了完美转发的功能。

「forward实现」

template 
T&& forward(typename std::remove_reference::type& param)
{
    return static_cast(param);
}

template 
T&& forward(typename std::remove_reference::type&& param)
{
    return static_cast(param);
}

forward有两个重载模板函数,一个接收左值,一个接收右值;

可以看到,std::forward模板函数对传入的参数进行强制类型转换,转换的目标类型符合引用折叠规则,因此左值参数最终转换后仍为左值,右值参数最终转成右值。由于forward只用了std::remove_reference,所以,它不仅可以保持左值或者右值不变,同时还可以保持const、Lreference、Rreference、validate等属性不变;

看完了forward的实现,我们使用Compiler Explorer更详细看下,上面的例子具体的转换过程:

  1. 先看下wrapper(1)

第一步转换为:void wrapper(int&&);首先对于wrapper模板函数,它的T类型就是int,下面是它的内部过程:

void wrapper(int&&):
.LFB1824:
        pushq   %rbp
        movq    %rsp, %rbp
        subq    $16, %rsp
        movq    %rdi, -8(%rbp)
        movq    -8(%rbp), %rax
        movq    %rax, %rdi
        call    void print(int&)   //第一个print
        movq    -8(%rbp), %rax
        movq    %rax, %rdi
        call    int&& std::forward(std::remove_reference::type&)
        movq    %rax, %rdi
        call    void print(int&&)  //第二print
        movq    -8(%rbp), %rax
        movq    %rax, %rdi
        call    std::remove_reference::type&& std::move(int&)
        movq    %rax, %rdi
        call    void print(int&&)  //第三print
        nop
        leave
        ret

从上面的代码看到:

  • 第一个print确实接收到的是左值(右值经过参数传递,被分配了内存);

  • 接下来看到第二个print调用了std::forward(),因为wrapper的T类型就是int,所以第二个print最终调用的是print(int&&)。

  • 第三个print调用了move,从第一个print可知道传入的参数v是左值,所以就是int&,因此最终调用std::move(int& &&),参数经过引用折叠就是std::move(int&),所以最终调用也是print(int&&)。

    从这里也可以更清楚理解move的作用,wrapper的参数我们看到的是T&&,但是经过了传参给他分配内存后变成了T&,从右值变成了左值,如果不用move,那么就是像第一个print一样调用的是print(int&),但其实你真实想要调用的是移动语义的函数而不是拷贝函数;

  1. 再看下wrapper(x)

第一步转换为:void wrapper(int&);首先对于wrapper模板函数,它的T类型就是int&,参数从T& &&折叠为T&,下面是它的内部过程:

void wrapper(int&):
.LFB1828:
        pushq   %rbp
        movq    %rsp, %rbp
        subq    $16, %rsp
        movq    %rdi, -8(%rbp)
        movq    -8(%rbp), %rax
        movq    %rax, %rdi
        call    void print(int&)
        movq    -8(%rbp), %rax
        movq    %rax, %rdi
        call    int& std::forward(std::remove_reference::type&)
        movq    %rax, %rdi
        call    void print(int&)
        movq    -8(%rbp), %rax
        movq    %rax, %rdi
        call    std::remove_reference::type&& std::move(int&)
        movq    %rax, %rdi
        call    void print(int&&)
        nop
        leave
        ret

这篇文章内容就到这里了。

参考:

C++高阶知识:深入分析移动构造函数及其原理 | 音视跳动科技 (avdancedu.com)

C++11: Perfect forwarding | De C++ et alias OOPscenitates (oopscenities.net)

你可能感兴趣的:(c++,开发语言,后端)