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的
cpp
复制代码
typedefchar* iterator;
typedefconstchar* const_iterator;
- 这里的话我就实现一下最常用的【begin】和【end】,首位的话就是
_str
所指向的这个位置,而末位的话则是_str + _size
所指向的这个位置
typedef char* iterator; typedef const char* const_iterator;
- 实现了普通版本的迭代器之后,我们再来看看常量迭代器。很简单,只需要修改一下返回值,然后在后面加上一个【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 << " "; }
这个方式去遍历的话还是很方便的,必须安利一波✌