欢迎拜访:Madison-No7个人主页
文章主题 : 探秘string的底层实现
隶属专栏:我的 C++ 成长日志
写作日期:2025年10月9日
目录
🚀string成员变量:
cpp
class string
{
private:
char* _str; //存储字符串
size_t _size; //表示有效字符的个数
size_t _capacity; //表示存储有效字符的容量,不包含\0
static size_t npos;
};
string本质就是动态顺序表,只不过数组里面存储的是字符,string当中的成员函数string& erase (size_t pos = 0, size_t len = npos);中涉及到npos,npos是一个静态成员常量值,需要在类中声明,类外定义。
🚀string的成员函数:
🪐构造函数:
构造函数就是初始化成员变量。
🎯空字符串构造(无参构造):
❌典型错误写法:
cpp
string()
:_str(nullptr)
,_size(0)
,_capacity(0)
{
}
构造函数将_str
初始化为nullptr,但在实际使用中(如调用c_str()
时),如果没有对nullptr进行特殊处理,可能会导致空指针访问错误。
cpp
void test01()
{
string s1;
cout << s1.c_str() << endl;
}
这里,我们并没有对流插入<<重载,后面再去重载,这里先借助一下c_str打印输出对象里的字符串。
🎯c_str的底层实现:
cpp
const char* c_str() const
{
return _str;
}
注意 :记得加上 const,这样普通的 string 类对象可以调用,const 类型的 string 类对象也可以调用,普通对象来调用就是权限的缩小。
创建对象s1,调用c_str函数,c_str会返回一个指向字符串对象的指针 。因为C++ 的输出流(std::cout)对 char* 或 const char* 类型的指针做了重载处理, 当检测到指针指向的是字符类型时,它会默认将其视为C 风格字符串 (以 \0 结尾的字符序列),所以会对指针解引用,打印出该指针指向的字符串,而_str是nullptr,空指针解引用导致系统崩溃。
✅修正:
cpp
string()
:_str(new char('\0'))
, _size(0)
, _capacity(0)
{
}
这样,给_str动态申请一个字节的空间,给了'\0',对_str解引用就没问题了。
🎯字符串构造(有参构造):
cpp
string(const char* s)
{
size_t len=strlen(s);
_str = new char[len+1];
strcpy(_str,s);
_size = len;
_capacity = len;
}
值得注意的是:_str要多开一个字节空间用于储存\0,因为len是字符串s的有效字符个数,不包含\0。
🎯默认构造函数:
我们可以将上面的无参构造和字符串构造 合并成一个默认构造函数:
cpp
string(const char*s="")
{
//计算字符窜长度
size_t len = strlen(s);
//为_str在堆上开辟空间
_str = new char[len + 1];
//将s字符串的内容拷贝到_str中
strcpy(_str, s);
_size = len;
_capacity = len;
}
只需要在字符串构造函数的基础上将函数的参数改为const char*s=""即可,当没有传参时,_str也不为空,很好的适配了无参构造。
注意: 合并后的构造函数和前面两个构造函数不能同时存在,因为会有调用歧义的问题。

