避免构造函数中常量引用和右值引用呈指数增长
Avoid exponential grow of const references and rvalue references in constructor
我正在为机器学习库编写一些模板化类,我经常遇到这个问题。我主要使用策略模式,其中类接收不同功能的模板参数策略,例如:
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)) {}
有没有办法避免这种情况?
实际上,这就是引入完美转发的确切原因。将构造函数重写为
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接受构造函数中的任何一对类型。巴里的回答显示了如何处理它。
这正是"按值传递和移动"技术的用例。虽然效率略低于左值/右值重载,但它还不错(多走一步),为您省去了麻烦。
LinearClassifier(Loss loss, Optimizer optimizer)
: _loss(std::move(loss)), _optimizer(std::move(optimizer)) {}
在左值参数的情况下,将有一个副本和一个移动,在右值参数的情况下,将有两个移动(前提是您类Loss
并Optimizer
实现移动构造函数)。
更新:一般来说,完美的转发解决方案效率更高。另一方面,此解决方案避免了并不总是可取的模板化构造函数,因为当不受 SFINAE 约束时,它将接受任何类型的参数,如果参数不兼容,则会导致构造函数内部的硬错误。换句话说,不受约束的模板化构造函数对 SFINAE 不友好。请参阅 Barry 对避免此问题的受约束模板构造函数的回答。
模板化构造函数的另一个潜在问题是需要将其放在头文件中。
更新2:Herb Sutter在他的CppCon 2014演讲"返璞归真"中谈到了这个问题,从1:03:48开始。他首先讨论了按值传递,然后在 rvalue-ref 上重载,然后在 1:15:22 处完美转发,包括约束。最后,他谈到构造函数是在 1:25:50 按值传递的唯一良好用例。
为了完整起见,最佳的 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 的解决方案:
LinearClassifier(Loss loss, Optimizer optimizer)
: _loss(std::move(loss))
, _optimizer(std::move(optimizer))
{ }
你想在兔子洞里走多远?
我知道有 4 种体面的方法来解决这个问题。 如果您匹配它们的前提条件,您通常应该使用较早的,因为后面的每个都显着增加复杂性。
在大多数情况下,要么移动
非常便宜,做两次是免费的,要么移动是复制的。
如果 move 是复制的,并且复制是非自由的,则按 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))
{}
这使您有能力对论点进行基于{}
构建。 此外,如果调用上述代码,可以生成多达指数数量的构造函数(希望它们将被内联)。
你可以以SFINAE失败为代价来删除std::enable_if_t
子句;基本上,如果你不小心这个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_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; }
};
我们在其中键入擦除从任意参数构造可选的操作。
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
的函数都是延迟 ctor T
所需要的。
- 将对象数组的引用传递给函数
- 什么时候在C++中返回常量引用是个好主意
- 我想将一个对T类型的非常量左值引用绑定到一个T类型的临时值
- 何时在引用或唯一指针上使用移动语义
- 如何在c++中使用引用实现类似python的行为
- 编译C++时未定义的引用
- Ctypes wstring通过引用传递
- 使用简单类型列表实现的指数编译时间.为什么
- c++r值引用应用于函数指针
- 理解c++中的引用
- C++取消引用指针.为什么会发生变化
- 如何修复此错误:未定义对"距离(浮点数,浮点数,浮点数,浮点数,浮点数)"的引用
- 我的项目不会像"undefined reference to `grpc::g_core_codegen_interface'"那样使用未定义的引用错误进行编译
- C++Boost Asio Pool线程,带有lambda函数和传递引用变量
- 强制转换为引用类型
- 引用一个已擦除类型(void*)的指针
- 向量元素的引用地址与它所指向的向量元素的地址不同.为什么
- 具有默认值的引用获取函数
- 如何使用基类指针引用派生类成员
- 避免构造函数中常量引用和右值引用呈指数增长