文章目录
- [一、 类框架搭建与整体结构设计](#一、 类框架搭建与整体结构设计)
-
- [1. 核心成员变量与常量定义](#1. 核心成员变量与常量定义)
- [2. 全缺省构造函数与析构函数](#2. 全缺省构造函数与析构函数)
- [3. 成员访问接口](#3. 成员访问接口)
- [二、 容量控制:动态扩容基石](#二、 容量控制:动态扩容基石)
- [三、 增删查改(CRUD)闭环实现](#三、 增删查改(CRUD)闭环实现)
-
- [1. 字符与字符串追加](#1. 字符与字符串追加)
- [2. 指定位置插入(`insert`)](#2. 指定位置插入(
insert)) - [3. 删除数据(`erase`)](#3. 删除数据(
erase)) - [4. 查找与子串截取(`find` & `substr`)](#4. 查找与子串截取(
find&substr))
- [四、 迭代器与范围 for 的底层模拟](#四、 迭代器与范围 for 的底层模拟)
-
- [1. 原生指针充当迭代器](#1. 原生指针充当迭代器)
- [2. 范围 for 的底层替换机制](#2. 范围 for 的底层替换机制)
- 五、进阶:深拷贝的"传统写法"与"现代写法"
-
-
- [1. 传统写法:亲力亲为](#1. 传统写法:亲力亲为)
- [2. 现代写法:资源置换](#2. 现代写法:资源置换)
-
- [自定义 `swap` 函数](#自定义
swap函数) - 现代拷贝构造
- 现代赋值重载(传值传参的极致优雅)
- [自定义 `swap` 函数](#自定义
- [3. `string_test03`:现代深拷贝安全验证](#3.
string_test03:现代深拷贝安全验证)
-
- [七、 知识全景复盘与核心速记](#七、 知识全景复盘与核心速记)
📌 相关专栏与源码导航:
- 💻 配套源码: 🚀 本专栏所有配套代码已同步至 GitHub,点击直达
- 📚 系统专栏:
在日常开发中,直接使用标准库的 std::string 本质上只是在调用标准库封装好的 API。知其然更需知其所以然,想要彻底搞懂 C++ 动态内存分配、深浅拷贝防坑、迭代器底层映射以及异常安全性,最扎实的方式莫过于亲手还原一个属于自己的 string 类。
一、 类框架搭建与整体结构设计
为了防止命名与全局命名空间中的 std::string 产生冲突,我们首先将整个实现置于命名空间 hy 内部。
1. 核心成员变量与常量定义
char* _str:指向堆区动态字符数组的指针。size_t _size:当前存储的有效字符数量 (不包含结尾的\0)。size_t _capacity:当前容器的有效容量空间 (不包含为\0额外预留的那 1 个字节)
cpp
namespace hy
{
class string
{
private:
// C++11 类内缺省值:防野指针与析构灾难的核心保障
char* _str = nullptr;
size_t _size = 0;
size_t _capacity = 0;
const static size_t npos = -1;
};
// 静态成员变量类外初始化:-1 隐式转换为无符号数即 size_t 的最大值 0xFFFFFFFF...
size_t string::npos = -1;
}
特例: \colorbox{#FFEAEA}{\color{#C22828}\text{特例:}} 特例:
静态成员变量需要在类外初始化,但是有一个特例可以直接在类内初始化,const static size_t npos = 1
细节把控 :
这里的
_str = nullptr设定极为关键。在后面引入"现代写法拷贝构造"时,临时对象与未初始化的对象进行资源置换(swap),如果当前对象是野指针,被换出去的临时对象在被析构时就会触发内存非法访问崩溃。
2. 全缺省构造函数与析构函数
cpp
// 全缺省构造函数
string(const char* str = "")
:_str(new char[strlen(str) + 1])
,_size(strlen(str))
,_capacity(_size)
{
strcpy(_str, str);
}

- 缺省空串的处理 :缺省参数传
""而非nullptr。空常量字符串本身自带一个\0,strlen("")计算结果为 0,程序安全开辟0 + 1 = 1字节空间并存放\0。如果传 nullptr,strlen会直接触发空指针解引用崩溃。
cpp
// 析构函数
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
- 析构的干净利落 :动态数组统一使用
delete[]回收,并手动将_str置空、容量清零,杜绝悬空指针。
3. 成员访问接口
对外暴露只读或只取状态的 API,保证封装性:
cpp
const char* c_str() const
{
return _str;
}
size_t c_size()
{
return _size;
}
size_t c_capacity()
{
return _capacity;
}
优点: \colorbox{#FFEAEA}{\color{#C22828}\text{优点:}} 优点: 对外只提供只读接口!,防止外部篡改数据,保证了数据安全性
二、 容量控制:动态扩容基石
字符串最强大的地方在于其长度是动态可变的。这一能力完全依托于底层的内存调度者 ------ reserve。
cpp
void string::reserve(size_t n)
{
if (n > _capacity)
{
// 预留 1 个字节空间专门用来存放 '\0'
char* tmp = new char[n + 1];
strcpy(tmp, _str);
// 释放原有旧堆空间
delete[] _str;
_str = tmp;
_capacity = n;
}
}
-
空间换取机制 :
new char[n + 1]确保了无论外部申请多大的空间,结尾字符\0永远有属于自己的合法席位。 -
扩容倍数策略 :在后续的尾插操作中,如果空间满载,遵循 初次给 4 字节、后续 2 倍阶梯扩容 的原则:
cppreserve(_capacity == 0 ? 4 : _capacity * 2);
注意: \colorbox{#FFEAEA}{\color{#C22828}\text{注意:}} 注意:仅在申请空间大于当前容量时才扩容,默认不缩容(与库保持一致),并且需要注意别忘记释放原空间,防止内存泄漏!
错误示范: \colorbox{#E53935}{\color{#FFFFFF}\text{ 错误示范: }} 错误示范:
reserve只有扩容的功能,传入参数多少他扩大到多少,不能越权
cpp
void string::reserve(size_t len)
{
if (len > _capacity)
{
size_t newcapacity = len > _capacity * 2 ? len : _capacity * 2;
char* tmp = new char[newcapacity + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = newcapacity;
}
}
虽然这样也完成了扩容,但是和库函数相比,reserve有了自主决定扩大多少的权力,应该是我们传入多少它扩大多少,而不是让他判断
三、 增删查改(CRUD)闭环实现
1. 字符与字符串追加
a.单字符尾插(push_back)
cpp
void string::push_back(char str)
{
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
_str[_size++] = str;
_str[_size] = '\0'; // 必须手动维护字符串合法结尾
}
b.字符串追加(append)
cpp
void string::append(const char* str)
{
size_t len = strlen(str);
if (_size + len > _capacity)
{
reserve(_size + len); // 精准预分配空间
}
for (int i = 0; i < len; i++)
{
_str[_size++] = str[i];
}
_str[_size] = '\0';
}
写完一个功能我们就测试一下,方便问题的查找 \colorbox{#FFEAEA}{\color{#C22828}\text{写完一个功能我们就测试一下,方便问题的查找}} 写完一个功能我们就测试一下,方便问题的查找

观察得到函数功能正常,我们便可以开始后面函数的编写了
c.运算符重载复用
通过复用底层方法,使操作符接口简洁明了:
cpp
void operator+=(char str)
{
push_back(str);
}
void operator+=(const char* str)
{
append(str);
}
验证了字符尾插、字符串尾部拓展以及复合运算符 += 的复用与扩容逻辑。
2. 指定位置插入(insert)
插入算法最容易出错的环节在于数据向后平移时的下标越界。
a.插入单个字符
先来看我们最常容易犯得错误:
❌❌❌错误示范: \colorbox{#FFEAEA}{\color{#C22828}\text{❌❌❌错误示范:}} ❌❌❌错误示范:

我们测试一下发现程序卡死崩溃,那是为什么呢?这就需要我们通过调试一步一步找到问题

通过调试我们发现,当end为0时本应该是最后一次进入循环,但是进入循环后end-- 本应该为-1,可是它变成了非常大的正数
因为end为无符号整型,所以-1被无符号整型接收后,成了最大值,并且编译器通过这个下标访问到了非法地址,所以崩溃报错
🔹解决办法①:强转end的类型为int,防止整型提升(但是这样就没能和库保持一致)
🔹 解决办法②:问题根源出现在end与pos相等这个阶段,所以我们将end + 1 ,整体交换向后移了一个单位即从**_strend + 1 = _strend 换成 _strend = _strend - 1**


✅✅✅正确写法: \colorbox{#FFEAEA}{\color{#C22828}\text{✅✅✅正确写法:}} ✅✅✅正确写法:
cpp
void string::insert(size_t pos, char ch)
{
assert(pos <= _size); // 断言保护,pos == _size 时等价于尾插
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
// 严防无符号数下溢导致死循环:设置 end 初始为 _size + 1
size_t end = _size + 1;
while (pos < end)
{
_str[end] = _str[end - 1];
end--;
}
_str[pos] = ch;
_size++;
}
- 避坑要点 :若将
end初始化为_size并使用_strend + 1 = _strend,循环终止条件很容易写成while (end >= pos)。一旦pos为 0,无符号整型 end-- 减至 0 之后再减 1 会下溢变为极大的正数(4294967295),直接陷入死循环并越界。因此,将end置为_size + 1,控制pos < end终止是最稳妥的写法。
b.插入字符串
与插入单个字符一致,需要注意无符号整型问题 \colorbox{#E53935}{\color{#FFFFFF}\text{ 与插入单个字符一致,需要注意无符号整型问题 }} 与插入单个字符一致,需要注意无符号整型问题
cpp
void string::insert(size_t pos, const char* ch)
{
assert(pos <= _size);
size_t len = strlen(ch);
if (_size + len > _capacity)
{
reserve(_size + len);
}
// 平移腾出 len 个字节的空间
size_t end = _size + len;
while (pos + len <= end)
{
_str[end] = _str[end - len];
end--;
}
// 将待插入内容填入空缺
for (int i = 0; i < len; i++)
{
_str[pos + i] = ch[i];
}
_size += len;
}
测试小技巧: \colorbox{#FFEAEA}{\color{#C22828}\text{测试小技巧:}} 测试小技巧:
在进行数据的增删查改功能测试时,我们不应只单单随机测试一组,而是最少测试三组,分别是数据中、首、尾,尤其是首尾的边界数据最容易出现问题
3. 删除数据(erase)
进行内容删除时,我们并不是
真正的删除释放数据,而是将有效数据范围缩小,_size决定了数据的边界,只要将_size缩小,外界就无法访问到被"剔除"的数据。
cpp
void string::erase(size_t pos)
{
assert(pos < _size);
// 截断式清空:将目标位置置为 \0 并缩减 _size
_str[pos] = '\0';
_size = pos;
}
测试一下:


可以非常直观的观察到在我们所给下边位置放置了 \0
4. 查找与子串截取(find & substr)
cpp
// 查找单个字符
size_t string::find(char ch, size_t pos = 0)
{
assert(pos < _size);
for (size_t i = pos; i < _size; i++)
{
if (_str[i] == ch)
{
return i;
}
}
return pos;
}
// 借力 C 库函数 strstr 匹配子串
size_t string::find(const char* ch, size_t pos)
{
assert(pos < _size);
char* re = strstr(_str + pos, ch);
if(re != nullptr)
{
return re - _str;
}
return npos;
}
// 截取子串
string string::substr(size_t pos, size_t len)
{
assert(pos < _size);
// 如果要求的长度超出剩余有效字符,则截取到末尾即可
if (len > _size - pos)
{
len = _size - pos;
}
string tmp;
tmp.reserve(len); // 提前预开辟,避免多次 push_back 产生无谓扩容
for (size_t i = 0; i < len; i++)
{
tmp.push_back(_str[pos + i]);
}
return tmp;
}
核心避坑 \colorbox{#E53935}{\color{#FFFFFF}\text{ 核心避坑 }} 核心避坑 :
在find字符串中,需要判断strstr是否找到,如果未找到不返回npos的话,外部会拿空指针解引用,造成非法访问。
四、 迭代器与范围 for 的底层模拟
在 STL 的设计架构中, 迭代器(Iterator)扮演着"连接容器与通用算法的桥梁" ,它对容器的底层存储形态(链表,树等)进行高度封装,使得外界在使用所有容器时,都可以像操作原生指针一样进行操作(遍历,比较等)
1. 原生指针充当迭代器
由于 string 类底层是一段连续分配的动态堆内存字符数组(物理地址线性递增),原生指针天然具备指针算术运算(++、--)、解引用获取数据(*)、以及求相对偏移量(-)的所有特性。
因此,在手撕 string 容器时,我们可以直接使用原生指针 char* 和 const char* 作为正向迭代器的实现载体。
cpp
namespace hy
{
class string
{
public:
// 1. 迭代器类型别名定义
typedef char* iterator;
typedef const char* const_iterator;
// 2. 普通对象迭代器(可读可写)
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size; // 指向有效数据末尾字符的下一个位置(即 '\0' 处)
}
// 3. const 对象迭代器(只读保护)
const_iterator begin() const
{
return _str;
}
const_iterator end() const
{
return _str + _size;
}
// ... 其余成员变量与成员函数
};
}
核心避坑 :必须成对提供普通与 const 版本 \colorbox{#E53935}{\color{#FFFFFF}\text{ 核心避坑 :必须成对提供普通与 const 版本 }} 核心避坑 :必须成对提供普通与 const 版本
初学者在模拟实现时极易写出如下"语法能通过,但设计出现严重逻辑后门"的代码:
// ❌ 错误示范:仅提供带 const 修饰但返回普通指针的接口
iterator begin() const { return _str; }
iterator end() const { return _str + _size; }
架空 const 防护 :当函数外部传参为
const string& st时,调用者的核心是严格只读。但若此时返回的是普通 iterator(即 char*),外部拿到指针后便能直接执行 *it = 'X' 篡改常量对象内部的字符,直接打破了封装的保护契约
迭代器的使用,底层我们可以认为迭代器和指针类似,使用方法上所以也相似

2. 范围 for 的底层替换机制
范围 for 的底层完全没有所谓的黑魔法,它是由编译器在语法分析阶段机械化展开生成的"宏式替换"。
|--------------------------------------------------|
| ⚠️ 注意:使用范围for时,编译器自动调用迭代器,而非新语法,而是无条件的机械式的替换 |

范围for两大铁律 \colorbox{#FFEAEA}{\color{#C22828}\text{范围for两大铁律}} 范围for两大铁律
1.函数命名死板绑定 :范围 for 严格且唯一识别名为全小写的 begin() 与 end() 接口。若要将接口名随意改写(如 Begin() 或 End()),范围 for 将立即报出找不到匹配标识符的编译硬错误。
2.const 属性自适应 :遍历对象若为 const string& st,编译器在底层解构时会自动调用 st.begin() const,直接推导出 const_iterator,从而自然锁死写入权限
五、进阶:深拷贝的"传统写法"与"现代写法"
默认浅拷贝(值拷贝)会导致两把指针指向同一块堆内存,引发析构时重复释放(Double Free) 因此,深拷贝是自定义容器不可逾越的坎。
1. 传统写法:亲力亲为
cpp
// 传统拷贝构造
string(const string& st)
{
_str = new char[st._capacity + 1];
strcpy(_str, st._str);
_size = st._size;
_capacity = st._capacity;
}
// 传统赋值重载
string& operator=(const string& s)
{
if (this != &s)
{
delete[] _str; // 释放自身原有堆内存
_str = new char[s._capacity + 1];
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
- 隐患剖析 :传统赋值重载中,若
delete[] _str后,后续new char[...]发生内存不足抛出异常,此时当前对象原有的数据已被销毁,指针变成野指针,破坏了对象的合法状态(缺乏强异常安全性保证)。
2. 现代写法:资源置换
自定义 swap 函数
cpp
void string::swap(string& tmp)
{
std::swap(_str, tmp._str);
std::swap(_size, tmp._size);
std::swap(_capacity, tmp._capacity);
}
思考 :为什么不直接在外部调用全局的 std::swap(s1, s2)?
全局的 std::swap 底层依赖于:
cpp
string c(a); // 拷贝构造 1 次深拷贝
a = b; // 赋值重载 1 次深拷贝
b = c; // 赋值重载 1 次深拷贝
这样会无端多触发 3 次深拷贝 与大量的堆区内存分配开销。而成员函数
swap仅直接交换内部的指针和数值,时间复杂度恒定为 O ( 1 ) O(1) O(1)。
现代拷贝构造
cpp
string(const string& st)
{
string tmp(st._str); // 借用构造函数开辟空间并装填数据
swap(tmp); // 窃取临时对象的资源
}
tmp是一个局部对象,借由构造函数开辟堆空间。- 通过
swap将tmp的资源换给*this。 - 离开作用域时,
tmp自动调用析构函数,顺带销毁了*this原先持有的nullptr(释放空指针在 C++ 中是合法且安全的空操作)。
现代赋值重载(传值传参的极致优雅)
cpp
// s1 = s2;
string& operator=(string s) // 注意此处是传值!不是引用传参!
{
swap(s);
return *this;
}
这是整个手撕过程中最精妙的一笔:
- 自动拷贝 :形参
s没有加引用,在函数调用时,编译器会自动调用拷贝构造函数生成一份副本。 - 交换资源 :将临时副本
s的堆区指针直接与this->_str调换。 - 安全自毁 :函数执行完毕后,形参
s作为局部对象出作用域,自动触发析构函数,临时副本 s 接管了 *this 原有的旧堆内存,出作用域自动销毁
3. string_test03:现代深拷贝安全验证
cpp
void string_test03()
{
string st1 = "hello world"; // 隐式类型转换构造
cout << st1.c_str() << endl;
string st2;
st2 = st1; // 触发传值现代赋值重载
cout << st2.c_str() << endl;
}
程序正常退出无断言崩溃,在没有内存泄漏的前提下,验证了 swap 配合传值赋值模式的测试。
七、 知识全景复盘与核心速记
为方便复盘与面试速记,本篇手撕 string 的核心底层技术要点归纳如下:
| 核心模块 | 工业级规范 / 关键机制 | 致命隐患与避坑要点 |
|---|---|---|
| 内存边界 | 空间按 n + 1 开辟,预留 1 字节专存 \0 |
越界写入 \0 或扩容漏算 1 字节导致堆破坏 |
| 下标移动 | 严格控制无符号边界(如 end = _size + 1; while(end > pos)) |
size_t end 减至 0 后继续 end-- 造成无符号整型下溢,引发死循环与段错误 |
| 深拷贝策略 | 传统手动申请与拷贝;现代借助构造与 swap 偷取资源所有权 |
默认浅拷贝引发多指针共享同一块堆内存,触发 Double Free 崩溃 |
| 迭代器设计 | 底层直接封装 char* 与 const char* 成对重载 |
遗漏 const_iterator 会破坏常量只读契约,导致数据被意外篡改 |
| 范围 for | 编译期语法糖,机械展开为小写 begin() / end() 遍历 |
依赖于全小写函数命名,底层自动适配对象的 const 属性 |
"更深处扎根,每一行底层代码的推演,都在构筑无可替代的技术护城河。"
Crafted with passion by Hhy
当前更新时间:{{ 2026 / 11 / 7 }} | 此博文系博主学习复盘,欢迎指正
本文专栏由 Hhy 维护。如果这篇文章曾带给你启发,请考虑关注我的 CSDN 博客或 GitHub 仓库以获取最新技术分享。