跳到主要内容C++ 从零实现 string 类详解 | 极客日志C++算法
C++ 从零实现 string 类详解
综述由AI生成C++ string 类模拟实现涵盖内存管理、深浅拷贝、迭代器及运算符重载。通过自定义命名空间避免冲突,采用深拷贝策略防止资源泄漏,利用 copy-and-swap 优化赋值操作。详细解析了扩容机制、插入删除逻辑及流输入输出处理,重点解决 size_t 下溢等边界问题,帮助深入理解标准库底层原理。
无尘11 浏览 前言
在掌握 string 类接口的基本用法后,深入理解其底层实现是提升 C++ 功底的关键。本文将带你从零开始,模拟实现一个完整的 string 类,重点剖析内存管理、深浅拷贝及运算符重载等核心机制。
一、string 类总览
为了避免与标准库中的 string 产生命名冲突,我们使用 mystd 命名空间进行封装。
namespace mystd {
class string {
public:
typedef char* iterator;
typedef const char* const_iterator;
string();
string(const char* str);
string(const string& s);
string& operator=(const string& s);
~string();
iterator begin();
iterator end();
const_iterator begin() const;
const_iterator end() const;
size_t size();
size_t capacity();
void reserve(size_t n);
;
;
;
;
string& +=( ch);
string& +=( * str);
;
;
;
;
;
;
& []( i);
& []( i) ;
;
;
;
;
:
* _str;
_size;
_capacity;
npos;
};
string::npos = ;
<( string& s1, string& s2);
<=( string& s1, string& s2);
>( string& s1, string& s2);
>=( string& s1, string& s2);
==( string& s1, string& s2);
!=( string& s1, string& s2);
ostream& <<(ostream& out, string& s);
istream& >>(istream& in, string& s);
}
void resize(size_t n, char ch = '\0')
bool empty() const
void push_back(char ch)
void append(const char* str)
operator
char
operator
const
char
string& insert(size_t pos, char ch)
string& insert(size_t pos, const char* str)
string& erase(size_t pos, size_t len)
void clear()
void swap(string& s)
const char* c_str() const
char
operator
size_t
const
char
operator
size_t
const
size_t find(char ch, size_t pos = 0) const
size_t find(const char* str, size_t pos = 0) const
size_t rfind(char ch, size_t pos = npos) const
size_t rfind(const char* str, size_t pos = 0) const
private
char
size_t
size_t
static
const
size_t
const
size_t
-1
bool
operator
const
const
bool
operator
const
const
bool
operator
const
const
bool
operator
const
const
bool
operator
const
const
bool
operator
const
const
operator
const
operator
char* _str:用来存储字符串内容。
size_t _size:记录当前字符串的长度(不包含 '\0')。
size_t _capacity:记录字符串当前的容量(不包含 '\0')。
static const size_t npos:这是一个静态成员变量,其值为 -1(即整型的最大值),通常用作函数的异常返回值。
typedef char* iterator:通过重命名 char* 指针作为迭代器。
typedef const char* const_iterator:通过重命名 const char* 指针作为常量迭代器。
二、默认成员函数
2.1 无参构造函数
string() : _str(new char[1] {'\0'}), _size(0), _capacity(0) {}
- 默认 string 类预留一个 '\0' 作为空字符的表示。例如:
string s; 调用无参构造,表示 s 为空字符。
- 初始化
_size 为 0。
- 初始化
_capacity 为 0。
注:空字符的字符个数 _size 和空间 _capacity 均为 0,即 '\0' 额外单独开空间。
2.2 带参构造函数
string(const char* str) {
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
注意: 使用初始化列表进行构造时,初始化顺序是按照成员变量声明顺序执行的。为了复用 strlen 计算结果,不建议在此处使用初始化列表方式。
2.3 拷贝构造函数
在模拟实现拷贝构造函数前,我们需要了解深浅拷贝的区别:
- 浅拷贝:拷贝出来的目标对象的指针和源对象的指针指向的内存空间是同一块空间,其中一个对象的改动会对另一个对象造成影响。
- 深拷贝:源对象与拷贝对象互相独立,其中任何一个对象的改动不会对另外一个对象造成影响。
- 每个 string 类对象需要独立的空间进行存储字符串,要求任何一个对象的改动不会对另外一个对象造成影响。
- 深拷贝给每个对象独立分配资源,保证多个对象之间不会因共享资源而造成多次释放导致程序崩溃。
string(const string& s) : _size(s._size), _capacity(s._size)
{
_str = new char[_capacity + 1];
strcpy(_str, s._str);
}
void swap(string& tmp) {
std::swap(_str, tmp._str);
std::swap(_size, tmp._size);
std::swap(_capacity, tmp._capacity);
}
string(const string& s) {
string tmp(s._str);
swap(tmp);
}
代码解析:
假设执行 string s2(s1);。
s1 传引用传参给 s,即 s 为 s1 的别名。
- 调用构造函数,构造出一个 C 字符串为
s._str 的对象 tmp。
tmp 由带参构造函数而来,字符串指向的地址和 s 不同,但字符串个数 _size 和容量 _capacity 与 s1 完全相同。
- 再将
s2 和 tmp 进行交换,此时 s2 的值就与 s1 的值相同,同时 tmp 的值为原来的 s2,函数结束时进行析构原来的 s2 指向的空间。
风险提示: 如果待拷贝对象 s2 的成员变量没有在类内给缺省值(比如 = nullptr),也没有在拷贝构造函数的初始化列表中被初始化,在执行 swap(tmp, s2) 时,由于 s2 指向的是随机内存空间,交换后当 tmp 调用析构函数时会导致严重错误。
2.4 赋值运算符重载
赋值运算符重载与拷贝构造函数类似,同样需要考虑深浅拷贝问题。
string& operator=(const string& other) {
if (this == &other) { return *this; }
delete[] _str;
_str = new char[other._capacity + 1];
strcpy(_str, other._str);
_size = other._size;
_capacity = other._capacity;
return *this;
}
- 防范自我赋值(
if (this == &other)):防止 s1 = s1 时,先把自己的内存释放了,导致后面的拷贝出现段错误。
- 清理旧资源(
delete[] _str):防止内存泄漏。
- 深拷贝新资源(
new 和 strcpy)。
- 返回自身引用(
return *this):支持 s1 = s2 = s3 这样的链式操作。
void swap(string& tmp) {
std::swap(_str, tmp._str);
std::swap(_size, tmp._size);
std::swap(_capacity, tmp._capacity);
}
string& operator=(string tmp) {
swap(tmp);
return *this;
}
- 第 1 步:利用参数'按值传递',外包【深拷贝】
当进入
operator= 函数时,因为参数是按值传递的(string tmp),C++ 编译器会自动调用'拷贝构造函数',用 s2 克隆出一个局部的临时对象 tmp。这个临时对象 tmp 已经是一份完美的深拷贝了!此时 s1 毫发无损,完美解决了'旧家拆了,新家没建好'的隐患。
- 第 2 步:偷天换日,外包
进入函数体后,
s1(即 this)看着临时对象 tmp 手里拿着新鲜出炉的数据,于是执行了 std::swap,直接把两者的底牌(指针、容量、大小)互换了。*s1 拿到了 tmp 申请好的新内存和新数据(更新完成!)。tmp 成了一个'背锅侠',手里拿着 s1 淘汰下来的旧内存和旧数据。
- 第 3 步:借刀杀人,外包【旧内存释放】
随着
return *this; 执行完毕,函数结束。局部对象 tmp 的生命周期走到了尽头。C++ 编译器会自动调用它的'析构函数',因为此时 tmp 手里捏着的是 s1 的旧内存,析构函数手起刀落,顺道就把 s1 之前的旧垃圾清理得干干净净!完全不需要手动写 delete[]。
2.5 析构函数
~string() {
delete[] _str;
_size = 0;
_capacity = 0;
}
三、迭代器
string 类中的迭代器本质上是字符指针,iterator 只是其类型别名。
typedef char* iterator;
typedef const char* const_iterator;
3.1 begin
string 类中的 begin 函数:返回字符串首字符的地址,即迭代器初始的位置。
iterator begin() { return _str; }
const_iterator begin() const { return _str; }
3.2 end
string 类中的 end 函数:返回字符串中最后一个字符的后一个字符的地址(即 '\0' 的地址),即迭代器的末位置。
iterator end() { return _str + _size; }
const_iterator end() const { return _str + _size; }
四、容量和大小相关函数
4.1 size
由于 string 类的成员变量是私有的,无法直接访问,因此它提供了 size() 这个成员函数,用于获取字符串的字符个数。
size_t size() const { return _size; }
4.2 capacity
由于 string 类的成员变量是私有的,无法直接访问,因此它提供了 capacity() 这个成员函数,用于获取字符串的容量个数。
size_t capacity() const { return _capacity; }
4.3 reserve
- 当
n 超过对象当前 capacity 时,将 capacity 扩展至不小于 n 的大小。
- 当
n 小于当前 capacity 时,维持现有 capacity 不变。
void string::reserve(size_t n) {
if (n > _capacity) {
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
4.4 empty
判断字符串是否为空,调用 strcmp 库函数进行实现。
bool string::empty() {
return strcmp(_str, "") == 0;
}
五、字符串修改函数
5.1 push_back
push_back 函数功能:在当前字符串的后面尾插上一个字符。
void string::push_back(char c) {
if (_size == _capacity) {
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
_str[_size++] = c;
_str[_size] = '\0';
}
- 在尾插操作前,首先需要检查当前容量是否足够。如果空间不足,则调用
reserve 函数进行扩容。
- 完成扩容检查后,执行字符的尾插操作。
- 特别需要注意的是,尾插字符后必须在其后添加 '\0' 终止符。否则在打印字符串时可能出现非法访问问题,因为新插入字符的后方位置不一定存在字符串终止符。
5.2 append
append 函数功能:在当前字符串的后面尾插一个字符串。
void string::append(const char* s) {
size_t len = strlen(s);
if (len + _size > _capacity) {
size_t newcapacity = (_size + len > _capacity * 2) ? _size + len : _capacity * 2;
reserve(newcapacity);
}
strcpy(_str + _size, s);
_size += len;
}
- 在执行尾插操作前,需先检查当前字符串缓冲区是否具备足够空间。
- 若空间不足,则需先进行扩容操作。
- 扩容完成后,将待插入字符串追加至目标字符串末尾。
注意:由于待插入字符串本身已包含终止符 '\0',故无需额外添加。
5.3 operator+=
- 重载运算符 += 使其能够通过运算符尾插一个字符串。
- 重载运算符 += 使其能够通过运算符尾插一个字符。
实现思想:对于字符串与字符的尾插,直接调用 push_back 函数即可实现;对于字符串与字符串的尾插,通过调用 append 函数进行实现。
string& string::operator+=(char c) {
this->push_back(c);
return *this;
}
string& string::operator+=(const char* s) {
this->append(s);
return *this;
}
5.4 insert
- 在 pos 位置插入一个字符。
- 在 pos 位置插入一个字符串。
void string::insert(size_t pos, char c) {
assert(pos <= _size);
if (_size == _capacity) {
size_t newcapacity = _capacity == 0 ? 4 : _capacity * 2;
reserve(newcapacity);
}
size_t end = _size + 1;
while (end > pos) {
_str[end] = _str[end - 1];
--end;
}
_str[pos] = c;
_size++;
}
注意事项:
化解 size_t 死循环危机:把 end 的初始值设为 _size + 1,并用 end > pos 作为条件。保证了即使在 pos = 0(头部插入)的最极端情况下,end 减到 0 循环就会立刻停止,绝不会发生 0 - 1 变成巨大正数(整型下溢)导致的内存越界崩溃。
size_t end = _size;
while (end >= pos) {
_str[end + 1] = _str[end];
--end;
}
- 假设你要在头部插入字符,也就是
pos = 0。
- 当
end 减到 0 时,把第 0 个字符往后挪完,接着执行 --end,因为 size_t 不能是负数,0 - 1 会直接发生整型下溢,变成一个极其巨大的正数(比如 4294967295)。
- 此时循环条件
end >= 0 依然成立!程序就会陷入死循环,并疯狂篡改非法内存,最终导致程序崩溃。
void string::insert(size_t pos, const char* s) {
assert(pos <= _size);
size_t len = strlen(s);
if (len == 0) return;
if (len + _size > _capacity) {
size_t newcapacity = len + _size > _capacity * 2 ? len + _size : _capacity * 2;
reserve(newcapacity);
}
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] = s[i];
}
_size += len;
}
- 先搬家 '\0':
end 初始等于 _size + len。_str[end] = _str[_size],把原本在末尾的结束符 '\0' 挪到了新的队尾。
- 依次搬家后续字符:随着
--end,依次把原来队伍最后的字符,挪到加上 len 之后的新位置。
- 停止:循环条件是
end > pos + len - 1(等价于 end >= pos + len),当 end 等于 pos + len 时,刚好执行 _str[pos + len] = _str[pos]。
- 这意味着,原本在
pos 位置的那个字符,被挪到了它该去的新位置,搬家正式结束。此时,从 _str[pos] 到 _str[pos + len - 1] 这段空间就被彻底腾空了,正好用来执行最后的那段 for 循环:把新的字符串 s 塞进这个空位里。
5.5 erase
erase 函数功能:删除字符串从 pos 位置开始的 len 个字符,len 的默认长度为 size_t npos=-1 (其值为 4294967295)。一般分为两种情况:
- pos 位置及其之后的有效字符都需要被删除。
- pos 位置及其之后的有效字符只需删除一部分。
void string::erase(size_t pos, size_t len) {
assert(pos < _size);
if (len >= _size - pos) {
_str[pos] = '\0';
_size = pos;
} else {
size_t begin = pos + len;
while (begin <= size()) {
_str[begin - len] = _str[begin];
++begin;
}
_size -= len;
}
}
5.6 substr
substr 函数功能:从 pos 位置开始 (包括 pos),截取 len 个大小的字符。
string string::substr(size_t pos, size_t len) {
assert(pos < _size);
if (len >= _size - pos) {
len = _size - pos;
}
string tmp;
tmp.reserve(len);
for (size_t i = 0; i < len; i++) {
tmp += _str[pos + i];
}
return tmp;
}
5.7 clear
clear 函数功能:用于将对象中存储的字符串置空。
void string::clear() {
_str[0] = '\0';
_size = 0;
}
六、访问字符串相关函数
6.1 operator[]
operator 函数重载使 string 对象能够像 C 字符串一样,通过下标访问特定位置的字符。
char& operator[](size_t pos) {
assert(pos < _size);
return _str[pos];
}
const char& operator[](size_t pos) const {
assert(pos < _size);
return _str[pos];
}
6.2 find
- 从 pos 位置(默认为 0)查找一个字符,查找失败返回
npos,查找成功返回第一个字符的位置下标。
- 从 pos 位置(默认为 0)查找一个字符串,查找失败返回
npos,查找成功返回字符串中第一个字符的位置。
size_t string::find(char c, size_t pos) {
assert(pos < _size);
for (size_t i = pos; i < _size; i++) {
if (_str[i] == c) {
return i;
}
}
return npos;
}
size_t string::find(const char* s, size_t pos) {
assert(pos < _size);
const char* ptr = strstr(_str, s);
if (ptr != nullptr) {
return ptr - _str;
} else {
return npos;
}
}
七、比较运算符重载
C++ 提供了六个关系运算符:>、>=、<、<=、==和!=。在实际应用中,我们只需为类重载其中两个运算符,其余四个可以通过已重载的运算符来实现。
例如:对于 string 类,我们可以选择只重载 < 和 == 这两个关系运算符。
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;
}
bool operator>(const string& s1, const string& s2) {
return !(s1 <= s2);
}
bool operator>=(const string& s1, const string& s2) {
return !(s1 < s2);
}
bool operator!=(const string& s1, const string& s2) {
return !(s1 == s2);
}
八、字符串输入与输出
8.1 >>运算符的重载
operator>> 函数重载旨在使 string 对象能够像内置类型一样直接使用输入操作。
istream& operator>>(istream& in, string& s) {
s.clear();
const int N = 256;
char buff[N] = { 0 };
int i = 0;
char ch = in.get();
while (ch == ' ' || ch == '\n' || ch == '\t') {
ch = in.get();
}
while (ch != ' ' && ch != '\n' && ch != '\t') {
buff[i++] = ch;
if (i == N - 1) {
buff[i] = '\0';
s += buff;
i = 0;
}
ch = in.get();
}
if (i > 0) {
buff[i] = '\0';
s += buff;
}
return in;
}
- 重置状态 (
s.clear()):确保 cin >> s 是覆盖写入,而不是追加。
- 准备'运货小推车' (
buff[256]):通过局部数组攒字符,避免频繁调用 += 导致底层频繁的内存扩容。
- 精准打击(跳过前导空白):无论用户在正式输入前敲了多少个空格或回车,全被这个 while 循环吞掉,直到碰见真正的有效字符。
- 高效搬运(核心循环):遇到非空白字符开始装车。车装满了(
i == N - 1)就卸货到 s 里,然后继续装。遇到下一个空格或换行,就意味着'这个单词读完了',立即停车。
- 打扫战场(扫尾工作):如果最后车里还有剩的货(
i > 0),一次性全卸给 s。
8.2 <<运算符的重载
operator<< 函数重载是为了让 string 对象能够像内置类型一样使用 << 运算符直接输出打印。
ostream& operator<<(ostream& out, const string& s) {
for (auto ch : s) {
out << ch;
}
return out;
}
相关免费在线工具
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- Gemini 图片去水印
基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online