文章目录
-
- 九、通过简化实现理解资源管理
-
- [9.1 先约定一个对象永远要满足什么](#9.1 先约定一个对象永远要满足什么)
- [9.2 构造和析构:资源管理的起点与终点](#9.2 构造和析构:资源管理的起点与终点)
- [9.3 默认拷贝为什么出问题:复制指针不等于复制字符](#9.3 默认拷贝为什么出问题:复制指针不等于复制字符)
- [9.4 深拷贝:新对象拥有新空间](#9.4 深拷贝:新对象拥有新空间)
- [9.5 传统赋值与拷贝交换](#9.5 传统赋值与拷贝交换)
- [9.6 扩容:长度不变,存储位置可能改变](#9.6 扩容:长度不变,存储位置可能改变)
- [9.7 插入和删除:方向不对会覆盖未处理数据](#9.7 插入和删除:方向不对会覆盖未处理数据)
- [9.8 查找、子串和比较,也必须尊重长度](#9.8 查找、子串和比较,也必须尊重长度)
- [9.11 对象大小、短字符串优化与写时拷贝,了解而不硬背](#9.11 对象大小、短字符串优化与写时拷贝,了解而不硬背)
- 十、扩展练习
-
- [10.1 删除文本中的普通空格](#10.1 删除文本中的普通空格)
- [10.2 LeetCode 58,最后一个单词的长度](#10.2 LeetCode 58,最后一个单词的长度)
- [10.3 LeetCode 387,字符串中的第一个唯一字符](#10.3 LeetCode 387,字符串中的第一个唯一字符)
- [10.4 LeetCode 917,仅仅反转字母](#10.4 LeetCode 917,仅仅反转字母)
- 十一、总结
续接上篇:
学会使用std::string类,并理解其内部是如何管理字符串的详细阐述(上)
代码仓库:《string类模拟实现》
九、通过简化实现理解资源管理
本篇创建了三个文件来对string进行简单的模拟实现:
String.h:基本结构实现与函数声明。String.cpp:功能函数具体实现。Test.cpp:测试函数的具体实现与测试。
9.1 先约定一个对象永远要满足什么
本篇使用by命名空间,名字是 by::string,不会覆盖标准库类型。它使用三个成员,先建立不变式,也就是每次公开操作完成后应成立的条件:
cpp
// 类内成员片段
private:
char* _str = nullptr;
size_t _size = 0;
size_t _capacity = 0;
_str指向本对象独立管理的字符数组,正常对象即便为空也有一个终止符位置。_size <= _capacity,分配数组的元素个数为_capacity + 1。[0,_size)是实际元素,_str[_size] == '\0'。_str对应的分配由本对象负责最终释放,不能让两个独立对象都误以为自己独占同一块数组。
类支持空串、深拷贝、赋值、容量调整、字符访问、begin/end、追加、插入、删除、查找、子串、比较及输出。
9.2 构造和析构:资源管理的起点与终点
下面是完整类中的构造与析构片段:
cpp
//构造函数(全缺省)
string(const char* str = "")
{
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
//析构函数(释放资源)
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
- 构造先取得 C 字符串长度,申请长度加一的空间,再复制包括末尾终止符在内的内容。空串长度零,仍然申请一个位置并写入零字符。
- 析构用
delete[]与数组分配匹配,然后把类内成员依次进行对应处理。
9.3 默认拷贝为什么出问题:复制指针不等于复制字符
假设只写构造和析构,却不处理拷贝。编译器生成的拷贝构造对成员进行拷贝:指针成员得到同一个地址,两个整数得到同样的值。对于这里的裸指针成员,这形成共享同一存储的浅拷贝。
默认拷贝不是对所有类都机械复制原始字节:若成员本身是类类型,会调用相应成员的拷贝

浅拷贝后的风险有三步:
- 修改一份对象会影响另一份所见内容;
- 一份销毁后另一份留下悬空地址;
- 另一份析构再释放该地址,就发生重复释放。
9.4 深拷贝:新对象拥有新空间
cpp
//拷贝构造(s1 = s)
//传统写法
String(const String& s)
{
// 按有效字符长度+1分配,_size+1 存内容 + 末尾'\0'
_str = new char[s._size + 1];
// 拷贝 _size+1 个字节,把全部内容连同末尾'\0'复制过来
memcpy(_str, s._str, s._size + 1);
_size = s._size;
_capacity = s._size;
}
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
//拷贝构造(s1 = s)
//现代写法
string(const string& s)
:_str(nullptr)
{
string tmp(s._str);
swap(tmp);
}
这里按源对象的有效长度申请空间,并复制字符与终止符。容量未必与源对象容量相同,只要新的容量足够、值相同,就满足约定。
- 传统写法 :手动
new/memcpy/delete[],直接操作内存 - 现代写法 :借助临时对象 +
swap交换内部资源,追求异常安全、代码简洁
| 维度 | 传统写法 | 现代写法 |
|---|---|---|
| 内存操作 | 手动 new、memcpy、delete [] |
临时对象完成拷贝,swap 交换指针,析构自动释放 |
| 拷贝赋值 | 要手动 delete [],必须处理自赋值 |
传值参数,无需 delete、无需自赋值判断 |
| 异常安全 | new 失败会破坏当前对象 |
new 失败,*this 不受任何影响 |
| 易错点 | 忘记自赋值、使用 strcpy 导致截断 |
需要正确实现 char * 构造函数与 swap |
注意两个细节。
- 第一,拷贝构造是在建立新对象,不能先
delete[] _str,因为当前对象没有一份需要替换的旧资源;如果指针未初始化,删除它更是错误。 - 第二,复制
_size + 1而不是调用strcpy,才能保留前面讲过的内部零字符。
9.5 传统赋值与拷贝交换
赋值与构造不同,左侧对象已存在,可能持有旧空间。
传统赋值: 先准备新资源,再处理旧资源。
拷贝交换: 把旧资源交给临时对象处理。
cpp
//重载=运算符
//传统写法
string& string::operator=(const string& s)
{
if (this != &s)
{
char* new_buf = new char[s._size+1]; // 先把新内存申请好
memcpy(new_buf, s._str, s._size+1);
delete[] _str; // new成功,才释放旧的
_str = new_buf;
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
//重载=运算符
//现代写法
string& string::operator=(string tmp)
{
swap(tmp);
return *this;
}
传统写法:
- 如果
new分配内存失败抛出异常,函数直接终止,不会执行 delete \[\] _str 。原来对象的_str完好无损,对象处于合法状态,做到异常安全。 if(this != &s)自赋值保护:防止a = a时把自己的内存释放掉。- 使用
memcpy(..., s._size+1),支持字符串中间内嵌\0,不会像strcpy发生截断。
现代写法:
- 参数是值传递 ,调用拷贝构造在函数外部完成深拷贝;拷贝构造
new失败,根本不会进入函数,*this不受影响。 - swap 交换内部
_str/_size/_capacity。 - 函数退出,局部
tmp析构,自动释放本对象原来的旧堆内存。 - 不需要自赋值判断,不需要手动
delete。

9.6 扩容:长度不变,存储位置可能改变
cpp
void string::reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
memcpy(tmp, _str, _size + 1);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
先分配、复制,再释放、接管,最后更新容量;没有一步增加 _size,所以扩容不创造新元素。复制的终止符也必须保留,不能只把"肉眼看得到的文字"搬过去。

push_back则先确认还有空间,再写一个元素,增加长度,补上新的终止符。
9.7 插入和删除:方向不对会覆盖未处理数据
插入一个字符以及一个字符串的类内实现如下:
cpp
//实现insert指定位置插入指定字符
void string::insert(size_t pos, char ch)
{
assert(pos <= _size);
if (_size == _capacity)
{
reserve(_capacity == 0 ? INIT_NUM : _capacity * 2);
}
size_t end = _size + 1;
while (end > pos)
{
_str[end] = _str[end - 1];
--end;
}
_str[pos] = ch;
_size++;
}
//实现insert指定位置插入指定字符串
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);
}
size_t end = _size + len;
while (end > pos + len - 1)
{
_str[end] = _str[end - len];
--end;
}
for (size_t i = 0; i < len; i++)
{
_str[pos + i] = str[i];
}
_size += len;
}
以 "abcd" 在位置 1 插入 X 为例,先移动终止符,再依次移动 d/c/b,最后写 X。若从前向后移动,新的 b 可能覆盖后面还没读到的 c,于是原数据被破坏。
插入字符串同理,先把指定位置之后的字符串往后移动需插入的字符串的长度,然后再进行插入。
此处需多加注意边界字符的处理,建议画图仔细先推演一遍完整过程,再逐步确定移动边界。
9.8 查找、子串和比较,也必须尊重长度
cpp
//实现查找单个字符
size_t string::find(char ch, size_t pos)
{
assert(pos < _size);
for (int i = pos; i < _size; i++)
{
if (_str[i] == ch)
return i;
}
return npos;
}
//实现查找字符串
size_t string::find(const char* str, size_t pos)
{
assert(pos < _size);
const char* pstr = strstr(_str + pos, str);
if (pstr == nullptr)
{
return npos;
}
else
{
return (pstr - _str);
}
}
//实现截取指定位置指定数量的子串
string string::substr(size_t pos, size_t len)
{
assert(pos < _size);
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;
}
//实现删除指定位置后指定数量的字符
void string::erase(size_t pos, size_t len)
{
assert(pos < _size);
if (len >= _size - pos)
{
_size = pos;
_str[_size] = '\0';
}
else
{
for (size_t i = pos + len; i <= _size; i++)
{
_str[i - len] = _str[i];
}
_size -= len;
}
}
//输出重载
std::ostream& operator<<(std::ostream& out, const string& s)
{
for (auto ch : s)
{
out << ch;
}
return out;
}
-
在字符串中使用字符查找时,扫描
[pos,_size),没找到返回公共的npos。空串查找字符返回npos。 -
substr(size())和erase(size())合法:前者得到空串,后者不改变内容。 -
子串按选定长度复制,所以包含零字符也保留。
-
比较时先比较共同前缀的字符,出现差异就返回大小结果(按照字典序比较),否则比较长度。
-
输出重载返回
std::ostream&,这样可以继续串联输出;它遍历全部实际元素,而不是用strlen决定长度。
| 项目 | 当前实现的选择 | 与标准库的关系 |
|---|---|---|
| 存储布局 | 裸指针、长度、容量,空串也分配 | 只解释一种模型,不代表标准库布局 |
| 下标 | 断言 pos < size() |
比标准库对 pos==size() 的规则更严格 |
| reserve | 只增长,容量按请求设置 | 不代表各版本标准库全部 reserve 行为 |
| C 字符串追加 | 先制作独立副本 | 简单处理别名,牺牲一些复制成本 |
| 内部零字符 | 已有内容的复制、扩容、子串、比较、输出按长度处理 | 保持基本序列模型,但未实现全部重载 |
9.11 对象大小、短字符串优化与写时拷贝,了解而不硬背
此处内容作为扩展了解,不需要完全理解,感兴趣的话可以自行查阅相关资料。
短字符串优化的想法,是让较短内容存放在对象内部的小缓冲区,减少动态分配;较长内容再使用外部空间。阈值、成员布局、对象大小和容量策略都与实现有关。
不同平台下的优化策略不同,不要死记硬背某一个平台下的小缓冲区大小
写时拷贝(COW)则让多个对象先共享字符存储,记录引用计数,需要修改时再分离。它不等于没有管理规则的浅拷贝:释放必须考虑剩余使用者,写入必须考虑共享状态。
SSO:短串放对象内部缓冲区,减少动态分配,阈值看平台实现;
COW 写时拷贝:多对象共享内存、引用计数,修改才分离,不能等同于裸浅拷贝,析构、写入都要处理共享状态。
十、扩展练习
10.1 删除文本中的普通空格
要求:输入 " a b c " 返回 "abc";只删除普通空格 ' ',不把制表符也视为本题要删除的字符。原串不变。
思路
遍历输入,只把不是空格的字符追加到新结果。结果最多与输入一样长,预留输入长度即可;预留不创建元素,所以之后仍要逐个追加。
参考答案
cpp
std::string removeSpaces(const std::string& input)
{
std::string result;
result.reserve(input.size());
for (char ch : input)
if (ch != ' ')
result += ch;
return result;
}
运行观察
removeSpaces(" a b c ") 得到 "abc";空串和全空格都得到空串;"a\tb" 保留制表符。按常见增长策略分析,时间 O(n),结果空间 O(n)。
10.2 LeetCode 58,最后一个单词的长度
题目中的单词由非空格字符组成,存在至少一个单词。例子 " fly me to the moon " 返回 4。
思路
从右侧跳过尾部空格,再向左找最后一个单词的起点。把 end 当成右开边界,只有 end > 0 才读取 s[end-1],避免空串时先计算 size()-1。
参考答案
cpp
inline int lengthOfLastWord(const std::string& s)
{
size_t end = s.size();
//去除末尾空格
while (end > 0 && s[end - 1] == ' ')
--end;
size_t begin = end;
//遍历最后一个单词
while (begin > 0 && s[begin - 1] != ' ')
--begin;
return (end - begin);
}
运行观察
对于上述输入,先跨过两个尾部空格,再跨过 moon 四个字符,差值为 4。最坏时间 O(n),额外空间 O(1)。
易错点
仅用 rfind(' ') 不能正确处理所有尾部空格情况;找不到空格时,也不能拿 npos 当普通下标做算术。不要用"某个无符号回绕结果恰好正确"解释题目逻辑。(牛客网同名题貌似使用rfind(' ')能过)
10.3 LeetCode 387,字符串中的第一个唯一字符
输入仅含小写英文字母,找第一个出现一次的字符下标,没找到返回 -1。
思路
用长度 26 的计数数组,ch - 'a' 得到字母编号。第一轮统计次数;第二轮按原字符串顺序寻找计数为 1 的字符。计数数组只能告诉频率,不能替你保留出现顺序。
参考答案
cpp
inline int firstUniqChar(const std::string& s)
{
int counts[26] = {0};
for (char ch : s)
++counts[ch - 'a'];
for (size_t e i = 0; i < s.size(); ++i)
if (counts[s[i] - 'a'] == 1)
return i;
return -1;
}
运行观察
"leetcode" 返回 0,"loveleetcode" 返回 2,"aabb" 返回 -1。时间 O(n),额外空间 O(1),因为题目字符集固定。题目长度范围允许下标转换为 int。
易错点
不能按 26 项数组的字母顺序返回"第一个",而应按输入顺序寻找。ch - 'a' 的索引只在题目限定的小写字母输入下正确。
10.4 LeetCode 917,仅仅反转字母
只交换英文字母,其他符号保持原位置,例如 "ab-cd" 得到 "dc-ba"。
思路
左右双位置收缩:左边不是字母就跳过,右边不是字母也跳过,两边都是字母才交换。右侧使用右开边界,不在空串上计算 size() - 1,也不将 rend() 当可读字符。
参考答案
cpp
// 练习答案
isEnglishLetter(char ch)
{
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z');
}
std::string reverseOnlyLetters(std::string s)
{
if(s.empty())
return s;
size_t left = 0;
size_t right = s.size();
while (left < right)
{
if (!isEnglishLetter(s[left]))
++left;
else if (!isEnglishLetter(s[right - 1]))
--right;
else
{
swap(s[right--], s[left++]);
}
}
return s;
}
运行观察
"ab-cd" 首先交换 a/d,然后交换 b/c,中间连字符不动。"123--" 只有符号和数字,结果不变;空串直接结束。扫描时间 O(n),对函数内副本的额外工作空间 O(1),但按值接收输入副本本身占 O(n) 空间。
易错点
直接反转整段文本会移动符号。不要先递减无符号右边界再判断有没有字符;字母判断基于本题英文字母范围。
十一、总结
- reserve 预留空间,但不能越界下标访问
reserve(n)只会扩容底层 capacity 容量,预分配堆内存,不会修改 size 有效字符长度 。哪怕底层内存足够大,[]下标只允许访问[0, size‑1]范围,不能直接用下标往 size 之后位置写入字符;想要追加内容,必须使用append、push_back。 - 查找接口 npos 与位置 0
find()查找成功返回对应下标,找到下标 0 也属于查找成功 ,只有完全找不到时,才返回string::npos。写判断逻辑不要简单写if(s.find(x)),这个写法会把下标 0 误判成查找失败,必须显式和npos对比:if(s.find(x) != string::npos)。 - 对象拷贝与独占裸资源
std::string内部持有独占的堆字符数组,拷贝对象不能直接浅拷贝裸指针。浅拷贝会造成多个对象共享同一块堆内存,析构时重复释放直接崩溃;所以 string 执行深拷贝,每个对象拥有独立资源,这也对应拷贝构造、拷贝赋值的资源管理逻辑。 - c_str () 只是借用存储
c_str()返回的const char*不是独立拷贝一份新内存,直接借用 string 内部存储。一旦 string 发生修改(扩容、追加、销毁),这个指针就会失效,不能长期保存这个裸指针。 - erase 删除后迭代器 / 下标需要更新
调用erase()删除字符之后,后面字符全部向前挪动,原来的迭代器、下标全部失效。不要直接继续使用原来的迭代器循环,需要接收 erase 返回的新迭代器,重新定位当前位置,否则漏元素、越界。 - 短字符串优化 SSO
现代标准库 string 实现短字符串优化,较短文本直接存放在 string 对象自身内部缓冲区,不需要堆 malloc;只有字符串长度超过阈值,才在堆上分配内存,减少小字符串的动态分配开销。C++11 之后标准库基本废弃 COW 写时拷贝。 - 阅读接口文档的思考习惯
看库接口不要死记全部重载版本,优先看:参数类型、位置 / 数量含义、合法边界、会不会修改原对象、返回值意义、操作之后旧指针 / 迭代器是否失效。
进一步核对接口契约可查阅:
- cppreference:std::basic_string
- cppreference:reserve
- cppreference:operator\[\]
- cppreference:c_str
- cppreference:getline
查阅时注意页面标明的语言版本,避免把旧版本特有的接口规则或实现截图当成当前所有环境的共同结论。