文章目录
前言
完整代码:string模拟实现
万字爆肝如何一步步模拟实现string,所有细节都在这,能明白string的底层究竟如何实现,会对string会有更深入的了解。
1 创建文件
//string.h
#pragma once
#include <iostream>
using namespace std;
//将自己定义的string放入命名空间my中,以便于和std库里的string区分
namespace my
{
class string
{
};
}
//test.cpp
#include "string.h"
int main()
{
return 0;
}
2 写出string的框架
从基本的资源管理的问题入手,只考虑资源管理的深浅拷贝问题,暂且不考虑增删查改。
2.1 粗略实现string
首先我们的目的是让自己写的string能够跑起来,也就是能够存储字符串,并且能实现简单的查改
//string.h
#define _CRT_SECURE_NO_WARNINGS 1
#pragma once
#include <iostream>
using namespace std;
#include <string.h>
namespace my
{
class string
{
public:
string(const char* str)
:_str(new char[strlen(str) + 1])
{
strcpy(_str, str);
}
~string()
{
if (_str)
{
delete[] _str;
}
}
private:
char* _str;
};
}
//test.cpp
#include "string.h"
void string_test1()
{
my::string s1("hello world");
}
int main()
{
string_test1();
return 0;
}
由于我们暂时还没有重载流提取和流插入运算符,因此我们要打印字符串可以现在类中写一个c_str()
成员函数来返回存储字符串的指针。
const char* c_str() const
{
return _str;
}
测试并输出查看结果:
void string_test1()
{
my::string s1("hello world");
cout << s1.c_str() << endl;
}
写一个通过下标访问string
的运算符重载的成员函数用于查/改:
char& operator[](size_t pos)
{
assert(pos < strlen(_str));
return _str[pos];
}
写一个放回string
大小的size()
成员函数用于遍历等:
size_t size()
{
return strlen(_str);
}
测试并输出查看结果:
void string_test1()
{
my::string s1("hello world");
cout << s1.c_str() << endl;
s1[0] = 'x';
cout << s1.c_str() << endl;
for (size_t i = 0; i < s1.size(); i++)
{
cout << s1[i] << " ";
}
cout << endl;
}
2.2 string的深拷贝实现
在我们自己定义了拷贝构造函数之前,编译器会默认生成一个拷贝构造,但是默认生成的拷贝构造是浅拷贝,也就是把一个对象按字节拷贝给新的对象,但是在string类中,用编译器默认生成的拷贝构造函数是会出问题的。
测试:
void string_test2()
{
my::string s1("hello world");
my::string s2(s1);
cout << s1.c_str() << endl;
cout << s2.c_str() << endl;
}
不难发现,拷贝确实是成功了,但是程序却崩了。
通过调试,我们可以发现s2
的成员变量_str
是通过值拷贝将s1
的成员变量_str
按字节拷贝过去的
也就是说两个对象的_str
指向同一块空间
因此在s1
和s2
对象的生命周期结束后,就会调用它们的析构函数,而析构函数的定义如下:
~string()
{
if (_str)
{
delete[] _str;
}
}
它的作用是将分配给每个对象的_str
指针所指向的堆中的空间给释放掉,然而上面的两个对象会分别调用一次析构函数,也就是重复释放同一块空间,因此程序就崩了。
除了有析构两次的问题,还有一个问题就是其中一个对象的修改会改变两一个对象:
void string_test2()
{
//浅拷贝两大问题:1.析构两次 2.一个对象修改影响另一个对象
my::string s1("hello world");
my::string s2(s1);
cout << s1.c_str() << endl;
cout << s2.c_str() << endl;
s1[0] = 'x';
cout << s1.c_str() << endl;
cout << s2.c_str() << endl;
}
那么深拷贝改怎么实现呢,其实就是为s2
的_str
申请一块一样大的空间,再将s1
的_str
所指向的空间的内容拷贝给s2
的_str
所指向的空间
深拷贝实现:
string(const string& s)
//1.开一样大的空间
:_str(new char[strlen(s._str) + 1])
{
strcpy(_str, s._str);
}
通过调试,我们发现深拷贝后,两个对象的_str都指向各自的空间
并且上面的两个浅拷贝问题都解决了
聊完了深拷贝的实现,我们可以再趁热实现string
的赋值构造函数
void string_test3()
{
my::string s1("hello world");
my::string s2("xxxxxxxxxxxxxxxxxxxxxxxxxxxx");
//这里调用赋值构造
s1 = s2;
}
只有在两个对象都存在的时候使用“=”
赋值才会调用赋值构造函数,也就是两个对象的_str
指针都指向它们各自的空间,有人可能会认为可以直接调用strcpy()
函数进行值拷贝但是如果s1
指向的空间内存大小小于s2
的话,会出现越界问题,而s1
指向的空间如果太大,远超于s2
的话又会出现空间浪费的问题。
因此我们选择这样实现它:
//s1 = s2
string& operator=(const string& s)
{
delete[] _str;
_str = new char[strlen(s._str) + 1];
strcpy(_str, s._str);
return *this;
}
测试样例:
但是有的时候抵挡不住有人用自身给自身赋值的情况:
出现这种结果的原因是调用delete[] _str;
语句后s1
的_str
指向的空间被编译器释放并置随机值,_str
就变成了一个野指针,就会出现上面的情况。
因此我们这样处理:
//s1 = s2
string& operator=(const string& s)
{
if (this != &s)
{
delete[] _str;
_str = new char[strlen(s._str) + 1];
strcpy(_str, s._str);
}
return *this;
}
测试样例:
但是上面的代码还会有些瑕疵:
也就是万一_str = new char[strlen(s._str) + 1];
开空间失败了,就会抛异常然后不会跑后面的代码。失败也就罢了,然而s1
的_str
已经被delete
掉了,数据就会丢失,因此稍稍改进成下面这样,便不会出现上面的问题。
//s1 = s2
string& operator=(const string& s)
{
if (this != &s)
{
//创建一个临时指针来存储s2的内容
char* tmp = new char[strlen(s._str) + 1];
strcpy(tmp, s._str);
//确保开空间成功后再释放空间
delete[] _str;
_str = tmp;
}
return *this;
}
还有一个好处就是:修改成上面这种写法后,是先进行深拷贝,再delete[] _str;
释放空间,就不会存在野指针问题,因此就算自己给自己赋值,也不会出问题,所以在这里就可以把if语句
给去掉
//s1 = s2
string& operator=(const string& s)
{
//创建一个临时指针来存储s2的内容
char* tmp = new char[strlen(s._str) + 1];
strcpy(tmp, s._str);
//确保开空间成功后再释放空间
delete[] _str;
_str = tmp;
return *this;
}
3 完善string的增删查改
由于string的增删查改需要扩容等操作,因此单靠一个成员变量_str
是不能满足要求的,需要增加两个成员变量_size
(有效字符个数)和_capacity
(能存储有效字符的空间)
注:这里的有效字符也就是不包含’\0’的字符个数
//class string
private:
char* _str;
size_t _size;//有效字符个数
size_t _capacity;//能存储有效字符的空间
3.1 修改先前定义的函数
对构造函数作出对应的修改:
//原先的构造函数
string(const char* str)
:_str(new char[strlen(str) + 1])
{
strcpy(_str, str);
}
//修改后的构造函数
string(const char* str)
:_size(strlen(str))
,_capacity(strlen(str))
{
_str = new char[_capacity + 1];// +1是为'\0'多开的空间
strcpy(_str, str);
}
之所以不把_str = new char[_capacity + 1]
放在初始化列表中的原因是,初始化列表是按照string
类中成员变量的声明顺序对其进行初始化的:
如果放在讲语句放在初始化列表中的话,在为_str
开空间的时候,_capacity
还未被初始化的,也就是一个随机值,所以不能放在初始化列表中。
除了这种带参的构造函数我们还需要一个无参的或者全缺省的构造函数,来提供一个默认构造函数,以解决下面的问题:
实现无参的构造函数:
string()
:_str(nullptr)
,_size(0)
,_capacity(0)
{}
上面这种写法还有些问题,测试样例如下:
但是库里的string并不会因为这种写法而程序崩溃,原因是库里的无参构造函数是给一个空字符串(实际上有1byte的空间来存储'\0'
),而不是一个空指针。
正确的实现:
string()
:_size(0)
,_capacity(0)
{
_str = new char[1];
*_str = '\0';
}
最优解:合并上面两个构造函数–>直接提供一个全缺省构造函数:
//全缺省
string(const char* str = "")
:_size(strlen(str))
,_capacity(strlen(str))
{
_str = new char[_capacity + 1];// +1是为'\0'多开的空间
strcpy(_str, str);
}
对析构函数进行修改:
~string()
{
if (_str)
{
delete[] _str;
_str = nullptr;
_capacity = _size = 0;
}
}
对拷贝构造函数进行修改:
string(const string& s)
//1.开一样大的空间
:_size(strlen(s._str))
,_capacity(_size)
{
_str = new char[_capacity + 1];
strcpy(_str, s._str);
}
对赋值构造函数进行修改:
string& operator=(const string& s)
{
if (this != &s)
{
char* tmp = new char[s._capacity + 1];
strcpy(tmp, s._str);
delete[] _str;
_str = tmp;
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
测试样例:
对operator[]
进行修改:
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
对返回_size
值的函数进行修改,并添加一个返回_capacity
值的函数:
size_t size() const
{
return _size;
}
size_t capacity() const
{
return _capacity;
}
3.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 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);
}
3.3 实现“增”操作
实现reserver()
函数:
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
实现resize()
函数:
注:和“增”操作没啥关系,但是与reserve相对应,因此在这里提到
实现要分为下面三种情况:
- 大于_capacity:先扩容,再改变_size
- 小于_capacity,但大于_size:直接改变_size
- 小于_size:直接改变_size
我们可以用库中的resize()函数的样例观察:
函数实现:
void resize(size_t n, char ch = '\0')
{
//先处理小于size的情况
if (n < _size)
{
_size = n;
_str[_size] = '\0';
}
else
{
//再处理大于capacity的情况
if (n > _capacity)
{
reserve(n);
}
for (size_t i = _size; i < n; ++i)
{
_str[i] = ch;
}
_size = n;
_str[_size] = '\0';
}
}
实现push_back()
函数:
void push_back(char ch)
{
//检查是否空间是否满了,满了则扩容
if (_size == _capacity)
{
reserve(_capacity * 2);
}
//插入数据
_str[_size] = ch;
++_size;
_str[_size] = '\0';
}
需要注意的是,如果是一个string是一个空字符串,那就会出问题了:
上面问题的原因是:_capacity
是有效字符个数,如果string是空字符串的话,那么_capacity
就是0,而新开辟的空间是二倍的_capacity+1
,也就是空间没变,只能够存储一个'\0'
,因此插入后会出现越界问题。
修改后的代码:
void push_back(char ch)
{
//检查是否空间是否满了,满了则扩容
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
//插入数据
_str[_size] = ch;
++_size;
_str[_size] = '\0';
}
运算符重载operator+=
:
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
实现append()
函数:
和push_back()
不同的是,如果需要插入的字符串很长的话,空间扩二倍不一定能够满足需求,因此要先计算插入子串后的字符串大小:
void append(const char* str)
{
//计算插入子串后的字符串大小
size_t len = _size + strlen(str);
//如果大于能存储的有效字符空间就扩容
if (len > _capacity)
{
reserve(len);
}
strcpy(_str + _size, str);
_size += len;
}
重载operator+=
:
string& operator+=(const char* str)
{
append(str);
return *this;
}
测试样例:
实现insert()
插入单个字符的函数:
insert()
函数三步走:1.检查是否需要扩容;2.把pos
位置到'\0'
位置的数据全部向后移动一位;3.插入数据并++_size
string& insert(size_t pos, const char ch)
{
assert(pos <= _size);
//检查空间是否满了,满了就扩容
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
//挪数据,必须从后往前挪,否则数据会被覆盖
size_t end = _size;//end索引'\0'的位置
while(end >= pos)
{
_str[end + 1] = _str[end];
--end;
}
//插入数据
_str[pos] = ch;
++_size;
return *this;
}
但是,还存在一个边界问题,当我们头插的时候,也就是传pos=0
的时候,无符号整形size_t end
再减一后,end
就会变成无符号整形的最大值:
为了解决这个问题你可以把end
和pos
都强转成int
,但我们可以不这么做,直接让end
索引'\0'
后面一个位置:
这样一来,只要end移动到pos位置的时候,循环就可以结束了
修改后的代码:
string& insert(size_t pos, const char ch)
{
assert(pos <= _size);
//检查空间是否满了,满了就扩容
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
//挪数据,必须从后往前挪,否则数据会被覆盖
size_t end = _size + 1;
while(end > pos)
{
_str[end] = _str[end-1];
--end;
}
//插入数据
_str[pos] = ch;
++_size;
return *this;
}
实现insert()
插入一个字符串的函数:
跟刚才同样的三步走:1.检查是否需要扩容;2.把pos
位置到'\0'
位置的数据全部向后移动需要插入的字符串的长度
位;3.插入数据并让_size+插入字符串的长度
string& insert(size_t pos, const char* str)
{
assert(pos <= _size);
size_t len = strlen(str);
//检查空间是否满了,满了就扩容
if (_size + len > _capacity)
{
reserve(_size + len);
}
//挪数据
size_t end = _size + len;
//如果写成end >= pos + len可能会出现越界问题
while (end > pos + len - 1)
{
_str[end] = _str[end - len];
--end;
}
//插入
strncpy(_str + pos, str, len);
_size += len;
return *this;
}
完成insert
后,push_back()
和append()
函数就可以复用它了:
void push_back(char ch)
{
insert(_size, ch);
}
void append(const char* str)
{
insert(_size, str);
}
至此,“增”操作就完成了。
3.4 实现string的遍历(迭代器)
- 通过
[]+下标
遍历(已实现)
但是像下面这样写会因为权限放大而报错:
因此需要重载一个const
版本:
const char& operator[](size_t pos) const
{
assert(pos < _size);
return _str[pos];
}
- 通过
迭代器
遍历
string
中的迭代器其实就是指针,begin()
指向第一个有效字符,end()
指向最后一个有效字符的下一个位置
实现:
typedef char* iterator;
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
使用:
- 用
范围for
遍历
由于范围for的底层其实就是用迭代器遍历,因此用范围for遍历的前提是实现了iterator迭代器
如果把自己实现的iterator迭代器注释掉,会出现下面的错误:
也就是说范围for
底层是通过begin()
和end()
来遍历的。
解决了遍历问题,但是同样,由于权限放大问题不支持下面这样使用:
因此要提供一个const
版本
//错误版本
iterator begin() const
{
return _str;
}
iterator end() const
{
return _str + _size;
}
但是还有一个问题,虽然编译器不会报语法错误了,但是你会发现,这里的const my::string& s
居然是可以被修改的:
要想解决,就必须再添加一个const
迭代器,并且将begin()
和end()
的返回值改为const
迭代器:
typedef const char* const_iterator;
const_iterator begin() const
{
return _str;
}
const_iterator end() const
{
return _str + _size;
}
这样就不支持被修改了:
3.5 实现“删”操作
实现npos
private:
char* _str;
size_t _size;
size_t _capacity;
//声明
const static size_t npos;
};
//定义
const size_t string::npos = -1;
实现erase()
函数
分两种情况:
1.将pos位置即之后的全删,只要在pos位置加个’\0’
2.删除部分,步骤如下:
string& erase(size_t pos, size_t len = npos)
{
assert(pos < _size);
//pos之后的数据全删除的情况:只需要在pos位置加上一个'\0'
if (len == npos || pos + len >= _size)
{
_str[pos] = '\0';
_size = pos;
}
//否则要挪动数据
else
{
size_t begin = pos + len;
while (begin <= _size)
{
_str[begin - len] = _str[begin];
begin++;
}
}
_size -= len;
return *this;
}
有一个细节,也就是越界问题:
本意是删除索引为5及其之后的字符,但是由于size_t
的范围为0~4,294,967,295,因此pos+len
就会变成3,就会出现上面的错误。
修改后:
3.6 实现“查”操作
//找单个字符
size_t find(const char ch, size_t pos = 0) const
{
assert(pos < _size);
for (; pos < _size; pos++)
{
if (ch == _str[pos])
{
return pos;
}
}
return npos;
}
//找字符串
size_t find(const char* str, size_t pos = 0) const
{
assert(pos < _size);
const char* p = strstr(_str + pos, str);
if (p == nullptr)
{
return npos;
}
return p - _str;
}
3.6 实现“流提取、流插入”操作
由于流提取(cout << s)
流插入(cin >> s)
中,对象s是做右操作数的,所以必须写在类外。
实现流提取
:
ostream& operator<<(ostream& out, const string& s)
{
out << s.c_str();
return out;
}
上面这种写法会因为插入’\0’而无法打印出所有字符:
注:'\0’也是个字符,不过不可见,因此我们加一个用于标识的字符
因此我们改成下面这样:
ostream& operator<<(ostream& out, const string& s)
{
for (auto ch : s)
{
out << ch;
}
return out;
}
实现流插入
:
实现流插入前要先清除数据再插入,因此要先写一个clear()
成员函数。
实现clear()
成员函数:
void clear()
{
_str[0] = '\0';
_size = 0;
}
流插入的实现需要用到istream cin
对象里的get()函数来从缓冲区里获取字符。
代码实现:
istream& operator>>(istream& in, string& s)
{
s.clear();
char ch;
ch = in.get();//从缓冲区获取字符
while (ch != ' ' && ch != '\n')
{
s += ch;
ch = in.get();
}
return in;
}
测试样例:
上面的写法如果在插入的字符串很长的时候,会频繁扩容,导致影响运行效率,我们可以用下面这种写法来优化:
istream& operator>>(istream& in, string& s)
{
s.clear();
char ch;
ch = in.get();//从缓冲区获取字符
//用于存放即将插入的字符,最多能存放128个有效字符,并且初始化为全'\0'
char buff[128] = { '\0' };
size_t i = 0;
while (ch != ' ' && ch != '\n')
{
buff[i++] = ch;
//索引值到127后就不能再插入字符了,因为127的索引值必须用来存放'\0'
if (i == 127)
{
s += buff;
memset(buff, '\0', 128);
i = 0;
}
ch = in.get();
}
//将剩下的字符串再插入s中
s += buff;
return in;
}
调试观察:
第一次存满后,先插入到s中,再将容器置全’\0’:
在第二次存到一半的时候遇到’\n’,结束循环,发现容器中还有65个有效字符,继续插入到s中,至此插入结束,总共扩容两次:
实现getline()
函数
跟>>重载
其实是一样的,只要改改循环条件就好啦:
istream& getline(istream& in, string& s)
{
s.clear();
char ch;
ch = in.get();//从缓冲区获取字符
//用于存放即将插入的字符,最多能存放128个有效字符,并且初始化为全'\0'
char buff[128] = { '\0' };
size_t i = 0;
while (ch != '\n')
{
buff[i++] = ch;
//索引值到127后就不能再插入字符了,因为127的索引值必须用来存放'\0'
if (i == 127)
{
s += buff;
memset(buff, '\0', 128);
i = 0;
}
ch = in.get();
}
//将剩下的字符串再插入s中
s += buff;
return in;
}
3.7 现代写法
现代写法写起来更加简洁,鼓励大家去写现代写法
传统写法:老老实实地开空间进行深拷贝,也就是目前的写法
string(const string& s)
//1.开一样大的空间
:_size(strlen(s._str))
,_capacity(_size)
{
_str = new char[_capacity + 1];
strcpy(_str, s._str);
}
//s1 = s2
string& operator=(const string& s)
{
if (this != &s)
{
char* tmp = new char[s._capacity + 1];
strcpy(tmp, s._str);
delete[] _str;
_str = tmp;
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
现代写法:本质是剥削,要完成深拷贝,自己却不干活,让别人干活
下面我们来把上面的写法改为现代写法就明白为什么要这么说了
先实现一个string
类里的swap
成员函数:
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
实现string(const string& s)
函数:
string(const string& s)
{
string tmp(s._str);
swap(tmp);
}
剥削本质:this
让tmp
借助默认构造函数去完成深拷贝,然后再通过swap()
函数来窃取tmp
的劳动成果
测试样例:
但是由于tmp对象是一个局部对象,出了作用域后要调用析构函数:
~string()
{
if (_str)
{
delete[] _str;
_str = nullptr;
_capacity = _size = 0;
}
}`
而swap
过后,tmp
的_str
变成一个随机值,如果调用delete[] _str
这个语句释放随机地址,程序就会崩溃。
虽然在我的vs2019下,这种情况会自动置空指针,但是其他的编译器就可能会出错:
vs2019:
vs2013:
因此为了避免上面这种错误,我们要把this._str
初始化一下:
string(const string& s)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
string tmp(s._str);
swap(tmp);
}
而赋值重载
更是诠释了什么是剥削
原先代码又要给_str开空间深拷贝,又要释放内存
string& operator=(const string& s)
{
char* tmp = new char[s._capacity + 1];
strcpy(tmp, s._str);
delete[] _str;
_str = tmp;
_size = s._size;
_capacity = s._capacity;
return *this;
}
使用现代写法后,tmp
又要替this
深拷贝,又要在出作用域的时候调用析构函数把this
的内存释放掉:
string& operator=(const string& s)
{
string tmp(s._str);
swap(tmp);
return *this;
}
其实还可以再简洁一点,把tmp
用形参来代替:
string& operator=(string s)
{
swap(s);
return *this;
}