移动语义和以右值引用传递的重载算术运算符

12

我正在使用C++编写一个小型的数值分析库。 我一直在尝试使用最新的C++11功能,包括移动语义来实现。 我理解了以下帖子中的讨论和最佳答案:C++11 rvalues and move semantics confusion (return statement),但仍有一种情况我还在努力理解。

我有一个类,叫做T,它完全装备了重载运算符,同时具有复制和移动构造函数。

T (const T &) { /*initialization via copy*/; }
T (T &&) { /*initialization via move*/; }

我的客户端代码大量使用操作符,因此我正在尝试确保复杂的算术表达式从移动语义中获得最大的好处。考虑以下情况:

T a, b, c, d, e;
T f = a + b * c - d / e;

没有移动语义,每次我的运算符都会使用复制构造函数创建一个新的本地变量,因此总共有4个副本。我希望使用移动语义可以将此减少到2个副本加上一些移动操作。在括号版本中:

T f = a + (b * c) - (d / e);

每个(b * c)(d / e)都必须按照通常的方式创建一个临时副本,但是如果我可以利用其中一个临时变量仅通过移动来累积其余结果,那就太好了。

使用g++编译器,我已经能够做到这一点,但我怀疑我的技术可能不安全,我想充分理解为什么。

下面是加法运算符的示例实现:

T operator+ (T const& x) const
{
    T result(*this);
    // logic to perform addition here using result as the target
    return std::move(result);
}
T operator+ (T&& x) const
{
    // logic to perform addition here using x as the target
    return std::move(x);
}

如果没有调用std::move,则每个运算符只会调用const &版本。但是,在上面使用std::move后,内部表达式之后的后续算术操作将使用每个运算符的&&版本进行执行。

我知道可以抑制RVO,但在非常计算密集型的真实问题中,似乎获益略微超过了缺乏RVO。也就是说,在数百万次计算中,当我包括std::move时,我确实获得了非常微小的加速。尽管如此,它已经足够快了。我真的只想完全理解这里的语义。

是否有一位友善的C++大师愿意花时间简单地解释一下,我的使用std::move是否合适以及原因?非常感谢。


1
第二个“move”没问题,只有第一个是不必要的。 - Kerrek SB
1
以上选项中存在一个未被利用的机会:当左侧已经是临时变量时。这可以通过重载右值引用的成员函数来利用。此外,请注意,通常应优先使用自由函数进行运算符重载,在这种情况下,重载将在第一或第二个参数为右值的情况下进行。这需要4种组合(左/右手边是否为右值/左值)。 - David Rodríguez - dribeas
T 是什么类型的数字类型?它是否通过指针在堆上管理某些内容?因为如果它只有一些 int 成员之类的东西,移动语义将不会带来任何好处。只是问一下 :) - fredoverflow
@FredOverflow: :o 是的,每个 T 在堆上管理一个结构体。 :) - Tientuinë
3个回答

8
你应该优先使用重载运算符作为自由函数,以获得完整的类型对称性(左右手边可以应用相同的转换)。这使得你从问题中缺少什么更加明显。将你的运算符重新表述为自由函数,你提供了以下内容:
T operator+( T const &, T const & );
T operator+( T const &, T&& );

但是您没有提供一个能够处理左侧为临时变量的版本:
T operator+( T&&, T const& );

为避免当两个参数都是rvalues时代码的歧义,您需要提供另一个重载函数:
T operator+( T&&, T&& );

通常的建议是将+=实现为修改当前对象的成员方法,然后编写operator+作为转发器来修改接口中的适当对象。

我没有真正考虑过这个问题,但可能有一种使用T(没有左/右值引用)的替代方法,但我担心它不会减少您需要提供的重载数量,以使operator+在所有情况下都高效。


