【问题标题】:Avoid exponential grow of const references and rvalue references in constructor避免构造函数中 const 引用和 rvalue 引用的指数增长
【发布时间】:2016-08-20 11:44:22
【问题描述】:

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

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

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

【问题讨论】:

  • 使用转发引用?
  • 然后你可以坚持使用最后一个构造函数(我认为,对吧?)
  • 这个问题的答案并不总是适用于所有可能是LossOptimizer 的类型。最佳答案取决于以下细节:LossOptimizer 复制成本高但移动成本低吗?此代码的编写者和维护者是否对约束​​模板 (e.g. enable_if) 感到满意?值传递解决方案有时是可行的方法。如果您使用转发参考解决方案,我强烈建议您适当地限制它。如果LossOptimizer 中只有一个可以廉价移动,则可以考虑使用混合解决方案。
  • 我认为问题中的代码不仅复杂,而且本质上是不正确的。查看初始化程序_loss(loss)。即使lossLoss&amp;&amp; 类型,这个初始化器仍然会将loss 视为一个左值。这很重要,如果不直观的话。 @Federico,您是否认为_loss(loss) 会从Loss&amp;&amp; loss “移动”?事实上,它会被复制进去。
  • _loss_optimizer 值还是引用?

标签: c++ c++11 rvalue-reference const-reference


【解决方案1】:

你想去兔子洞多远?

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


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

如果move是copy,copy是non-free,取const&amp;的参数。如果没有,请按价值计算。

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

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&lt;T&gt; 之类的东西可用,并且可能会使类更大一些。

比分段构造快。它确实抽象了 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);
}

其中_lossstd::experimental::optional&lt;Loss&gt;。要删除 _loss 的可选性,您必须使用 std::aligned_storage_t&lt;sizeof(Loss), alignof(Loss)&gt; 并非常小心地编写一个 ctor 来处理异常和手动销毁事物等。这是一个令人头疼的问题。

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

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

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


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

【讨论】:

  • 我只是喜欢代码随着“最优性”的每次迭代而爆炸的方式;-) 不知何故,C++ 似乎已经把 C 的所有简单性抛在了脑后……
  • 我不知道我应该感到厌恶还是敬畏。
  • @cmaster 在某种程度上;但是在 C 中执行相同类型的操作会更加庞大且完全无法维护,并且几乎不可能在每次要使用它时都重复进行。 delayed_construct&lt;T&gt;(这是最疯狂的)实际上有一个非常短的“每次使用”主体(与第一个解决方案的长度相同!),它的作用在 C 中将是一个真正的头痛。你最好早点放弃你达到了那个实际发生的事情,并且没有机会做一般性的事情。在 C++ 中,我编写了一次混乱(并且混乱比 C 等价物),并且可以重用它。
  • @cmaster 现在,我可能不会使用它;除非极端情况,否则我会主张#1。并且 #1 已经比等效的 C 实现短得离谱。 C 解决方案可能与 #1 一样短,但这是因为它通常不会像 #1 在“幕后”所做的那样做同样数量的极端情况优化。跨度>
  • 我怎样才能在这样的构造函数中使用 std::forward 提供不会更改的左值对象?这里我们有 L&& 损失,如果我们给出 lvalue - 我们将有 L& 损失,它可以改变。每次的好习惯是使用 const SomeType&,但是这里我们不能只写 const L&& loss,因为我们想要有移动的能力。解决办法是什么?
【解决方案2】:

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

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

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

作为 Howard Hinnant has told,我的方法可能对 SFINAE 不友好,因为现在 LinearClassifier 接受构造函数中的任何类型对。 Barry's answer 展示了如何处理它。

【讨论】:

  • 我们现在有两个响应 'this is [exactly the |的精确]用例' - 两种不同的方法,这两种方法对我来说都很有意义。谁能澄清两者是否都很好,或者为什么我们甚至不应该考虑另一个,或者它们是否确实可以互换?
  • @FedericoAllocati 是的,确实如此。
  • 并非所有招式都很便宜...有些招式是复制品。
  • 模板化构造函数并不总是可取的,它可以使“复制和移动”更具吸引力。
  • 这个设计是一个不错的方向,但就目前而言,有一个可能很严重的缺陷:std::is_constructible&lt;LinearClassifier, int, int&gt;::valuetrue(你可以为int 加入任何你想要的东西)。如果你不在乎,没关系。但是正确的 SFINAE 变得越来越重要。要解决此问题,您可以使用另一个答案的按值解决方案,或者您限制 LO 以便它们只会为 LossOptimizer 实例化,而这个答案还没有(还) 解释如何做到这一点。
【解决方案3】:

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

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

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

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

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

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

【讨论】:

  • “完美的转发解决方案更高效。” 实际上,它可能更高效。
  • 我不理解“”的部分,因为它会使每个参数类型不受 SFINAE 约束,如果参数不兼容,则会导致构造函数内部出现硬错误。头文件放置不是问题,因为这是一个只有头文件的库:)
  • @lisyarus 显示的构造函数将适合任何带有两个参数的调用,无论它们的类型如何。这会导致几个后果:您不能有任何其他带有两个参数的构造函数,并且如果其他代码尝试使用您的构造函数执行任何 SFINAE 技巧,它将不起作用(因为构造函数将接受任何类型,然后在内部产生错误构造函数体)。 (有关示例,请参见 Howard Hinnant 的评论)。
  • "模板化构造函数对 SFINAE 不友好" 这只是同义反复。非 SFINAE 友好的构造函数模板不是 SFINAE 友好的......但 SFINAE 友好的......
  • @Barry 这就是我说“然后不受约束”的原因。当然,如果约束得当,它们对 SFINAE 友好。
【解决方案4】:

为了完整起见,最佳的 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's solution

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

【讨论】:

  • 有趣的约束选择。我同意,这很难做到正确,而且我因臭名昭著的错误而陷入困境(youtube.com/watch?v=xnqTKD8uD64):-)。使用std::is_convertible&lt;L, Loss&gt; 进行约束怎么样?这将(例如)允许const char* L 构造std::string Loss。并且还允许您的 Derived -> Base 示例。
  • @HowardHinnant 也可以使用std::is_constructible&lt;Loss, L&amp;&amp;&gt;。只是想对通用性尽可能严格。但是,是的,很难选择......我在那个视频中寻找什么? :)
  • 就在那次谈话前一个小时,在我出城的路上,赫伯问我对于这样的问题应该有什么限制。演讲接近尾声(1:15:00?)。我想多了,弄错了。没有什么比测试更重要的了! :-)
  • @Howard 我认为约束很好!所以你要求用户是明确的 - 这几乎不是臭名昭着的
  • 这里有一些 syntactic sugar 使用折叠表达式,允许将 -IMO 非常易读的表达式 forward_compatible_v&lt;std::pair&lt;L, Loss&gt;, std::pair&lt;O, Optimizer&gt;&gt; 传递给 enable_if_t 约束。
猜你喜欢
  • 1970-01-01
  • 2011-09-12
  • 1970-01-01
  • 2014-04-15
  • 2021-09-17
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多