避免在构造函数中使用常量引用和右值引用的指数级增长。

44

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

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

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

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

有没有什么方法可以避免这种情况?


17
使用转发引用? - Piotr Skotnicki
2
这个问题没有一个通用的答案适用于所有可能是“Loss”和“Optimizer”的类型。最佳答案取决于一些细节,例如:复制“Loss”和“Optimizer”是否昂贵,但移动成本较低?代码的编写者和维护者是否习惯于限制模板(例如,使用“enable_if”)?有时候通过传值来解决问题可能更好。如果您采用转发引用的解决方案,我强烈建议对其进行适当的约束。如果仅有“Loss”和“Optimizer”中的一个可轻松移动,则可以考虑混合解决方案。 - Howard Hinnant
@Unda 不完全正确:为了使这些右值引用转化为转发引用,LossOptimizer必须是推导类型。 - Quentin
4
我认为问题中的代码不仅复杂,而且本质上是错误的。看一下初始化程序_loss(loss)。即使loss是类型为Loss&&,该初始化程序仍将把loss视为lvalue。这很重要,但不直观。@Federico,你是否认为_loss(loss)会从Loss&& loss中“移动”?实际上,它将被复制进去。 - Aaron McDaid
1
_loss_optimizer是值还是引用? - M.M
显示剩余7条评论
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在构造函数中接受任何一对类型。Barry的答案显示了如何处理它。

3
现在我们有两个回复都使用了“这正是适用于”的表达方式 - 两种不同的方法,我都觉得很有道理。是否有人可以澄清一下这两种方法是否都好,或者为什么我们甚至不考虑另一个方法,或者它们是否真的可以互换? - peterchen
1
@FedericoAllocati 是的,它确实如此。 - lisyarus
1
不是所有的举动都很廉价...有些举动是复制。 - Barry
1
模板化构造函数并非总是理想的,这可能会使“复制和移动”更具吸引力。 - Ilya Popov
7
这个设计是一个不错的方向,但它存在一个可能很严重的缺陷:std::is_constructible<LinearClassifier, int, int>::valuetrue(你可以用任何值替换int)。如果您不关心这个问题,那就没问题了。但是正确的SFINAE变得越来越重要。要修复这个问题,您可以采用其他答案中的按值传递的解决方案,或者约束LO只能实例化为LossOptimizer,本回答尚未解释如何做到这一点。 - Howard Hinnant
显示剩余4条评论

31

这正是“传值和移动”技术的使用案例。虽然比lvalue/rvalue重载略微低效,但也不太糟糕(只需要一个额外的移动),而且可以节省麻烦。

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

对于左值参数,将有一个复制和一个移动操作;对于右值参数,将有两个移动操作(前提是你的类 LossOptimizer 实现了移动构造函数)。
更新:一般来说,完美转发方案 更有效率。另一方面,这种解决方案避免了模板化构造函数,这并不总是理想的,因为当没有使用 SFINAE 进行约束时,它将接受任何类型的参数,并在构造函数内导致严重错误如果参数不兼容。换句话说,未受约束的模板化构造函数不符合 SFINAE。请参见 Barry's answer 以获取避免此问题的受约束模板构造函数的答案。
模板化构造函数的另一个潜在问题是需要将其放置在头文件中。

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


“完美的转发解决方案更有效率。”实际上,它可能会更有效率。 - edmz
我没有理解“因为它将每个参数类型都不受SFINAE限制,如果参数不兼容,则会在构造函数内导致严重错误”的部分。头文件的放置不是问题,因为这是一个仅包含头文件的库 :) - Federico Allocati
@lisyarus所示的构造函数将适用于任何具有两个参数的调用,无论它们的类型如何。这导致了几个后果:您不能有任何其他具有两个参数的构造函数,如果某些其他代码尝试使用您的构造函数进行任何SFINAE技巧,则不起作用(因为构造函数将接受任何类型,然后在构造函数体内产生错误)。 (请参见Howard Hinnant的评论以获取示例)。 - Ilya Popov
"模板构造函数不友好于SFINAE" 这只是重言。非SFINAE友好的构造函数模板不友好于SFINAE...但友好的则是... - Barry
@Barry,这就是我说“然后不受限制”的原因。当然,如果适当地加以限制,它们是SFINAE友好的。 - Ilya Popov

30

为了完整起见,最佳的两个参数构造函数将采用两个转发引用,并使用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))
{ }