🎯拷贝构造:
一定得是深拷贝,因为涉及到资源的申请与释放。
cpp
string(const string& s)
{
_str = new char[_capacity+1];
strcpy(_str,s._str);
_size = s._size;
_capacity = s._capacity;
}
其实拷贝构造和字符串构造差不多,但是要注意的是:_str申请空间一定得是_capacity+1,不能是_size+1,否者调用其他成员函数的时候会有问题。
🪐析构函数:
编译器默认生成的析构函数达不到我们的要求,要自己实现析构。因为涉及到在堆上申请空间,申请的空间需要我们自己释放。
cpp
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
🪐operator[]:
cpp
//可读可写
//针对于普通对象
char& operator[](size_t pos)
{
assert(pos<_size);
return _str[pos];
}
//只读
//针对于常对象const string s2("haha");
const char& operator[](size_t pos) const
{
assert(pos < _size);
return _str[pos];
}
这两个运算符重载函数构成函数重载(返回值类型不一样),对象在调用的时候会走最匹配的,普通对象会调用读写版本,const 对象会调用只读版本。
**注意:**要先判断pos的值是否小于_size,返回值是_str[pos]的引用,因为对于函数的传值返回,对于内置类型,临时变量(eax寄存器)作为返回值,是将_str[pos]的值拷贝到临时变量中,返回值的引用,可以减少拷贝,提高一点效率。
🌟函数的返回类型加不加引用,j就看返回的值出函数的作用域后销毁了吗?没有销毁就可以加引用,销毁了就不能加引用。
🪐size():
cpp
size_t& size()
{
return _size;
}
🪐迭代器模拟实现:
除了[]可以遍历访问string对象,迭代器也可以。
cpp
string::iterator it = s2.begin();
while (it != s2.end())
{
cout << *it;
it++;
}
我们之前使用迭代器遍历字符串相信大家都不陌生,知道遍历字符串需要首(begin)尾(end),而对于string来说,它的底层是一个动态的数组,我们知道数组的空间在内存中是连续的,所以我们可以使用指针的方式去遍历字符串,对于string的迭代器可以理解成用指针去实现的,begin指向的就是字符串第一个字符,end指向的就是字符串最后一个字符的下一个字符。
我们以前使用iterator(string::iterator)时可以发现,iterator就像string类的内嵌类型一样,因为每次使用iterator的时候,都要到string中去找,而 在一个类里面定义类型有两种方法,typedef
和内部类,我们采用typedef,对于string,字符窜是储存在动态数组(_str)中的,指针类型应该是char*,用typedef把char*重命名为了iterator。
cpp
//可读可写
typedef char* iterator;
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
//可读,针对常对象
//也就是库里面的const迭代器
typedef char* const_iterator;
const_iterator begin() const
{
return _str;
}
const_iterator end() const
{
return _str + _size;
}
🪐reverse:
因为reverse成员函数不常调用,所以将该成员函数的声明和定义分离了。
cpp
void string::reverse(size_t n)
{
//当想要预留的空间大于容量时,就要扩容了
if (n>_capacity)
{
//申请空间
char* temp = new char[n+1];
//将旧空间数据拷贝到temp
strcpy(temp, _str);
//释放旧空间
delete[] _str;
//让_str指向新空间
_str = temp;
//将容量更新
_capacity = n;
}
//当n小于capacity时,就不用管了
}
之前我们讲过在C++中没有realloc函数,扩容只能使用new去模拟实现realloc的异地扩容。当想要预留的空间小于_capacity时,reverse函数相当于无效(不扩容也不缩容)。
🪐push_back:
cpp
//尾插
void string::push_back(const char c)
{
//得先判断是否需要扩容
//情况一:_size==_capacity=0
//情况二:_size==_capacity
if (_size==_capacity)
{
//需要扩容
//可以调用reverse来扩容
reverse(_capacity==0?4:_capacity*2);
}
//插入数据
_str[_size++] = c;
//因为_size位置的\0被覆盖了,所以在末尾需要把\0加上
_str[_size] = '\0';
}
🪐operator+=:
相当于尾插。
cpp
string& string::operator+=(char c)
{
//复用push_back
push_back(c);
return *this;
}
因为this指针指向调用该函数的对象,所以解引用this就表示调用的对象了。
🪐append:
cpp
void string::append(const char* str)
{
//首先要判断_size+strlen(str)是否大于_capacity
//大于就要扩容
if ((_size + strlen(str)) > _capacity)
{
//同样,可以借助reverse扩容
//这里不用多开一个空间给\0哦!因为reverse里面会多开一个
reverse(_size+_capacity);
}
//空间开好了,把字符串追加上
//从_str+strlen(str)追加
//法一:推荐
strcpy(_str+strlen(str), str);
//法二:
//strcat(_str+strlen(str),str);
//法三
//+1是加上\0
//memcpy(_str+strlen(str),str,strlen(str)+1);
//最后要将_size更新
_size += strlen(str);
}
空间开好后,最后追加字符串可以使用strcpy(推荐),strcat,memcpy。
但是不推荐使用strcat:

因为strcat的底层是要先遍历目标字符串直到找到 \0
(字符串结束符),这个位置就是拼接的起始点,然后从找到的 \0
位置开始,将源字符串(src
)的所有字符(包括 \0
)逐个拷贝到目标字符串后面,覆盖原来的 \0
并延续拼接。
它的过程是:查找目标字符串结尾 + 拷贝源字符串,而strcpy直接拷贝源字符串(src)到目标字符串(dest
)的对应位置,效率稍高一点。
不推荐使用memcpy:
因为如果不清楚memcpy的底层机制,容易出错。

