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

Fed*_*ati 41 c++ rvalue-reference const-reference c++11

我正在为一个机器学习库编写一些模板化的类,我很多时候都面临这个问题.我主要使用策略模式,其中类接收作为不同功能的模板参数策略,例如:

template <class Loss, class Optimizer> class LinearClassifier { ... }
Run Code Online (Sandbox Code Playgroud)

问题在于构造函数.随着策略量(模板参数)的增长,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)) {}
Run Code Online (Sandbox Code Playgroud)

有没有办法避免这种情况?

lis*_*rus 36

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

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

但是,做到伊利亚·波波夫在答案中建议的内容可能要简单得多.说实话,我通常这样做,因为移动的目的是便宜,而且一次移动不会戏剧性地改变.

由于霍华德Hinnant(欣南特)已经告诉我的方法可以SFINAE不友好,因为现在LinearClassifier接受构造任何一对类型.巴里的答案显示了如何处理它.

  • 这个设计是一个不错的方向,但是就目前而言,它有一个可能很严重的缺陷:`std :: is_constructible <LinearClassifier,int,int> :: value`是'true`(你可以在任何你想要`int`).如果你不在乎,好的.但正确的SFINAE正变得越来越重要.要解决这个问题,你可以选择其他答案中的by-value解决方案,或者约束`L`和`O`,这样他们只会实例化`Loss`和`Optimizer`,而这个答案却没有( )解释如何做到这一点. (7认同)
  • 我们现在有两个回复*'这是[完全是| 用于'* - 两种不同方法的精确用例,这两种方法对我来说都很有意义.任何人都可以澄清两者是好还是为什么我们不应该考虑另一个,或者它们是否确实可以互换? (3认同)

Ily*_*pov 30

这正是"通过价值和移动"技术的用例.虽然效率低于左值/右值超载,但它不会太糟糕(一次额外的移动)并且可以省去麻烦.

LinearClassifier(Loss loss, Optimizer optimizer) 
    : _loss(std::move(loss)), _optimizer(std::move(optimizer)) {}
Run Code Online (Sandbox Code Playgroud)

在lvalue参数的情况下,将有一个副本和一个移动,在rvalue参数的情况下,将有两个移动(假设您分类LossOptimizer实现移动构造函数).

更新:通常,完美的转发解决方案更有效.另一方面,这个解决方案避免了模板化的构造函数,这些构造函数并不总是令人满意,因为它不接受SFINAE约束时会接受任何类型的参数,并且如果参数不兼容,会导致构造函数内部出现硬错误.换句话说,无约束的模板化构造函数不是SFINAE友好的.请参阅Barry对受约束的模板构造函数的回答,以避免此问题.

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

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


Bar*_*rry 29

为了完整起见,最优的2参数构造函数将采用两个转发引用并使用SFINAE来确保它们是正确的类型.我们可以介绍以下别名:

template <class T, class U>
using decays_to = std::is_convertible<std::decay_t<T>*, U*>;
Run Code Online (Sandbox Code Playgroud)

然后:

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))
{ }
Run Code Online (Sandbox Code Playgroud)

这确保了我们只接受类型LossOptimizer(或从它们派生)的参数.不幸的是,它写得非常满口,并且非常分散了原意.这很难做到 - 但如果表现很重要,那么这很重要,这真的是唯一的出路.

但是,如果它没有关系,如果LossOptimizer是廉价移动(或者更好的是,这种构造性能完全无关),喜欢伊利亚·波波夫的解决方案:

LinearClassifier(Loss loss, Optimizer optimizer)
: _loss(std::move(loss))
, _optimizer(std::move(optimizer))
{ }
Run Code Online (Sandbox Code Playgroud)


Yak*_*ont 15

兔子洞要走多远?

我知道有4种方法可以解决这个问题.如果匹配它们的前置条件,通常应该使用较早的条件,因为后者在复杂性方面会显着增加.


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

如果move是copy,并且copy是非自由的,请使用参数by const&.如果没有,按价值取值.

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

LinearClassifier(Loss loss, Optimizer const& optimizer)
  : _loss(std::move(loss))
  , _optimizer(optimizer)
{}
Run Code Online (Sandbox Code Playgroud)

对于便宜的移动Loss和移动复制optimizer.

在所有情况下,这对于每个值参数下面的"最佳"完美转发(注意:完美转发不是最佳的)进行1次额外移动.只要移动便宜,这是最好的解决方案,因为它可以生成干净的错误消息,允许{}基于结构,并且比任何其他解决方案更容易阅读.

考虑使用此解决方案.


如果移动比复制但非自由便宜,一种方法是完美的转发基础:或者:

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

或者更复杂,更容易过载:

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))
{}
Run Code Online (Sandbox Code Playgroud)

这会让你有能力{}根据你的论点进行构建.此外,如果调用上述代码,则可以生成指数数量的构造函数(希望它们将被内联).

您可以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)
  )
{}
Run Code Online (Sandbox Code Playgroud)

在哪里我们推迟建设直到内部LinearClassifier.这允许您在对象中具有非复制/可移动对象,并且可以说是最有效的.

要了解其工作原理,现在piecewise_construct可以使用示例std::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; }
};
Run Code Online (Sandbox Code Playgroud)

我们在哪里键入 - 擦除从任意参数构造可选项的操作.

LinearClassifier( delayed_construct<Loss> loss, delayed_construct<Optimizer> optimizer ) {
  loss(_loss);
  optimizer(_optimizer);
}
Run Code Online (Sandbox Code Playgroud)

哪里_lossstd::experimental::optional<Loss>.要删除_loss你必须使用的选项,std::aligned_storage_t<sizeof(Loss), alignof(Loss)>并非常小心编写一个ctor来处理异常并手动破坏事物等.这是一个令人头疼的问题.

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

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

代码未经过测试,因此可能存在错别字.


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

  • 我不确定我是否应该反感或敬畏. (3认同)