【问题标题】:What optimization does move semantics provide if we already have RVO?如果我们已经有了 RVO,移动语义会提供什么优化?
【发布时间】:2011-06-29 05:47:56
【问题描述】:

据我了解,添加移动语义的目的之一是通过调用特殊构造函数来复制“临时”对象来优化代码。例如,在this 答案中,我们看到它可以用来优化string a = x + y 这样的东西。因为 x+y 是一个右值表达式,我们可以只复制指向字符串的指针和字符串的大小,而不是深度复制。但正如我们所知,现代编译器支持return value optimization,因此如果不使用移动语义,我们的代码根本不会调用复制构造函数。

为了证明这一点,我写了这段代码:

#include <iostream>

struct stuff
{
        int x;
        stuff(int x_):x(x_){}
        stuff(const stuff & g):x(g.x)
        {
                std::cout<<"copy"<<std::endl;
        }
};   
stuff operator+(const stuff& lhs,const stuff& rhs)
{
        stuff g(lhs.x+rhs.x);
        return g;
}
int main()
{
        stuff a(5),b(7);
        stuff c = a+b;
}

在 VC++2010 和 g++ 中以优化模式执行后,我得到了空输出。

如果没有它,我的代码仍然运行得更快,这是一种什么样的优化?你能解释一下我理解错了吗?

