在日常编码中,很多人对 string 的理解往往停留在“知道怎么用”的层面——调用几个接口,完成功能,便以为足够。然而,一旦面试中被要求“手动实现一个 string 类”,不少人就陷入困境。更常见的是,代码在把 string 对象作为参数传递或返回值之后,程序莫名崩溃,其根源往往在于对“浅拷贝”所造成的内存问题缺乏认知。
深入理解 string 的底层机制,其意义远不止于应对面试。它直接关系到我们日常开发的效率与代码的健壮性。比如:
reserve 预分配容量的机制,能够有效减少字符串动态扩容带来的性能开销;swap 函数,有助于我们写出更高效、更安全的代码。更重要的是,string 的底层设计是 C++ 容器实现思想的一个“缩影”——吃透 string,再学习 vector、list 等其他容器,将会事半功倍。
通过对string类中接口的学习,我们不难发现,string类的私有成员变量应该包括以下三个:
既然知道了这些,我们就可以很快的写出相对应的代码:
string.h
namespace carrot
{
class string
{
private:
char* _str;//数组用来存储字符串
size_t _size;//有效字符个数
size_t _capacity;//空间大小
};
}也许这时候,会有小伙伴会感到疑惑,为什么这里要加上命名空间?其实这是为了和库中的string做区分。
构造函数为string对象分配初始内存,初始化状态;析构函数则在对象生命周期结束时,回收动态分配的内存,避免内存泄露。
在看相应的构造之前,我们先来看看如何进行打印操作。
通过上面的学习,我们知道string类的底层中有一个_str的数组,我们是将数据存储在这个数组中,既然是这样的话,那我们打印的操作就是对这个数组进行了,只要我们知道数组的地址,我们就可以很轻松的打印出相应的数据。
在string类的接口中,有一个成员函数——c_str,这个函数就是可以返回底层的字符串,所谓返回底层的字符串就是返回底层中指向数组的指针,ok,既然已经这么清晰了,直接上代码:
const char* c_str() const
{
return _str;
}对于使用来说,频率较高的应该是无参构造和有参构造,我们一一来看:
1、无参构造

测试代码:
#include<iostream>
using namespace std;
namespace carrot
{
class string
{
public:
//无参构造
string()
:_str(nullptr)
,_size(0)
,_capacity(0)
{}
const char* c_str() const
{
return _str;
}
private:
char* _str;//数组用来存储字符串
size_t _size;//有效字符个数
size_t _capacity;//空间大小
};
}#include"string.h"
namespace carrot
{
void testString1()
{
string s1;
cout << s1.c_str() << endl;
}
}
int main()
{
carrot::testString1();
return 0;
}我们构造一个对象,使用上面的c_str,运行一下,会出现什么意想不到的事情发生:

嗯?这是为什么?为什么运行的结果不正确?
ok,其实这是因为cout进行输出的时候,输出的是const char* 类型的一个指针,const char*的指针,cout在自动识别类型的时候,const char* 不会按指针进行输出,而是按照字符串进行输出,会对指针指向的字符串进行解引用操作,只有遇到‘\0’才结束。
简单来说,就是当cout遇到const char* 类型时,他会将其视为C风格的字符串,并输出该指针指向的字符串内容,若const char* 指向的是nullptr,则cout是未定义的行为,通常会导致程序崩溃。
所以,我们不能给_str初始化为nullptr,而是应该加上'\0'。
正确代码:
//无参构造
string()
:_str(new char[1]{'\0'})
, _size(0)
, _capacity(0)
{}我们再运行测试一下:

此时就没有什么问题了~
2、有参构造

这~代码有没有什么问题?
ok,我们知道strlen 是一个时间复杂度为O(n)的接口,如果按照上图中的写法,这里会算三遍,效率会有点子低。
那我们可以改成下面这种写法吗?

