C++STL:从 0 到 1 手写 C++ string以及高频易错点复盘


目录

一、整体结构
class string { public: private: char* _str; int _size; int _capacity; };1. char* _str
这是一个指向字符数组的指针,用来存储字符串的实际字符数据。
在 C++ 标准库的std::string中,它指向一块动态分配的内存,里面存放着以'\0'结尾的字符序列。模拟实现时,需要自己管理这块内存的分配、扩容和释放。
2.int _size
记录当前字符串中有效字符的个数(不包括末尾的'\0')。
3. int _capacity
记录当前已分配内存能容纳的最大字符数(不包括末尾的'\0')。
它代表了在不需要重新分配内存的情况下,字符串最多能存储的字符数量。当 _size 即将超过 _capacity 时,就需要触发扩容(比如申请一块更大的内存,将旧数据拷贝过去,再释放旧内存)。通常扩容会按一定倍数(如 1.5 倍或 2 倍)进行,以减少频繁扩容带来的性能开销。
二、构造/析构函数
2.1 默认构造
1.无参构造
编写构造函数的时候需要注意的是,char* _str不能初始化为nullptr。需要开辟一字节的空间并默认初始化为'\0'也就是空串,因为字符串默认以'\0'结尾。否则就会导致字符串无法以'\0'结尾,打印的时候字符串尾部就会出现乱码。
string() { _str = new char[1]{'\0'}; _size = _capacity = 0; }为了方便后续管理,这里开辟的一字节用来存储'\0'的空间不计入总的空间大小。
2. 字符串构造string类型
string(const char* str) { int len = strlen(str); _str = new char[len + 1]; strcpy(_str, str); _size =_capacity =len; }这里需要注意的是,传进来的str字符串默认是带有\0的。strlen会计算字符串的长度并记录在len变量中(不包含\0),strcpy会将str(包含\0)拷贝在_str这个空间中所以在开辟空间大小的时候开辟len+1是为了存储\0。
2.2 拷贝构造
string(const string& ch) { _str = new char[ch._capacity + 1]; strcpy(_str, ch._str); _size = ch._size; _capacity = ch._capacity; }编写拷贝构造接口需要特别注意的是:C++语法明确禁止拷贝构造传值传参,否则会导致无限递归。这是因为使用传值传参编译器会默认调用拷贝构造,但是如果拷贝构造接口本身就是传值传参的话就会一直重复递归调用自己最终导致栈溢出。

要解决这个问题,拷贝构造函数的参数必须采用传常量引用(const 类名&),也可以传普通引用(类名&)。
2.3 析构函数
~string() { delete[] _str; _str = NULL; _size = _capacity = 0; }三、功能接口
3.1 reserve

reserve接口的唯一作用是修改string的_capacity,它会提前为字符串分配一块足够大的内存空间,让后续添加字符时无需频繁触发自动扩容,reserve接口通常有以下特点:
- 它只改变容量(_capacity),不改变有效长度(_size):调用reserve 后,字符串的有效字符个数不变,也不会填充任何额外的字符,只是预留了内存空间。
- 它只负责 扩容,不负责 缩容:如果传入的参数小于当前的_capacity,std::string的 reserve通常不会做任何操作。
- 预留的内存空间包含末尾\0的位置:比如reserve(10),意味着_capacity会被设置为 10,实际分配的内存大小是
11(额外存放'\0')。
void reserve(int n) { if (n > _capacity) { char* temp = new char[n+1]; strcpy(temp,_str); delete[] _str; _str = temp; _capacity = n; } }注意:如果我们提前实现了reserve可不可以在构造函数中调用reserve来开辟空间呢?
答案是,坚决不可以。因为调用reserve时其中的成员变量_str,_size,_capacity并没有第一时间初始化其内容可能是随机值。执行strcpy(temp,_str);时_str可能指向的是一块非法的空间导致非法访问,或者有可能_capacity的随机值比n大导致开辟空间失败。
3.2 c_str

