C++ STL 完整入门笔记[3]:深入剖析C++ std::string底层实现:迭代器、扩容、增删截取全图解

前言

std::string 是 C++ 开发中最常用的字符串容器,但多数开发者只停留在 API 调用层面,不清楚其底层内存模型、扩容逻辑、插入删除时的数据搬移开销,以及迭代器本质。本文结合手绘内存示意图 + 模拟源码,逐层拆解string核心底层原理,覆盖迭代器、reserve 扩容、insert 插入、erase 删除、substr 截取五大核心操作。

一、std::string 基础内存结构与迭代器本质

1. 底层存储结构

标准string内部维护三个核心成员变量:

  • char* _str:指向堆上字符数组,末尾自动存放\0兼容 C 风格字符串;

  • size_t _size:当前有效字符个数(不含末尾\0);

  • size_t _capacity:堆数组总容量(可容纳的最大字符数,不计\0)。

2. string 迭代器本质:char* 原生指针

复制代码
typedef char* iterator;
iterator begin() { return _str; }
iterator end()   { return _str + _size; }

这是最关键的底层知识点:string::iterator就是char*裸指针。

  • begin()返回_str,指向第一个有效字符;

  • end()返回_str + _size,指向最后一个有效字符的下一位,不存储有效数据,仅作遍历终止标记。

3. 迭代器遍历示例图解

复制代码
string s1 = "hello world";
string::iterator it1 = s1.begin();
while (it1 != s1.end())
{
    cout << *it1 << " ";
    ++it1;
}

内存示意:

复制代码
_str → [h e l l o  w o r l d \0]
        ↑it1(begin)               ↑end(_str + _size)

循环逻辑:指针it1从头部逐位后移,直到等于end()地址停止,*it1解引用拿到当前字符。

拓展:const_iterator 等价于 const char*,禁止通过迭代器修改字符。

二、reserve () 预扩容:string 内存重分配逻辑

接口功能

void string::reserve(size_t n):预分配至少 n 个字符的容量,避免多次插入时频繁扩容拷贝。 仅当 n > _capacity 时才会触发内存重新分配,流程如下:

  1. 在堆上新开辟 n+1 大小的字符数组(+1 用于存放末尾\0);

  2. 使用memcpy把旧_str_size+1个字符(含\0)拷贝到新空间;

  3. delete[]释放旧堆内存;

  4. 更新_str指向新数组,更新_capacity = n

内存变化图解

扩容前:

复制代码
旧_str → [hello world\0]  capacity较小

扩容后:

复制代码
新_str → [hello world\0][预留空白容量]
旧堆内存被释放

开发要点

  1. 提前用reserve预分配已知长度字符串,能大幅减少插入操作的性能损耗;

  2. reserve只改capacity,不改变size,不会新增有效字符;

  3. 扩容后所有旧迭代器、指针、引用全部失效(底层数组地址变了)。

三、insert 插入操作:单字符 / 多字符数据搬移原理

insert是理解字符串性能开销的重点:在 pos 位置插入数据,必须先把 pos 及后面所有字符向后搬移腾出空间,长度越长,搬移开销越大。

1. 单个字符 insert (pos, char ch)

底层伪代码
复制代码
void string::insert(size_t pos, char ch)
{
    // 容量不足先扩容,默认2倍扩容
    if (_size >= _capacity)
    {
        size_t newcap = _capacity == 0 ? 4 : 2 * _capacity;
        reserve(newcap);
    }
    // 从末尾向前搬移字符,避免覆盖
    size_t end = _size;
    while (end >= pos)
    {
        _str[end + 1] = _str[end];
        --end;
    }
    _str[pos] = ch;
    ++_size;
}
内存搬移图解

原字符串:[h e l l o w o r l d \0],在 pos=5 插入 'x'

  1. end = _size开始,循环把每个字符向后挪一位;

  2. pos 位置留出空位,写入字符 'x';

  3. _size自增 1,结果:[h e l l o x w o r l d \0]

关键细节:倒序搬移,如果从前向后搬移会覆盖未拷贝的原始字符,导致数据丢失。

2. 多字符插入 insert (pos, "xxx")

插入长度为len的字符串,需要向后搬移len个位置,逻辑和单字符一致,仅循环终止条件变化:

