【C++11上手篇】壹、右值引用与移动语义

1,287 阅读3分钟

前言

前段时间换工作,技术栈渐渐从Android偏向到跨端开发,需要重拾C++,所以打算总结成一个系列。

这个系列会从实用的角度总结C++11的一些新特性,尽量做到简单通俗易懂。

面对人群是有C++基础想快速了解现代C++新特性的同学,感觉比较多,大学基本都是教传统C++。

​废话不多说,本文讲一下右值引用的概念和以及它的应用场景移动语义。

右值引用的由来

在C++11之前,所有引用都是左值引用(lvalue reference),也就是对左值的引用。左值一般放在赋值表达式左边,是在堆或栈上分配的命名对象,它们有明确的内存地址。

而左值的另一位朋友右值(rvalue),在赋值表达式右边,没有可识别的内存地址。如果从硬件层面理解,右值只存在于临时寄存器中。

比如下面这段代码:

    int a = 1;
    int& b = a;

很明显,这里 a 是左值,1 是右值,b 是一个左值引用,也就是a的别名。

再比如这段:

    int& a = 1;

g++编译,会显示错误如下:
non-const lvalue reference to type 'int' cannot bind to a temporary of type 'int'
意思是非常量左值引用不能指向右值。

大家都不会犯这样的错,这里想说的是,我们还可以使用常量左值引用来指向右值,像这样:

    const int& a = 1; //常量左值引用

问题来了,常量左值引用为什么可以指向右值?

因为const常量值不可修改,可以理解为内部产生了一个临时量,可以取到地址。类似于以下:

    const int tmp = 1; 
    const int &a = tmp;

可以看到,const Type& 是 C++ 中一个常见的习惯,函数的参数使用常量引用 const Type& 接收,以避免创建不必要的临时对象:

    void func(const std::string& a);
    func("hello");	

但是这种方式有个缺点,就是没法修改这个const常量,有一定局限性。

C++11引入的这位新朋友,右值引用,一定程度上解决了其中的这个问题。

右值引用,Type&&,用来指向右值,并且可以修改右值。

    void func(const std::string&& a){
        a = "world"; //修改右值
    }
    func("hello");	

OK,到这里,我们简单总结下

  • 左值可以寻址,右值不可以寻址,这是它们的关键区别;
  • 函数传参使用左右值引用可以避免拷贝,但右值引用更为灵活。

那么,右值引用的具体应用场景是什么?

移动语义提升性能

右值引用有一个非常重要的作用是支持移动语义。而相对于移动语义,拷贝语义可能比较好理解。

比如下面代码,我们可以定义拷贝构造函数来实现对象的深拷贝,如果没有定义,编译器会有默认实现,是浅拷贝。

class Stack
{
public:
    Stack(int size = 100) : size_(size)
    {
        cout << "构造函数" << endl;
        stack_ = new int[size];
    }
    Stack(const Stack &src):size_(src.size_)
    {
        cout << "拷贝构造函数" << endl;
        stack_ = new int[src.size_];

        //深拷贝
        for (int i = 0; i < size_; ++i)
        {
            stack_[i] = src.stack_[i];
        }
    }

    ~Stack()
    {
        cout << "析构函数" << endl;
        delete[] stack_;
        stack_ = nullptr;
    }

private:
    int size_;
    int *stack_;
};

int main()
{
    Stack stack(10);
    Stack stack2 = stack;
}

运行输出是:

构造函数
拷贝构造函数
析构函数
析构函数

除此之外,在某些场景,比如被拷贝者之后不再需要,我们其实可以使用 std::move 触发移动语义,避免深拷贝,提升性能。

所以在上面代码中,我们可以加一个移动构造函数,这种方式在STL和自定义类广泛应用。

Stack(Stack&& src):size_(src.size_) {
    cout << "移动构造函数" << endl;
    stack_ = src.stack_;
    src.stack_ = nullptr;
}

int main(){
    Stack stack(10);
    //Stack stack2 = stack; //走拷贝构造
    Stack stack2 = std::move(stack); //走移动构造
}

运行的输出是:

构造函数
移动构造函数
析构函数
析构函数

这里,std::move 的作用是把左值转换为右值引用,而移动构造函数的作用是传入对象的所有权转让给当前对象,然后掏空了传入对象。

std::move真面目

大家可能以为std::move施展了什么神奇的魔法,其实并没有,仅仅做了类型转换而已,真正的移动操作是在移动构造函数或者移动赋值操作符中发生的。可以瞧一瞧代码

  template<typename _Tp>
    constexpr typename std::remove_reference<_Tp>::type&&
    move(_Tp&& __t) noexcept
    { return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }

发现只是一个static_cast转换,其中,remove_reference的作用去除T中的引用部分,无论T是左值还是右值,只获取其中的类型。我们来简化一下,当_TP是string时,这个函数其实就是

string&& move(string&& __t)
{
    return static_cast<string&&>(__t);
}

所以,不管传参是左值右值,最后返回的一定是个右值引用。

实际上,std::move 运行期不做任何事情,因为编译后不会生成可执行代码,内部只是变量地址的透传,完全可以被优化掉。有兴趣的同学可以看看 通过汇编浅析 C++ 右值引用

最后,聪明的你可能也发现了,std::move 的函数参数&&看起来是一个右值引用类型,可以传入左值吗?

公众号.png