这并不涉及使用std::move是否合适。 - ildjarn
@Xeo:如果类型是可移动的,我不确定这是否会有所不同。您不是在制作副本,而是移动内容(应该很)。是的,它将抑制参数的复制省略...但是,如果您正在经历多个重载的痛苦,我只能假设移动比副本更便宜且更好。 - David Rodríguez - dribeas
没关系,我刚才忘记了你不能重载T const&T。然而,在理论上,它与operator=相同。你可以使用(T const&)T&&进行两个重载,也可以使用(T)进行单个重载。这里的情况也是一样的,只不过有两个参数。在你的(T const&, T const&)中,你正在操作符内部复制一个参数 - 而应该在参数中进行复制。无论如何,最终似乎确实需要4个重载。:( - Xeo
1
@Xeo:是的,我不太记得细节了,但几个月前我尝试过类似的东西,最终需要4个重载。我不记得确切的细节,但我花了几个小时来寻找正确的事情。最后,我没有明确的用例表明移动比复制更好,所以我把它留作实验并继续前进(通常采用相同的旧C++03方法,即使在某些情况下可能不是最优的)。 - David Rodríguez - dribeas
@Xeo:我刚才在想你的最后一条评论,特别是(T const&, T const&)的情况。在函数调用或函数内部进行复制没有任何区别。在接口中进行复制更有效的情况是参数本身是临时的(因此可以省略复制)。在这种情况下,有三个其他重载处理rvalue在一个或两个参数中,因此(T const&, T const&)只会被调用与无法省略复制的rvalues,由调用者或函数完成复制都无所谓。 - David Rodríguez - dribeas
@DavidRodríguez-dribeas:谢谢。我简直不敢相信我错过了左侧的T&&的情况!吸取教训。实际上,我不依赖于类型转换;我对各种操作数类型都有显式的友元重载——只有对于T const&T&&参数才是成员函数。我的类是一个现有C库的包装器-类型转换引入了太多开销。在我的问题域中,我想尽可能地挤出每一个周期。不过,我仍然对我的情况下的std::move行为感到好奇。移出类的所有成员运算符后,我将回报。 - Tientuinë

5

在其他人所说的基础上:

  • T::operator+( T const & )中对std::move的调用是不必要的,可能会阻止RVO。
  • 最好提供一个非成员operator+,它委托给T::operator+=( T const & )

我还想补充一点,完美转发可用于减少所需的非成员operator+重载数量:

template< typename L, typename R >
typename std::enable_if<
  std::is_convertible< L, T >::value &&
  std::is_convertible< R, T >::value,
  T >::type operator+( L && l, R && r )
{
  T result( std::forward< L >( l ) );
  result += r;
  return result;
}

对于一些操作符来说,这个“通用”版本已经足够了,但由于加法通常是可交换的,我们可能想要检测右操作数是否为rvalue,并修改它而不是移动/复制左操作数。这需要一个版本来处理右操作数为lvalue的情况:
template< typename L, typename R >
typename std::enable_if<
  std::is_convertible< L, T >::value &&
  std::is_convertible< R, T >::value &&
  std::is_lvalue_reference< R&& >::value,
  T >::type operator+( L && l, R && r )
{
  T result( std::forward< L >( l ) );
  result += r;
  return result;
}

对于作为右值的右操作数,还需要另一个。

template< typename L, typename R >
typename std::enable_if<
  std::is_convertible< L, T >::value &&
  std::is_convertible< R, T >::value &&
  std::is_rvalue_reference< R&& >::value,
  T >::type operator+( L && l, R && r )
{
  T result( std::move( r ) );
  result += l;
  return result;
}

最后,您可能也会对Boris KolpackovSumant Tambe提出的一种技术以及Scott Meyers对该想法的回应感兴趣。


太棒了!非常感谢这些精彩的文章!在某个时候,我打算尝试减少这堆令人发指的过载,也许通过完美转发来实现,但由于目前一切都运作良好,我并不急着着手处理。 - Tientuinë
Durwald:我有一个关于你的两个operator+重载的问题。使用g++-4.7,如果我尝试将operator+作为类T的友元(以访问私有数据成员),那么编译器会抱怨访问私有成员。显然,它没有将实现视为友元声明template <typename T, typename L, typename R> friend T operator+ (L&&, R&&)的特化。如果我在友元声明中省略T,那么它仍然将它们视为不同的,并抱怨模糊的重载。我错在哪里了?(也许我应该把这个问题作为一个新问题提出来。) - Tientuinë
@Tientuinë,你必须使用相同的签名来声明非成员运算符作为T的友元,即template <typename L,typename R> friend typename std :: enable_if <...> :: type operator +(L &&,R &&); - Andrew Durward
起初,我就是这样做的。问题在于两个重载之间存在歧义,编译器会报错。因此,我尝试添加第三个模板参数,并将重载转换为特化,但也没有成功。顺便说一下,之前我的名称自动完成功能出了点问题,对于拼写错误我表示抱歉。 - Tientuinë
@Tientuinë 对我来说,使用LWS (gcc 4.7.2)似乎可以工作。如果您仍然遇到问题,我只能建议您开始一个新的问题。 - Andrew Durward

3

我同意David Rodríguez的观点,使用非成员operator+函数会更好一些,但我会放到一边,专注于回答你的问题。

我很惊讶你在编写代码时遇到了性能下降的问题。

T operator+(const T&)
{
  T result(*this);
  return result;
}

替代

T operator+(const T&)
{
  T result(*this);
  return std::move(result);
}

由于在前面的情况下,编译器应该能够使用RVO在函数返回值的内存中构造result。而在后一种情况下,编译器需要将result移动到函数的返回值中,因此会产生额外的移动成本。
一般来说,假设你有一个返回对象的函数(即不是引用类型):
- 如果你返回一个本地对象或按值传递的参数,则不要对其应用std::move。这允许编译器执行RVO,这比复制或移动更便宜。 - 如果你返回一个右值引用类型的参数,请对其应用std::move。这将使参数变为一个右值,从而允许编译器从中移动。如果你只是返回该参数,则编译器必须将其复制到返回值中。 - 如果你返回一个通用引用类型的参数(即具有推断类型的"&&"参数,可以是右值引用或左值引用),请对其应用std::forward。如果没有使用,编译器必须将其复制到返回值中。如果使用了它,如果引用绑定到右值,编译器可以执行移动。

谢谢你的帮助。正如我在回复David R时所说,我实际上对许多操作数类型都进行了友元重载,但对于我提到的这两个,我将它们作为成员函数。我想我应该把它们全部设为友元,因为我错过了左手边的T&&情况。至于性能差异,它略小于1%,非常小但可测量。我只是不明白为什么编译器既不执行RVO也不执行移动语义,而它显然应该至少执行其中之一。如果我明确包含移动,则似乎可以解决问题,但我担心这不安全。 - Tientuinë
@Tientuinë:无论您将它们实现为成员函数还是自由函数都没有关系。您可以在this指针的lvalue-rvalue-reference-ness上进行重载。至于RVO/NRVO,这取决于一些编译器和标志(一些编译器需要启用一些优化)以及代码。请注意,在参数为T&&的情况下,编译器不会从参数到返回值隐式移动。 - David Rodríguez - dribeas
@KnowItAllWannabe:我正在尝试理解在我的情况下std::movestd::forward之间的区别。如果我有operator+(T&& x, T&& y),那么假设我可以使用std::move返回x是不安全的吗?实际上,在返回之前我修改了x,所以如果我不能move它,那么修改它也可能不安全,这两者都会让我的实现受到很大的打击。 - Tientuinë
@Tientuinë: std::move(x)static_cast<decltype(x)&&>(x),它的作用是将 x 转换为右值引用。另一方面,std::forward(x) 会根据参数生成一个 右值引用左值引用(即如果参数为左值,则产生左值引用,否则产生右值引用)。它们具有不同的目的,move 保证 右值引用forward 则进行选择。至于返回值,在函数内部,如果参数是引用,则可以使用 move 并确保返回值的移动构造。 - David Rodríguez - dribeas
@DavidRodríguez-dribeas:好的,我明白了。但是重载选择的规则怎么样呢?当我有一个语句c = a + b;,所有变量都具有类型T,那么我是否保证调用operator+(T const&, T const&)而不是operator+(T&&, T&&)?由于后者将移动其中一个参数,我不希望在这种情况下调用它,但是当参数是临时对象时,我确实希望调用它。 - Tientuinë
显示剩余3条评论

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