• C++0x, rvalue reference, move semantics, RVO, NRVO


    Visual C++ 2010 (VC10) 实现了一些颇有用处的 C++0x 新特性,其中就包括(万众期待的)rvalue reference 。

    本文不打算详述 rvalue reference 是什么了,关于这方面的文章已经不少,读者可以自己搜索来看看。我要说的是,今天我做了一些非常简单的关于 rvalue reference 的性能测试,其中有非常鼓舞人心的部分,也有 C++ 一以贯之的复杂和越来越复杂的部分。

    好消息:性能的极大提升
    从原理上讲,rvalue reference 使得 move semantics 成为可能,从而让编译器可以从rvalue对象中“偷走”资源,而不是拷贝数据,在很多情况下,这会带来性能的极大提升。

    测试代码很简单:比较 copy 和 move 一个 vector<string> 对象的时间:

    view plaincopy to clipboardprint?
    #include <string>   
    #include <vector>   
    #include <iostream>   
    #include <ctime>  
    using namespace std;  
    vector<string> make_vector()   
    {   
        vector<string> v(1, string("this is a string"));   
        return v;   
    }  
    int main()   
    {   
        vector<string> src(make_vector()), s(make_vector());  
        clock_t start, end;  
        start = clock();   
        for (int i = 0; i < 1000000; ++i)   
        {   
            vector<string> s = src;   
        }   
        end = clock();   
        cout << "Vector copy ctor takes: " << end - start << endl;  
        start = clock();   
        for (int i = 0; i < 1000000; ++i)   
        {   
            vector<string> s = move(src);   
        }   
        end = clock();   
        cout << "Vector move ctor takes: " << end - start << endl;   
        return 0;   

    #include <string>
    #include <vector>
    #include <iostream>
    #include <ctime>
    using namespace std;
    vector<string> make_vector()
    {
        vector<string> v(1, string("this is a string"));
        return v;
    }
    int main()
    {
        vector<string> src(make_vector()), s(make_vector());
        clock_t start, end;
        start = clock();
        for (int i = 0; i < 1000000; ++i)
        {
            vector<string> s = src;
        }
        end = clock();
        cout << "Vector copy ctor takes: " << end - start << endl;
        start = clock();
        for (int i = 0; i < 1000000; ++i)
        {
            vector<string> s = move(src);
        }
        end = clock();
        cout << "Vector move ctor takes: " << end - start << endl;
        return 0;
    }
     

    在我的相当老旧的笔记本上,Release 版本的输入是这样的

    Vector copy ctor takes: 4562 
    Vector move ctor takes: 4


    看来相当鼓舞人心不是?对于一个还不是太大太复杂的对象,move 比 copy 竟然能有千倍的性能提高!如果把 vector 的尺寸加大,move 版本的执行时间并不会有多大区别,而 copy 版本的执行时间则会随对象增大而延长。我们以后写一个函数来构造对象时,再也不需要使用丑陋的类似

    view plaincopy to clipboardprint?
    void make_vector(vector<string>& out) 
    void make_vector(vector<string>& out)


    之类的办法来避免对象拷贝,我们只需要在返回点或者调用点加上 move !

    好吧,如果够细心,你会发现上面的代码玩了个花招:它没有在循环中调用 make_vector ,它只是把值保存起来,然后采用 copy 和 move 。第一个原因是如果在循环中调用 make_vector ,我们测得的大多数时间就都在构造上了,copy 和 move 之间的区别无法显示出来;第二个原因,后面会谈到。

    如果你一定要看看在循环中调用 make_vector 的结果,也就是说把测试代码中的

            vector<string> s = src;

            vector<string> s = move(src);

    分别替换为

            vector<string> s = make_vector();

            vector<string> s = move(make_vector());

    在我这里运行结果是这样的

    Vector copy ctor takes: 7928 
    Vector move ctor takes: 3587


    很明显,两个循环多执行的时间大致相同,那就是构造对象的时间了。

    在 C++ 里,凡事都有例外
    如果我们把测试对象换成 string,在 string 的大小比较大的时候,结果大体相似,例如下面的程序测试 copy 和 move 大小为 20 的 string:

    view plaincopy to clipboardprint?
    #include <string>   
    #include <iostream>   
    #include <ctime>  
    using namespace std;  
    int main()   
    {  
        string src(20, 'e');   
        clock_t start, end;  
        start = clock();   
        for (int i = 0; i < 1000000; ++i)   
        {   
            string s = src;   
        }   
        end = clock();   
        cout << "String copy ctor takes: " << end - start << endl;  
        start = clock();   
        for (int i = 0; i < 1000000; ++i)   
        {   
            string s = move(src);   
        }   
        end = clock();   
        cout << "String move ctor takes: " << end - start << endl;    
        return 0;   

    #include <string>
    #include <iostream>
    #include <ctime>
    using namespace std;
    int main()
    {
        string src(20, 'e');
        clock_t start, end;
        start = clock();
        for (int i = 0; i < 1000000; ++i)
        {
            string s = src;
        }
        end = clock();
        cout << "String copy ctor takes: " << end - start << endl;
        start = clock();
        for (int i = 0; i < 1000000; ++i)
        {
            string s = move(src);
        }
        end = clock();
        cout << "String move ctor takes: " << end - start << endl; 
        return 0;
    }

    在我这里,输出差不多是

    String copy ctor takes: 1728
    String move ctor takes: 40


    由于拷贝 string 是一个比较快的操作,所以差距没有那么大,但仍然相当明显。

    到这里,你一定会说“好,从此我一定会在代码里让 rvalue reference 和 move 满天飞”

    然而,如果你把 string src 的尺寸缩小一点,到达15的时候,情况变了,输出差不多是

    String copy ctor takes: 40

    String move ctor takes: 42


    为什么拷贝一个15个字符的 string 比拷贝20个字符快那么多?读读 string 类就会发现,string 类会给自己预分配16字节的 buffer,如果拷贝对象不超过15个字符,就不需要重新分配空间,只需要调用 memcpy 就可以,这是一个相当高效的操作。而 move 在这种情况下则选择不进行指针交换,而是调用 memmove,这往往比 memcpy 要慢一些。

    我们得出什么结论呢?有几个

    拷贝,尤其是少量数据的拷贝,其实很高效
    动态内存分配相当昂贵,从上面的结果可以大致推断出,分配一片空间大概比拷贝20个字节多花40倍的时间
    小字符串(15个字符以下)的拷贝已经足够优化了
    我还没有打算到此打住,如果就这么简单,那就不是 C++ 了。如果仔细考察对象的 copy 和 move ,事情会更加复杂。

    返回值和 RVO
    写一个很简单的类 Foo,它的作用是帮我们了解 copy 和 move 之间,到底发生了什么事。

    view plaincopy to clipboardprint?
    #include <iostream>  
    using namespace std;  
    struct Foo   
    {   
        Foo() { cout << "Foo ctor" << endl; }   
        Foo(const Foo&) { cout << "Foo copy ctor" << endl; }   
        void operator=(const Foo&) { cout << "Foo operator=" << endl; }   
        Foo(Foo&&) { cout << "Foo move ctor" << endl; }   
        ~Foo() { cout << "Foo dtor" << endl; }   
        void bar() {}   
    };  
    Foo make_foo()   
    {   
        return Foo();   
    }  
    int main()   
    {   
        cout << "Copy from rvalue: " << endl;   
        Foo f1 = make_foo();   
        cout << "-----------------------" << endl;   
        cout << "Move from rvalue: " << endl;   
        Foo f2 = move(make_foo());   
        cout << "-----------------------" << endl;   
        return 0;   

    #include <iostream>
    using namespace std;
    struct Foo
    {
        Foo() { cout << "Foo ctor" << endl; }
        Foo(const Foo&) { cout << "Foo copy ctor" << endl; }
        void operator=(const Foo&) { cout << "Foo operator=" << endl; }
        Foo(Foo&&) { cout << "Foo move ctor" << endl; }
        ~Foo() { cout << "Foo dtor" << endl; }
        void bar() {}
    };
    Foo make_foo()
    {
        return Foo();
    }
    int main()
    {
        cout << "Copy from rvalue: " << endl;
        Foo f1 = make_foo();
        cout << "-----------------------" << endl;
        cout << "Move from rvalue: " << endl;
        Foo f2 = move(make_foo());
        cout << "-----------------------" << endl;
        return 0;
    }

    输出是什么呢?

    Copy from rvalue:
    Foo ctor
    -----------------------
    Move from rvalue:
    Foo ctor
    Foo move ctor
    Foo dtor
    -----------------------
    Foo dtor
    Foo dtor


    怎么回事?当我们 copy 的时候,仅仅只调用了一个 constructor,甚至没有调用 copy constructor ,而我们 move 的时候,却需要调用一个 constructor,一个 move constructor 和一个 destructor。

    Move 的情况比较容易理解,分为三步:

    调用 constructor 构造一个临时对象
    从这个临时对象进行 move constructing
    销毁这个临时对象
    而 copy 为什么这么省事?因为编译器会使用 RVO(return value optimization),在返回值是一个 rvalue 的时候,这个对象会直接构造在接收返回值的对象空间中,从而减少了拷贝。而相反 move 则会阻碍编译器进行 RVO,反而增加了两个函数调用,如果 destructor 涉及动态空间的释放以及一些耗时的操作,那可是偷鸡不成蚀把米。

    那我们又得到什么结论呢?

    RVO 是个好东西
    在有 RVO 的时候,move semantics 未必比较快
    我还没有打算住手,好戏在后面:

    NRVO
    如果把函数 make_foo 改成这个模样:

    view plaincopy to clipboardprint?
    Foo make_foo()   
    {   
        Foo f;   
        return f;   

    Foo make_foo()
    {
        Foo f;
        return f;
    }

    在 Debug 模式运行一下,结果就更加有趣了:

    Copy from rvalue:
    Foo ctor
    Foo move ctor
    Foo dtor
    -----------------------
    Move from rvalue:
    Foo ctor
    Foo move ctor
    Foo dtor
    Foo move ctor
    Foo dtor
    -----------------------
    Foo dtor
    Foo dtor


    为什么?为什么我们明明打算 copy ,却调到了 move constructor;而 move 的时候,却调用了两个 move constructor ?我们一条条的分析。

    copy
    首先,无论 copy 还是 move,函数 make_foo 中 的 Foo f 都会导致一个 constructor 。

    在这里 f 是一个 lvalue,所以在 copy 时,编译器没法对它进行 RVO,而在 Debug 模式下其它的优化又关掉了,于是只好用返回值构造对象 f1。

    这里有新东西出现了:新的 C++ 标准要求,在构造返回的临时对象时,如果不使用 RVO,而类定义了 move constructor,优先使用 move constructor。所以我们看到的 move constructor 调用,是用来初始化临时对象的。

    而有了这个临时对象,编译器倒是可以直接把它扔给 f1,从而节省一道从临时对象到 f1 的拷贝。

    之后局部对象 f 超出作用范围,被销毁。

    move
    构造对象 f。

    和 copy 一样,用 move constructor 构造临时对象。

    这里问题来了:加入 move() 调用使得编译器无法优化掉临时对象到 f2 的拷贝,于是编译器退而求其次,用 move constructor 来初始化 f2。

    局部对象 f 被销毁。

    临时对象被销毁。

    好的,我们看到 move() 又杯具了,如果是 Release 模式,会如何呢?结果是这样的:

    Copy from rvalue:
    Foo ctor
    -----------------------
    Move from rvalue:
    Foo ctor
    Foo move ctor
    Foo dtor
    -----------------------
    Foo dtor
    Foo dtor


    这里 copy 和 move 双方各减少了一个对象生成。是哪一个呢?答案是临时对象。这要归功于编译器的 NRVO (Named Return Value Optimization),这种优化让编译器能够在返回一个 lvalue 的情况下,也减少一个对象 copy(或 move),但是这并没能优化掉对于 f2 的构造。

    结论
    Rvalue reference, move semantics 都是好东西,std::move() 也是好东西,但是用得不对可能会适得其反。

    事实上,在有了 move semantics 之后,最高效的的返回正是我们熟悉的形式:

    view plaincopy to clipboardprint?
    Foo make_foo()   
    {   
        Foo f;   
        return f;   
    }  
    ……  
    Foo f1 = make_foo(); 
    Foo make_foo()
    {
        Foo f;
        return f;
    }
    ……
    Foo f1 = make_foo();

    因为编译器会尽可能的使用 RVO 和 NRVO,而在无法使用这些优化时,由于 make_foo 返回一个 rvalue ,编译器仍会尽力调用 move constructor,而只有这些都失败了,编译器才会采取我们熟悉的 copy constructor --- 总之,不会比这个更坏了。

    本文来自CSDN博客,转载请标明出处:file:///C:/Documents%20and%20Settings/Administrator/桌面/C++0x,%20rvalue%20reference,%20move%20semantics,%20RVO,%20NRVO%20—%20我们到底要什么%20-%20口得堂%20-%20CSDN博客.mht

  • 相关阅读:
    c/c++面试45-50之字符串
    c/c++面试39-44之内存动态分配
    使用spring配合Junit进行单元测试的总结
    使用springBoot进行快速开发
    配置项目使用weblogic的JNDI数据源
    转载-解决使用httpClient 4.3.x登陆 https时的证书报错问题
    SpringData JPA查询分页demo
    Lucene中的域选项
    代码片段,lucene基本操作(基于lucene4.10.2)
    配置maven使用nexus
  • 原文地址:https://www.cnblogs.com/kex1n/p/2286488.html
Copyright © 2020-2023  润新知