以下内容为本人的学习笔记,如需要转载,请声明原文链接 微信公众号「ENG八戒」mp.weixin.qq.com/s/WFaYXCAKa...
很多同学经常反馈说,C++ STL 提供的容器真的很慢欸!
标准库的容器性能虽然经常被吐槽,但大多属于这几个方面的问题,比如:
- 我觉得拷贝的开销很大
- 查表速度太慢了
- 自己写的可插入 list 比 std::list 快多啦!
其实,STL 标准模板库提供的容器算得上是效率比较高的了,如果自己在使用中经常碰到性能问题,不妨反思一下是否是自己操作比较粗放,再谈优化的问题。
Bjarne Stroustrup 也说过对标准库提供的容器优化前,应该弄清楚是不是真的存在性能问题。代码效率高不高应该以参照物为准,也就是说和谁比的问题。所以对自己的代码性能不要瞎猜,先测试一下,万一还行呢?比如说,拷贝就一定开销大吗?这个真不一定啊。
arduino
std::vector<int> vi;
int i = 2;
vi.push_back(i);
像上面这段代码,把整数推到 vector 里需要拷贝内容,但是这样的拷贝非常简单,对效率没有影响,再优化也没有意义。
那么,哪些场景下,优化容器的使用是必须的呢?我们以上面的几个问题为例,展开一下。
拷贝的巨大开销
假设我们有个图片类 Image,用于表示一张图,创建这张图的对象可以通过导入文件路径来创建。需求是把这些实例化的图片对象放到列表中集中管理,一般我们会直接这么写
arduino
std::vector<Image> imgs;
Image img("pic.jpg");
imgs.push_back(img);
对于这样的写法,开销大不大完全依赖于被插入元素 Image 的占用空间。将 img 推入 vector 时这里应用了值传递,需要为 img 在 vector 内预留一块内存空间,然后直接拷贝 img 内容到预留空间。被推入的元素如果比较小,显然效率不会受到影响,但是如果一张图片占用几 MB 的大小,那么这个推入元素内容的过程将会非常低效率。实际上,图片稍微高清一些都可能达到几 MB 的 level。
怎么办?为了解决 std::vector 的 「低效率」,有的人可能会自己实现一个定制的容器,比如 XXX_container。
事实上,我们只需要在使用 std::vector 时稍微变通一下,比如把焦点放在元素类型上,就可以去除拖慢容器执行效率的因素。
指针
在系统中,指针实际就是一个代表地址的数值,拷贝指针和拷贝数值的效率是一样的。
某些场合下,元素对象的拷贝语义还是有必要保留的。所以,不妨将 vector<X>
改为 vector<X*>
,这样剩下的开销就主要集中在调用元素类型拷贝构造函数身上,而且还不会影响元素对象本身的拷贝语义。
arduino
std::vector<Image*> imgs;
Image img("pic.jpg");
imgs.push_back(&img);
移动感知类型
另一方面,在使用 std::vector 时,元素对象类型比较占空间,如果你使用的是现代版 C++,可以选用带有移动感知的类型作为元素类型。比如该类型实现了对应的移动构造函数和移动赋值操作符,利用移动语义就可以进一步提升 vector 的插入执行效率。
因为拷贝旧内容到新空间,包含状态和堆资源两种,状态的拷贝对效率影响有限,而成员指针指向的堆内存资源通常占据主要空间,可以依赖移动语义的高效率而受益。
关于移动语义的介绍,这里不展开了,可以看看笔者之前写的文章 《现代 C++ 的巨大性能飞跃之:移动语义》,本文末尾有跳转阅读链接。
以 std::vector 为例,针对移动感知类型的优化作用在什么地方呢?
- 向 vector 追加元素:使用
emplace_back
方法创建新元素并添加到 vector 末尾时,如果参数列表允许直接构建对象,系统会尝试直接在 vector 预留的内存位置上构造对象,避免额外的移动或拷贝。
使用 push_back(T&& value)
时,如果传入的是右值引用(临时对象或明确标记为将要移动的对象),vector 会调用元素类型的移动构造函数,而不是复制构造函数,将传入对象的内容「移动」到 vector 的存储区域内,这能有效地避免元素类型的深拷贝。
如果你对「深拷贝」的概念不是很清楚,可以查阅一下笔者之前的一篇文章《C++ 代码性能空间之极限拉扯:「COW」 真乃神助攻》,本文末尾有跳转阅读链接。
-
容器扩容:vector 的容量包含隐藏的预留空间,当新插入的元素超出剩余容量时,系统会分配一块更大的内存区域,将现有元素移动(而不是复制)到新内存区域,然后再插入新元素。对于移动构造的元素来说,这意味着只需调整内部资源的所有权,而非复制资源本身。
-
插入操作:对于像 insert 这样的插入操作,当需要移动现有元素以腾出空间,用于插入移动语义的元素对象时,也会优先使用移动操作而非复制。
-
赋值、swap 和其他重新分配等操作:在涉及容器整体交换或容器自身赋值等操作时,vector 同样会利用移动赋值运算符(operator=)来进行更高效的资源转移。
来看一下下面的示例代码:
c
class MoveAwareType
{
public:
MoveAwareType() {
std::cout << "Default constructor" << std::endl;
}
MoveAwareType(const MoveAwareType&) {
std::cout << "Copy constructor" << std::endl;
}
MoveAwareType(MoveAwareType&&) {
std::cout << "Move constructor" << std::endl;
}
MoveAwareType& operator=(const MoveAwareType&) {
std::cout << "Copy assignment operator" << std::endl;
return *this;
}
MoveAwareType& operator=(MoveAwareType&&) {
std::cout << "Move assignment operator" << std::endl;
return *this;
}
~MoveAwareType() {
std::cout << "Destructor" << std::endl;
}
};
int main() {
std::vector<MoveAwareType> vec;
MoveAwareType obj;
vec.push_back(obj);
vec.push_back(MoveAwareType());
return 0;
}
上面的操作中,vec.push_back(obj)
会调用类 MoveAwareType 拷贝构造函数或者移动构造函数,将 obj 的副本或被移动后的对象添加到 vec 中。具体是调用拷贝构造函数还是移动构造函数,取决于编译器的优化和配置。
vec.push_back(MoveAwareType());
创建一个临时的 MoveAwareType 对象,并将其添加到 vec 中。由于是临时对象,所以是不具名的,必然会触发调用类 MoveAwareType 的移动构造函数或移动赋值运算符将内容移动到 vec 中。
如果把前面的 Image 类型作为元素类型呢?
这就要求 Image 类型需要实现移动语义了,而且占空间的图像数据应该被抽象到单独的数据类中,方便移动。
全文未结束,本文只是上篇。如果各位同学朋友有什么疑问可以联系笔者,当然笔者也愿意和你进一步探讨这方面的问题。另外,八戒有自己的技术圈朋友群,如果读者朋友想进群交流技术问题,欢迎联系我。下拉到文章底部有我的联系方式!
最后,非常感激各位朋友的点 「赞」 和点击 「在看」,谢谢!
prompt:一个会写代码的牛仔,帽子印有 Cpp 字样,背着袋子,在西部荒野骑马狂奔,数字艺术