memcpy的底层机制:
从源内存地址(src) 开始,将指定字节数(num) 的数据,精准复制到目标内存地(dest) ,整个过程不解析数据内容(不识别 \0),num是多少字节就复制多少字节。
🪐operator+=:
cpp
string& string::operator+=(const char* str)
{
//复用append即可
append(str);
return *this;
}
🪐insert:
在指定下标位置之前插入一个字符:
pos表示的是下标。
cpp
void string::insert(size_t pos, char c)
{
//首先要判断pos是否合法,即在不在(_size+1)范围内
assert(pos<=_size);
//其次判断是否需要扩容
//别忘了_capacity为0的情况
if (_size==_capacity)
{
//依然借助reverse扩容
reverse(_capacity==0?4:(_capacity*2));
}
//然后是插入数据,涉及到字符的挪动
//挪动的顺序是从后往前
int end = _size + 1;
int begin = pos;
while (begin<end)
{
//挪动字符
_str[end] = _str[end-1];
end--;
//验证一下,是否正确
//如果end为12,begin为0,那就要移动12次
//(0,12)
}
//插入字符
_str[pos] = c;
_size += 1;
}
首先pos要小于等于_size,因为insert具体要实现的是把新内容插入到pos位置之前,_size位置是\0,如果pos==_size的话,插入的字符就会把\0覆盖掉,所以最后还要将\0补上。其次判断是否需要扩容,再是插入数据的问题。插入数据是在指定下标(pos)之前插入,那pos之后的字符就要往后挪动,我们先来考虑极端的情况。
当pos=0时:

当pos=_size=11时:

我们可以看到,pos=0时,是挪动次数最多的情况,共挪动了12次,即_size+1次,每次字符挪动一格,因为插入的是一个字符。pos=11时,需要挪动1次。我们不难推出pos=0时,挪动_size+1,pos=1时,挪动_size次,以此类推,当pos=_size=11时,挪动1次。
🪐在指定下标位置之前插入字符串:
cpp
void string::insert(size_t pos, const char* str)
{
//同样得判断pos合法吗?
assert(pos<=_size);
//其次判断空间够吗?
if ((_size+strlen(str))>_capacity)
{
//还是用reverse去开空间
reverse(_size+strlen(str));
}
//然后是插入数据,涉及到字符的挪动
//挪动的顺序是从后往前
int end = _size + 1;
int begin = pos;
//挪动次数与插入字符的insert一样
//只是挪动的距离发生了变化
while(begin>end)
{
//挪动距离是插入字符串的有效字符个数
_str[end + strlen(str) - 1] = _str[end-1];
end--;
}
//插入字符串
for (int i=0;i<strlen(str);i++)
{
_str[pos+i] = str[i];
}
//最后记得更新_size哦!
_size += strlen(str);
}
该函数的实现与前面的insert函数类似,因为插入的是字符串,字符挪动的距离不是1了,而是插入字符串的长度了,字符挪动的次数没有变化,挪动完成以后,把字符一个一个的循环插入目标字符串中就行了。
分析极端的两种情况,有助于我们理解整个挪动和插入数据的过程。
当pos=0时:

当pos=_size=11时:

🪐erase:
cpp
//类中声明
void erase(size_t pos, size_t len = npos);
//类外定义
void string::erase(size_t pos, size_t len )
{
//判断pos合法吗?
assert(pos<=_size);
//判断删除的部分
//此情况删除pos位置后的全部字符,包括pos位
if ((pos+len)>=_size || len==npos)
{
_str[pos] = '\0';
//更新_size
_size = pos;
}
else
{
//其他情况就是删除中间的一段
//挪动数据把要删除的字符覆盖掉
//挪动距离是len
//挪动次数是(_size+1-len-pos)
for (int i = pos; i < _size + 1 - len; i++)
{
_str[i] = _str[i + len];
}
//更新_size
_size -= len;
}
}
该函数的参数部分有一个缺省参数len,当我们不传len时,就会默认用npos,npos是一个静态成员变量,默认为-1,因为它是size_t类型,实际上就是整型的最大值了,对于静态成员变量要在类中声明,类外定义,定义时不加static
关键字,但是要指定类域,表示它属于某一个类。
cpp
namespace hwy
{
class string
{
private:
char* _str;
size_t _size;
size_t _capacity;
static size_t npos;
};
size_t string::nop=-1;
}
我们要判断删除的部分,有两种情况,一种是从pos开始后面的字符全删掉,判断条件是当pos+len大于等于_size或者npo==-1时,这种情况比较简单,另外一种情况是:删除指定长度,
我们来画图分析一下:

