欢迎光临
我们一直在努力

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 时才会触发内存重新分配,流程如下:

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

  • 使用memcpy把旧_str中_size+1个字符(含\\0)拷贝到新空间;

  • delete[]释放旧堆内存;

  • 更新_str指向新数组,更新_capacity = n。

  • 内存变化图解

    扩容前:

    旧_str → [hello world\\0]  capacity较小

    扩容后:

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

    开发要点

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

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

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

  • 三、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'

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

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

  • _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'

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

  • _size减去删除长度,末尾自动补\\0;

  • 结果恢复:[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":

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

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

  • 返回 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://xxx,find(':')找到冒号下标,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)

    已知字符串长度提前预分配

    七、常见踩坑点

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

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

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

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

  • 赞(0)
    未经允许不得转载:171主机测评 » C++ STL 完整入门笔记[3]:深入剖析C++ std::string底层实现:迭代器、扩容、增删截取全图解
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址