复制代码
size_t end = _size + len;
while (end > pos + len - 1)
{
    _str[end] = _str[end - len];
    --end;
}

内存变化:原 pos 及后续所有字符整体后移 len 位,腾出连续空间存放待插入字符串。

性能总结

  • 头部插入:搬移全部现有字符,时间复杂度 O (n),频繁头部拼接建议用stringstream或反转后操作;

  • 尾部插入:无需搬移(容量足够时),O (1) 效率,推荐优先使用push_back/operator+=

四、erase 删除操作:内存向前覆盖压缩

void string::erase(size_t pos, size_t len) 删除 pos 开始、长度 len 的字符,底层逻辑和 insert 相反:将 pos+len 之后的字符向前覆盖,压缩内存

底层伪代码

复制代码
void string::erase(size_t pos, size_t len)
{
    assert(pos < _size);
    // 超出剩余字符长度,直接截断到末尾
    if (len == npos || len >= _size - pos)
    {
        _size = pos;
        _str[_size] = '\0';
        return;
    }
    // 用memmove向前覆盖字符(存在内存重叠不能用memcpy)
    size_t i = pos + len;
    memmove(_str + pos, _str + i, _size + 1 - i);
    _size -= len;
}

内存图解

原字符串:[h e l l o x w o r l d \0],erase (5,1) 删除 'x'

  1. pos+len(6)之后的字符整体向前拷贝到 pos 位置;

  2. _size减去删除长度,末尾自动补\0

  3. 结果恢复:[h e l l o w o r l d \0]

关键区分 memmove vs memcpy

删除时源地址_str+i 小于目标地址_str+pos,内存区间重叠,memcpy未处理重叠会数据错乱,必须使用memmove

五、substr 截取子串:生成全新临时 string 对象

string substr(size_t pos, size_t len) const 截取子字符串,不会修改原 string,而是创建全新临时 string

底层伪代码

复制代码
string string::substr(size_t pos, size_t len) const
{
    // 修正越界长度
    if (len == npos || len >= _size - pos)
        len = _size - pos;
    string ret;
    ret.reserve(len);
    for (size_t i = 0; i < len; ++i)
        ret += _str[pos + i];
    return ret;
}

内存图解

原 string:_str -> hello world\0 调用substr(0,5)截取 "hello":

  1. 创建局部临时对象ret,预分配 len 容量;

  2. 循环拷贝 pos 起始的 len 个字符到 ret 内部_str

  3. 返回 ret 触发拷贝 / 移动构造,函数结束局部 ret 销毁。

实战示例:URL 拆分

复制代码
void split_url(const string& url)
{
    size_t i1 = url.find(':');
    if (i1 != string::npos)
        cout << url.substr(0, i1) << endl;
}

输入http://xxxfind(':')找到冒号下标,substr(0,i1)截取协议头http,完全依托 substr 生成新字符串实现拆分。

六、核心操作性能与开发规范汇总

|-----------------|-------------|---------------|-------------------|
| 操作 | 底层行为 | 时间复杂度 | 开发建议 |
| 尾部 push_back/+= | 无搬移,容量不足仅扩容 | O (1) 均摊 | 优先使用,高频拼接首选 |
| 头部 insert | 全部字符后移 | O(n) | 尽量避免,改用尾部拼接后反转 |
| 中间 insert | 后半段字符后移 | O(n) | 提前 reserve 减少扩容开销 |
| erase 中间字符 | 后半段字符前移覆盖 | O(n) | 批量删除优于多次单字符删除 |
| substr | 新建字符串拷贝数据 | O (k) k 为截取长度 | 大量截取注意临时对象开销 |
| reserve | 重分配 + 全量拷贝 | O(n) | 已知字符串长度提前预分配 |

七、常见踩坑点

  1. 迭代器失效 :reserve 扩容、insert/erase 修改字符长度后,原有begin/end迭代器全部失效,不能继续使用;

  2. size 与 capacity 混淆:size 是有效字符,capacity 是总容量,reserve 只改 capacity,resize 才会修改 size;

  3. 大量头部拼接性能差 :循环s = "a" + s每次触发全量搬移,数据量大时性能暴跌;

  4. substr 产生大量临时对象:循环中频繁 substr 会频繁堆分配,可改用 char 指针直接读取原字符串。