现代 C++ 跨平台开发-内存篇:STL 内存管理
本文是整个【现代 C++ 跨平台开发-内存篇】系列的第 7 篇,主要涉及:STL 内存管理。
现代 C++ 跨平台开发-内存篇:STL 内存管理
通用问题
堆 or 栈?
除了
std::array这种固定容量的,其他绝大多数容器都会动态分配内存;就算容器本身可能存储在栈上(局部变量),内部成员仍然在堆上;
扩容
C++11 开始,扩容会优先移动,无法移动才拷贝,都不行就编译错误;
对于大对象或者创建开销很大的对象,存储指针可降低扩容开销。
扩容会重新动态分配内存,所以元素内存地址要不要缓存而要动态获取!
reserve()
只改变
capacity(避免后续频繁扩容影响性能),但不改变size(未初始化数据);若要拿 STL 的裸指针去写数据,应使用构造函数传入容量保证内存初始化;
resize()
resize(n, x)=clear()/reserve()+assign(n, x);用于清空并初始化;resize(n)不建议用于删除结尾多余元素,并未真正释放空间;
删除元素
std::remove_if() (C++17)
仅仅往前移动元素覆盖要删除的元素,并返回新的迭代器结尾,并未真正释放元素,必须配结合
erase(),否则会内存泄漏;不支持 map/set;
v.erase(std::remove_if(v.begin(), v.end(), {}), v.end());
std::erase_if() (C++20)
结合了
remove_if()+erase(),真正移除元素;针对所有容器都有特化实现;
std::erase_if(v, [](auto &x){});
更安全的类型
unsigned char -> std::byte
C 没有专门的基础类型标识原始字节,所以“数字”、“字节”、“字符”三者语意混为一谈(甚至隐式转换);
C++17 引入的
std::byte专用于表示原始字节:默认只支持位运算,更符合原始字节数据的语义;
算数运算需要显式转换为
int才能进行,更安全;
enum -> enum class
enum:底层类型由编译器决定(通常是
int,但嵌入式系统可能使用short),可通过-fshort-enums强制使用最小类型;会污染全局命名空间;
可以和 int 自由转换;
enum class:可以直接显式指定类型;
有自己的命名空间;
不允许跟
int隐式转换。
union -> std::variant
性能接近
union;支持非平凡类型;且支持 RAII,无需手动释放;
index()自动记录了当前活跃的类型,无需额外字段;类型安全:
通过
std::holds_alternative()判断类型;通过
std::get<T>()或std::visit()访问;错误访问会抛异常,而不是 UB;
std::variant<int, std::string> v = "C++";
std::visit([](auto&& arg) {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, int>) {
std::cout << "int: " << arg << "\n";
} else if constexpr (std::is_same_v<T, std::string>) {
std::cout << "string: " << arg << "\n";
}
}, v);
char* -> std::string
通过
char*构造:始终会拷贝内存,所以原有
char*必须及时free(),否则会内存泄漏;根据重载决议,指针和整型会隐式转换,所以传入数字能编译通过,但会 crash;
重载了跟
const char*的所有比较运算符,所以能直接比较;重载了
+运算符,频繁调用会产生临时对象;push_back()适合插入单字符,append()适合插入字符串;
小字符串优化
内部有一个小的
char数组,直接存储小字符串;对缓存友好;若
capacity大于数组大小,则使用了动态堆内存;
class string {
size_t capacity;
union {
struct {
char *ptr;
size_t size;
} heapbuf;
char stackbuf[sizeof(heapbuf)];
};
};
stringstream
复用 buffer,适合频繁插入各种类型;
如果确定长度,不建议使用,会有动态扩容和 format 开销;
C 静态数组 -> std::array
- 解决 C 数组没有值语义的问题(不能以传值的方式作为函数参数和返回值,也不能直接深拷贝):
array<int, 5> func(array<int, 5> arr) { //array 作为参数不会退化为指针
auto a = arr; //array 赋值可以复制整个数组(内部实现了深拷贝),而内置数组只会拷贝指针
return a; //array 作为返回值不会退化为指针
}
相比内置数组也没有性能损失;
属于 POD(pure old data),没有动态内存分配,包含
std::array的结构体可以直接:用
memset()清零;用
memcpy()拷贝;序列化/反序列化;
C 动态数组 -> std::vector
用于替换 C 的动态数组(本身也并不是标准,只是某些编译器的扩展);
内存连续,可快速随机访问;1.5 倍或 2 倍扩容;
删除元素效率低;
std::vector(size):初始化传入容量,同时更新capacity和size,并初始化数据;内存只增不减,
clear()并不能释放空间,可以使用swap()强制释放旧vector;C++11 提供了shrink_to_fit();push_back():适用于对象已存在的场景;
传右值会调用移动构造函数(避免拷贝和构造);
emplace_back():适用于对象不存在,需要构造的场景;
原地构造(通过完美转发,将参数原封不动传给构造函数),避免拷贝(仍然会构造新对象);
用它直接传递对象,并不能提升性能,因为对象已存在,仍会调用移动/拷贝构造函数;
异构容器
void* -> std::any
提供了一种类型安全、自动内存管理、支持任意类型的值语义容器,但引入一定运行时性能开销。
class any {
void* ptr_; // 指向实际对象
const std::type_info* type_; // 指向 typeid(T),可选
void (*destroy_)(void*); // 析构函数指针
void (*copy_)(const void*, void*); // 拷贝函数指针
};
类型限制:可拷贝、可析构;
本质是类型擦除,有 SOO(小对象优化);
不要通过
typeid()去判断类型(RTTI 有运行时开销):RTTI 本是为了
dynamic_cast()等场景追踪完整的继承树,用于std::any有点“杀鸡用牛刀”;std::any_cast<>()更高效:编译期生成唯一地址作为类型 ID,实现 O(1) 指针比较;
不依赖 RTTI,即使
-fno-rtti关闭 RTTI 也能正常工作;
tuple vs struct
当多个值逻辑相关、生命周期一致、且不值得(或不能)定义新类型时,
tuple是优雅之选。
性能对比:
构造与返回开销:
struct属于聚合初始化,直接在调用方栈帧构造,100% 触发 RVO;tuple需要make_tuple()构造临时对象,不容易 RVO;
成员访问开销:
struct直接根据内存布局偏移完全内联访问成员,性能更高;tuple的get<>()有模板展开开销;
小对象/平凡类型,性能几乎无差异;
tuple特有的:数据打包:
std::make_tuple(): 值语义,通常会拷贝或移动(完美转发);std::tie():引用语义,只接收已有数据的左值引用,不持有;
结合
std::apply()用于延迟/动态调用;std::pair<T, U>本质是std::tuple<T, U>的特化;
轻量级视图容器
std::string_view
视图类型,内部仅包含(指针和长度)两个成员,非常轻量:
作为函数参数,建议直接按值传递,无需
const引用:支持传入所有字符串类型,包括
const char*和std::string;自带长度信息,比
const char*安全;
性能接近
const char*:- 传入
const char*时,不会像const std::string&一样隐式构造临时对象;
- 传入
功能接近
std::string:提供了
std::string几乎所有只读接口;可平凡复制;
注意:
不持有数据,需要外部确保生命周期;
无法调用
c_str()等写内存接口,但可通过data()读取指针;若需长期持有外部传入的内存(赋值给内部),还是应该用
std::string;
std::span (C++20)
比
std::string_view更通用的视图类型,只要求内存连续;C++23 开始,可平凡复制;
可接受:
C 数组;
T*+len;std::string、std::array、std::vector;迭代器范围;
其他 STL容器
理论上,频繁增删节点时链表效率更高;但实际上,链表指针跳转引起的 cache miss 对性能的影响可能更大。
据 C++ 之父之前测试,除非节点数据量大(如超过 1MB)且频繁在中间增删,否则std::vector仍是最优。
折衷的方案:数组作为链表节点、在预分配的大块内存中创建链表等。
链表
- 没有 capacity 的概念,无法通过
reserve()预留空间;
list
双向链表;
适合频繁增删元素,不适合随机访问;
forward_list
- 单向链表;
队列
deque
双向队列;
内存分段连续:
融合了
vector的数组 和list的链表;一个中控数组管理多个分段缓冲区地址信息;
扩容比
vector更平滑:扩容时仅需分配新的中控数组,降低扩容开销;
同样没有 capacity 的概念,无法通过
reserve()预留空间;
任何插入/删除都会导致迭代器失效;
核心成员:
cur:指向当前访问的元素;map_ptr:指向中控数组中当前缓冲区的指针;first/last:指向当前缓冲区的起始/结束地址;
适合频繁首尾增删节点(被
stack/queue采用),O(1) 耗时;中间增删仍然 O(N)。
queue
实际上是个适配器,而不是真正的容器;
底层默认使用
deque,也可指定list或vector:
std::queue<int, std::vector> q;
基于红黑树的有序容器
稳定的时间复杂度 O(logN);
自动排序,依赖比较运算符;
主要是
map、set;
multiset/multimap
- 允许键值重复;
flat_map(C++23)
存储空间连续(类似
vector<pair<K,V>>),缓存命中率高;使用二分查找 O(logN);查找/遍历效率高,插入删除效率低(O(N));
基于哈希的无序容器
不稳定的时间复杂度 O(1) ~ O(N);
无序;
空间成本高;
遍历效率低;
拉链法解决冲突,查询效率依赖 hash 算法;
可自定义 hash 函数;
unordered_map
at():如果不存在会抛异常;operator[]:不存在不会抛异常,会返回默认值;
可以修改,赋值会插入新值;
避免对同一 key 多次调用(会重复计算哈希),应调用
find()拿到iterator再执行后续操作。
emplace():原地构造(避免拷贝),接受右值;
emplace(std::move(k), std::move(v)),无需临时变量pair;
insert(std::pair(std::move(k), std::move(v))),虽接受右值,但需要临时pair;contains()仅适合单纯判断是否存在;若查询后需立即访问,建议用find()(会返回指针);
unordered_set
- 元素唯一,不允许重复;
unordered_multiset/unordered_multimap
- 允许键值重复;