C++11的右值引用和move语义

695 阅读6分钟

我理解的右值引用

右值,顾名思义,可以先理解为 “=” 号右边的值,通常是个常量或者临时变量,比如代码:

int foo = 123; //foo是lvalue, 123是rvalue
int plus(int left, int right) {
    return left + right;
}
int result = plus(1, 2);   //result是lvalue, plus函数的计算结果是一个rvalue

右值引用提供了什么能力

右值引用提供了对右值进行操作的能力,以此来达到对临时变量进行复用,避免无意义的临时变量深拷贝。
举一个例子,在没有右值引用的年代,我们用std::string时常常会想起大师的教导,不要写出下面的代码: “std::string(“aaa”) + std::string(“bbb”) + std::string(“ccc”) + std::string(“ddd”)”
这种写法是为人所不齿的,因为会产生无数个临时变量。
但是有了右值引用之后, “std::string(“aaa”) + std::string(“bbb”)” 是两个右值相加,那么可以把right 累加到 left上,返回left, left又是一个右值, 继续和后面的string做运算,类似于下面的代码:

auto str = std::string("aaa");
str += "bbb";
str += "ccc";
str += "ddd";

右值引用配合unique_ptr和pImpl写法

核心思想是,把占用内存空间大的对象封装到一个impl嵌套类里面,用unique_ptr进行维护,unique_ptr有独占语义,并且如果不自定义销毁函数的话,他所占用的空间和一个裸指针无异,享受方便封装的同时不带来任何的额外负担。
所有对象都封装到impl里面的另一个好处是,move语义时可以很方便的实现,只需要move pImpl本身即可。代码如下:

#include <memory>
#include <iostream>
int g_getObjectId() {
    static int seed = 0;
    return seed++;
}
int g_getObjectId2() {
    static int seed = 0;
    return seed++;
}
class Widget {
    struct Impl {
        int m_objId;
        Impl() {
            m_objId = g_getObjectId();
            std::cout << "Widget::Impl " << m_objId << " constructed" << std::endl;
        }
        ~Impl() {
            std::cout << "Widget::Impl " << m_objId << " destructed" << std::endl;
        }
    };
public:
    Widget(Widget&& w) : m_pImpl(std::move(w.m_pImpl)), m_selfObjectId(g_getObjectId2()) {   //右值构造函数
        std::cout << "Widget " << m_selfObjectId << " constructed" << std::endl;
    }
    Widget() : m_pImpl(new Impl()), m_selfObjectId(g_getObjectId2()) {
        std::cout << "Widget " << m_selfObjectId << " constructed" << std::endl;
    }
    ~Widget() {
        std::cout << "Widget " << m_selfObjectId << " destructed" << std::endl;
    }
    int getObjectId() const {
        return m_pImpl->m_objId;
    }
private:
    std::unique_ptr<Impl> m_pImpl;
    int m_selfObjectId;
};
void foo(Widget w) {
    std::cout << "foo widget for object " << w.getObjectId() << std::endl;
}
void foo2(const Widget& w) {
    std::cout << "foo widget for const reference" << w.getObjectId() << std::endl;
}
Widget makeWidget() {   //模拟工厂
    Widget w;
    return std::move(w);   //w以右值返回,会构造一个新的Widget,但是因为右值有右值构造函数,成本很低
}
Widget makeWidgetWithoutMove() {   //模拟工厂
    Widget w;
    return w;   //利用编译器的RVO(返回值优化),可能不会有新的Widget被构造
}
Widget makeWidgetWithoutMove2(int type) {
    Widget a, b;
    if (type == 0) {
        return a;
    }
    else {
        return b;
    }
}
int main() {
    {
        Widget w(makeWidget());
        foo(std::move(w));   //move语义,表示w下面不再使用
    }
    std::cout << "--------------------------------------------------" << std::endl;
    {
        Widget w(makeWidgetWithoutMove());
        foo(std::move(w));   //move语义,表示w下面不再使用
    }
    std::cout << "--------------------------------------------------" << std::endl;
    {
        Widget w(makeWidgetWithoutMove2(0));
        foo(std::move(w));   //move语义,表示w下面不再使用
    }
    std::cout << "--------------------------------------------------" << std::endl;
    {
        Widget w2(makeWidget());
        foo2(std::move(w2));//move语义,表示w2下面不再使用
    }
    std::cout << "--------------------------------------------------" << std::endl;
    {
        Widget w3(makeWidget());
        foo2(w3);   //直接传递w
    }
}