1
有趣的约束选择。我同意,这很难做到正确,而且我因为在这方面出了名地做错了而感到内疚(https://www.youtube.com/watch?v=xnqTKD8uD64):-)。那么使用`std::is_convertible<L, Loss>作为约束怎么样?这将允许一个const char* L构造一个std::string Loss`(例如)。并且也允许你的派生类->基类的例子。 - Howard Hinnant
@HowardHinnant 可以使用 std::is_constructible<Loss, L&&>。只是想尽可能严格地实现通用性。但是,是的,很难选择...我在那个视频中寻找什么? :) - Barry
就在那次演讲前一个小时,在我离开城镇的路上,赫伯特问我这类问题应该是什么限制条件。 在演讲结束时(1:15:00 在?),我想得太多了,答案错了。没有什么比测试更好了! :-) - Howard Hinnant
@Howard 我认为这个约束很好!所以你要求用户明确表示 - 这几乎不值得称赞。 - Barry
这里有一些使用折叠表达式的“语法糖”,可以将 -IMO 很容易阅读的表达式 forward_compatible_v<std::pair<L, Loss>, std::pair<O, Optimizer>> 传递给 enable_if_t 约束。 - TemplateRex
如何在使用std::forward的构造函数中确保lvalue对象不会被更改?这里我们有L&& loss,如果我们传递一个lvalue,则会得到L& loss,并且它可能会被更改。通常的做法是使用const SomeType&,但是在这里我们不能只写const L&& loss,因为我们想要具有移动能力。解决方案是什么? - I.S.M.

16

你想要深入了解这个问题吗?

我知道4种解决这个问题的方法。如果你符合早期方法的前提条件,通常应该使用它们,因为每个后续方法的复杂性都会显著增加。


在大多数情况下,无论是移动还是复制,两次操作的成本都很低。如果移动是复制,并且复制是非免费的,则通过const&获取参数。否则,通过值获取参数。这将基本上以最优方式运行,并使您的代码更易于理解。
LinearClassifier(Loss loss, Optimizer const& optimizer)
  : _loss(std::move(loss))
  , _optimizer(optimizer)
{}

对于一个成本低廉的Loss和移动即复制的optimizer

相对于下面的“最优”完美转发,每个值参数在所有情况下都会多进行1次移动(注意:完美转发并不是最优)。 只要移动成本低廉,这就是最佳解决方案,因为它可以生成清晰的错误消息,允许基于{}的构造,并且比其他任何解决方案都容易阅读。

考虑使用此解决方案。


如果移动操作比复制操作便宜但不免费,一种完美的转发方法是基于以下两种方式之一: 要么:
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。否则,请使用更简单的一个。

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


下一步是使用元组进行就地构造。
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内部。这样可以在对象中拥有不可复制/移动的对象,并且可能是最大效率的。

为了看看它是如何工作的,现在让我们以std::pair为例来说明piecewise_construct的工作方式。您首先传递piecewise construct,然后使用forward_as_tuple构造每个元素的参数(包括复制或移动构造函数)。

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


一种可爱的最终技巧是类型擦除构造。实际上,这需要类似于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);
}

其中_lossstd::experimental::optional<Loss>。为了消除_loss的可选性,您需要使用std::aligned_storage_t<sizeof(Loss), alignof(Loss)>并非常小心地编写一个ctor来处理异常和手动销毁等问题。这真是令人头疼。

这种模式的一些好处是ctor的主体可以移出标头,最多只会生成线性数量的代码,而不是指数数量的模板构造函数。

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

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


在C++17中,通过保证省略,延迟构造函数的可选部分已经过时。任何返回T的函数都足以成为T的延迟构造函数。请参考

3
我不确定自己应该感到恶心还是惊叹。 - isanae
1
在某种程度上是可以的;但是在C中执行相同类型的操作将会更加笨重,完全无法维护,并且每次想要使用它时都几乎不可能避免重复。delayed_construct<T>(最疯狂的部分)实际上有一个非常短的“每次使用”体(与第一种解决方案长度相同!),而在C中执行它所做的事情将会是一个真正的头痛。你最好在达到那个点之前就放弃了,也没有机会以通用的方式实现它。在C++中,我只需编写一次混乱的代码(而且这段代码比C等效代码还要),就可以重复使用它。 - Yakk - Adam Nevraumont
1
@cmaster 现在,我可能不会使用它;除非极端情况下,否则我会选择 #1。而且 #1 已经比等效的 C 实现短得离谱了。C 解决方案可能与 #1 一样短,但这是因为它通常不会像 #1 那样在“底层”执行相同数量的边角情况优化操作。 - Yakk - Adam Nevraumont
如何在使用std::forward的构造函数中确保lvalue对象不被更改? 这里我们有L&& loss,如果我们传递lvalue,则会得到L&loss,并且它可能会被更改。 好的实践是始终使用const SomeType&,但是这里我们不能只写const L && loss,因为我们想要具有移动能力。 解决方案是什么? - I.S.M.
@i.s.m. 可以被更改。使用 const& 传递参数并不能防止其被更改,使用 const_cast 去除 const 是合法的 C++ 操作。而且 rvalue 通过移动操作也会被更改。或者你担心函数体出现问题?如果这是你的顾虑,你可以编写一个移动或复制转发器来解决。 - Yakk - Adam Nevraumont
显示剩余3条评论

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