一、std::emplace_back
大家都知道从C++11后STL中的容器中支持了类似std::emplace_back的函数(包括emplace函数,以下不再说明,只要提到std::emplace_back就是指这一类)。可既然已经有了push,push_back函数,为什么还要引入新的函数呢?不过大家肯定明白,不会有人会做一些无用功的。更别提在这种STL中的接口函数,一定是有着很强的目的性。
大家是不是在面试C++时,遇到过容器中插入一个对象需要几次拷贝动作之类的问题。其实,这就是一个非常重要的问题。普通的开发者可能无法想象在一个有着几百万元素甚至更多的容器中,不断插入对象所引起内存开销和性能的损失情况。特别是当对象中的数据较大时,拷贝动作的耗费是非常大的。
C++对效率和性能本身就是锱铢必较的,更别提这种如何能够显示减少拷贝次数的方法一定会引起重视的。std::emplace_back系列函数就实现了这个目标,即通过完美转发支持在容器中就地构造来减少拷贝和移动的次数,这的确是一种非常有效的措施。
在最初的std::push_back应用中,需要先构造对象本身,然后再将其复制或移动到容器中。这其中就涉及到了临时对象的处理,有可能会有多次的拷贝动作;而std::emplace_back本身可以接收可变参数包,从而直接在容器尾部内存位置直接调用构造函数生成对象。这就避免了除本次以外的所有的临时对象的构造和析构,同时,也减少了相关对象数据的拷贝操作。另外,其可以支持右值的移动语义。这意味着性能和效率的大幅提高。也就是说,td::emplace_back有三个主要的技术点:原地构造、完美转发和移动语义。
二、原地(位)构造
原地构造,就地构造,In-place Construction。本质是直接在目标内存位置调用对象的构造函数,避免拷贝或移动操作对象引起的效率和性能的损失。适用于构造成本高或需要高频操作的场景。把话说的直白一些,就是去掉中间商,省去差价。
在C++中,原地构造需要Placement New(前面的文章有分析,不清楚请稳步相关文章学习)和完美转发(std::forward)的技术支持。而Placement New又需要内存预分配的技术支持。完美转发提供了模板参数的自动推导以及参数原始类别的保留,可以为构造函数提供不同参数准确的调用机制。而预分配内存又可以使类在完美转发支持下在指定位置创建对象本身,同时,预分配的地址可以有效的控制内存布局,从而影响操作的性能(如内存填充和内存对齐)。通过二者的合作,实现了高效、灵活的对象构造。
原地构造的特点就体现了它可以应用的场景:
- 构造开销成本很高的对象
比如内存较大、含有网络连接等资源的情况下,拷贝还是移动都有可能产生较多的开销,这时就利用就地构造 - 反复高频的构造
对于某些场景下,可能会有不断的创建和销毁的情况,此时也是就地构造的一个应用场景 - 拷贝和移动禁止使能
一些特定对象可能无法进行拷贝或移动操作(如mutex),则只能使用原地构造
大家可以根据自己的需要,尝试着在工程中引入一下原地构造的应用,看看到底有没有效果。
三、完美转发和移动语义
移动语义就是为了避免不必要的拷贝(特别是深拷贝),本质就是节省内存拷贝的开销。移动语义从原则上要把握几点:
- 资源所有权的转移而不是拷贝
- 移动操作后对象保持有效即不受状态影响
- 不能抛出异常
完美转发在前面反复分析过,此处就不再专门讨论,只在此处做一个针对移动语义相关的说明。在原地构造中可以使用完美转发进行参数的控制,同时,它也意味着完美转发可以有机会触发移动语义,特别是在泛型即模板编程中,而std::emplace_back恰恰就是这个应用场景。
移动语义从C++11推出后,大受欢迎,它有很多的优点,最核心的就是省去拷贝这个内存操作的复杂步骤。而这恰恰是提升效率和性能的最基础最简单的方法。不过技术应用上有一个问题,就是喜欢 “言必称希腊”。比如现在的AI,国内的大模型种类之多让人看得眼花缭乱。但真正有几个能打的,还真是没人说得清楚。同样在使用移动语义时也不能“一哄而上”,觉得它无所不能,一切全都交由“移动”解决。下面就分析一下移动语义受限的情况:
- 小对象
正如前面分析排序算法一样,对于量级达到一定小的地步,优化的意义基本就不存在了,甚至有可能产生反向的效果 - 返回值优化
RVO和NRVO,编译如果在确定的情况下,可以进行更深的优化。而移动语义反而可能阻止这种优化。 - 一些复杂类型
如果对象中仍然存在大理的不可移动的操作,或者说仍然需要大量的拷贝的情况下,移动语义和拷贝可能没有什么区别
四、 回到std::emplace_back
通过上面的分析,大家已经对std::emplace_back的主要依赖技术有了一个很清晰的认知,那么在使用std::emplace_back需要注意哪些呢?
- 容器扩容
STL库的容器在到达阀值后就会触发扩容机制,也就是原地构造首先得需要原地的内存。而扩容会引发容器的对象的的重定位,这对于某些自定义类(特别是未定义移动拷贝构造函数)来说可能大幅的降低性能。 - 插入对象和插入对象类构造参数
在使用类似emplace_back(T(args…)) 与 emplace_back(args…),即插入一个对象和插入一个对象类的构造相关参数。要明白前者并不能发挥太大的优化作用。它其实等同于调用拷贝构造函数或移动构造函数。一般来说,只在比较特殊的场景下应用,比如已经有现成对象的情况。或者说,原则是推荐使用 emplace_back(args…),否则其优势无法发挥 - 继承的处理
如果使用 emplace_back()在将子类对象添加到基类容器中是,可以避免切片的发生即只看到基类对象而无法看到子类对象。 - 控制异常
原地构造会直接在容器中进行,如果此时抛出异常,就可能导致容器整体的问题。所以开发者要尽量保证不使用有可能抛出异常的代码,当无法保证时应保证异常操作的可控制。 - 在大量插入对象时应提前分配内存
原地构造的锅,只能reserve来背了。不然就性能上看 - 初始化列表的问题
其实就是初始化列表无法进行完美转发,解决问题的方法是使用std::initializer_list来实现完美转发 - 支持隐式转化
这就比push_back要好,如果遇到类对象显式的声明了构造函数(explicit),emplace_back是不受影响的。不过需要注意的是,隐式转化的风险也同样出现 - 编译器的优化问题
这个比较复杂,主要表现有RVO/NRVO和移动语义的协同;编译器均未确保移动语义的使用以及对noexcept的移动构造和赋值的非安全处理;编译单元范围控制以及多态等对数据类型的影响等
此时再看一下其定义:
template<typename _Tp, typename _Alloc>
template<typename... _Args>
#if __cplusplus > 201402L
_GLIBCXX20_CONSTEXPR
typename vector<_Tp, _Alloc>::reference
#else
void
#endif
vector<_Tp, _Alloc>::
emplace_back(_Args&&... __args)
{
if (this->_M_impl._M_finish != this->_M_impl._M_end_of_storage)
{
_GLIBCXX_ASAN_ANNOTATE_GROW(1);
_Alloc_traits::construct(this->_M_impl, this->_M_impl._M_finish,
std::forward<_Args>(__args)...);
++this->_M_impl._M_finish;
_GLIBCXX_ASAN_ANNOTATE_GREW(1);
}
else
_M_realloc_insert(end(), std::forward<_Args>(__args)...);
#if __cplusplus > 201402L
return back();
#endif
}
五、应用和例程
std::emplace_back应用的场景其实是很丰富的,常见的主要包括:
- 各种池对象
很容易想到的就是各种网络资源、数据库连接资源等等 - 性能敏感的情况
实时系统或低延时的系统,比如需要不断反复的插入对象情况下,典型的如股票交易、系统上下文管理等情况 - 模板和泛型编程中
使用它可以和完美转发配合实现多种功能 - 其它
包括对禁止拷贝和移动的相关类设计等,只能应用emplace_back
给几个小例子,大家分析下问题:
//1:直接构造
std::vector<std::pair<int, std::string>> vec;
// 推荐
vec.emplace_back(100, "abc");
// 不推荐
vec.emplace_back(std::pair<int, std::string>(100, "abc"));
//2:歧义
std::vector<std::vector<int>> vec;
vec.emplace_back(10, 100); // 创建 vector, 有10个值为100的成员
vec.push_back({10, 100}); // 创建vector,有两个成员,10和100
//3:高效
//反复高频创建
for (int i = 0; i < 1000000; ++i) {
// 高效
vec.emplace_back(i, i * 100.0);
// 低效,存在 移动或拷贝
// vec.emplace_back(Demo(i, i * 100.0));
}
//4:显式构造
struct Demo {
explicit Demo(int x) : x(x) {}
int x;
};
std::vector<Demo> d;
d.push_back({1}); // 错误
d.push_back(Demo(1)); // 显式构造临时对象
d.emplace_back(1); // 成功
//5:预分配空间
std::vector<Demo> vec;
vec.reserve(1000); //预分配空间
for (int i = 0; i < 1000; ++i) {
vec.emplace_back(i);
}
//6:RVO与emplace_back
// 函数返回单个元素,直接通过emplace_back插入
std::string getString() {
return "OK"; // RVO
}
int main() {
std::vector<std::string> vec;
//根据此种情况可以自行考虑如何RVO覆盖emplace_back优化
vec.emplace_back(getString()); // RVO+emplace_back原地构造
}
代码很简单,看着注释就明白了。
六、总结
技术的进步不简单是单一某项技术的突破,它往往是在不同的单项突破后有一个整体的演进。这和现实世界中的进步是一致的,即从点到线然后暴发性的大规模突破。容器中emplace此类的方法就是一个典型的例子,它从多次的拷贝到移动语义再到通过完美转发支持就地构造,从而把多个技术点融合在一起,提高了性能和效率。

1397

被折叠的 条评论
为什么被折叠?



