如果我们已经有了RVO,移动语义提供了什么优化?

37
据我所了解,添加移动语义的目的之一是通过调用特殊的构造函数来优化代码以复制“临时”对象。例如,在答案中,我们可以看到它可以用于优化这样的string a = x + y操作。因为x+y是一个右值表达式,所以我们只需要复制指向字符串的指针和字符串的大小,而不需要进行深拷贝。但是,我们知道现代编译器支持返回值优化,因此如果不使用移动语义,我们的代码根本不会调用复制构造函数。
为了证明这一点,我编写了以下代码:
#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++进行优化模式编译后,我得到了空输出。这是什么样的优化呢?如果不进行优化,我的代码还能更快地运行。你能解释一下我理解错了什么吗?

8个回答

31

移动语义不应被视为优化工具,即使它们可以被用作这样的工具。

如果你只是需要对象的拷贝(无论是函数参数还是返回值),那么RVO和复制省略将在能够使用时完成工作。移动语义可以提供帮助,但比此更强大。

当你想在传递的对象是临时的(它将绑定到右值引用)或有名称的“标准”对象(所谓的常量左值)时,移动语义非常方便。例如,如果你想窃取临时对象的资源,则需要移动语义(例如:您可以窃取std::unique_ptr指向的内容)。

移动语义允许你从函数中返回不可拷贝的对象,这在当前的标准下是不可能的。此外,如果包含的对象是可移动的,那么非可拷贝的对象可以放置在其他对象中,并且那些对象将自动可移动。

非可拷贝的对象很棒,因为它们不会强制你实现容易出错的拷贝构造函数。很多时候,拷贝语义并没有真正意义上的意义,但移动语义有(好好思考一下)。

这还使你能够使用可移动的std::vector<T>类,即使T是不可拷贝的。当处理不可拷贝对象(例如多态对象)时,std::unique_ptr类模板也是一个很好的工具。


2
相信容器是可移动的,无论它们包含什么-因为我们现在在移动容器时不需要触摸其中的元素。 - Puppy
2
@Puppy:这取决于情况。vector通常在堆上分配内存,因此你的说法似乎是正确的。但std::array的内容是就地存储的,这意味着如果其元素不支持移动操作,则不支持移动操作,因为它不能仅通过指针交换来交换其内容。那么你可以认为std::array不是一个容器。但我认为这应该被视为具有堆栈分配器的向量。而vector确实具有灵活的分配器,因此你不能假设任何东西。 - v.oddou

11

在深入研究后,我发现Stroustrup的FAQ中有一个优化使用右值引用的绝佳示例。

是的,swap函数:

    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++中进行了检查)。如果我们的类可以更快地移动,那么使用移动构造函数就会有优化。


1
尽管在C++0x中,库可能会提供一个高效的移动构造函数,但在C++03中,它通常会提供一个高效的自定义交换操作,因为良好的交换和适当的编译器优化同样可以有效地构建经常使用的“真实世界”函数,例如异常安全赋值。 - CB Bailey
完美的交换已经以有限的方式由boost::swap提供,它在可能的情况下使用内部方法来实现最佳优化。然而,C++11版本更加简洁,因为它自动扩展到所有类型。 - v.oddou

7

假设您的东西像字符串一样有堆分配内存的类,并且它具有容量概念。为其提供一个运算符+=,以几何方式增加容量。在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个释放。这意味着代码更快。