我们通过画图分析:可以知道字符是向前覆盖,挪动距离是len,挪动次数是:_size+1-len-pos,然后通过for循环挪动覆盖。最后记得更新_size哦。
🪐find:
查找字符:
cpp
//类中定义
int find(char c,size_t pos=0);
//类外定义
int string::find(char c, size_t pos)
{
assert(pos < _size);
//for遍历一遍字符串
for (int i = pos; i < _size; i++)
{
//如果有匹配的就返回下标
if (_str[i] == c)
{
return i;
}
}
//出循环还没返回,表示没找到
return -1;
}
**注意:**成员函数声明和定义分离时,函数参数的缺省值一般在函数声明中定义,不能函数声明和定义同时定义缺省值。还有C++规定半缺省参数必须从右往左依次连续缺省,不能间隔跳跃给缺省值。
查找字符串:
cpp
size_t string::find(const char* str, size_t pos)
{
assert(pos<_size);
//可以使用strstr函数匹配字符串
//strstr函数返回的是匹配的字符串的起始地址
char* temp=strstr(_str,str);
if (temp!=nullptr)
{
//说明找到了
return temp - _str;
}
else
{
return npos;
}
}
在一个字符串中查找字符串,可以使用strstr(暴力查找)函数,还可以使用KMP算法,BM算法。
🪐substr:
表示从字符串的pos位置开始截取长度为len的子串并返回
cpp
//声明
string substr(size_t pos = 0, size_t len = npos);
//定义
string string::substr(size_t pos, size_t len)
{
assert(pos < _size);
//要判断截取的长度
if (pos + len >= _size || len == npos)
{
len = _size - pos;
}
//临时创建一个字符串对象
string temp;
temp.reverse(len);
//将截取到的字符尾插到temp中
for (size_t i = 0; i < len; i++)
{
temp += _str[pos];
pos++;
}
return temp;
}
我们首先要判断截取的长度,如果pos+len大于_size或者没有定义len的值,那截取的长度就是从pos位开始剩余的全部字符。其次需要创建一个临时对象用于储存截取出来的字符串,还需要给临时对象申请足够的空间用于储存截取出来的字符串,申请len个空间就够用了,最后将截取到的字符尾插到temp中。
**注意:**函数的传值返回,在编译器不优化的情况下,要构造一个临时对象,把temp拷贝构造给临时对象,临时对象作为函数的返回值,所以我们需要自己定义拷贝构造函数(深拷贝),因为涉及到在堆上的资源申请与释放。
🪐clear:
cpp
void clear()
{
_str[0] = '\0';
_size = 0;
}
🪐operator=:
关于赋值运算符的重载。赋值的前提是两个对象都已经存在了。
cpp
string& string::operator=(const string& s)
{
//释放掉接收赋值的对象,防止内存泄漏
delete[] _str;
//申请新空间
_str = new char[s._capacity+1];
strcpy(_str,s._str);
_size = s._size;
_capacity = s._capacity;
return *this;
}
因为我们难以确定接收赋值的对象的容量是否能够存下要赋值对象的字符串,所以我们要另外申请一块空间以确保能存在要要赋值对象的字符串,值得注意的是:在申请新空间之前,要先把旧的空间给释放掉,不然会出现分配的内存不再使用,却未被释放的情况,就是"内存泄漏"了,申请空间的大小就是要赋值对象空间的大小。
🪐string的比较运算符重载:

库里面的比较运算符是重载成了全局函数,是为了满足不同的比较需求,如果一个字符串和对象比较大小,重载成成员函数就做不到了。
cpp
"hello world"<s1
因为字符串("hello world")不是实例化对象,所以没有this指针指向该字符串,所以成员函数无法完成比较。
🎯operator<:
cpp
//重载声明
bool operator<(const string& s1, const string& s2);
字符串该如何比较大小呢?相信你一下就想到了strcmp这个专门用来比较字符串大小的函数吧。


