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

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

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

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

其实这就是为什么完美转发被引入的确切原因。 重写构造函数

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

但是伊利亚·波波夫(Ilya Popov)在回答中提出的build议可能会简单得多。 说实话,我通常是这样做的,因为动作是便宜的,再多一个动作也不会显着改变。

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

这正是“传值与移动”技术的用例。 虽然比左值/右值过载的效率略低,但不会太差(一个额外的步骤),并且可以节省您的麻烦。

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

在左值参数的情况下,将会有一个副本和一个移动,在右值参数的情况下,将会有两个移动(假设您的类Loss and Optimizer实现移动构造函数)。

更新:一般来说, 完美的转发解决scheme更有效率。 另一方面,这个解决scheme避免了模板化的构造函数,这并不总是可取的,因为当不受SFINAE限制时它将接受任何types的参数,并且如果参数不兼容,会导致构造函数中的硬错误。 换句话说,无约束的模板构造函数不是SFINAE友好的。 请参阅Barry的答案 ,以避免出现此问题的约束模板构造函数。

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

更新2:香草萨特在他的CppCon 2014年的演讲“回到基础” 从1:03:48开始讨论这个问题。 他先讨论传值,然后在右值处超载,然后在1:15:22完善转发,包括约束。 最后他谈到build设者是在1:25:50传递价值的唯一好用例。

为了完整起见,最佳的2参数构造函数会带两个转发引用,并使用SFINAE来确保它们是正确的types。 我们可以引入下面的别名:

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

这确保我们只接受LossOptimizertypes的参数(或从它们派生)。 不幸的是,写这本书是相当口吻,而且很偏离原来的意图。 要做到这一点很难,但是如果performance很重要的话,这很重要,而且这是唯一的出路。

但是,如果没有关系,如果LossOptimizer移动便宜(或者更好,该构造函数的性能完全不相关),则更喜欢Ilya Popov的解决scheme :

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

你想走多远的兔子洞?

我知道有4种不错的方式来解决这个问题。 如果符合先决条件,通常应该使用先前的先决条件,因为每个先决条件的复杂性都会显着增加。


大多数情况下,两次都是免费的,或者移动是复制。

如果移动是复制,并且复制是非空闲的,则使用const&参数。 如果没有,请按价值收购。

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

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

对于移动便宜的Loss和move-is-copy optimizer

在所有情况下,每个值参数在下面的“最佳”完美转发(注意:完美转发不是最佳)上做了额外的移动。 只要移动便宜,这是最好的解决scheme,因为它生成干净的错误消息,允许基于构造,比其他解决scheme更容易阅读。

考虑使用这个解决scheme。


如果移动比复制还要便宜,则一种方法是基于完美的转发:或者:

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

这会使您有能力基于{}构build您的论点。 另外,如果被调用,上面的代码可以产生多达指数的构造函数(希望它们会被内联)。

您可以以SFINAE失败为代价来删除std::enable_if_t子句; 基本上,你的构造函数的错误重载可以被挑选,如果你不小心使用该std::enable_if_t子句。 如果构造函数重载的参数数量相同,或者关心早期失败,那么你需要std::enable_if_t 。 否则,使用更简单的一个。

这个解决scheme通常被认为是“最优化的”。 它是可接受的最佳,但它不是最优化的。


下一步是使用元组来构buildemplace构造。

 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内build设。 这允许您在对象中拥有非复制/可移动的对象,并且可以说是最有效的。

为了看看这是如何工作的,现在的例子是piecewise_constructstd::pair 。 你首先传递分段构造,然后forward_as_tuple参数构造每个元素(包括复制或移动ctor)。

通过直接构build对象,与上述完美转发解决scheme相比,我们可以消除每个对象的移动或复制。 它也可以让你转发一个副本或移动如果需要的话。


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

并不比分段施工快。 它可以抽象出emplace构造所做的工作,使其在每次使用的基础上变得更简单,并且允许您从头文件中分割ctor正文。 但是在运行时和空间上都有less量的开销。

有一堆你需要开始的样板。 这将生成一个模板类,它表示“稍后在别人会告诉我的地方构build一个对象”的概念。

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

where _lossstd::experimental::optional<Loss> 。 要删除_loss的select性,你必须使用std::aligned_storage_t<sizeof(Loss), alignof(Loss)>并且要非常小心地写一个ctor来处理exception并手动销毁东西等等。这是一个头痛的问题。

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

这个解决scheme的效率比放置构build版本略低,因为并不是所有的编译器都能够内联std::function使用。 但是它也允许存储不可移动的对象。

代码没有经过testing,所以可能有错别字。