一、整体结构
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--; }
3.6 erase
string 的 erase 接口是用来删除字符串中指定位置或指定范围的字符的,它有三种重载的形式:
删除指定位置的单个字符
string& erase(size_t pos = 0);
从指定位置开始,删除指定长度的字符
string& erase(size_t pos, size_t len);
删除迭代器指向的字符 / 迭代器范围的字符
库中的 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;
在流输入和流输出的代码实现中输出比较简单,就是将 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; }
( * str) { len = (str); _str = [len + ]; (_str, str); _size =_capacity =len; }
( string& ch) {
{ _str; }
{ std::(_str,str._str); std::(_size, str._size); std::(_capacity, str._capacity); }
string& =(string str) { (str); *; }
;
;
;
;
;
;
;
;
+=( * str) { (str); }
+=( ch) { (ch); }
[]( pos) { (pos >= &&pos<_size); _str[pos]; }
{ _size; }
{ _capacity; }
~() { [] _str; _str = ; _size = _capacity = ; }
:
* _str;
_size;
_capacity;
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);
std::ostream& <<(std::ostream& out, string& s);
std::istream& >>(std::istream& in, string& s);
#include"string.h"
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, * str) { (pos <= _size && pos >= ); len = (str); (_size+len > _capacity) { (_size + len > *_capacity ? _size + len : * _capacity); } cur = _size; (cur >= pos) { _str[cur + len] = _str[cur]; cur--; }
{ (pos>=&&pos<_size); (len >= _size - pos) { _str[pos] = ; _size -= (_size - pos); } { (pos + len <= _size) { _str[pos] = _str[pos + len]; pos++; } _size -= len; } }
{ (pos>=&&pos<_size); (len > _size - pos) { len = _size - pos; } string temp; ( i = pos; i < pos + len; i++) { temp += _str[i]; } temp; }
{ (pos<_size); ( i = pos; i < _size; i++) { (_str[i] == ch) { i; } } npos; }
{ (pos < _size); * ret=(_str+pos,str); (ret != ) { ret - _str; } npos; }
<( string& s1, string& s2) { (s(), s()) < ; }
<=( string& s1, string& s2) { s1 < s2 || s1 == s2; }
>( string& s1, string& s2) { !(s1 <= s2); }
>=( string& s1, string& s2) { !(s1 < s2); }
==( string& s1, string& s2) { (s(), s()) == ; }
!=( string& s1, string& s2) { !(s1 == s2); }
std::ostream& <<(std::ostream& out, string& s) { ( ch : s) { out << ch; } out; }
std::istream& >>(std::istream& in, string& s) { s.(); N = ; buff[N]; i = ; ch;