注意strcmp函数的参数是指针,也就是说我们要访问对象中私有的成员变量_str,但是我们把他重载成了全局函数,访问类中的私有成员变量就需要做友元(friend)声明,才能够访问类中的成员变量,别忘记了,我们前面实现一个c_str成员函数,通过调用它可以获取到_str。
cpp
//定义
bool operator<(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str());
}
🎯operator==:
cpp
bool operator==(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str()) == 0;
}
其实,我们只需要重载<、==即可,其他的比较运算符复用就可以了。
🎯operater<=、>、>=、!=:
cpp
bool operator>(const string& s1, const string& s2)
{
return !(s1 < s2);
}
bool operator>=(const string& s1, const string& s2)
{
return (s1 > s2) || (s1 == s2);
}
bool operator<=(const string& s1, const string& s2)
{
return !(s2 >= s2);
}
bool operator!=(const string& s1, const string& s2)
{
return !(s1==s2);
}
🪐operator<<:
cpp
ostream& operator<<(ostream& out, const string& s)
{
//通过迭代器把一个个字符写入缓冲区
for (auto ch:s)
{
out << ch;
}
return out;
}
对于内置类型,C++标准库中已经帮我们实现好了,对于自定义类型这需要我们自己去定义。
我们是要向输出流中写入字符,用一个迭代器就可以把对象中的字符串写入到输出流中了。其实这个写入的过程在底层比较复杂,以后会专门讲解具体的过程。
🪐operator>>:
同样,对于内置类型,C++标准库中已经帮我们实现好了,对于自定义类型这需要我们自己去定义。
❌错误写法:
cpp
istream& operator>>(istream& in,string& s)
{
char ch;
in >> ch;
while (ch!=' '&& ch!='\n')
{
s += ch;
in >> ch;
}
return in;
}
实际上,流提取是到输入缓冲区中挨着挨着取字符,从第一次遇到空白字符 (空格 、制表符 \t
、换行符 \n
等 )开始提取,到下一次遇到空白字符结束提取。也就是说:in>>ch 提取字符,是不会提取空白字符的,那while的判断条件就失去作用咯!那就会造成死循环了;

输入永远都结束不了。
在C++库中,有一个cin.get函数用来读取单个字符,它表示从输入流中读取一个字符 (包括空白字符,如空格、回车 \n
、制表符 \t
等),函数原型是:istream& cin.get(char& ch)。
✅用cin.get函数读取字符就可以了:
cpp
istream& operator>>(istream& in,string& s)
{
//为了保持与库中的功能一致,所以首先要把s清空
s.clear();
char ch;
in.get(ch);
while (ch != ' ' && ch != '\n')
{
s += ch;
in.get(ch);
}
return in;
}
**还有一个问题:**如果输入的字符串太长,+=操作会进行不断的扩容,而扩容为异地扩就会不断的申请新空间和释放就空间,就会有很大的消耗,所以我们可以reverse提前预留一块空间。
关键是预留多少呢?给小了,不够用,还得扩容(只是扩容次数变少了一点),给大了,又浪费了
OK,这里可以用一个类似于缓冲的思想。
cpp
istream& operator>>(istream& in,string& s)
{
s.clear();
const int N = 256;
char baf[N];
int i = 0;
char ch;
in.get(ch);
while (ch != ' ' && ch != '\n')
{
baf[i++] = ch;
//说明buff满了
if (i==N-1)
{
//将数组中的数据追加到s
s+=baf;
i = 0;
}
in.get(ch);
}
//出循环了,i还大于0,说明数组中还有字符没追加完
if (i>0)
{
baf[i] = '\0';
s+=baf;
}
return in;
}
**这样做的好处:**在插入的字符串长度不是特别特别大的情况下:可以大幅减少扩容的次数,提高程序的效率。因为局部数组是存储在栈上,栈上开空间极快,且空间的释放自动完成,我们就是用了数组存储在栈上的机制,把字符先放在baf数组里,等baf数组满了,再追加到对象中,相当于每次追加字符串的长度都是256,扩容机制相当于是:4-(4+len)-(4+257)-(4+257+len)-(4+257+257+len)...(0<len<256),而原来+=字符的话,空间变化:

完。
今天的分享就到这里,感谢各位大佬的关注,大家互相学习,共同进步呀!