1
哇,很高兴看到Rvalue引用主要论文的首席作者回答了这个问题。 - Ray Toal
@Howard 2个问题:1)第一个“allocating 20”是由stuff(int x_)打印的(我添加了标记)。我们只添加/分配stuff对象而不是整数,它如何被调用?2)为什么operator+返回std::move(lhs += rhs)而不是lhs += rhs?我尝试了后者,但效率较低,但您的其他答案说这是一种不好的做法?- tmp上的std::move是不必要的,实际上可能会成为性能下降,因为它会抑制返回值优化 - Valentin
@Valentin:我应该在我的原始答案中将x命名为size。我已经编辑了答案来做到这一点。假设stuff存储size个项目,并保留cap个项目的容量。stuff(int)构造函数指定初始大小。 - Howard Hinnant
1
对于您的第二个问题,了解何时move将自动应用于return表达式以及何时不会很有帮助。当return表达式是具有与返回类型相同类型的本地堆栈变量或具有与返回类型相同类型(在C ++11中)的按值参数时,move将自动应用。 C ++14和C ++17不断调整确切情况。但在任何时候,都不会自动将move应用于声明为引用(rvalue或lvalue)的类型。 - Howard Hinnant
谢谢您。现在#2有意义了!顺便说一下,您现在需要将“x”重命名为“size”,放到“stuff(stuff&&g):x(g.size),cap(g.cap)”中。#1: 我很清楚构造函数的作用,无论是“x”还是“size”。我的问题不同:我希望您的代码在初始化“a”、“b”、“c”和“d”时调用它恰好4次,但是在“start addition”和“end addition”之间它被多调用了一次。为什么?我希望stuff e=a+b+c+d;只调用复制和移动构造函数,因为您从未显式地给它一个整数。 - Valentin
operator+(const stuff& lhs,const stuff& rhs) 构造了一个带有整数的 stuff,而 operator+=(const stuff& y) 有时会分配更大的 stuff - Howard Hinnant

4

有很多地方提到过,其中一大部分是在其他答案中提到的。

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

此外,右值引用允许可移动类型的概念,这是语义上的差异,而不仅仅是优化。C++03中不可能使用unique_ptr,这就是为什么我们需要auto_ptr这种丑陋的东西


1

仅因为现有的优化已经涵盖了这种特定情况,并不意味着不存在其他情况,其中右值引用是有帮助的。

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


那么你的意思是,operator+ 在我的例子中被内联了? - UmmaGumma
@Ashot:很有可能是的。在某些调用约定下,即使没有内联,返回值优化也可能是可能的,但移动构造肯定适用于无法进行返回值优化的情况。 - Ben Voigt
如果我的类具有移动语义的复制构造函数,那么我的 operator+ 是否会调用它,或者编译器是否仍然可以通过返回值优化来进行优化? - UmmaGumma
1
@Ashot:正如Fred所说,可以期望C++0x编译器像当前的编译器一样省略移动操作。 - Ben Voigt

1

你发布的示例只接受const左值引用,因此明确地说,无法应用移动语义,因为其中没有单个右值引用。如果您实现了一种没有右值引用的类型,那么移动语义如何使您的代码更快呢?

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


你能解释一下吗?在添加了带有右值引用的复制构造函数后,编译器是否能够使用RVO进行优化?谢谢。 - UmmaGumma
移动语义适用于op+的返回值,无论在函数内部还是外部,即使没有明确指定rvalue-ref类型。 (至少如果隐式声明了移动构造函数,我仍然不太清楚。)移动可以省略,就像当前发生的复制省略一样。 - Fred Nurk
@Fred:没有隐式移动构造函数存在,因为提供了用户定义的复制构造函数。 - Ben Voigt
@Ben:这取决于编译器。请记住,MSVC的右值引用是根据旧草案实现的。@Ashot:是的,它们可以。编译器将首先省略移动和复制,然后在无法省略时回退到复制或移动语义。 - Puppy
关于VC2010,你说得完全正确。我希望下一个Visual C++会使用新的行为。 - Ben Voigt

0

我可以想到另一个很好的例子。假设你正在实现一个矩阵库,并编写了一个算法,它接受两个矩阵并输出另一个矩阵:

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,因为算法会对它们进行调整。理论上,您可以通过引用传递它们,但这看起来很丑陋,并且会使U和V处于某些中间状态(因为您调用Transform(U)),这可能对调用者没有任何意义,或者根本没有任何数学意义,因为它只是内部算法变换之一。如果您在调用此函数后不打算使用U和V,则通过值传递它们并使用移动语义会使代码看起来更清晰:
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

0

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

stuff a(5),b(7);

使用显式的常规左值引用调用加号运算符。

stuff c = a + b;

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

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

使用RVO进行赋值,因此不需要复制。不需要从返回的对象复制到“c”。

stuff c = a+b;

由于没有 std::cout 的引用,编译器会自动处理你的 c 值从未被使用。因此,整个程序被剥离,导致一个空程序。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接