为什么移动语义和 RVO 都没有按预期工作?

Why do neither move semantics nor RVO work as expected?

我最近在我的方程求解器中偶然发现了一些 st运行ge 行为,这让我问自己是否真的理解移动语义和 RVO 如何协同工作。

这方面有很多 related questions on this forum, and I've also read many general explanations。但我的问题似乎很具体所以我希望有人能帮助我。

涉及的结构总体上有点复杂,但至少可以分解为:

struct Foo
{
    Bar* Elements;

    Foo(void) : Elements(nullptr)
    {
        cout << "Default-constructing Foo object " << this << endl;
    }

    Foo(Foo const& src) : Elements(nullptr)
    {
        cout << "Copying Foo object " << &src << " to new object " << this << endl;
        if (src.Elements != nullptr)
        {
            Allocate();
            copy (src.Elements, src.Elements + SIZE, Elements);
        }
    }

    Foo(Foo&& src) : Elements(nullptr)
    {
        cout << "Moving Foo object " << &src << " into " << this << endl;
        Swap(src);
    }

    ~Foo(void)
    {
        cout << "Destructing Foo object " << this << endl;
        Deallocate();
    }

    void Swap(Foo& src)
    {
        cout << "Swapping Foo objects " << this << " and " << &src << endl;
        swap(Elements, src.Elements);
    }

    void Allocate(void)
    {
        Elements = new Bar[SIZE]();
    }

    void Deallocate(void)
    {
        delete[] Elements;
    }

    Foo& operator=(Foo rhs)
    {
        cout << "Assigning another Foo object to " << this << endl;
        Swap(rhs);
        return *this;
    }

    Foo& operator+=(Foo const& rhs)
    {
        cout << "Adding Foo object " << &rhs << " to " << this << endl;
        // Somehow adding rhs to *this
        cout << "Added Foo object" << endl;
        return *this;
    }

    Foo operator+(Foo rhs) const
    {
        cout << "Summing Foo objects" << endl;
        return rhs += *this;
    }

    static Foo Example(void)
    {
        Foo result;
        cout << "Creating Foo example object " << &result << endl;
        // Somehow creating an 'interesting' example
        return result;
    }
};

现在让我们考虑以下短程序:

int main()
{
    Foo a = Foo::Example();
    cout << "Foo object 'a' is stored at " << &a << endl;
    Foo b = a + a;
    cout << "Foo object 'b' is stored at " << &b << endl;
}

这些是我在 运行 此代码之前的期望:

  1. Example 方法实例化一个本地 Foo 对象,导致调用 默认构造函数
  2. Example return 本地 Foo 对象的值。但是,我希望由于 RVO.
  3. 而删除此副本
  4. copy ctor 的后续调用也可能得到优化。相反 a 可能会被赋予临时对象在 Example.
  5. 中的地址
  6. 为了计算表达式 a + a,在左侧 ope运行d.
  7. 上调用 operator+ 方法
  8. 右侧的 ope运行d 是按值传递的,因此可能必须制作本地副本。
  9. 在该方法中,operator+= 被调用到该副本,*this 通过引用传递。
  10. 现在 operator+= return 再次引用相同的本地副本,跳回到调用 operator+ 方法的 return 语句。
  11. 引用的对象最终 return 按值编辑。在这里,我预计会出现另一个副本省略,因为本地副本的值现在只需由 b 持有(就像之前在第 2 步和第 3 步中发生的那样)。
  12. 对象 ab 最终都会超出范围,因此调用它们的析构函数。

令人惊讶的观察结果(至少对我而言)是,在第 8 步中深拷贝没有被优化掉(无论使用什么编译器选项)。相反,输出如下所示:

01  Default-constructing Foo object 0x23fe20
02  Creating Foo example object 0x23fe20
03  Foo object 'a' is stored at 0x23fe20
04  Copying Foo object 0x23fe20 to new object 0x23fe40
05  Summing Foo objects
06  Adding Foo object 0x23fe20 to 0x23fe40
07  Added Foo object
08  Copying Foo object 0x23fe40 to new object 0x23fe30
09  Destructing Foo object 0x23fe40
10  Foo object 'b' is stored at 0x23fe30
11  Destructing Foo object 0x23fe30
12  Destructing Foo object 0x23fe20

