使用右值引用重载运算符有什么意义吗?

5

有一个模板向量类(与容器无关,与数学相关),我需要重载常见的数学运算符。像这样重载有意义吗:

template <typename T, size_t D>
Vector<T, D> operator+(const Vector<T, D>& left, const Vector<T, D>& right)
{
    std::cout << "operator+(&, &)" << std::endl;
    Vector<T, D> result;

    for (size_t i = 0; i < D; ++i)
        result.data[i] = left.data[i] + right.data[i];

    return result;
}

template <typename T, size_t D>
Vector<T, D>&& operator+(const Vector<T, D>& left, Vector<T, D>&& right)
{
    std::cout << "operator+(&, &&)" << std::endl;
    for (size_t i = 0; i < D; ++i)
        right.data[i] += left.data[i];

    return std::move(right);
}

template <typename T, size_t D>
Vector<T, D>&& operator+(Vector<T, D>&& left, const Vector<T, D>& right)
{
    std::cout << "operator+(&&, &)" << std::endl;
    for (size_t i = 0; i < D; ++i)
        left.data[i] += right.data[i];

    return std::move(left);
}

这段测试代码运行得很好:

auto v1 = math::Vector<int, 10>(1);
auto v2 = math::Vector<int, 10>(7);
auto v3 = v1 + v2;

printVector(v3);

auto v4 = v3 + math::Vector<int, 10>(2);

printVector(v4);

auto v5 = math::Vector<int, 10>(5) + v4;

printVector(v5);

//      ambiguous overload
//      auto v6 = math::Vector<int, 10>(100) + math::Vector<int, 10>(99);

并打印输出这个:
operator+(&, &)
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 
operator+(&, &&)
10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 
operator+(&&, &)
15, 15, 15, 15, 15, 15, 15, 15, 15, 15,

有两个右值引用的问题,但我认为这并不重要。

为什么我想这么做?因为出于性能原因,在理论上,不创建新对象会比较快,但是真的吗?也许编译器会优化使用operator+(const Vector& left,const Vector& right)的简单代码,没有任何理由重载右值引用?


不是很清楚你的问题。如果你想知道编译器可以优化的内容是否有差异,你可以随时查看它的输出,例如在这里 https://godbolt.org/。 - 463035818_is_not_a_number
在模板函数中,&&与非模板函数中的&&不同。在模板函数中,&&是一个__转发引用__(并且适用于引用折叠规则);而在非模板函数中,&&是一个右值引用,不进行折叠。你有转发引用。 - Richard Critten
2
@Richard:只有当参数形式为T&&时,才是实际的右值引用。在foo<T>(就像这里一样)的情况下,它是一个真正的右值引用。 - MikeMB
@DrewDormann,是的,我提到了它。 - devalone
2
@devalone 右值引用可以引用任何对象,而不仅仅是临时对象。此外,如果它是临时数据并且人们编写了 auto&& result = x + temporary();,那么结果是一个悬空引用,因为它引用的临时对象已经失效。 - M.M
显示剩余7条评论
2个回答

3

这取决于您对 Vector 的实现方式:

  • 如果移动类比复制类更快,则提供移动的额外重载可能会带来性能上的提升。
  • 否则,提供重载不会更快。

在评论中,您提到 Vector 的样子是这样的:

template <typename T, size_t D>
class Vector
{
   T data[D];
   // ...
};

从您的代码中,我还可以推断出T是一个简单的算术类型(例如float, int),其中复制操作与移动操作一样快。在这种情况下,您不能为Vector<float, D>实现比复制操作更快的移动操作。

要使移动操作比复制操作更快,您可以更改Vector类的表示方式。您可以存储指向数据的指针,而不是存储C数组,如果大小D较大,则可以实现更高效的移动操作。

类比一下,您当前的Vector实现就像是一个std::array<T, D>(内部保存C数组并需要复制),但是您可以切换到std::vector<T>(它持有指向堆的指针并且易于移动)。当值D变得更大时,从std::array切换到std::vector应该更具吸引力。

让我们更仔细地看一下提供移动操作重载时的差异。

改进:就地更新

您重载的优点是可以使用就地更新来避免像在operator+(&,&)实现中一样需要为结果创建副本:

template <typename T, size_t D>
Vector<T, D> operator+(const Vector<T, D>& left, const Vector<T, D>& right)
{
    std::cout << "operator+(&, &)" << std::endl;
    Vector<T, D> result;

    for (size_t i = 0; i < D; ++i)
        result.data[i] = left.data[i] + right.data[i];

    return result;
}

在您的重载版本中,您可以原地更新:

template <typename T, size_t D>
Vector<T, D>&& operator+(const Vector<T, D>& left, Vector<T, D>&& right)
{
    std::cout << "operator+(&, &&)" << std::endl;
    for (size_t i = 0; i < D; ++i)
        right.data[i] += left.data[i];

    return std::move(right);
}

然而,使用您目前的 Vector 实现移动结果将导致复制,而在非重载版本中,编译器可以使用返回值优化来摆脱它。如果使用类似于 std :: vector 的表示方法,则移动速度很快,因此原地更新版本应该比原始版本( operator +(&,&))更快。

编译器能自动执行原地更新优化吗?

没有帮助的情况下,编译器极不可能自动执行原地更新优化。

在非重载版本中,编译器看到两个常量引用的数组。通常情况下,它将能够执行返回值优化,但是要知道它可以重用现有对象中的一个,需要大量额外的知识,在那个点上编译器并不具备。

总结

从纯性能角度考虑,如果 Vector 移动速度更快,则为rvalue提供重载是合理的。如果移动不更快,则提供重载没有任何收益。


数组的移动速度不能比复制慢,因此它不应该使程序变慢。我想使用右值重载的原因是为了防止在函数中创建额外的对象,但正如你所提到的,有返回值优化,但当我尝试在gcc中使用-O4编译时,优化并没有生效。 - devalone

2
如果您的向量移动比复制更便宜(通常情况下,当内部存储指向可以便宜复制的数据的指针时),那么为rvlaue引用提供重载将有助于提高性能。您可以查看std::string,以及它在两个参数中具有各种可能引用的重载:http://en.cppreference.com/w/cpp/string/basic_string/operator%2B 编辑:由于OP澄清了内部数据表示是C风格数组,因此提供多个重载没有任何好处。 C风格数组不能便宜地移动-它们被复制-因此这些多个重载没有任何作用。

我的向量只是一个简单的数组,定义如下:T data[D];,它仅用于简单类型,如int、double等,我认为向量的大小不会超过4。 - devalone
我想使用rvalue重载的原因是为了防止创建额外的对象。 - devalone

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