避免构造函数中 const 引用和右值引用的指数增长

Avoid exponential grow of const references and rvalue references in constructor

我正在为机器学习库编写一些模板 类,我多次遇到这个问题。我主要使用策略模式,其中 类 接收不同功能的模板参数策略,例如:

template <class Loss, class Optimizer> class LinearClassifier { ... }

问题出在构造函数上。随着策略(模板参数)数量的增长,const 引用和右值引用的组合呈指数级增长。在前面的例子中:

LinearClassifier(const Loss& loss, const Optimizer& optimizer) : _loss(loss), _optimizer(optimizer) {}

LinearClassifier(Loss&& loss, const Optimizer& optimizer) : _loss(std::move(loss)), _optimizer(optimizer) {}

LinearClassifier(const Loss& loss, Optimizer&& optimizer) : _loss(loss), _optimizer(std::move(optimizer)) {}

LinearClassifier(Loss&& loss, Optimizer&& optimizer) : _loss(std::move(loss)), _optimizer(std::move(optimizer)) {}

有什么办法可以避免这种情况吗?

实际上,这正是引入 perfect forwarding 的确切原因。重写构造函数为

template <typename L, typename O>
LinearClassifier(L && loss, O && optimizer)
    : _loss(std::forward<L>(loss))
    , _optimizer(std::forward<O>(optimizer))
{}

但按照 Ilya Popov 在其 中的建议进行操作可能会简单得多。老实说,我通常这样做,因为走法是为了便宜,再走一步也不会发生太大的变化。

正如 Howard Hinnant , my method can be SFINAE-unfriendly, since now LinearClassifier accepts any pair of types in constructor. 展示了如何处理它。

这正是 "pass by value and move" 技术的用例。 尽管效率略低于 lvalue/rvalue 重载,但它还不错(一个额外的步骤)并为您省去了麻烦。

LinearClassifier(Loss loss, Optimizer optimizer) 
    : _loss(std::move(loss)), _optimizer(std::move(optimizer)) {}

如果是左值参数,就会有一份拷贝和一次移动,如果是右值参数,则会有两次移动(前提是你类 LossOptimizer 实现移动构造函数)。

更新:一般来说, is more efficient. On the other hand, this solution avoids templated constructors which are not always desirable, because it will accept arguments of any type when not constrained with SFINAE and lead to hard errors inside the constructor if arguments are not compatible. In other words, unconstrained templated constructors are not SFINAE-friendly. See 用于避免此问题的约束模板构造函数。

模板化构造函数的另一个潜在问题是需要将它放在头文件中。

更新 2:Herb Sutter 在他的 CppCon 2014 演讲中谈到了这个问题 "Back to the Basics" starting at 1:03:48. He discusses pass by value first, then overloading on rvalue-ref, then perfect forwarding at 1:15:22 including constraining. And finally he talks about constructors as the only good use case for passing by value at 1:25:50.

为了完整性,最佳的 2 参数构造函数将采用两个转发引用并使用 SFINAE 来确保它们是正确的类型。我们可以引入如下别名:

template <class T, class U>
using decays_to = std::is_convertible<std::decay_t<T>*, U*>;

然后:

template <class L, class O,
          class = std::enable_if_t<decays_to<L, Loss>::value &&
                                   decays_to<O, Optimizer>::value>>
LinearClassifier(L&& loss, O&& optimizer)
: _loss(std::forward<L>(loss))
, _optimizer(std::forward<O>(optimizer))
{ }

这确保我们只接受类型为 LossOptimizer 的参数(或派生自它们)。不幸的是,它写起来很啰嗦,而且很容易偏离初衷。这很难做到正确 - 但如果性能很重要,那么它就很重要,这确实是唯一的方法。

但是如果没关系,并且 LossOptimizer 移动起来很便宜(或者,更好的是,这个构造函数的性能完全无关紧要),更喜欢 :

LinearClassifier(Loss loss, Optimizer optimizer)
: _loss(std::move(loss))
, _optimizer(std::move(optimizer))
{ }

你想在兔子洞里走多远?

我知道有 4 种不错的方法可以解决这个问题。如果你符合他们的先决条件,你通常应该使用较早的那些,因为每个后面的都会显着增加复杂性。


在大多数情况下,要么是太便宜了,做两次是免费的,要么就是复制。

如果move是copy,copy是non-free,取参数const&。如果没有,按值取值。

这基本上会以最佳方式运行,并使您的代码更容易理解。

LinearClassifier(Loss loss, Optimizer const& optimizer)
  : _loss(std::move(loss))
  , _optimizer(optimizer)
{}

对于 cheap-to-move Loss 和 move-is-copy optimizer

在所有情况下,每个值参数都比下面的 "optimal" 完美转发(注意:完美转发不是最优的)多移动 1 步。只要 move 便宜,这就是最好的解决方案,因为它会生成干净的错误消息,允许基于 {} 的构造,并且比任何其他解决方案都更容易阅读。

考虑使用此解决方案。


如果移动比复制便宜 non-free,一种方法是基于完美转发: 或者:

template<class L, class O    >
LinearClassifier(L&& loss, O&& optimizer)
  : _loss(std::forward<L>(loss))
  , _optimizer(std::forward<O>(optimizer))
{}

或者越复杂越多overload-friendly:

template<class L, class O,
  std::enable_if_t<
    std::is_same<std::decay_t<L>, Loss>{}
    && std::is_same<std::decay_t<O>, Optimizer>{}
  , int> * = nullptr
>
LinearClassifier(L&& loss, O&& optimizer)
  : _loss(std::forward<L>(loss))
  , _optimizer(std::forward<O>(optimizer))
{}

这会使您失去 {} 构建论点的能力。此外,如果调用上述代码,最多可以生成指数数量的构造函数(希望它们将被内联)。

您可以删除 std::enable_if_t 子句,代价是 SFINAE 失败;基本上,如果您不小心 std::enable_if_t 子句,则可以选择构造函数的错误重载。如果您有具有相同数量参数的构造函数重载,或者关心 early-failure,那么您需要 std::enable_if_t。否则,使用更简单的。

此解决方案通常 被视为"most optimal"。它是可以接受的最佳选择,但不是最佳选择。


下一步是对元组使用 emplace 构造。

private:
template<std::size_t...LIs, std::size_t...OIs, class...Ls, class...Os>
LinearClassifier(std::piecewise_construct_t,
  std::index_sequence<LIs...>, std::tuple<Ls...>&& ls,
  std::index_sequence<OIs...>, std::tuple<Os...>&& os
)
  : _loss(std::get<LIs>(std::move(ls))...)
  , _optimizer(std::get<OIs>(std::move(os))...)
{}
public:
template<class...Ls, class...Os>
LinearClassifier(std::piecewise_construct_t,
  std::tuple<Ls...> ls,
  std::tuple<Os...> os
):
  LinearClassifier(std::piecewise_construct_t{},
    std::index_sequence_for<Ls...>{}, std::move(ls),
    std::index_sequence_for<Os...>{}, std::move(os)
  )
{}

我们将构造推迟到 LinearClassifier 内部。这允许您在 object 中拥有 non-copy/moveable objects,并且可以说是最有效的。

要了解这是如何工作的,示例现在 piecewise_constructstd::pair 一起使用。您首先传递分段构造,然后 forward_as_tuple 之后构造每个元素的参数(包括复制或移动 ctor)。

通过直接构建objects,与上面的perfect-forwarding解决方案相比,我们可以消除每个object的移动或复制。如果需要,它还允许您转发副本或移动。


最后一个可爱的技巧是type-erase构造。实际上,这需要像 std::experimental::optional<T> 这样的东西可用,并且可能会使 class 大一点。

比分段构造快。它确实抽象了 emplace 构造所做的工作,使其在 per-use 基础上更简单,并且它允许您从 header 文件中拆分 ctor body。但是在运行时和 space.

中都有少量开销

您需要从一堆样板开始。这会生成一个模板 class,代表 "constructing an object, later, at a place someone else will tell me."

的概念
struct delayed_emplace_t {};
template<class T>
struct delayed_construct {
  std::function< void(std::experimental::optional<T>&) > ctor;
  delayed_construct(delayed_construct const&)=delete; // class is single-use
  delayed_construct(delayed_construct &&)=default;
  delayed_construct():
    ctor([](auto&op){op.emplace();})
  {}
  template<class T, class...Ts,
    std::enable_if_t<
      sizeof...(Ts)!=0
      || !std::is_same<std::decay_t<T>, delayed_construct>{}
    ,int>* = nullptr
  >
  delayed_construct(T&&t, Ts&&...ts):
    delayed_construct( delayed_emplace_t{}, std::forward<T>(t), std::forward<Ts>(ts)... )
  {}
  template<class T, class...Ts>
  delayed_construct(delayed_emplace_t, T&&t, Ts&&...ts):
    ctor([tup = std::forward_as_tuple(std::forward<T>(t), std::forward<Ts>(ts)...)]( auto& op ) mutable {
      ctor_helper(op, std::make_index_sequence<sizeof...(Ts)+1>{}, std::move(tup));
    })
  template<std::size_t...Is, class...Ts>
  static void ctor_helper(std::experimental::optional<T>& op, std::index_sequence<Is...>, std::tuple<Ts...>&& tup) {
    op.emplace( std::get<Is>(std::move(tup))... );
  }
  void operator()(std::experimental::optional<T>& target) {
    ctor(target);
    ctor = {};
  }
  explicit operator bool() const { return !!ctor; }
};

我们type-erase从任意参数构造一个可选的动作。

LinearClassifier( delayed_construct<Loss> loss, delayed_construct<Optimizer> optimizer ) {
  loss(_loss);
  optimizer(_optimizer);
}

其中 _lossstd::experimental::optional<Loss>。要删除 _loss 的可选性,您必须使用 std::aligned_storage_t<sizeof(Loss), alignof(Loss)> 并且在编写 ctor 来处理异常和手动销毁东西等时要非常小心。这很让人头疼。

关于最后一个模式的一些好处是 ctor 的 body 可以移出 header,并且最多生成线性数量的代码而不是指数数量的代码模板构造器。

此解决方案的效率略低于放置构造版本,因为并非所有编译器都能够内联 std::function 使用。但它也允许存储 non-movable objects.

代码未经测试,因此可能存在拼写错误。


在保证省略的 中,延迟构造函数的可选部分已过时。任何返回 T 的函数都是 T.

的延迟构造函数所需要的