【问题讨论】:

    标签: c++ optimization c++11 move-semantics


    【解决方案1】:

    移动语义不应该被视为一种优化设备,即使它们可以这样使用。

    如果您想要对象的副本(函数参数或返回值),那么 RVO 和复制省略将在可能的情况下完成这项工作。移动语义可以提供帮助,但比这更强大。

    当你想要做某事不同时,移动语义很方便,无论传递的对象是临时对象(然后绑定到右值引用)还是“标准”对象有一个名字(一个所谓的const lvalue)。例如,如果您想窃取临时对象的资源,那么您需要移动语义(例如:您可以窃取 std::unique_ptr 指向的内容)。

    移动语义允许您从函数返回不可复制的对象,这在当前标准中是不可能的。此外,不可复制的对象可以放在其他对象中,如果包含的对象是,这些对象将自动可移动。

    不可复制的对象很棒,因为它们不会强迫您实现容易出错的复制构造函数。很多时候,复制语义并不真正有意义,但移动语义确实有意义(考虑一下)。

    这也使您能够使用可移动的std::vector&lt;T&gt; 类,即使T 是不可复制的。在处理不可复制的对象(例如多态对象)时,std::unique_ptr 类模板也是一个很好的工具。

    【讨论】:

    • 相当肯定容器是可移动的,无论它们包含什么——因为我们现在在移动容器时不需要触摸里面的元素。
    • @Puppy:这取决于。矢量通常在堆上分配,因此您的主张似乎是正确的。但是 std::array 的内容是 in-place,这使得如果它的元素不支持移动操作则不支持它,因为它不能仅通过指针交换来交换它的内容。那么您可以争辩说 std::array 不是容器。但我认为这应该被简单地视为带有堆栈分配器的向量。而且vector确实有灵活的分配器,因此你不能假设任何事情。
    【解决方案2】:

    经过一番挖掘,我发现了这个在Stroustrup's FAQ 中使用右值引用进行优化的优秀示例。

    是的,交换功能:

        template<class T> 
    void swap(T& a, T& b)   // "perfect swap" (almost)
    {
        T tmp = move(a);    // could invalidate a
        a = move(b);        // could invalidate b
        b = move(tmp);      // could invalidate tmp
    }
    

    这将为任何类型的类型生成优化代码(假设它具有移动构造函数)。

    编辑: RVO 也不能优化这样的东西(至少在我的编译器上):

    stuff func(const stuff& st)
    {
        if(st.x>0)
        {
            stuff ret(2*st.x);
            return ret;
        }
        else
        {
            stuff ret2(-2*st.x);
            return ret2;
        }
    }
    

    这个函数总是调用复制构造函数(用 VC++ 检查)。如果我们的类可以移动得比使用移动构造函数更快,我们将进行优化。

    【讨论】:

    • 尽管库可能在 C++0x 中提供有效的移动构造函数,但它通常会在 C++03 中提供有效的自定义交换操作,因为通常是良好的交换和适当的编译器优化在构建频繁使用的“现实世界”功能方面同样有效,例如异常安全分配。
    • 完美的交换已经由boost::swap 以有限的方式提供,它尽可能使用内部方法。以最佳方式实施。但是,C++11 版本更简洁,因为自动扩展到所有类型。
    【解决方案3】:

    想象你的东西是一个像字符串一样具有堆分配内存的类,并且它具有容量的概念。给它一个 operator+= 它将以几何方式增长容量。在 C++03 中,这可能看起来像:

    #include <iostream>
    #include <algorithm>
    
    struct stuff
    {
        int size;
        int cap;
    
        stuff(int size_):size(size_)
        {
            cap = size;
            if (cap > 0)
                std::cout <<"allocating " << cap <<std::endl;
        }
        stuff(const stuff & g):size(g.size), cap(g.cap)
        {
            if (cap > 0)
                std::cout <<"allocating " << cap <<std::endl;
        }
        ~stuff()
        {
            if (cap > 0)
                std::cout << "deallocating " << cap << '\n';
        }
    
        stuff& operator+=(const stuff& y)
        {
            if (cap < size+y.size)
            {
                if (cap > 0)
                    std::cout << "deallocating " << cap << '\n';
                cap = std::max(2*cap, size+y.size);
                std::cout <<"allocating " << cap <<std::endl;
            }
            size += y.size;
            return *this;
        }
    };
    
    stuff operator+(const stuff& lhs,const stuff& rhs)
    {
        stuff g(lhs.size + rhs.size);
        return g;
    }
    

    还假设您想一次添加两个以上的东西:

    int main()
    {
        stuff a(11),b(9),c(7),d(5);
        std::cout << "start addition\n\n";
        stuff e = a+b+c+d;
        std::cout << "\nend addition\n";
    }
    

    对我来说这是打印出来的:

    allocating 11
    allocating 9
    allocating 7
    allocating 5
    start addition
    
    allocating 20
    allocating 27
    allocating 32
    deallocating 27
    deallocating 20
    
    end addition
    deallocating 32
    deallocating 5
    deallocating 7
    deallocating 9
    deallocating 11
    

    我计算 3 个分配和 2 个释放:

    stuff e = a+b+c+d;
    

    现在添加移动语义:

        stuff(stuff&& g):size(g.size), cap(g.cap)
        {
            g.cap = 0;
            g.size = 0;
        }
    

    ...

    stuff operator+(stuff&& lhs,const stuff& rhs)
    {
            return std::move(lhs += rhs);
    }
    

    再次运行我得到:

    allocating 11
    allocating 9
    allocating 7
    allocating 5
    start addition
    
    allocating 20
    deallocating 20
    allocating 40
    
    end addition
    deallocating 40
    deallocating 5
    deallocating 7
    deallocating 9
    deallocating 11
    

    我现在只剩下 2 次分配和 1 次解除分配。这转化为更快的代码。

    【讨论】:

    • 哇。很高兴看到main paper on Rvalue references 的主要作者的回答。
    • @Howard 2 个问题:1)第一个allocating 20stuff(int x_) 打印(我添加了标记)。当我们只添加/分配stuff 对象而不是整数时,如何调用它? 2)为什么operator+返回std::move(lhs += rhs),而不是lhs += rhs?我尝试了后者,但效率较低,但您的 other answer 说这是一种不好的做法? - The std::move on tmp is unnecessary and can actually be a performance pessimization as it will inhibit return value optimization
    • @Valentin:我应该在我的原始答案中将x 命名为size。我已经编辑了答案来做到这一点。假设stuff 正在存储size 项目并保留cap 项目的容量。 stuff(int) 构造函数指定初始大小。
    • 对于第二个问题,了解move 何时会自动应用于return 表达式以及何时不会。当return表达式是与返回类型相同类型的局部堆栈变量时,或者当它是与返回类型相同类型的按值参数时(在C++11中),move将被自动应用)。 C++14 和 C++17 不断调整具体情况。但move 绝不会自动应用于声明为引用(右值或左值)的类型。
    • 谢谢。 #2 现在有意义了!顺便说一句,您现在需要在stuff(stuff&amp;&amp; g):x(g.size), cap(g.cap) 中将x 重命名为size。 #1:我理解构造函数的作用,无论是x 还是size。我的问题不同:我希望您的代码准确地调用它 4 次(当您初始化 abcd 时),但是它会在 start addition 之间再调用一次和end addition。为什么?我希望 stuff e = a+b+c+d; 只调用复制和移动构造函数,因为你从来没有明确地给它一个整数。
    【解决方案4】:

    其他答案中提到了很多地方。

    一个重要的问题是,当调整std::vector 的大小时,它会将移动感知对象从旧内存位置移动到新内存位置,而不是复制和销毁原始内存位置。

    另外,右值引用允许可移动类型的概念,这是一种语义差异,而不仅仅是一种优化。 unique_ptr 在 C++03 中是不可能的,这就是我们有 abomination of auto_ptr 的原因。

    【讨论】:

      【解决方案5】:

      仅仅因为这个特殊情况已经被现有的优化所覆盖,并不意味着其他情况下不存在右值引用有用的情况。

      移动构造允许优化,即使是从无法内联的函数(可能是虚拟调用,或通过函数指针)返回的临时值。

      【讨论】:

      • 所以你是说,operator+ 在我的例子中是内联的?
      • @Ashot:很有可能,是的。在某些调用约定下,可能无需内联即可进行返回值优化,但移动构造绝对适用于无法进行返回值优化的情况。
      • 所以如果我的类有移动语义复制构造函数,我的 operator+ 会调用它,还是编译器仍然可以通过返回值优化来优化它?
      • @Ashot:正如 Fred 所说,可以预期 C++0x 编译器会像当前编译器忽略副本一样忽略移动。
      【解决方案6】:

      您发布的示例仅采用 const 左值引用,因此明确不能对其应用移动语义,因为其中没有单个右值引用。当您实现没有右值引用的类型时,移动语义如何使您的代码更快?

      此外,RVO 和 NRVO 已经涵盖了您的代码。移动语义适用于远比这两种情况多得多的情况。

      【讨论】:

      • 你能解释一下吗?在使用右值引用添加复制构造函数后,编译器是否能够使用 RVO 对其进行优化?谢谢。
      • 移动语义适用于函数内部和外部的 op+ 的返回值,即使没有实际拼写出 rvalue-ref 类型。 (至少它们适用于隐式声明的移动 ctor,对此我仍然很模糊。)移动可以被省略,就像当前发生的复制省略一样。
      • @Fred:不会存在隐式移动 ctor,因为提供了用户定义的复制 ctor。
      • @Ben:这取决于编译器。请记住,MSVC 的右值引用是根据旧草案实现的。 @Ashot:是的,他们可以做到。编译器会先省略移动和复制,如果不能省略则回退到复制或移动语义。
      • 关于 VC2010 完全正确。我希望下一个 Visual C++ 将使用新行为。
      【解决方案7】:

      这一行调用第一个构造函数。

      stuff a(5),b(7);
      

      使用显式公共左值引用调用加号运算符。

      stuff c = a + b;
      

      在运算符重载方法中,没有调用复制构造函数。 同样,仅调用第一个构造函数。

      stuff g(lhs.x+rhs.x);
      

      分配是使用 RVO 进行的,因此不需要副本。不需要从返回的对象复制到“c”。

      stuff c = a+b;
      

      由于没有 std::cout 参考,编译器会注意您的 c 值永远不会被使用。然后,整个程序被剥离出来,导致一个空程序。

      【讨论】:

        【解决方案8】:

        我能想到的另一个好例子。想象一下,您正在实现一个矩阵库并编写一个算法,该算法采用两个矩阵并输出另一个矩阵:

        Matrix MyAlgorithm(Matrix U, Matrix V)
        {
            Transform(U); //doesn't matter what this actually does, but it modifies U
            Transform(V);
            return U*V;
        }
        

        请注意,您不能通过 const 引用传递 U 和 V,因为算法会调整它们。从理论上讲,您可以通过引用传递它们,但这看起来很恶心,并使UV 处于某种中间状态(因为您调用Transform(U)),这对调用者可能没有任何意义,或者只是没有任何意义完全有数学意义,因为它只是内部算法转换之一。如果您只是按值传递它们,代码看起来更简洁,如果您在调用此函数后不打算使用UV,则使用移动语义:

        Matrix u, v;
        ...
        Matrix w = MyAlgorithm(u, v); //slow, but will preserve u and v
        Matrix w = MyAlgorithm(move(u), move(v)); //fast, but will nullify u and v
        Matrix w = MyAlgorithm(u, move(v)); //and you can even do this if you need one but not the other
        

        【讨论】:

          猜你喜欢
          • 2014-01-05
          • 2018-10-05
          • 1970-01-01
          • 1970-01-01
          • 2023-04-03
          • 2013-10-16
          相关资源
          最近更新 更多