前言
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 指针直接读取原字符串。