44

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

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

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

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)) {}

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

4

4 回答 4

37

实际上,这就是引入完美转发的确切原因。将构造函数重写为

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

但是按照 Ilya Popov 在他的回答中建议的操作可能会简单得多。老实说,我通常这样做,因为移动的目的是为了便宜,而再移动一次并不会显着改变事情。

正如 Howard Hinnant所说,我的方法可能对 SFINAE 不友好,因为现在 LinearClassifier 接受构造函数中的任何类型对。巴里的回答显示了如何处理它。

于 2016-04-26T14:53:59.603 回答
31

这正是“按值传递并移动”技术的用例。尽管比左值/右值重载效率略低,但它还不算太糟糕(一个额外的动作)并且为您省去了麻烦。

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

在左值参数的情况下,将有一个副本和一个移动,在右值参数的情况下,将有两个移动(前提是您类LossOptimizer实现移动构造函数)。

更新:一般来说,完美的转发解决方案效率更高。另一方面,该解决方案避免了并非总是可取的模板化构造函数,因为它在不受 SFINAE 约束时将接受任何类型的参数,如果参数不兼容,则会导致构造函数内部出现硬错误。换句话说,不受约束的模板化构造函数对 SFINAE 不友好。有关避免此问题的受约束模板构造函数,请参阅Barry 的答案。

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

更新 2:Herb Sutter 在 1:03:48开始的 CppCon 2014 演讲“回归基础”中谈到了这个问题。他首先讨论了按值传递,然后在 rvalue-ref 上重载,然后在 1:15:22完美转发,包括约束。最后,他谈到构造函数是在 1:25:50传递值的唯一好用例。

于 2016-04-26T14:54:34.453 回答
29

为了完整起见,最佳的 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移动起来很便宜(或者,更好的是,这个构造函数的性能完全无关紧要),更喜欢Ilya Popov 的解决方案

LinearClassifier(Loss loss, Optimizer optimizer)
: _loss(std::move(loss))
, _optimizer(std::move(optimizer))
{ }
于 2016-04-26T16:03:10.650 回答
16

你想去兔子洞多远?

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


在大多数情况下,要么移动如此便宜,两次都是免费的,要么移动就是复制。

如果 move 是 copy,并且 copy 是 non-free,则取参数 by const&。如果没有,请按价值计算。

这将基本上表现最佳,并使您的代码更容易理解。

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

对于一个便宜的移动Loss和移动即复制optimizer

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

考虑使用此解决方案。


如果移动比复制便宜但非免费,则一种方法是基于完美转发:

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

或者更复杂和更易于重载的:

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基本上,如果您对该子句不小心,则可以选择构造函数的错误重载。如果您有具有相同数量参数的构造函数重载,或者关心早期故障,那么您需要std::enable_if_t一个。否则,请使用更简单的。

该解决方案通常被认为是“最佳的”。它是可以接受的最优,但不是最优的。


下一步是使用带有元组的 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. 这允许您在对象中拥有不可复制/可移动的对象,并且可以说是最大效率的。

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

通过直接构造对象,与上述完美转发解决方案相比,我们可以消除每个对象的移动或复制。如果需要,它还允许您转发副本或移动。


最后一个可爱的技术是类型擦除构造。实际上,这需要类似的东西std::experimental::optional<T>可用,并且可能会使类更大一些。

并不比分段构造快。它确实抽象了 emplace 构造所做的工作,使其在每次使用的基础上变得更简单,并且它允许您从头文件中拆分 ctor 主体。但是在运行时和空间方面都有少量开销。

您需要从一堆样板开始。这会生成一个模板类,它表示“稍后在其他人会告诉我的地方构造一个对象”的概念。

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; }
};

我们在其中类型擦除从任意参数构造可选的操作。

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

_loss在哪里std::experimental::optional<Loss>。要删除_loss你必须使用的可选性,std::aligned_storage_t<sizeof(Loss), alignof(Loss)>并且在编写一个 ctor 来处理异常和手动销毁事物等时要非常小心。这是一个令人头疼的问题。

关于最后一种模式的一些好处是 ctor 的主体可以移出头部,并且最多生成线性数量的代码,而不是指数数量的模板构造函数。

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

代码未经测试,所以可能有错别字。


在保证省略的中,延迟 ctor 的可选部分已过时。任何返回 aT的函数都是延迟的 ctor 所需要的T

于 2016-04-26T17:18:27.717 回答