我们来了解一下string的底层是怎么工作的,我们还是需要三个文件

底层讲解
类的整体结构与成员定义
cpp
namespace box
{
class string
{
public:
typedef char* iterator; // 迭代器本质:对于连续空间,指针就是最好的迭代器
typedef const char* const_iterator;
private:
char* _str; // 指向堆区的指针,存放字符串内容
size_t _size; // 有效字符个数(不含 \0)
size_t _capacity; // 最大容量(不含 \0)
static const size_t npos; // 静态常量,表示不存在的位置(通常是 -1)
};
}
-
指针即迭代器 :初学者常觉得迭代器很神秘,但其实
string底层是连续数组,我们要移动位置只需指针++。所以begin()返回首地址,end()返回末尾\0的地址,这样就足以支撑起所有的算法操作。 -
npos 的奥秘 :
size_t是无符号数。给它赋值-1,它会在内存中溢出变成全1,即4294967295。这代表了一个"理论上不存在的最大位置",常用于查找失败的返回值
构造与析构:如何安全地管理内存
cpp
// 构造函数:支持带参和空构造(通过全缺省实现)
string(const char* str = "")
{
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1]; // +1 是为了存末尾的 '\0'
strcpy(_str, str); // 拷贝内容
}
// 析构函数:RAII 思想
~string()
{
delete[] _str; // 释放堆区内存
_str = nullptr; // 指针置空,好习惯
_capacity = _size = 0;
}
-
为什么开空间要 +1? 这是一个经典陷阱。
strlen算的是看得见的字符,比如"abc"是 3。但内存里必须有\0才能标识字符串结束。如果你不开这第 4 个字节,strcpy就会越界写入,导致程序崩溃。 -
RAII 原则 :对象创建即分配内存,对象销毁即自动回收。有了析构函数,我们就再也不用像 C 语言那样担心忘记
free导致的内存泄漏了
深拷贝与"现代写法"(拒绝浅拷贝的崩溃)
这是面试最爱考的点:如果你直接赋值指针(浅拷贝),两个对象指向同一块内存,析构时会释放两次,程序直接报错
cpp
void swap(string& s)
{
std::swap(_str, s._str); // 交换底层指针
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
// 拷贝构造函数(现代写法)
string(const string& s) : _str(nullptr), _size(0), _capacity(0)
{
string tmp(s._str); // 1. 先调用构造函数造出一个临时的 tmp
swap(tmp); // 2. 将 tmp 的劳动果实和自己交换
} // 3. 临时对象 tmp 销毁时,顺便带走并释放了你原来的旧内存
"偷梁换柱"法 :我们不再手动写 new 和 strcpy,而是让编译器先帮我们构造一个 tmp。通过 swap,我们拿到了正确的数据,而 tmp 拿到了我们的"空壳子"。这种写法代码极短,且天然具备异常安全性
扩容与插入(无符号溢出的致命陷阱)
cpp
void string::insert(size_t pos, char ch)
{
assert(pos <= _size); // 边界检查
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : _capacity * 2); // 2倍扩容逻辑
}
size_t end = _size + 1; // 1. 指向末尾 '\0' 的后一个位置
while (end > pos)
{ // 2. 只有 end 大于 pos 时才继续
_str[end] = _str[end - 1]; // 3. 将前面的字符往后挪
--end;
}
_str[pos] = ch; // 4. 在腾出的空位放入新字符
++_size;
}
-
无符号数溢出(Big Pitfall) :很多初学者会写
while (end >= pos)。假设pos是 0(在最前面插入),当end挪动完 0 号位置字符后变为 0,循环继续。执行--end时,0减1会因为是size_t类型而溢出变成一个巨大的正数!循环永远停不下来。 -
解决之道 :代码中让
end初始位置比数据多挪一位,判断条件改为end > pos。这样当end等于 1 时,它会搬动0号位字符到1号位,然后end减为 0 退出循环,完美避开了溢出
输入输出流(get() 的妙用)
cpp
istream& operator>>(istream& in, string& s)
{
s.clear(); // 1. 进来先清空原有的旧数据
char ch = in.get(); // 2. 核心:必须用 get() 才能读取空格
while (ch != ' ' && ch != '\n')
{
s += ch;
ch = in.get();
}
return in;
}
为什么不能直接 in >> ch? 因为 operator>> 在流读取时会默认跳过空格、制表符和换行。如果你想模拟 std::cin 遇到空格停止的行为,就必须用 in.get()。它像一只"细致的触角",不管是什么字符都会抓回来,让你能够手动判断何时停止
进阶修改:insert 与 erase 的"空间位移"
在处理字符串插入和删除时,最核心的逻辑就是数据的挪动
cpp
void string::insert(size_t pos, const char* str)
{
assert(pos <= _size);
size_t len = strlen(str);
if (_size + len > _capacity)
{
// 扩容逻辑:至少扩容到刚好能装下
reserve(_size + len > 2 * _capacity ? _size + len : 2 * _capacity);
}
// 1. 搬家:为新字符串腾出 len 个位置
size_t end = _size + len;
while (end > pos + len - 1)
{ // 边界控制:挪动到 pos 停止
_str[end] = _str[end - len];
--end;
}
// 2. 填充:把 str 拷贝到腾出来的空位上
for (size_t i = 0; i < len; i++)
{
_str[pos + i] = str[i];
}
_size += len;
}
-
逻辑重点 :这不再是一个字符的挪动,而是"跨步搬运"。
_str[end - len]的写法保证了数据能一次性后移len个单位。 -
避坑指南 :注意循环终止条件
pos + len - 1。这代表了最后一个待挪动字符的新位置,稍微算错 1 个位就会导致数据覆盖错误或越界
erase:高效的"覆盖式"删除
cpp
void string::erase(size_t pos, size_t len)
{
if (len >= _size - pos)
{
// 情况 A:后面全删光
_str[pos] = '\0';
_size = pos; // 注意这里 size 直接更新为 pos
} else {
// 情况 B:只删中间一段,后面部分前移
strcpy(_str + pos, _str + pos + len);
_size -= len;
}
}
-
小技巧 :如果我们要删掉
pos之后的所有字符,最快的方法不是逐个删,而是直接把_str[pos]置为\0。 -
strcpy 的妙用 :在删除中间部分时,直接利用
strcpy将后面的子串覆盖到pos位置,省去了写for循环搬运的麻烦
查找与截取:find 与 substr
find:字符与子串的定位
cpp
size_t string::find(const char* str, size_t pos)
{
// 借用 C 语言库函数 strstr 在 _str+pos 开始查找
const char* ret = strstr(_str + pos, str);
if (ret == nullptr)
{
return npos;
}
// 指针减指针得到下标
return ret - _str;
}
指针运算 :这是 C++ 程序员的必备技能。ret 是找到子串的首地址,_str 是整个字符串的首地址,两者相减得到的差值就是子串在原字符串中的索引位置
substr:子串截取(涉及匿名对象返回)
cpp
string string::substr(size_t pos, size_t len)
{
if (len > _size - pos)
{
len = _size - pos; // 修正长度,防止越界
}
string sub;
sub.reserve(len);
for (size_t i = 0; i < len; i++)
{
sub += _str[pos + i]; // 逐个追加
}
return sub; // 这里会触发拷贝构造或移动构造
}
异常安全性 :我们先通过 reserve 预留空间,避免在循环中频繁扩容
关系运算符:让类支持"像"数字一样比较
cpp
bool operator<(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str()) < 0;
}
bool operator==(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str()) == 0;
}
// 剩余的 >, <=, >=, != 全部通过逻辑复用实现
bool operator<=(const string& s1, const string& s2)
{
return s1 < s2 || s1 == s2;
}
逻辑复用(Code Reuse) :这是一个资深程序员的自我修养。只要写好了 < 和 ==,剩下的四个比较运算符都可以通过这二者组合出来。这不仅减少了代码量,还保证了逻辑的一致性
这一篇难度较大,建议搭配文档学习:https://legacy.cplusplus.com/reference/string/string/?kw=string