这个接口的核心作用是获取一个指向string内部以 \0 结尾的 C 风格字符串的常量指针,用来兼容那些只支持 C 语言风格字符串的函数接口。
- 标准规定返回const char*(常量字符指针),这意味着你只能读取该字符串的内容,不能通过这个指针修改字符串(否则会触发未定义行为)。
- 返回的指针指向string内部存储的字符数组首地址,且该数组必然以
\0结尾 - 返回的指针依赖于string对象的生命周期,且当string对象发生修改(如添加字符、扩容)或销毁后,这个指针会失效(变成野指针),不能再使用。
const char* c_str() const { return _str; }3.3 PushBack

Push_Back接口的作用是在原有string的结尾添加一个字符,并将其的长度增加1。
void string::PushBack(const char ch) { if (_size == _capacity) { reserve(_capacity == 0 ? 4 : 2 * _capacity); } _str[_size++] = ch; _str[_size] = 0; }这里需要注意的是当空间不够扩容时不能直接按照2倍或者1.5倍扩容,首先需要判断_capacity是否为0,不然就会导致新空间的大小一直为0导致错误。
运行示例:

3.4 Append

append这个接口的核心作用是在当前字符串的末尾追加字符或字符串(支持多种格式)。
- 核心行为:在字符串有效字符的末尾追加内容,追加后会更新
_size,并保证末尾有\0。 - 灵活性:支持多种追加格式(单个字符、C 风格字符串、
string对象、字符串的子串等)。 - 内存处理:追加前会检查
_size + 追加内容长度 > _capacity,如果满足则触发扩容(和push_back逻辑一致),避免内存越界。
在代码中我们只模拟实现一种追加格式就是在原有string的末尾追加一段C 风格字符串,代码如下:
void string::Append(const char* str) { int len = strlen(str); if (_size + len > _capacity) { reserve(_size + len > 2*_capacity ? _size + len : 2 * _capacity); } strcpy(_str+_size,str); _size += len; }在这里同样需要注意的是,如果追加的字符串加上已有的字符串的长度大于以及开辟的空间大小_capacity就会触发扩容。但是我们不能一上来就开始2倍扩容,可能_size+len的大小比2*_capacity更大,这时我们就需要判断_size+len与2*_capacity的大小关系。如果_size+len>2*_capacity就按照_size+len的大小扩容否则就按照2倍扩容。
3.5 Insert

insert这个接口的核心作用是在字符串的指定位置插入字符或字符串,相比append(仅能在末尾追加),insert提供了更灵活的插入位置选择。
- 在字符串的
pos索引位置(从 0 开始)插入内容,插入后原pos及之后的字符会向后移动,同时更新_size,保证末尾有'\0'。 - 插入位置
pos的有效范围是[0, _size](pos = _size等价于在末尾追加,和append效果一致),超出范围会触发越界错误。 - 插入前会检查
_size + 插入内容长度 > _capacity,满足则触发扩容,避免内存越界。 - 标准库中
insert返回string&(当前对象的引用),支持链式调用。 - 因为插入会导致后续字符向后移动,频繁在字符串头部或中间插入数据,性能开销较大(时间复杂度
O(n))。
在我们的代码中主要实现两个版本,在指定位置插入字符或者在指定位置插入字符串:
//在指定位置插入一个字符 void string::Insert(int pos, const char ch) { assert(pos <= _size && pos>=0); if (_size == _capacity) { reserve(_capacity == 0 ? 4 : 2 * _capacity); } int cur = _size; while (cur >= pos) { _str[cur + 1] = _str[cur]; cur--; } _str[pos] = ch; _size++; } //在指定位置插入字符串 void string::Insert(int pos, const char* str) { assert(pos <= _size && pos >= 0); int len = strlen(str); if (_size+len > _capacity) { reserve(_size + len > 2*_capacity ? _size + len : 2 * _capacity); } int cur = _size; while (cur >= pos) { _str[cur + len] = _str[cur]; cur--; } //这里不能strcpy因为strcpy会将\0也拷贝进去 strncpy(_str+pos,str,len); _size+=len; }3.6 erase

string的erase接口是用来删除字符串中指定位置或指定范围的字符的,它有三种重载的形式:
删除指定位置的单个字符
// pos:要删除的字符的下标(从0开始) string& erase(size_t pos = 0);从指定位置开始,删除指定长度的字符
// pos:起始删除下标;len:要删除的字符个数 string& erase(size_t pos, size_t len);删除迭代器指向的字符 / 迭代器范围的字符
// 子形式3.1:删除迭代器it指向的单个字符 iterator erase(iterator it); // 子形式3.2:删除[first, last)区间内的所有字符(左闭右开,不包含last指向的字符) iterator erase(iterator first, iterator last);库中的erase返回string&主要是支持链式调用,在我们的代码中实现了其中的一种情况即从指定位置删除指定长度的字符:
void string::erase(size_t pos, size_t len) { assert(pos>=0&&pos<_size); if (len >= _size - pos) { _str[pos] = 0; _size -= (_size - pos); } else { while (pos + len <= _size) { _str[pos] = _str[pos + len]; pos++; } _size -= len; } }
3.7 substr

substr接口用于从原字符串中截取一段子字符串并返回,它不会修改原字符串本身。注意关键字 const,这表明该成员函数不会修改调用它的 std::string 对象
string string::substr(size_t pos, size_t len) const { assert(pos>=0&&pos<_size); if (len > _size - pos) { len = _size - pos; } string temp; for (int i = pos; i < pos + len; i++) { temp += _str[i]; } return temp; }需要注意的是,substr接口必须使用值返回。代码执行到return temp;时会调用拷贝构造,当我们不显式地定义时编译器自动会生成一个拷贝构造。但是这里有个大坑,编译器默认生成的拷贝构造是浅拷贝(两个指针会指向同一块空间,运行程序的时候会导致同一空间被析构两次导致程序崩溃!)。所以在实现substr接口之前必须显式地定义拷贝构造函数。
下面我们可以将显式实现的拷贝构造函数禁用,使用编译器默认生成的拷贝构造进行浅拷贝复现一下这个bug:

此时我们运行程序会发现提供调用substr拿到的字串打印出来是乱码,结果调试我们发现编译器自动生成的拷贝构造并没有为对象开辟新空间,而是将仅仅将就旧空间的地址拷贝到ret管理的空间指针中。substr调用结束里面的临时对象就被析构回收了,此时外部ret拿到的就是一段非法空间打印出来的结果自然是乱码。


所以这就要求我们显式地进行深拷贝,自己定义拷贝构造接口。在我们地拷贝构造中,并不是单纯的将旧空间地址这一数值拷贝给新对象。而是重新开辟一段空间,首先将要拷贝的数据放到新空间中然后将新空间的地址交给新对象进行管理从而完成深拷贝。
下面当我们自己显式进行深拷贝后,代码的运行结果:

3.8 Find

find接口的核心作用是在字符串中查找指定的字符或子串,返回其第一次出现的起始下标,如果查找失败则返回std::string::npos(这是size_t 类型的最大值,表示无效下标其可用于判断是否查找成功)
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); char* ret=strstr(_str+pos,str); if (ret != nullptr) { return ret - _str; } return npos; }在代码中我们们实现了Find接口的两种不同形式,在指定位置之后查找指定字符或字符串第一次出现的位置并返回其下标。其中查找字符串我们使用到了strstr,该函数的核心作用是在一个 C 风格字符串(以 \0 结尾的字符数组)中查找另一个 C 风格子串的首次出现位置。但是需要注意的是,strstr的返回值是一个指向首次出现的起始位置的指针,查找失败则会返回NULL,因为我们要求Find接口的返回值是被查找元素第一次出现位置的下标所以我们还需要计算相对位置。
四、运算符重载
4.1 =
1. 传统写法
string& operator=(const string& str) { if(*this==str) { return; } delete[] _str; reserve(str._capacity); strcpy(_str,str._str); _size = str._size; return *this; }2.现代写法
void Swap(string& str) { std::swap(_str,str._str); std::swap(_size, str._size); std::swap(_capacity, str._capacity); } string& operator=(string str) { Swap(str); return *this; }在赋值运算符的传统写法中需要我们手动开辟空间并释放旧空间,然后将数据拷贝到新空间中,这种手动管理内存的代码不仅复杂而且还可能存在隐患。而现代写法的赋值运算符重载则要求传值传参,在此过程中拷贝构造函数就自动完成了临时对象str的拷贝构造,我们只需要把临时对象的资源交换给当前对象。因为str是临时的,而且经过交换之后它所管理的空间和数据都是旧空间和旧数据函数调用结束str自动被析构,完成资源的回收。
| 特性 | 传统写法 | 现代写法(拷贝并交换) |
|---|---|---|
| 异常安全 | ❌ 存在风险 | ✅ 绝对安全 |
| 代码复杂度 | 较高,需手动管理内存 | 极低,依赖拷贝构造和交换 |
| 自赋值处理 | 需要额外判断 if (this != &str) | ✅ 天然支持,无需额外判断 |
| 性能 | 正常 | 与拷贝构造性能一致,无额外开销 |