operator+ 中的以下小改动在我看来根本没有任何区别:

Foo operator+(Foo rhs) const
{
    cout << "Summing Foo objects" << endl;
    rhs += *this;
    return rhs;
}

然而这次的结果完全不同:

01  Default-constructing Foo object 0x23fe20
02  Creating Foo example object 0x23fe20
03  Foo object 'a' is stored at 0x23fe20
04  Copying Foo object 0x23fe20 to new object 0x23fe40
05  Summing Foo objects
06  Adding Foo object 0x23fe20 to 0x23fe40
07  Added Foo object
08  Moving Foo object 0x23fe40 into 0x23fe30
09  Swapping Foo objects 0x23fe30 and 0x23fe40
10  Destructing Foo object 0x23fe40
11  Foo object 'b' is stored at 0x23fe30
12  Destructing Foo object 0x23fe30
13  Destructing Foo object 0x23fe20

显然,编译器现在将 rhs 识别为 xvalue(就像我显式编写 return move(rhs += *this); 时一样)并调用 移动 ctor 代替。

此外,使用 -fno-elide-constructors 选项,您将始终得到:

01  Default-constructing Foo object 0x23fd30
02  Creating Foo example object 0x23fd30
03  Moving Foo object 0x23fd30 into 0x23fe40
04  Swapping Foo objects 0x23fe40 and 0x23fd30
05  Destructing Foo object 0x23fd30
06  Moving Foo object 0x23fe40 into 0x23fe10
07  Swapping Foo objects 0x23fe10 and 0x23fe40
08  Destructing Foo object 0x23fe40
09  Foo object 'a' is stored at 0x23fe10
10  Copying Foo object 0x23fe10 to new object 0x23fe30
11  Summing Foo objects
12  Adding Foo object 0x23fe10 to 0x23fe30
13  Added Foo object
14  Moving Foo object 0x23fe30 into 0x23fe40
15  Swapping Foo objects 0x23fe40 and 0x23fe30
16  Moving Foo object 0x23fe40 into 0x23fe20
17  Swapping Foo objects 0x23fe20 and 0x23fe40
18  Destructing Foo object 0x23fe40
19  Destructing Foo object 0x23fe30
20  Foo object 'b' is stored at 0x23fe20
21  Destructing Foo object 0x23fe20
22  Destructing Foo object 0x23fe10

据我所知,编译器必须执行

  1. RVO(如果可能),或
  2. 移动构造(如果可能),或
  3. 复制构造(否则),

按照这个顺序。所以我的问题是:有人可以向我解释一下,第 8 步中到底发生了什么以及为什么上述优先规则不适用(或者如果适用,我在这里看不到的是什么)?对于冗长的示例,我们深表歉意,在此先感谢您。

我目前正在使用 gcc mingw-w64 x86-64 v.4.9.2 -std=c++11 并关闭优化。

p.s。 - 请克制就如何编写正确的 OO 代码和确保封装向我提出建议的冲动 ;-)

如果你想去掉临时对象,我建议你使用下面的实现:

Foo operator+(const Foo& rhs) const
{
    cout << "Summing Foo objects" << endl;
    Foo result(rhs);
    result += *this;
    return result;
}

允许应用 NRVO。您的第二个版本可能会被 "Sufficiently Smart Compiler" 优化掉,但我的版本目前适用于大多数编译器。这不是标准的真正问题,而是编译器实现质量的问题。

您还可以查看像 Boost.Operators or df.operators 这样的库,它们将为您实现大部分样板代码。

按值参数不受 NRVO (Why are by-value parameters excluded from NRVO?) so they are moved instead (Are value parameters implicitly moved when returned by value?)

一个相当简单的解决方案是通过 const 引用获取两个参数并在函数体内复制:

Foo operator+(Foo const& rhs) const
{
    cout << "Summing Foo objects" << endl;
    Foo res{*this};
    res += rhs;
    return res;
}