其实是不行的,这时候就有UU想问了,为什么不能这么写? 在前面的学习中,我们学到过这么一个知识—— 初始化的顺序要跟声明的顺序是一致的,先初始化_str,再_size,最后_capacity,如果按照上面的写法在初始化_str时,_str中的_size是一个随机值,会有问题。
那我们该怎么写这个代码呢?
我们知道私有成员变量初始化时是最好走初始化列表的,但是这并没有说必须走初始化列表,在下面的括号中进行初始化也是可以的。
正确代码:
//有参构造
string(const char* str)
:_size(strlen(str))//可以走初始化的尽量走初始化
{
_str = new char[_size + 1];//多开的一个空间给\0
_capacity = _size;
//strcpy(_str, str);//再将str中的数据拷贝到_str中
memcpy(_str, str, _size + 1);//再将str中的数据拷贝到_str中
}通过前面的学习,我们知道,无参构造和有参构造可以合并成一个带有缺省值的构造函数
代码演示:
string(const char* str = "")
:_size(strlen(str))//可以走初始化的尽量走初始化
{
_str = new char[_size + 1];//多开的一个空间给\0
_capacity = _size;
//strcpy(_str, str);//再将str中的数据拷贝到_str中
memcpy(_str, str,_size+1);//再将str中的数据拷贝到_str中
}
str=""要比str="\0" 的要好,这是因为常量字符串中的末尾默认有\0
//析构
~string()
{
delete[] _str;//释放_str的空间
_str = nullptr;
_size = 0;
_capacity = 0;
}下标访问是string最常用的操作之一,通过重载operator ,可以像访问数组一样操作string中的字符,底层本质是对 _str 指针的索引访问,同时也需要确保访问不会越界(这个可以加断言)
//普通对象
char& operator[](size_t pos)
{
assert(pos<=_size);
return _str[pos];
}//const 对象
const char& operator[](size_t pos) const
{
assert(pos<=_size);
return _str[pos];
}迭代器是遍历容器元素的抽象机制,对于string,可以通过封装指针来实现简单迭代器,结合下标访问可以覆盖不同遍历场景。
//普通对象
typedef char* iterator;
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}//const对象
typedef const char* const_iterator;
const_iterator begin() const
{
return _str;
}
const_iterator end() const
{
return _str + _size;
}通过上面的重载 运算符,我们就可以通过下标返回数组上对应数组元素的引用,从而修改相应的值(这里的修改是对于普通对象,const对象只能读,不能被修改)
在进行上面的操作前,我们先来看一个简单的算法:求数组的长度,也就是string 类中size
代码演示:
//size
size_t size() const
{
return _size;
}ok,我们接着来看如何使用下标+ 进行遍历+修改的操作
代码演示:
string s2("hello bit");
for (size_t i = 0; i < s2.size(); i++)
{
s2[i]++;
cout << s2[i] << " ";
}s2是一个普通对象,可以进行读和写的操作。
如果是一个const对象,那只能进行读的操作——
const string s3("hello world");
for (size_t i = 0; i < s3.size(); i++)
{
//s3[i]++;//const对象只能读,不能写
cout << s3[i] << " ";
}void testString2()
{
//普通对象
string s1("hello world");
string::iterator it = s1.begin();
while (it != s1.end())
{
(*it)++;
cout << *it << " ";
++it;
}
cout << endl;
//const 对象
const string s2("hello bit");
string::const_iterator it2 = s2.begin();
while (it2 != s2.end())
{
cout << *it2 << " ";
++it2;
}
}支持迭代器的都支持范围for!!!
通过前面的学习,我们知道范围for的底层其实就是迭代器!!!
void testString2()
{
//范围for
//普通对象(可以读,可以写)
string s3("hello world");
for (auto& ch : s3)
{
ch++;
cout << ch << " ";
}
cout << endl;
//const 对象(可以读,不可以修改)
const string s4("hello bit");
for (auto& ch : s4)
{
//ch++;
cout << ch << " ";
}
}在学习push_back,append,insert与+=之前,我们先来看看,我们该怎么对空间容量进行操作
ok,话不多说,直接上代码:
public:
//扩容
void reserve(size_t n);//扩容
void string::reserve(size_t n)
{
//一般情况下,reserve不缩容
if (n > _capacity)
{
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}public:
//resize
void resize(size_t n, char ch = '\0');//resize
void string::resize(size_t n, char ch)
{
//n <= _size,删除数据,保留前n个
if (n <= _size)
{
_size = n;
_str[_size] = '\0';
}
else
{
reserve(n);
for (size_t i = _size; i < n; i++)
{
_str[i] = ch;
}
_size = n;
_str[_size] = '\0';
}
}注意:resize用于删除数据的场景用的不多,用来一次性插入数据的场景较多!!!
void string::reserve(size_t n)
{
//一般情况下,reserve不缩容
if (n > _capacity)
{
char* tmp = new char[n+1];
//strcpy(tmp, _str);
memcpy(tmp,_str,_size+1);
delete[] _str;
_str = tmp;
_capacity = n;
}
}ok,接下来我们来看一下push_back的代码:
string.h
public:
void push_back(char ch);string.cpp
void string::push_back(char ch)
{
//空间不够,需要扩容
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
//空间足够,直接尾插
_str[_size] = ch;
_size++;
_str[_size] = '\0';
}关键逻辑:
我们先来看看append的扩容机制,append在进行扩容时,就不能再延续push_back的2倍扩容的机制,可以进行下面操作中的一个:
//需要多少空间,开多少空间
reserve(_size + len);reserve(max(_size + len, 2 * _capacity));这种扩容方式,可以防止空间开大了
扩容的大逻辑:开多了浪费。开少了不够用!!!
public:
void append(const char* str);//append
void string::append(const char* str)
{
//空间不够,需要扩容
size_t len = strlen(str);
if (_size + len > _capacity)
{
//需要多少空间,开多少空间
reserve(_size + len);
//reserve(max(_size + len, 2 * _capacity));
}
//空间足够,直接操作
//这里就不需要再手动添加\0了,strcpy会将str中的\0拷贝过去
//strcpy(_str + _size, str);
//字符串中间有\0,使用memcpy
memcpy(_str + _size, str, len + 1);
_size += len;
}public:
//+=
//单个字符
string& operator+=(char ch);
//字符串
string& operator+=(const char* str);//+=
//单个字符
string& string::operator+=(char ch)
{
push_back(ch);
return *this;
}
//字符串
string& string::operator+=(const char* str)
{
append(str);
return *this;
}优势:

通过前面的学习,我们很快就可以写出相应的代码,但是,这个代码正确吗?
当我们执行头插时,会不会有啥问题呢?我们运行一下

这是为什么?
这是因为end的类型是size_t,也就是无符号整型,永远不会小于0,这就导致end>pos恒成立。
那我们这样改?

这样还是不行,end是int ,pos是size_t ,在运算时,会进行算术转化,范围大的向范围小的转换,end>pos还是恒成立
我么应该这么改——

最终代码:
public:
//在pos位置上插入一个字符
void insert(size_t pos, char ch);//insert
//在pos位置上插入一个字符
void string::insert(size_t pos, char ch)
{
//代码改进
assert(pos < _size);
//空间不够,需要扩容
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
//空间足够,先挪动数据,在插入数据
int end = _size;
while (end >= (int)pos)
{
_str[end + 1] = _str[end];
--end;
}
_str[pos] = ch;
++_size;
}移动示意图:

这时候就有UU想说了,上面的操作感觉有点麻烦,有没有比较简洁的方法?当然有
我们可以按照下图的方式进行移动:

end为\0的下一个位置,然后我们将end-1位置上的数据移动到end位置上,这样就可以避免end==pos的情况的发生。
void string::insert(size_t pos, char ch)
{
//代码改进
assert(pos < _size);
//空间不够,需要扩容
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
//空间足够,先挪动数据,在插入数据
int end = _size + 1;
while (end > pos)
{
_str[end] = _str[end - 1];
--end;
}
_str[pos] = ch;
++_size;
}数据移动方式一:

把end位置的数据挪动到end+len的位置上,直到end<pos,终止挪动
public:
//在pos位置上插入一个字符串
void insert(size_t pos, const char* str);//在pos位置上插入一个字符串
void string::insert(size_t pos, const char* str)
{
//代码改进
assert(pos < _size);
// 空间不够,需要扩容
size_t len = strlen(str);
if (_size + len > _capacity)
{
//需要多少空间,开多少空间
reserve(_size + len);
//reserve(max(_size + len, 2 * _capacity));
}
//空间足够,先挪动数据,在插入数据
int end = _size;
while (end >= (int)pos)
{
_str[end + len] = _str[end];
--end;
}
//strncpy(_str + pos, str, len);
//字符串中间有\0,用memcpy
memcpy(_str + pos, str, len);
_size += len;
}数据移动方式二:

//在pos位置上插入一个字符串
void string::insert(size_t pos, const char* str)
{
// 空间不够,需要扩容
size_t len = strlen(str);
if (_size + len > _capacity)
{
//需要多少空间,开多少空间
reserve(_size + len);
//reserve(max(_size + len, 2 * _capacity));
}
//空间足够,先挪动数据,在插入数据
int end = _size+len;
while (end >pos+len-1)
{
_str[end] = _str[end-len];
--end;
}
//strncpy(_str + pos, str, len);
memcpy(_str + pos, str, len);
_size += len;
}erase是从pos位置开始,删除len个字符
public:
//erase
void erase(size_t pos = 0, size_t len = npos);
const static size_t npos = -1;//erase
void string::erase(size_t pos, size_t len)
{
assert(pos < _size);
if (len == npos || len >= _size - pos)
{
_size = pos;
_str[_size] = '\0';
}
else
{
//strcpy(_str + pos, _str + pos + len);
memcpy(_str + pos, _str + pos + len, _size - (pos + len) + 1);
_size -= len;
}
}clear是用来快速清空数据,但是不销毁空间,仅需重置_size和结束符
public:
//clear
void clear()
{
_str[0] = '\0';
_size = 0;
}substr是拷贝从pos位置开始的len个字符,然后构造一个string对象返回
public:
//substr
string substr(size_t pos = 0, size_t len = npos);
const static size_t npos = -1;//substr
string string::substr(size_t pos, size_t len)
{
assert(pos <= _size);
if (len == npos || len > _size - pos)
{
len = _size - pos;
}
string tmp;
for (size_t i = 0; i < len; i++)
{
tmp += _str[pos + i];//从pos位置开始的len个字符
}
return tmp;
}通过前面的学习,我们知道,对于自定义类型中有资源的,编译器自动生成的默认拷贝构造是行不通的,这是因为自动生成的构造为浅拷贝,析构时会析构两次

具体见:C++拷贝构造与运算符重载实战
所以我们需要自已写拷贝构造完成深拷贝,也就是新开一块空间将拷贝后的数据放入新开的空间中
public:
//拷贝构造
string(string& str);//拷贝构造
string::string(string& str)
{
_str = new char[str._capacity+1];
//多开的一个空间给\0,capacity中不包含\0
//strcpy(_str, str._str);//完成的是深拷贝
memcpy(_str,str._str,str._size+1);
_size = str._size;
_capacity = str._capacity;
}
在进行赋值操作前,我们总结出有三种情况,如果我直接进行赋值操作,会有些问题,那我们该如何做呢?
执行步骤
char* tmp = new char[str._size + 1];_size 确定,额外+1用于存放字符串结束符 \0tmp 来管理这块新内存//strcpy(tmp, str._str);
memcpy(tmp, s._str, s._size + 1);str 中的实际字符数据(包括结束符)完整复制到新分配的内存中delete[] _str;
_str = tmp;_str 指针,完成所有权的转移_size = str._size;
_capacity = str._capacity;_size,使其与源字符串保持一致_capacity,反映新的内存分配情况public:
//赋值重载
string& operator=(string& str);//赋值重载
string& string::operator=(string& str)
{
if (this != &str)
{
char* tmp = new char[str._size + 1];//和str中的数组开一样的大小
delete[] _str;
//strcpy(tmp, str._str);
memcpy(tmp, s._str, s._size + 1);
_str = tmp;
_size = str._size;
_capacity = str._capacity;
}
return *this;
}写到这里,我们稍微暂停一下~~~
我们来想一个问题,通过对上面代码的学习,发现了一个问题:上面的代码中的字符串好像没有中间有\0的情况,那如果我们在中间插入一个\0,并打印这个字符串,还会是正确的吗?

嗯?为什么会是上面的结果?打印的结果不应该是hello worldxyyy吗?为什么是hello worldx?
很奇怪,其实这是因为c_str,c_str在打印的过程中遇到\0就终止了,如果中间有\0,并且还是用c_str打印,\0后面的数据就无法打印。
这就要求我们不得不自己实现流插入以及顺便实现一下流提取、getline
流插入<<、流提取>>和getline 这三个都是非成员函数
位于string类的外面
std::ostream& operator<<(std::ostream& out, const string& str); std::ostream& operator<<(std::ostream& out, const string& str)
{
for (auto ch : str)
{
out << ch;
}
return out;
}位于string类的外面
std::istream& operator>>(std::istream& in, string& str);std::istream& operator>>(std::istream& in, string& str)
{
str.clear();
char ch;
ch = in.get();
while (ch != ' ' && ch != '\n')
{
str += ch;
ch = in.get();
}
return in;
}也许会有uu看见上面的代码,会想问:为什么不使用>>,而是使用get呢?
这是因为“operator>>”会跳过空白字符(空格和换行),如果使用它,就永远无法检测到空格和换行,而get函数是一个字符一个字符的获取,这样就可以检测到空格和换行
为什么会有str.clear();的操作呢?
当我们不加str.clear();会出现下面的情况:

ok,我么原本是想给s2初始化为"hello",s3为"bit",结果成了上面的样子,这是因为s3中原本就有数据,“>>”会在原有的数据后面继续追加,导致结果的错误,所以我们一不做二不休直接将数据清空(并没有销毁空间)
这里面还有一个问题:若有一个很长的字符串,会进行多次扩容,会很麻烦,此时我们该怎么做?
我们可以这样做:
std::istream& operator>>(std::istream& in, string& str)
{
str.clear();
char ch;
ch = in.get();
char buff[256];
int index = 0;
while (ch != ' ' && ch != '\n')
{
buff[index++] = ch;
if (index == 255)
{
buff[index] = '\0';
str += buff;
index = 0;
}
ch = in.get();
}
if (index > 0)
{
buff[index] = '\0';
str += buff;
}
return in;
}创建一个buff数组,大小为256(任何大小都可以),数据一开始先放到buff数组中,若数组满了,再拼接到str中,index置为0,重新往数组中插入数据。这样就可以减少扩容的次数,提高效率
位于string类的外面
std::istream& getline(std::istream& in, string& str,char delim='\n');std::istream& getline(std::istream& in, string& str, char delim)
{
str.clear();
char ch;
ch = in.get();
while (ch !=delim)
{
str += ch;
ch = in.get();
}
return in;
}注意:getline默认是以\n为间隔,也可以指定其他间隔符!!!
改进代码:
std::istream& getling(std::istream& in, string& s, char delim)
{
s.clear();
char ch;
ch = in.get();
char buff[256];
int index = 0;
while (ch !=delim)
{
buff[index++] = ch;
if (index == 255)
{
buff[index] = '\0';
s += buff;
index = 0;
}
ch = in.get();
}
if (index != 0)
{
buff[index] = '\0';
s += buff;
}
return in;
}ok,实现完流插入、流提取以及getline,我们继续来看中间有\0的情况:
如果字符串中间有\0,那就不能再继续使用strcpy,strcpy遇到\0就停止拷贝,也会出现和c_str一样的问题,那我们可以将strcpy换成memcpy,这样就可以解决问题~~~
查找单个字符时,是从指定pos位置开始通过遍历字符串,逐个比较字符,若匹配成功,则返回该下标;若没有,则返回npos
public:
//查找单个字符
size_t find(char ch, size_t pos = 0);默认是从头开始查找,也可以指定位置开始查找
//查找单个字符
//从pos位置开始查找
size_t string::find(char ch, size_t pos)
{
assert(pos < _size);
for (size_t i = pos; i < _size; i++)
{
if (_str[i] == ch) {
return i;
}
}
return npos;
}关键逻辑:
public:
//查找子字符串
size_t find(const char* str, size_t pos = 0);//从pos位置开始查找
size_t string::find(const char* str, size_t pos)
{
const char* ptr = strstr(_str+pos, str);
if (ptr)
{
return ptr - _str;
}
else
{
return npos;
}
}ok,我们先来看一下下面的这张图:

嗯?为什么这里会有三个交换算法?一个是算法库中的swap,另外两个是string 类中的swap。
在前面的学习中,我们会经常使用算法库中的swap,感觉它比较好用。那这里就有个问题:既然算法库中的swap已经很好用了,string 类中为什么还要自己搞个swap呢?
其实这是因为算法库中的swap有巨大问题:算法库中的swap对于内置类型的交换肯定是可以的,但是对于string类这种,并且内部有资源的自定义类型,算法库中的swap会进行3次拷贝,代价很大,所以string类就自己搞了swap
对于string类型没有必要这么做,对于两个string类的交换,仅仅只需要交换内部资源,_size和_capacity即可~
public:
void swap(string& s);//交换
void string::swap(string& s)
{
std::swap(_str, s._str);//直接调用算法库中的swap,直接调换其中资源地址
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}内部有资源,浅拷贝有问题,只能进行深拷贝,对于深拷贝的类型,内部都会实现一个自己的swap函数,仅仅交换内部资源即可
那如果我们想调用像算法库中的swap,这该怎么实现呢?
这时候我们就可以直接复用上面的代码,并搞成inline就可以了
inline void swap(string& s1, string& s2)
{
s1.swap(s2);
}这样的话,以后要交换string就都可以用了~
完整模拟代码+测试代码:
各位UU们,你们感觉string类模拟实现这块哪里比较难呢?评论区可以聊聊哦~~~