注意:
string s2=s1;//这里调用的是拷贝构造 string s2; s2=s1; //这里调用的才是赋值运算符重载4.2 <、<=、>、>=、==、!=
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 strcmp(s1.c_str(), s2.c_str()) == 0; } bool operator!=(const string& s1, const string& s2) { return !(s1 == s2); }在实现大小比较的运算符重载时我们只需要通过strcmp实现<和==其余运算符都通过复用这两个基础实现来完成,减少了重复代码和出错概率。
还有一点需要注意的是,几个函数的声明不可以放在类内。因为类内的函数参数列表有隐含的this指针。
4.3 +=
void operator+=(const char* str) { Append(str); } void operator+=(const char ch) { PushBack(ch); }4.4 <<、>>
void clear() { _str[0] = 0; _size = 0; }std::ostream& operator<<(std::ostream& out, const string& s) { for (auto ch : s) { out << ch; } return out; } std::istream& operator>>(std::istream& in, string& s) { s.clear(); const int N = 256; char buff[N]; int i = 0; char ch; //in >> ch; ch = in.get(); while (ch != ' ' && ch != '\n') { buff[i++] = ch; if (i == N - 1) { buff[i] = '\0'; s += buff; i = 0; } //in >> ch; ch = in.get(); } if (i > 0) { buff[i] = '\0'; s += buff; } return in; } 在流输入和流输出的代码实现中输出比较简单,就是将string中的各个字符依次输出到ostream对象中这是最直接的实现方式,时间复杂度为 O (n),n 为字符串长度。
在流输入的代码实现比较复杂,代码流程如下:
- 清空旧数据:先调用s.clear(),避免输入内容追加到旧字符串之后。
- 缓冲区设计:使用一个固定大小(256 字节)的字符数组buffer 作为临时缓冲区,避免频繁的内存分配。
- 读取到分隔符为止:用in.get()逐个读取字符,直到遇到空格(
' ')或换行符('\n')才停止,这让它能读取一整段连续的非空白字符。 - 缓冲区满时追加:当缓冲区满时,在末尾加上
'\0'转成 C 风格字符串,追加到目标字符串s中,然后重置缓冲区索引。 - 处理剩余字符:循环结束后,若缓冲区还有未处理的字符,同样追加到
s中。 - 返回输入流:返回in以支持链式调用。

需要注意的是,除了clear函数>>和<<运算符重载的函数声明不可以放在类内。因为类内的函数参数列表有隐含的this指针。
五、完整代码
#define _CRT_SECURE_NO_WARNINGS #pragma once #include<string.h> #include<assert.h> #include<iostream> class string { public: typedef char* iterator; iterator begin() const { return _str; } iterator end() const { return _str+_size; } void clear() { _str[0] = 0; _size = 0; } void reserve(int n) { if (n > _capacity) { char* temp = new char[n+1]; strcpy(temp,_str); delete[] _str; _str = temp; _capacity = n; } } //构造 string() { _str = new char[1]{'\0'}; _size = _capacity = 0; } //拷贝构造(C++语法明确禁止拷贝构造传值传参,否则会导致无限递归) //string(const string ch) //但是可以传引用传参 //string(const string& ch) string(const char* str) { int len = strlen(str); _str = new char[len + 1]; strcpy(_str, str); _size =_capacity =len; } string(const string& ch) { //reserve(len);构造函数中不能用reserve,因为该string还没完成初始化strcpy会访问到 //随机值或者空指针 _str = new char[ch._capacity + 1]; strcpy(_str, ch._str); _size = ch._size; _capacity = ch._capacity; } const char* c_str() const { return _str; } //赋值运算符重载 /*string& operator=(const string& str) { if (*this == str) { return; } delete[] _str; reserve(str._capacity); strcpy(_str,str._str); _size = str._size; return *this; }*/ void Swap(string& str) { std::swap(_str,str._str); std::swap(_size, str._size); std::swap(_capacity, str._capacity); } string& operator=(string str) { Swap(str); return *this; } //尾插一个字符 void PushBack(const char ch); //追加一个字符串 void Append(const char* str); //指定位置插入一个字符 void Insert(int pos, const char ch); //指定位置插入一个字符串 void Insert(int pos, const char* str); //删除指定位置之后的len个字符(包括pos) void erase(size_t pos, size_t len); //将指定位置之后长度为len的子串返回(包括pos) string substr(size_t pos, size_t len) const; //从pos位置开始寻找字符ch或者字符串str第一次出现的位置并返回下标 size_t find(char ch, size_t pos = 0); size_t find(const char* str, size_t pos = 0); void operator+=(const char* str) { Append(str); } void operator+=(const char ch) { PushBack(ch); } char operator[](int pos) { assert(pos >= 0&&pos<_size); return _str[pos]; } int size() { return _size; } int capacity() { return _capacity; } ~string() { delete[] _str; _str = NULL; _size = _capacity = 0; } private: char* _str; int _size; int _capacity; static const size_t npos; }; bool operator<(const string& s1, const string& s2); bool operator<=(const string& s1, const string& s2); bool operator>(const string& s1, const string& s2); bool operator>=(const string& s1, const string& s2); bool operator==(const string& s1, const string& s2); bool operator!=(const string& s1, const string& s2); std::ostream& operator<<(std::ostream& out, const string& s); std::istream& operator>>(std::istream& in, string& s);#include"string.h" //npos为无符号整形赋值为-1会赋值成为整形最大值 const size_t string::npos = -1; void string::PushBack(const char ch) { if (_size == _capacity) { reserve(_capacity == 0 ? 4 : 2 * _capacity); } _str[_size++] = ch; _str[_size] = 0; } void string::Append(const char* str) { int len = strlen(str); if (_size + len > _capacity) { reserve(_size + len > 2*_capacity ? _size + len : 2 * _capacity); } strcpy(_str+_size,str); _size += len; } void string::Insert(int pos, const char ch) { assert(pos <= _size && pos>=0); if (_size == _capacity) { reserve(_capacity == 0 ? 4 : 2 * _capacity); } int cur = _size; while (cur >= pos) { _str[cur + 1] = _str[cur]; cur--; } _str[pos] = ch; _size++; } void string::Insert(int pos, const char* str) { assert(pos <= _size && pos >= 0); int len = strlen(str); if (_size+len > _capacity) { reserve(_size + len > 2*_capacity ? _size + len : 2 * _capacity); } int cur = _size; while (cur >= pos) { _str[cur + len] = _str[cur]; cur--; } //这里不能strcpy因为strcpy会将\0也拷贝进去 strncpy(_str+pos,str,len); _size+=len; } void string::erase(size_t pos, size_t len) { assert(pos>=0&&pos<_size); if (len >= _size - pos) { _str[pos] = 0; _size -= (_size - pos); } else { while (pos + len <= _size) { _str[pos] = _str[pos + len]; pos++; } _size -= len; } } //值返回调用拷贝构造,默认浅拷贝,需要实现深拷贝 string string::substr(size_t pos, size_t len) const { assert(pos>=0&&pos<_size); if (len > _size - pos) { len = _size - pos; } string temp; for (int i = pos; i < pos + len; i++) { temp += _str[i]; } return temp; } 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); char* ret=strstr(_str+pos,str); if (ret != nullptr) { return ret - _str; } return npos; } 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 strcmp(s1.c_str(), s2.c_str()) == 0; } bool operator!=(const string& s1, const string& s2) { return !(s1 == s2); } std::ostream& operator<<(std::ostream& out, const string& s) { for (auto ch : s) { out << ch; } return out; } std::istream& operator>>(std::istream& in, string& s) { s.clear(); const int N = 256; char buff[N]; int i = 0; char ch; //in >> ch; ch = in.get(); while (ch != ' ' && ch != '\n') { buff[i++] = ch; if (i == N - 1) { buff[i] = '\0'; s += buff; i = 0; } //in >> ch; ch = in.get(); } if (i > 0) { buff[i] = '\0'; s += buff; } return in; }