依赖unique_ptr的独占语义,只需要在Widget的右值构造函数中把右值的m_pImpl作为参数给新的Widget的m_pImpl构造即可,unique_ptr会把右值的指针设置为空,以此来保证独占性。右值销毁时,什么都不会做,其指向的m_pImpl内存顺利让渡到新的Widget

下面是程序的输出:

Widget::Impl 0 constructed
Widget 0 constructed
Widget 1 constructed
Widget 0 destructed
Widget 2 constructed
foo widget for object 0
Widget 2 destructed
Widget::Impl 0 destructed
Widget 1 destructed
--------------------------------------------------
Widget::Impl 1 constructed
Widget 3 constructed
Widget 4 constructed
foo widget for object 1
Widget 4 destructed
Widget::Impl 1 destructed
Widget 3 destructed
--------------------------------------------------
Widget::Impl 2 constructed
Widget 5 constructed
Widget::Impl 3 constructed
Widget 6 constructed
Widget 7 constructed
Widget 6 destructed
Widget::Impl 3 destructed
Widget 5 destructed
Widget 8 constructed
foo widget for object 2
Widget 8 destructed
Widget::Impl 2 destructed
Widget 7 destructed
--------------------------------------------------
Widget::Impl 4 constructed
Widget 9 constructed
Widget 10 constructed
Widget 9 destructed
foo widget for const reference4
Widget 10 destructed
Widget::Impl 4 destructed
--------------------------------------------------
Widget::Impl 5 constructed
Widget 11 constructed
Widget 12 constructed
Widget 11 destructed
foo widget for const reference5
Widget 12 destructed
Widget::Impl 5 destructed

可以看到4次测试,真正的内存块Impl都是只构造了一次。

move到底做了啥

根据 《modern effective c++》 中的描述,move啥也没做,只是把参数做了一个强制右值转换,返回就完了。那么 unique_ptr为何move之后,老的值就变成null了呢。
写出上面的测试代码后,我理解了这个问题,其实靠的不是move,而是类的右值构造函数,大概可以模拟一下unique_ptr的代码:

template<typename T>
class unique_ptr {
public:
    unique_ptr(unique_ptr<T>&& rv) {
        m_pRaw = rv.m_pRaw;
        rv.m_pRaw = nullptr;
    }
private:
    T* m_pRaw;
}

根据上面的代码,当写出auto newptr = std::move(oldptr)时,oldptr作为一个右值引用被传入newptr的构造函数,然后其原始指针的地址被修改为null了。

RVO(返回值优化)和右值引用

第二次测试中,我调用了makeWidgetWithoutMove作为工厂函数,从输出来看,他是构造Widget次数最少的一种方法,这种性能的提升得益于编译器的RVO(返回值优化)策略,但是这个策略比较复杂,比如如下代码:

Widget makeWidgetWithoutMove() {   //模拟工厂
    Widget w;
    return w;   //利用编译器的RVO(返回值优化),可能不会有新的Widget被构造
}
Widget w(makeWidgetWithoutMove());
foo(std::move(w));   //move语义,表示w下面不再使用

在编译器经过优化后,会生成如下代码:

void makeWidgetWithoutMove(Widget &_result) {   //模拟工厂
}
Widget w;
makeWidgetWithoutMove(w);
foo(std::move(w));   //move语义,表示w下面不再使用

这种在函数开头就定义一个Widget实例,在函数结尾返回的例子,通常可以被RVO,但是稍微复杂一点,比如需要根据不同的条件return不同的local var时,RVO就不一定奏效了,如下面的代码所示

Widget makeWidgetWithoutMove2(int type) {
    Widget a, b;
    if (type == 0) {
        return a;
    }
    else {
        return b;
    }
}
Widget w(makeWidgetWithoutMove2(0));
foo(std::move(w));

本质上来讲,makeWidgetWithoutMove2 函数内,通过一个变量判断返回哪一个local var,那么编译器就无法确定要用&_result 代替哪一个变量,优化就无从谈起。
所以,除了RVO以外,在一些复杂的场景,我们还是需要右值引用来为我们解决函数返回值的性能问题。

总结

纸上得来终觉浅,绝知此事要躬行。右值引用看过好多次了,每次都囫囵吞枣,希望这次代码练习和写作可以把它牢牢掌握。

参考