现代 C++ 跨平台开发-内存篇:STL 内存管理

本文是整个【现代 C++ 跨平台开发-内存篇】系列的第 7 篇,主要涉及: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):初始化传入容量,同时更新 capacitysize,并初始化数据;

  • 内存只增不减,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 直接根据内存布局偏移完全内联访问成员,性能更高;

      • tupleget<>() 有模板展开开销;

    • 小对象/平凡类型,性能几乎无差异;

  • 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::stringstd::arraystd::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,也可指定 listvector

std::queue<int, std::vector> q;

基于红黑树的有序容器

  • 稳定的时间复杂度 O(logN);

  • 自动排序,依赖比较运算符;

  • 主要是 mapset

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

  • 允许键值重复;