我正在为机器学习库编写一些模板化的类,我很多次都面临这个问题。我主要使用策略模式,其中类接收作为不同功能的模板参数策略,例如:
template <class Loss, class Optimizer> class LinearClassifier { ... }
问题在于构造函数。随着策略量(模板参数)的增长,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)) {}
有没有办法避免这种情况?
答案 0 :(得分:36)
实际上,这就是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显示了如何处理它。
答案 1 :(得分:30)
这正是&#34;传递值并移动&#34;的用例。技术。 虽然效率低于左值/右值超载,但它不会太糟糕(一次额外的移动)并且可以省去麻烦。
LinearClassifier(Loss loss, Optimizer optimizer)
: _loss(std::move(loss)), _optimizer(std::move(optimizer)) {}
在lvalue参数的情况下,将有一个副本和一个移动,在rvalue参数的情况下,将有两个移动(假设您的类Loss
和Optimizer
实现移动构造函数)。
更新:通常,perfect forwarding solution效率更高。另一方面,这个解决方案避免了模板化的构造函数,这些构造函数并不总是令人满意,因为它不接受SFINAE约束时会接受任何类型的参数,并且如果参数不兼容,会导致构造函数内部出现硬错误。换句话说,无约束的模板化构造函数不是SFINAE友好的。有关约束模板构造函数,请参阅Barry's answer,以避免此问题。
模板化构造函数的另一个潜在问题是需要将其放在头文件中。
更新2:Herb Sutter在他的CppCon 2014演讲中谈到了这个问题&#34;回归基础&#34; starting at 1:03:48。他首先讨论了按值传递,然后在rvalue-ref上进行了重载,然后完美转发at 1:15:22,包括约束。最后他将构造函数称为传递值at 1:25:50的唯一好用例。
答案 2 :(得分: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))
{ }
这确保我们只接受Loss
和Optimizer
类型的参数(或从它们派生的)。不幸的是,它写得非常满口,并且非常分散了原意。这很难做到 - 但如果表现很重要,那么这很重要,这真的是唯一的出路。
但如果无关紧要,并且Loss
和Optimizer
移动成本便宜(或者更好的是,此构造函数的性能完全无关紧要),则更喜欢Ilya Popov's solution:
LinearClassifier(Loss loss, Optimizer optimizer)
: _loss(std::move(loss))
, _optimizer(std::move(optimizer))
{ }
答案 3 :(得分:15)
兔子洞要走多远?
我知道有4种方法可以解决这个问题。如果你匹配它们的前提条件,你通常应该使用较早的条件,因为后来每个条件的复杂性都会显着增加。
在大多数情况下,要么移动是如此便宜,两次是免费的,或移动是复制。
如果移动是复制,并且复制不是免费的,请按const&
取参数。如果没有,请按值进行。
这将基本上表现最佳,并使您的代码更容易理解。
LinearClassifier(Loss loss, Optimizer const& optimizer)
: _loss(std::move(loss))
, _optimizer(optimizer)
{}
便宜的移动Loss
和移动复制optimizer
。
这会对&#34;最佳&#34;进行一次额外的移动。在所有情况下,每个值参数下面的完美转发(注意:完美转发不是最佳的)。只要移动很便宜,这是最好的解决方案,因为它会生成干净的错误消息,允许基于{}
的构造,并且比任何其他解决方案更容易阅读。
考虑使用此解决方案。
如果移动比复制但非免费移动便宜,一种方法是完美转发: 之一:
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))
{}
这使您有能力基于{}
构建您的参数。此外,如果调用上述代码,则可以生成指数数量的构造函数(希望它们将被内联)。
您可以以SFINAE失败为代价删除std::enable_if_t
条款;基本上,如果您对std::enable_if_t
子句不小心,可以选择构造函数的错误重载。如果你有相同数量的参数的构造函数重载,或者关心早期失败,那么你需要std::enable_if_t
。否则,请使用更简单的一个。
此解决方案通常考虑&#34;最佳&#34;。它是可接受的最佳选择,但并不是最优的。
下一步是使用带有元组的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_construct
适用于std::pair
。先传递分段构造,然后再forward_as_tuple
构造每个元素的参数(包括复制或移动ctor)。
通过直接构造对象,与上面的完美转发解决方案相比,我们可以消除每个对象的移动或复制。如果需要,它还允许您转发副本或移动。
最后一种可爱的技术是键入 - 擦除构造。实际上,这需要std::experimental::optional<T>
这样的东西可用,并且可能使类更大。
比分段构造更快。它确实抽象了emplace构造所做的工作,使其在每次使用的基础上更简单,并且它允许您从头文件中拆分ctor体。但是在运行时和空间中都存在少量开销。
你需要从一堆样板开始。这会生成一个模板类,它代表&#34;构建一个对象的概念,以后,在别人会告诉我的地方。&#34;
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
使用。但它也允许存储不可移动的物体。
代码未经过测试,因此可能存在拼写错误。
在保证省略的c++17中,延迟ctor的可选部分变得过时。任何返回T
的函数都是T
的延迟ctor所需的全部内容。