string的模拟实现
对STL中的string类有了一个基本的认识后,本模块,我会带着你从0 ~ 1去模拟一下s库中string的这些接口,当然是比较常用的一些,代码量大概600行左右
1、前情提要
- 首先第一点,为了不和库中的string类发生冲突,我们可以在外层包上一个名称为
bit的命名空间,此时因为作用域的不同,就不会产生冲突了,如果这一块有点忘记的同学可以再去看看 namespace命名空间
namespace bit
{
class string {
public:
//...
private:
size_t _size;
size_t _capacity;
char* _str;
};
}
- 接下去呢,就在测试的
test3.cpp中包含一下这个头文件,此时我们才可以在自己实现的类中去调用一些库函数
#include <iostream>
#include <assert.h>
using namespace std;
#include "string.h"
2、Member functions —— 成员函数
构造函数
好,首先第一个我们要来讲的就是【构造函数】
- 首先我们从无参的构造函数开始讲起,看到下面的代码,你是否有想起了 C++初始化列表,我们默认给到
_size和_capacity的大小为,然后给字符数组开了一个大小的空间,并且将其初始化为\0
// 无参构造函数
string()
:_size(0)
, _capacity(0)
,_str(new char[1])
{
_str[0] = '\0';
}
- 然后我们立即来测试一下,因为我们自己实现的 string类 是包含在了命名空间
bit中的,那么我们在使用这个类的时候就要使用到 域作用限定符::
bit::string s1;
然后打印一下这个string对象发现是一个空串
- 有无参,那一定要有带参的,可以看到这里我们在初始化
_size的时候先去计算了字符串str的长度,因为_size取的就是到\0为止的有效数据个数(不包含\0),那么【strlen】刚好可以起到这个功能 - 然后在
_str这一块,我们为其开出的空间就是 ==容量的大小 + 1==,最后的话还要在把有效的数据拷贝到这块空间中,使用到的是【strcpy】
// 有参构造函数
string(const char* str)
: _size(strlen(str))
, _capacity(_size)
,_str(new char[_capacity + 1])
{
// 最后再将数据拷贝过来
strcpy(_str, str);
}
- 同样地来进行一个测试
💬 不过呢,我这里再给出一个改进的版本
- 此处没有使用到初始化列表,而是在直接写在函数体内,注意观察这里的形参部分,这里运用到的知识点为 C++缺省参数,如果忘记了的同学记得去回顾一下
- 如果外界在构造对象的时候不进行传参,此时使用的便是这个默认的参数,
“”代表的是一个空的字符串,但是无论怎样,对于一个字符串来说末尾是一定有\0的,此刻你可以将它带入下面的表达式,发现算出后的结果与前面无参是一样的
// 构造函数
string(const char* str = "")
{
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
memcpy(_str, str, _size + 1);
}
- 可能有的读者注意到了这个
memcpy(),如果有度过 字符串函数与内存函数 一文的话就可以清楚它们的区别在哪里了,对于strcpy()来说拷贝到\0就会发生终止而不会拷贝了,这是我在测试一些极端场景的时候考虑到的
可以看到换回【strcpy】的时候\0后面的内容就不会去进行一个拷贝了,不过这里其实体现得不是很明显,我们在下面的 拷贝构造、赋值运算符重载 中会继续提到这个
💬 有同学觉得上面的缺省参数很是奇妙,于是提出能不能写成下面这样
- 这肯定是不可以的,从运行结果我们可以看出虽然运行出来也是空串的结果,但是这么写的话总归不太好
string(const char* str = "\0")
- 但是呢对于下面这种就更不可以了,因为这在调用【strlen】的时候就会触发 ==空指针异常== 的问题
string(const char* str = nullptr)
拷贝构造函数
马上,我们就来聊聊有关【拷贝构造函数】的内容
- 在 深度探索类的六大天选之子 中我们有提到过若是一个类在没有显示定义拷贝构造对于内置类型不做处理,而对于自定义类型会去调用 类中默认提供的拷贝构造函数 此时就会造成浅拷贝的问题
- 我们可以通过调试来浅浅地看一下,便可以看出浅拷贝所带来的危害,光是在调用析构这一块就出现了 ==二次析构== 的问题
- 所以我们要自己去做一个实现,可以看到我们这里在进数据的拷贝时也是使用到了
memcpy()
string(const string& s)
{
_str = new char[s._capacity + 1];
memcpy(_str, s._str, s._size);
_size = s._size;
_capacity = s._capacity;
}
- 通过调试再去观察的话,我们可以发现,此时 对象
s1和 对象s2中的数据存放在不同的空间中,此时去修改或者是析构的话都不会受到影响
下面呢还有一个新的版本,这一块我放到【赋值重载】去进行讲解
// 拷贝构造函数(新版本)
string(const string& s)
: _str(nullptr)
, _size(0)
, _capacity(0)
{
string tmp(s._str);
// tmp出了当前函数作用域就销毁了,和this做一个交换
this->swap(tmp);
}
赋值运算符重载
对于赋值运算符重载这一块我们知道它也是属于类的默认成员函数,如果我们自己不去写的话类中也会默认地生成一个
- 但是呢默认生成的这个也会造成一个 ==浅拷贝== 的问题。看到下面图示,我们要执行
s1 = s3,此时若不去开出一块新空间的话,那么s1和s3就会指向一块同一块空间,此时便造成了下面这些问题- 在修改其中任何一者时另一者都会发生变化;
- 在析构的时候就也会造成二次析构的;
- 原先
s1所指向的那块空间没人维护了,就造成了内存泄漏的问题
- 那么此时我们应该自己去开出一块新的空间,将
s3里的内容先拷贝到这块空间中来,然后释放掉s1所指向这块空间中的内容,然后再让s1指向这块新的空间。那么这个时候,也就达成了我们所要的【深拷贝】,不会让二者去共同维护同一块空间 - 最后的话不要忘记去修改一下
s1的【_size】和【_capacity】,因为大小和容量都发生了改变
下面是具体的代码,学习过 类的六大天选之子 的同学应该不陌生
string& operator=(const string& s)
{
if (this != &s)
{
char* tmp = new char[s._capacity + 1];
memcpy(tmp, s._str, s._size + 1);
delete[] _str;
_str = tmp;
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
但是呢,就上面这一种写法并不是最优的,我们来看看下面的这种写法
- 很多同学非常地震惊,为何这样子就可以做到【深拷贝】呢?
// 赋值重载(pua版本)
string& operator=(const string& s)
{
if (this != &s)
{
string tmp(s);
this->swap(tmp);
}
return *this;
}
- 有关这个
swap()函数,本来是应该下面讲的,既然这里使用到了,那就在这里讲吧,这个接口我在上面并没有介绍到,但是在讲 C++模版 的时候有提到过库中的这个 swap() 函数,它是一个函数模版,可以 根据模版参数的自动类型推导去交换不同类型的数据 - 可以看到在我们自己实现的这个
swap(string& s)函数中就去调用了std标准库中的函数然后交换一个string对象的所有成员变量
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
- 接下去来解释一下这里的原理,我们在这个赋值重载的函数内部调用了拷贝构造去获取到一个临时对象
tmp,然后再通过swap()函数去交换当前对象和tmp的指向,此时s1就刚好获取到了赋值之后的内容,而tmp呢则是一个临时对象,出了当前函数的作用域后自动销毁,那么原本s1所维护的这块空间刚好就会销毁了,也不会造成内存泄漏的问题
💬 那有同学就说:这个妙啊!太妙了!
- 哈哈,不知读者有没有听过最近很火的一个词叫做【PUA】
“PUA”的原理,就是打击你的自尊,摧毁你的独立思考能力,让你觉得自己一无所事,
然后对方趁虚而入,让你产生依赖,让你觉得只有对方才能帮助自己,从而被对方操控。
- 泡面🍜的话相信大家都有吃过,假设说呢有这么一个场景:你呢是家里的哥哥,你还有一个弟弟,这一天的中午你很想吃冰箱里的那桶泡面,但是呢妈妈又不让吃,于是你就和你弟弟说:“冰箱里有一桶很好吃的泡面,你快去泡一下试试看”。那此时你傻傻的弟弟就立马去做了,当他泡完的时候呢你再去找你的妈妈告状,于是这个时候弟弟就被狠狠地骂了一顿(╯▔皿▔)╯
- 此时这碗泡面就没人吃了,于是这个时候你就乘虚而入把这碗泡面给吃了,但是呢又不想洗碗,于是又把你弟弟给叫了过来,说:“要不你把这个碗去洗了,晚上我给你买雪糕吃🍦”。听到雪糕后你的弟弟又精神起来了,马上就把碗去给洗了
- 透过上面这个小例子读者应该对新的这种拷贝构造有了一定的理解:反正你这个
tmp对象出了作用域也要销毁的,你手上呢刚好有我想要的东西,那我们换一下吧,此时我得到了我想要的东西,你呢拿到了我的东西,这块地址中的内容刚好就是要销毁的,那tmp在出了作用域后顺带就销毁了,这也就起到了【一石二鸟】的效果
好,我们通过这个调试来观察一下,可以看到就是这个“PUA技术”,很好地达成了我们的目标
💬 但是呢,我觉得上面的这种PUA还不够,还可以再 “精妙” 一些,我们一起来看一下下面这个版本
- 可以看到,真的是非常简洁,两行代码就足够了,那为什么可以起到这样的效果呢?原因其实就在于这个形参部分,可以看到我并没有使用像上面那样的【引用传参】,而是直接使用的传值传参
- 那仔细学习过【类和对象】的同学一定可以知道对于【传值传参】的话会先去调用拷贝构造拷贝出一个临时的对象,那么这不就是我们在写上面一个版本的时候在函数内部去调用拷贝构造所做的事吗?那么当外界在给这个函数传递参数对象的时候,此时这个
tmp便是外面这个对象的一个临时拷贝,我们直接去操作这个对象的时候也可以到达同样的效果
// 赋值重载(究极pua版本)
string& operator=(string tmp)
{
this->swap(tmp);
return *this;
}
一样,我们通过调试来看就可以看得很清晰,一开始按F11的时候我们可以看到进入到了拷贝构造函数内部,这个时候其实就是因为传值传参去调用拷贝构造的缘故
💬 此时我们就可以去谈谈在一模块所讲到的这个【新版本的拷贝构造函数】
- 这里我把代码再放一遍,读者在看到赋值重载之后再来看这个应该就没有那么陌生了
// 拷贝构造函数(新版本)
string(const string& s)
: _str(nullptr)
, _size(0)
, _capacity(0)
{
string tmp(s._str);
// tmp出了当前函数作用域就销毁了,和this做一个交换
this->swap(tmp);
}
- 我在这边主要想讲的还是这个初始化列表的问题,读者一定知道如果我们没有手动地去初始化成员变量的话,对于内置类型编译器是不做处理的,对于自定义类型则会去调用默认生成的拷贝构造,那交给编译器去做安全吗?当然是极度地不安全
string(const string& s)
{
string tmp(s._str);
// tmp出了当前函数作用域就销毁了,和this做一个交换
this->swap(tmp);
}
- 通过调试我们可以观察到,当直接去调用拷贝构造的时候,编译器对当前的对象做了一个初始化的工作,于是在析构的时候就没有出现问题,但是继续执下去到达我们上面的赋值
=的时候,因为传值传参的缘故首先会去调用这个拷贝构造拷贝一份临时对象,但是呢在调试的时候可以发现编译器并没有去对当前对象中的成员变量做一个初始化的工作,在执行swap()函数后这个没被初始化的对象就交给tmp来进行维护了,但是呢tmp在出了作用域之后又要销毁,那么此时在执行析构函数的时候便会出问题了,去释放了一块并没有初始化的空间,一定会出现问题的!
💬 所以我们还是不能去相信编译器所做的一些工作,而是要自己经手去做一些事,避免不必要的麻烦
析构函数
最后的话就是析构函数这一块,前面在调试的过程中我们已经看到很多遍了,此处不再细述
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
2、Element access —— 元素访问
基本的成员函数我们已经讲完了,string对象也构造出来了,接下去我们来访问一下对象里面的内容吧
operator[ ]
- 首先最常用的就是这【下标 + [ ]】的形式去进行一个访问,那很简单,我们通过当前所传入的下标值去访问对应的数据即可
- 下面的话有两种实现形式,一个是可读可写的,一个则是可读不可写的
// 可读可写
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
// 可读不可写
const char& operator[](size_t pos) const
{
assert(pos < _size);
return _str[pos];
}
- 里面我们就通过循环来访问一下,这里的
size()函数和流插入我们会在下面讲到
- 此时我们去调用的时候可读可写的版本,是可以在边访问的时候去做一个修改的,效果如下
- 但是呢,如果我在定义这个对象的时候在前面加上一个
const的话此时这个对象就具有常性了,在调用operator[]的时候调用的便是 可读不可写 的那一个,所以此刻我们去做一个修改操作的话就会出问题了
const bit::string s2("world");
- 通过调试我们可以观察到编译器在调用这一块会默认去匹配最相近的重载函数非常得智能
3、Iterator —— 迭代器
那经过上面的学习我们可以知道,要去遍历访问一个string对象的时候,除了【下标 + []】的形式,我们还可以使用迭代器的形式去做一个遍历
- 而对于迭代器而言我们也是要去实现两种,一个是非const的,一个则是const的
typedef char* iterator;
typedef const char* const_iterator;
- 这里的话我就实现一下最常用的【begin】和【end】,首位的话就是
_str所指向的这个位置,而末位的话则是_str + _size所指向的这个位置
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
- 实现了普通版本的迭代器之后,我们再来看看常量迭代器。很简单,只需要修改一下返回值,然后在后面加上一个【const成员】,此时就可以构成函数重载了
const_iterator begin() const
{
return _str;
}
const_iterator end() const
{
return _str + _size;
}
- 首先我们来看一下这个普通的迭代器,成功地遍历了这个string对象
- 那么对于常对象来说的话,就要使用常量迭代器来进行遍历,但你是否觉得这个迭代器的长度过于长了呢?
- 这一点我们在上面也讲到过了,使用C++11中的
auto关键字进行自动类型推导即可
auto cit = s2.begin();
之前我们有讲过,一个类只要支持迭代器的话那一定支持范围for,马上我们来试试看吧
- 分别去遍历一下这两个 string对象 ,可以看到都不成问题
for (auto ch : s1)
{
cout << ch << " ";
}
cout << endl;
for (auto ch : s2)
{
cout << ch << " ";
}
这个方式去遍历的话还是很方便的,必须安利一波✌
4、Capacity —— 容量
下面四个接口我们一起来看看,然后一同测试
size
- 首先是 size(),这里的话我们直接返回
_size即可,因为不会去修改成员变量,所以我们可以加上一个【const成员】
size_t size() const
{
return _size;
}
capacity
- 对于 capacity() 也是同样的道理
size_t capacity() const
{
return _capacity;
}
clear
- 对于 clear() 而言就是去清除当前对象的数据,我们直接在
_str[0]这个位置放上一个\0即可,并且再去修改一下它的_size = 0即可 - 不过这个接口来说我们不要去加【const成员】,因为修改了其成员变量
_size
void clear()
{
_str[0] = '\0';
_size = 0;
}
empty
- 对于 empty() 来说呢就是对象中没有数据,那么使用
0 == _size即可
bool empty() const
{
return 0 == _size;
}
💬 然后我们来测试一下
reserve
然后我们来看【reserve】扩容
- 很明显,只有当这个 新容量大于旧容量的时候,才会去选择去开空间,这里的扩容逻辑和我们在实现旧版本的拷贝构造函数时类似的:也是先开出一块新的空间(这里主要使用这个
newCapacity去开),然后再将原本的数据拷贝过来,释放旧空间的数据后让_str指向新空间即可。最后的话不要忘了去更新一下容量大小 - 但是呢对于VS下如何去实现 1.5倍 的扩容就不做展开了,读者有兴趣可以自己试试
// 扩容(修改_capacity)
void reserve(size_t newCapacity = 0)
{
// 当新容量大于旧容量的时候,就开空间
if (newCapacity > _capacity)
{
// 1.以给定的容量开出一块新空间
char* tmp = new char[newCapacity + 1];
// 2.将原本的数据先拷贝过来
memcpy(tmp, _str, _size);
// 3.释放旧空间的数据
delete[] _str;
// 4.让_str指向新空间
_str = tmp;
// 5.更新容量大小
_capacity = newCapacity;
}
}
马上来做一个测试
通过调试再去看一下,可以发现
_str的空间确实发生了一个很大的改变
resize
然后我们再来讲讲【resize】,博主觉得下面的这个算法是比较优的,读者可以参考一下
- 首先我们来分析一下,对于【resize】而言主要对对象中的数据去做一个变化,那就需要去进行分类讨论
- 如果这个
newSize < _size的话,那我们要选择去删除数据 - 如果这个
newSize > _size,但是呢newSize < _capacity的话,此时要做的就是新增数据但是呢不去做扩容 - 如果这个
newSize > _size的话,我们便要选择去进行扩容了
- 如果这个
- 在分析完了之后,我们立即来实现一下相关的代码。可以看到,一上来我就直接去判断了
newSize是否大于_size,然后在内部又做了一层判断,只有当newSize > _capacity时,才去执行【reserve】的扩容逻辑 - 如果
newSize并没有超过容量大小的话我们要做的事情就是去填充数据,这里用到的是一个内存函数【memset】- 我们从
_str + _size的位置开始填充; - 填充的个数是
newSize - _size个; - 填充的内容是
c
- 我们从
- 若是
newSize <= _size的话,我们所要做的就是去截取数据,到newSize为止直接设置一个 \0,然后更新一下当前对象的_size大小
// 改变大小
void resize(size_t newSize, char c = '\0')
{
// 1.当新的_size比旧的_size来得小的话,则进行删除数据
if (newSize > _size)
{
// 只有当新的size比容量还来的大,才去做一个扩容
if (newSize > _capacity)
{
reserve(newSize);
}
// 如果newSize <= _capacity,填充新数据即可
memset(_str + _size, c, newSize - _size);
}
// 如果 newSize <= _size,不考虑扩容和新增数据
_size = newSize;
_str[newSize] = '\0';
}
💬 马上我们就来分类测试一下
- 首先是
resize(8),可以看到这里发生了一个数据截断的情况,_size也相对应地发生了一个变化
- 接下去的话是
resize(12),这并没有超过其容量值,但是却超出了_size大小,所以我们要去做一个扩容
- 最后一个则是
resize(18),此时的话就需要去走一个扩容逻辑了,并且在扩完容之后还要再进一步去填充数据
5、Modifiers —— 修改器
好,接下去我们来讲讲修改器这一块
push_back
- 首先第一块的话简单一点,我们去追加一个字符,那首先要考虑到的也是一个扩容逻辑,因为我们是一个字符一个字符去进行插入的,那么当这个
_size == _capacity的时候,就要去执行一个扩容的逻辑了,这边的话是运用到了这个三目运算符,若是容量的大小为0的话,默认开个大小为4的空间就可以了;其他的情况都是以2倍的形式去进行扩充 - 最后在扩完容之后我们就在末尾去增加数据了,因为
_size指向的就是 \0 的位置,所以就把字符放在这个位置上就可以了,顺带地记得去后移一下这个_size,再放上一个 \0
// 追加一个字符
void push_back(char ch)
{
// 如果数据量大于容量的话,则需要进行扩容
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
_str[_size++] = ch;
_str[_size] = '\0';
}
- 立马来测试一下看看
append
- 接下去的话是【append】,要追加的是一个字符串,所以我们要先去算出它的长度,接下去判断一下在加上这个长度后是否要去做一个扩容,最后的话还是通过我们熟悉的【memcpy】通过字节的形式一一拷贝到
_str + _size的位置(注意拷贝len + 1个,带上最后 \0),最后再把大小_size给增加一下即可
// 追加一个字符串
void append(const char* s)
{
int len = strlen(s); // 获取到待插入字符串的长度
// 若是加上len长度后超出容量大小了,那么就需要扩容
if (_size + len > _capacity)
{
reserve(_size + len);
}
// 将字符串拷贝到末尾的_size位置
memcpy(_str + _size, s, len + 1);
// 大小增加
_size += len;
}
- 也是一样来测试一下
读者一定会觉得上面的函数调用太过于冗余,不过没关系,我们还有【+=】呢
operator+=(char ch)
- 首先的话是去【+=】一个字符,这里我们直接复用前面的
push_back()接口即可,最后因为【+=】改变的是自身,所以我们return *this,那么返回一个出了作用域不会销毁的对象,可以采取 引用返回 减少拷贝
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
operator+=(const char* s)
- 而对于【+=】一个字符串,我们则是去复用前面的
append()即可
string& operator+=(const char* s)
{
append(s);
return *this;
}
💬 立马来测试一下吧
从pos位置开始插入n个字符
接下去我们就要来实现一下【insert】这个接口了
- 不过在这之前呢我们先要去声明并初始化一个静态的成员变量
npos,它是最大的无符号整数值。但是对于 静态的成员变量 来说我们需要 在类内声明并且在类外进行初始化
// 类内声明
static size_t npos;
// 类外初始化
size_t string::npos = -1;
- 首先第一个的话就是要在
pos位置插入n个字符
void insert(size_t pos, size_t n, char ch)
- 因为这里会传入一个
pos位置,所以第一步我们就是要去考虑这个pos位置是否合法
assert(pos <= _size);
- 接下去第二步的话就是去考虑过扩容的问题了,如果
_size + n之后的大小大于_capacity的话那就要调用【reserve】接口去实现一个扩容的逻辑了
// 考虑扩容
if (_size + n > _capacity)
{
reserve(_size + n);
}
- 第三步呢并不是直接去插入数据,而是要先给需要插入的n个字符腾出位置。从
_size位置开始,让字符以n个单位地从后往前挪即可,若是从前往后挪的话就会造成覆盖的问题
// 挪动数据
size_t end = _size;
while (end >= pos)
{
_str[end + n] = _str[end];
--end;
}
- 不过呢,我们在这里还要考虑一种极端的情况,如果这个
pos == 0的话,也就是在这个位置开始插入数据,那也就相当于头插,此时需要将全部的数据向后进行挪动,可是呢当这个end超出pos的范围时,也就减到了-1,但是呢这个end的数据类型则是【size_t】,为一个无符号整数,我们知道对于无符号整数来说是不可能为负数的,那么这个时候就会发生一个轮回,变成最大的无符号正数
- 我们可以来看看当这个
end在不断减少直至减到0的时候就会突然变成一个很大的数字,这个其实就是npos的值了,此时就会造成一个死循环,导致程序崩溃
- 所以我们应该在循环的结束条件中加上一个
end != npos才对
// 挪动数据
size_t end = _size;
while (end >= pos && end != npos)
{
_str[end + n] = _str[end];
--end;
}
- 当这个挪动的逻辑结束后,我们就可以从pos这个位置去插入n个字符了。最后再去更新一下这个
_size的大小即可
// 插入n个字符
for (size_t i = 0; i < n; i++)
{
_str[pos + i] = ch;
}
_size += n;
从pos位置开始插入一个字符串
void insert(size_t pos, const char* s)
- 对于在【pos位置插入一个字符串】来说,其他逻辑和上面这个接口都是一样,也是要经过 扩容、移位、放数据 这些操作,只是这里在放数据的时候换成了字符串而言
// 插入字符串
for (size_t i = 0; i < len; i++)
{
_str[pos + i] = s[i];
}
_size += len;
删除从pos位置开始的len个有效长度字符
void erase(size_t pos, int len = npos)
- 意思很简单,就是从
pos位置开始去删,删除len个有效长度的字符,那这几个字符就相当于是不要了,但是呢后面的字符串还是要的,所以有的同学就会想到用这个 拼接 的方法去完成 - 但是呢没必要这样,这只会增加算法的复杂性,对于【erase】来说更多地还是去做一个 ==移位覆盖==
读者可以通过下面的算法分解图去思考一下代码该如何书写,我们是从【w】这个位置开始删除长度为3的有效字符
- 但是呢,我们还要考虑到一些特殊的情况,例如说我们要取的长度
len很大很大,甚至是最大的无符号整数npos,或者呢在pos + len之后的长度超出了当前_size的大小,此时我们可以直接对pos之后的字符去做一个截断的操作,让这个位置变成新的_size
下面就是具体的代码展示,对于正常的情况而言,最后呢不要忘记了在覆盖字符后去改变一下这个
_size的大小
// 删除从pos位置开始的len个有效长度字符
void erase(size_t pos, int len = npos)
{
if (len == npos || pos + len > _size)
{
_size = pos;
_str[_size] = '\0';
}
else
{
size_t end = pos + len;
while (end <= _size)
{
_str[pos++] = _str[end++];
}
_size -= len;
}
}
- 首先呢我们从第五个位置开始,去删除长度为5的有效字符
- 接下去呢我们再从
pos == 2的位置开始,删除长度为30的字符,那这个就是pos + len > _size的情况
- 那如果第二个参数不传递呢?那使用的便是缺省值【npos】,这就是
len == npos的情况
swap
- 对于【swap】函数我们在上面已经有讲解过了,此处不再过度赘述
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
6、String Operations —— 字符串操作
然后再来讲讲有关字符串的一些操作
c_str
- 首先的话就是这个【c_str】,可以看到上面我在测试完一个结果后都会去
cout << s << endl;打印一下,如果你就使用了上面这些代码的话,一定是会报错的,因为流插入运算符<<和 string类对象并没有对应的重载函数,这一块我后面在讲流插入的时候会提到,报错的同学可以先用下面这种
const char* c_str()
{
return _str;
}
- 这个【c_str】就是string类的对象转换成为字符串,那么我们知道对于字符串而言与流插入
<<是有重载的,所以才可以起到一个很好地匹配
cout << s1.c_str() << endl;
从pos位置开始找指定的字符
- 这个很简单,就是去遍历一下当前对象中的
_str,若是在遍历的过程中发现了字符ch的话就返回这个位置的下标,如果遍历完了还是没有找到的话就返回npos这个最大的无符号数
size_t find(char ch, size_t pos) const
{
assert(pos <= _size);
for (size_t i = 0; i < _size; i++)
{
if (_str[i] == ch)
{
return i;
}
}
return npos;
}
从pos位置开始找指定的字符串(找子串)
- 上面是在找单独的一个字符,现在我们来找找一个字符串,那么string类的对象本身就是一个字符串,这也就演化成了在一个字符串中寻找一个子串,还记得我们在 数据结构 | 串的模式匹配问题 中所讲到的如何在一个主串中寻找子串,那时我们有谈到【暴搜】和【KMP】两种写法
- 不过在这里呢,我直接使用的是C语言中的库函数 strstr,这个的话我们在 字符串函数与内存函数解读 的时候也有讲解并模拟过,如果找到了的话就会返回子串第一次出现在主串中的指针。那我们如果要去计算这个指针距离起始位置有多远的话使用
指针 - 指针的方式即可。那如果没找到的话我们返回【npos】即可
size_t find(const char* s, size_t pos) const
{
assert(pos < _size);
char* tmp = strstr(_str, s);
if (tmp)
{
// 指针相减即为距离
return tmp - _str;
}
return npos;
}
那我们立马来测试一下
- 首先是去找一个字符
a,我们从第0个位置开始找
size_t pos = s1.find('a', 0);
- 再来试试去找一个字符串
从pos位置开始取len个有效字符(取子串)
上面是去匹配子串,现在我们要将这个子串给取出来,要如何去取呢?
string substr(size_t pos, size_t len = npos)
- 首先要考虑到的是,如果我们从
pos位置开始所要取的子串长度大于剩余的串长,那最多能取到的有效范围也就是从pos位置开始的到末尾的_size结束这段距离,所以当这个所取长度过长的话,我们就要考虑去更新一下取子串长度的有效范围
- 可以看到,我以这个
n作为可取的子串长度,一开始得让其等于传入进来的len长,因为如果这个所取长度没有超出有效范围的话,我们所用的还是len - 但是如果呢这个长度超出了有效范围后,我们便要去更新这个
n = _size - pos
size_t n = len;
if (len == npos || pos + len > _size)
{
// 就算要取再大的长度,也只能取到pos - _size的位置
n = _size - pos;
}
- 那接下去的话我们就可以去取这个子串了,使用循环的方式从
pos位置开始取,取【n】个即可,然后追加到这个临时的 string对象 中去,最后呢再将其返回即可,那我们返回一个出了作用域就销毁的临时对象,只能使用【传值返回】,而不能使用【传引用返回】
string tmp;
tmp.reserve(n);
for (size_t i = pos; i < pos + n; i++)
{
tmp += _str[i];
}
return tmp;
- 测试一下可以看到,我们从第5个位置开始取,取5个有效字符,最后拿到【world】
- 再来试一下这个len很大的情况,此时可以看到我们取到的还是这个【world】
- 我们可以通过调试来看看这个
n是怎么发生变化的
- 当然,你也可以不传递,此时这个len取的就是默认的【npos】
7、Non-member function overloads —— 非成员函数重载
最后的话再来模拟一些【非成员函数重载】,使用到的也是非常多
relational operators
这里有很多的关系运算符我们来模拟实现一下
① 小于
bool operator<(const string& s)
- 首先读者要清楚的是我们在比较两个 string对象 的时候,所使用的规则并不是去比较它们的长度,而是去比较它们的ASCLL码值,这里我首先要介绍的第一种方法就是采取 ==双指针== 的形式去进行一一比较,有点类似于我们所讲过的 数据结构 | 归并排序 的遍历思维
- 在遍历的过程中,只有当 前一个对象中的字符 出现小于 后一个对象中的字符 时,才返回
true;出现大于的情况就返回false;如果是相等情况的话则双指针继续向后进行遍历,直接有一个遍历结束位置跳出循环
size_t i1 = 0;
size_t i2 = 0;
while (i1 < _size && i2 < s._size)
{
if (_str[i1] < s._str[i2])
{
return true;
}
else if (_str[i1] > s._str[i2])
{
return false;
}
else
{
i1++;
i2++;
}
}
- 当这个循环跳出的时候,我们可以将比较的情况分为以下三种
- 第一种是两个对象的长度是相等的,此时双指针都遍历结束,那
return false - 第二种是后一个遍历结束,但是前一个没有结束,那就是前一个来的大,那
return false - 第三种则是前一个遍历结束,但是后一个没有结束,那就是后一个来的大,是符合的,所以
return true
- 第一种是两个对象的长度是相等的,此时双指针都遍历结束,那
- 那我下面给出两种判断的方式,第一种呢则是去比较两个指针的位置
return i1 < _size && i2 == s._size;
- 第二种呢就方便一些,直接去比较两个对象中数据个数的大小即可
return _size < s._size;
不过呢,上面这种方法虽然易懂一些,但是并不精炼
- 下面我再介绍一种方法,可读性不是那么强,考察到了对【三目运算符】的理解
bool operator<(const string& s)
{
int ret = memcmp(_str, s._str, _size < s._size ? _size : s._size);
return ret == 0 ? _size < s._size : ret < 0;
}
- 还记得我们讲过的 memcmp 吗?这是一个内存比较函数,其是以字节的形式去一个个进行比较,那比较的长度我们可以先以二者中小的那个为准,所以后面的三目运算符起到的就是这个作用
- 接下去呢,在两个对象相同的部分比较完后,再去比较后面的那些部分呢,所以需要这个
ret == 0为前提条件,然后比较的便是二者的_size大小;那如果这个ret != 0的话我们只需要返回小于0的那种情况即可
② 等于
- 然后再来讲讲
operator==,这里我们可以使用到的是【逻辑运算符】先去排除掉一部分的情况,因为若是两个对象的_size都不相同的话,那一定是不会相同的 - 那么在
_size相同的情况下,我们再去使用memcpy()根据字节去一一比价两个对象中_str的内容,只有其返回值为0的时候才表示两个对象完全相同
bool operator==(const string& s)
{
return _size == s._size && memcmp(_str, s._str, s._size) == 0;
}
那有了上面的【小于】和【等于】之后,下面的我们就可以去做一个复用了,这一块我们在 类的六大天选之子 中讲解日期类的关系运算符重载时有提到过
③ 小于等于
bool operator<=(const string& s)
{
return *this < s || *this == s;
}
② 大于
bool operator>(const string& s)
{
return !(*this <= s);
}
② 大于等于
bool operator>=(const string& s)
{
return !(*this < s);
}
② 不等于
bool operator!=(const string& s)
{
return !(*this == s);
}
立马来测试一下吧
最后的话再来补充两个【流插入】和【流提取】,也是非常地重要
operator<< 流插入
- 那有认真学习过【类和对象】的话,就可以知道为了不让
this所指向的对象默认成为第一个参数的话,我们需要将这个函数实现到类外来,如果要访问类内私有成员的话,就可以使用到【右元】这个东西,不过呢我们不建议使用这个,会破坏类的封装性
// 流插入
ostream& operator<<(ostream& out, const string& s)
{
for (size_t i = 0; i < s.size(); i++)
{
out << s[i];
}
return out;
}
- 还有一点要提醒的是对于这个流插入来说我们是一定要进行引用返回的,这样就不会去调用拷贝构造了。因为在库中对这个函数是做了一个 ==防拷贝== 的效果,即在后面加上一个
= delete
ostream operator<<(ostream& out, const string& s)
💬 好,那到这里的话,我们是时候来讲讲这个
cout << s.c_str() 和 cout << s 的区别了
- c的字符数组, 以\0为终止算长度
- string不看\0, 以size为终止算长度
operator>> 流提取【⭐】
接下去再来看看这个【>>流提取】
- 这里首先要注意的一点就是第二个参数的前面不能和【<<流插入】一样加
const,因为我们会去修改这个 s
istream& operator>>(istream& in, string& s)
【第一版本】:无法读取 空格 和 换行符
- 首先可以来看下代码,我们通过
cin >> ch来将缓冲区内的字符放到【ch】中,接着以换行作为结束读取的标志来不断读取下一个字符并拼接到对象 s 中去
istream& operator>>(istream& in, string& s)
{
char ch;
in >> ch;
while (ch != '\n')
{
s += ch;
in >> ch;
}
return in;
}
- 下面我展示一下这个写法的两种BUG,一个是在读取到
\n的时候缓冲区会继续等待字符的输入,而不是结束读取
- 还有一种则是在读取的过程中如果读到空格了,是不会识别到的,而是会继续读取下一个字符
【第二版本】:使用
get()读取到流中流中的空格和换行符
- 在流提取
istream中有一个接口叫做【get】,我们使用它就可以读取到空格和换行符了
代码如下,可先参考
// 流提取
istream& operator>>(istream& in, string& s)
{
char ch = in.get();
while (ch != '\n') // 以换行作为分隔符
{
s += ch;
ch = in.get();
}
return in;
}
- 然后我们通过调试来观察一下就可以发现我们使用
ch = in.get()读取到了中间的空格,而且在读取到\n换行符的时候也成功退出了循环
【第三版本】:
clear()清理缓冲区内的字符
- 接下去我们再来看一种现象,就是当我们重复去操作同一个对象的时候,此时可以看到缓冲区内的字符并没有去做一个清除,所以我们后面再去输入的时候就会造成一个追加的现象
- 还记得我们在上面讲到过的
clear()吗,用来清理 string对象 中的数据
void clear()
{
_str[0] = '\0';
_size = 0;
}
- 在加上这个后,可以发现第二次再去输入的时候就不会造成追加的现象了
- 我们也可以通过调试来进行观察
【第四版本】:预存数组减少扩容
- 难道写成上面这样就好了吗,我们知道当这个 string对象 的容量不够的时候可以去做一个扩容,那若是这个对象本身的大小就很大的话随着
s += ch就会去产生频繁扩容的现象,这其实是不好的
💬 那有同学说:那我们在读取数据之前就开出一个很大的数组来不就好了,这样肯定能装得下无需扩容了
- 那我想问:如果这个数据比你开出的数组大小还要再大很多呢?该怎么办?
- 如果这个对象中只有一个字符,那你开了一个大小为
1024B的空间, 剩下的1023B不是造成了很大的浪费吗?
带着上面的这些疑问,我们一起改造一下这个流提取的接口
- 首先我们确实是要先开出一个数组,数组的大小给
128即可
char buf[128];
int i = 0;
- 然后还是以循环的方式去读取,在循环内部呢把每次读取到的字符放到数组中去,并使用变量
i去做一个计数
while (ch != '\n') // 以换行作为分隔符
{
buf[i++] = ch;
// ...
ch = in.get();
}
- 在每次将字符存放到数组中后,我们便要去判断一下这个
i是否到达了 127,若是的话就不能再继续读取了,而是要把最后的\0给手动加上,那这就算是一个完整的字符串了,追加到 string对象 中的即可,最后的话别忘了把i重置为0,继续下一组数据的读取
if (i == 127)
{
buf[i] = '\0';
s += buf;
i = 0;
}
- 当跳出循环的时候,我们还要对这个【i】再去判断一下,若是这个
i != 0的话,即没有到达127,只能说明这一组数据还无法追加到对象中。那我们还要再去做一个手动追加,防止数据丢失
if (i != 0)
{
buf[i] = '\0';
s += buf;
}
整体代码如下:
// 流提取
istream& operator>>(istream& in, string& s)
{
s.clear();
char ch = in.get();
char buf[128];
int i = 0;
while (ch != '\n') // 以换行作为分隔符
{
buf[i++] = ch;
// 不能等到128再去判断,要为最后的\0留空间
if (i == 127)
{
buf[i] = '\0';
s += buf;
i = 0;
}
ch = in.get();
}
// 若是有数据且不到127的话,进行倒入
if (i != 0)
{
buf[i] = '\0';
s += buf;
}
return in;
}
最后我们再来测试一下,发现确实扩容的次数大大减少了
难道你认为这样就完了吗?不,还有一点我们没考虑到
【第五版本】:清理字符前多余的空格
- 对比一下我们自己实现的和库里的,就可以发现存在不同之处,库里对于字符串前面的【空格】会去做处理,但是我们在流提取的逻辑中没有考虑到这一点
- 不仅如何,库里面对于【换行】这一块也会去做处理,但是呢我们实现的一敲下回车Enter就直接结束了
- 所以我们应该在读取第一个字符的时候先将【空格】或【换行】给清理掉,直接用
get函数即可,它可以读取到缓冲区中的所有内容
// 处理前缓冲区前面的空格或者换行
while (ch == ' ' || ch == '\n')
{
ch = in.get();
}
整体代码如下:
// 流提取
istream& operator>>(istream& in, string& s)
{
s.clear();
char ch = in.get();
// 处理前缓冲区前面的空格或者换行
while (ch == ' ' || ch == '\n')
{
ch = in.get();
}
char buf[128];
int i = 0;
while (ch != '\n') // 以换行作为分隔符
{
buf[i++] = ch;
// 不能等到128再去判断,要为最后的\0留空间
if (i == 127)
{
buf[i] = '\0';
s += buf;
i = 0;
}
ch = in.get();
}
// 若是有数据且不到127的话,进行倒入
if (i != 0)
{
buf[i] = '\0';
s += buf;
}
return in;
}
然后再去测试一下上面的两个场景,就发现什么问题了
四、写时拷贝(了解)
最后我们再来介绍一个东西叫做【写时拷贝】
1、概念理解
- 前面我们有谈到过什么是 ==深拷贝==,而 ==浅拷贝== 又会引发怎样的问题,这边再来回顾一下
- 浅拷贝会导致一块空间被析构两次
- 浅拷贝会导致一个对象修改也引发另一个对象一并修改
- 此时我们只有使用 深拷贝 才能解决问题,但是你是否有想过深拷贝所带来的代价呢?我们每去创建一个对象就进行一个深拷贝,但是呢在后面这个对象去不会去做任何的修改,那么深拷贝的意义其实没有多大,还多浪费了一块内存空间,虽然这对操作系统来说算不得什么,但若是你在长期运行这个代码所跑起来的程序时,则会造成内存枯竭💀
所以呢有人就提出了这么一个东西,叫做【写时拷贝】,全称叫做【引用计数 · 写时拷贝】
- 看到下面的图示, s2 呢是 s1 的一份临时拷贝,并且在这个地方我们使用的就是浅拷贝,二者指向的是同一块空间,此处我们会引入一个变量作为引用计数,每当构建出来一个对象的时候,计数器 + 1,所以在当 s2 拷贝完后这个计数器即为【2】
- 那么此时在析构的时候其所采用的机制便是:当一个对象去进行析构的时候,会先去看这个计数器的值是否为【1】,如果
>= 1的话,说明这块空间的维护者不止它一个,那么其就不可以去释放掉这块空间,而是将计数器--,那么此时这个计数器就变成了【1】;接下去当另一个对象再去调用析构函数的时候,发现这个计数器的值是为【1】,表示现在只有它在维护这块空间,其便会去释放掉这块空间 - 那对于上面的这种机制你可以认为是 ==最后一个走的关灯==
当然除了解决析构两次的问题,面对拷贝修改这一块它也做了一些文章
- 当我们要对一个对象中的空间做修改的时候,此时再去执行一个 深拷贝 的逻辑,重新开出一块空间来,把原本的数据拷贝过来,让其指向这块新的空间,然后就在这个新的空间中做修改。最后在将这个
计数器-- - 可以看到这个机制就很好地防止了同时修改的问题
💬 那有的同学说:那反正这最后不还是要去做一个深拷贝的,直接深拷贝不就完了,有什么意义呢?
- 其实你可以认为这是编译器是在做一个【博弈】,因为在不修改的情况下我们所执行的都是 浅拷贝,那么即可能很多对象都在维护同一块空间,此时如果这几个对象都不会去做写操作的话,那其实我们就是赚的,大家都展示同一块空间的内容即可,共同维护同一块空间,无需再多的开销
- 而只有当我们对这个对象去进行写操作的时候,才去开辟出一块新的空间进行修改,随开随用,此时也不算太晚。==所以只要你浅拷贝了但是不去修改我就是赚的==
💬 其实读者可以这么来理解
- 如果有读者像博主一样喜欢健身的话,就可以知道一般去健身房都是需要办卡的,只有当办卡的人数到达一定量的时候,老板才是赚的,为什么呢?原因就在于很多人办了健身卡后一般很少会来,甚至是不来,那么这个时候老板一定是赚的,如果每个会员每天都来的话,这健身器材都要不够了😓
- 那么老板赌的这个【办了卡不来】和我们上面所聊【拷贝了但是不修改】是一个道理的
2、双平台对比
清楚了什么叫做【写时拷贝】,我们现在就来测试一下
首先我们现到Linux平台下去看看
1 #include <iostream>
2 #include <stdio.h>
3 #include <string.h>
4 using namespace std;
5
6 int main(void)
7 {
8 string s1("abc");
9 string s2(s1);
10
11 printf("Show copy\n");
12 printf("%p\n", s1.c_str());
13 printf("%p\n", s2.c_str());
14 cout << "-----------------" << endl;
15
16 s2[0] = 'x';
17 printf("Show modify\n");
18 printf("%p\n", s1.c_str());
19 printf("%p\n", s2.c_str());
20 cout << "-----------------" << endl;
21
22 return 0;
23 }
- 可以看到,一开始在拷贝完之后两块空间中的内容都是一致的,说明这是【浅拷贝】,但是呢我在修改了对象 s2 的空间后,再去打印观察的时候就发现其所维护的空间所在地址发生了变化,也就意味着在修改前它做了一个【深拷贝】
- 所以在Linux下严格执行的就是我们本模块所讲到的 ==写时拷贝==
- 不过呢在Windows环境下的VS中,就不是这样了。我们可以看到一开始在打印的时候 对象s1 和对象s2 所维护的空间是不同的,所以在拷贝的时候就直接去做了一个【深拷贝】,而不是【浅拷贝】
- 而且在进行 ==写操作== 之后,它们的空间并没有发生改变,还是之前所维护的那一块空间
那可能有同学就会觉得VS还是比较奇怪的,包括我们在前面对各类接口做对比的时候,VS都会去做一些比较反常的事
VS你可以把他当做是一个财大气粗的老板,下面我们再来谈一谈VS对 string对象 这一块的容量设计
- 请读者思考一下这个 对象s1 有多大
string s1("abc");
cout << sizeof(s1) << endl;
💬 可能有读者认为这个对象中一共就三个成员变量,一个指针两个无符号整数,那大小应该就是 12
size_t _size;
size_t _capacity;
char* _str;
- 但是当我们运行起来可以发现,它的大小竟然是 28
- 对于这一块而言我们就要去了解一下 string对象 的底层封装了,在【监视】窗口中我们可以看到,它是把字符串
“abc”存到了一个 Buf 数组中,这个数组可容纳的大小为16个字节,虽然下面我圈起来的是【15】,是因为最后还有一个\0 - 那么这就可以解释为什么大小为 28 了,一个Buf数组16个字节,三个成员变量12个字节,那即为 28
💬 刚才说到这个 Buf数组 只能存放下16个字节的数据,但是当这个数据量变大的时候怎么办呢?
string s2("abcxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
- 也是通过【监视】窗口看出,这么多的数据存放到了
_Ptr所指向的堆空间中去,我们知道向堆中去申请的空间都是很大的,完全就能放得过了。那就可以看到VS这个机制还是蛮不错的
总结一下:
- 当字符串的
_size < 16的时候,字符串是存放在【Buf数组】中的 - 当字符串的
_size >= 16的时候,字符串存在【_Ptr】所指向的堆空间中
五、总结与提炼
最后来总结一下本文所学习的内容
- 本文我们重点讲到的是STL中的string类,首先我们初步认识了这个类,逐个地去了解了它的一些接口函数,包括【默认成员函数】、【常见容量操作】、【访问及遍历操作】、【修改操作】、【其他字符串操作】以及【非成员函数重载】。基本上文档中的每一个接口我们都有去了解过,希望读者可以烂熟于心,常常翻阅使用
- 但仅仅是了解了这些接口后还不够,接下去我们自己去模拟实现了这个string类,去逐步实现每一个接口的功能,不仅让我们对各个接口的性质更加地了解,而且还让我们对类和对象的一些基础语法知识有了很好的巩固。望读者也能够在阅读完本文后自己试着去模拟实现一下
- 最后呢我们又拓展了一块知识点叫做【写时拷贝】,面对 ==浅拷贝的危害和深拷贝的资源浪费问题==,编译器呢做出了这一块的优化,通过双平台的观察我们可以了解到Linux下的【gcc / g++】采取的就是这种拷贝机制
以上就是本文要介绍的所有内容,感谢您的阅读:rose::rose::rose: