前言
本篇文章主要是讲string类的模拟实现,模拟实现的是string类的常用接口以及成员函数。让读者对string类有更深的理解。适当的“造轮子”,有助于我们的语言学习。
简单描述string类
string类其实就是一个管理字符数组的线性表,我们可以使用string头文件内提供的接口来对string类进行数据的增删查改。
第一部分:初步搭建一个string类最基本的框架
我们以上面图中的一个简单的程序为模拟实现的第一个部分。首先,我们需要定义一个string类。然后,定义string类的成员变量。提供构造函数和析构函数。最后,提供一个c_str接口能够是<<运算符能够提取出模拟实现的string类的数据。
定义成员变量
我们将要实现的string类实现的成员变量如下,定义一个char类型指针的成员变量_str,来保存在堆上动态开辟的内存空间。分别定义两个无符号整型成员变量_size和_capacity,顾名思义_size用于记录string类对象的有效数据个数,_capacity用于记录string类对象的在堆上开辟空间的大小。需要将成员变量私有,以防止在类域外面能够访问修改数据。仅提供对应接口让用户能够访问成员变量的内容。
构造函数的模拟实现
这里我们实现的是通过一个c字符串的内容来构造一个string类的全缺省构造函数。它同样可以完成string类的默认构造的对应功能。
实现思路: 首先,我们先通过strlen()求出需要形参部分的字符串的长度,并将结果保存到一个临时变量中。然后,new一块长度为形参部分长度的空间+1赋值给_str(这里+1是为了放’\0’)。将形参部分的字符串的长度赋值给_size和_capacity。最后,在函数体内直接通过c库函数strcpy来将形参的内容拷贝给_str。
这里我们在默认参数中给的是一个空串,其实这里是一个很妙的处理。c语言中规定""引起的字符串内容中包含一个隐藏的’\0’。当我们不传参数时,我们还是会开辟一个字节的空间存放’\0’字符来初始化string类对象。
析构函数的模拟实现
因为string类的数据需要存放在堆区上,所以析构函数需要我们实现,编译器默认生成的析构函数无法完成对堆区资源的释放。只需要将堆区空间释放并处理_size和_capacity即可。
c_str()函数的模拟实现
c_str()函数是获取string类的内筒,以c字符串形式返回。简而言之,c_str()接口返回的是成员变量_str。需要注意的是需要使用const来修饰一下返回值,以保护成员变量_str。
至此第一部分的最最基本的string类的框架就搭建好了,下面我们再稍微完善完善模拟实现的string类。
第二部分:进一步完善功能
size()函数的模拟实现和capacity()的模拟实现
size()函数用于获取string类有效元素的个数,本质其实就是返回_size的值。capacity()函数用于获取string类在堆区开辟的空间的长度,返回的是成员变量_capacity。
[]运算符重载的模拟实现
由于库里string类是重载了操作符[],使得string类像数组一样。这极大程度上方便用户对于string类的某一个位置的数据进行增删查改。
实现思路: 实现思路就是返回_str的某一个位置的字符。需要注意的是库里面对于下标有效性的检查是assert的暴力检查。
迭代器及相关成员函数模拟实现
string类的迭代器其实就是一个类似于指针的一个东西,本质是其成员函数_str,但是迭代器不一定都是指针。例如list,其迭代器就不是指针,具体细节等模拟实现list时再说。begin()其实就是返回string类对象第一个元素的地址,end()返回的是string类对象有效字符的下一个地址('\0’的地址)。
有了迭代器就可以支持范围for语法。因为范围for的底层是通过迭代器来实现的。这里就简单做个演示。
reserve()函数的模拟实现
reserve函数用于将_str在堆区申请的空间大小进行调整。
实现思路:当调整的值 > _capacity时就进行扩容,当n<_capacity不做处理。开辟一段长度为n的堆区空间,将_str的值拷贝给新开辟的空间(需要拷贝’\0’),随后释放_str。最后将tmp赋值给_str,将_capacity修改成n。
resize(size_t n, char ch = ‘\0’)函数的模拟实现
resize()函数用于调整string类对象有效字符的长度至指定的长度,也支持指定字符来进行初始化。
实现思路:当n小于当前string对象有效长度时,将n赋值给_size,有效长度的后一个位置添上’\0’。当n大于string对象有效长度时,先用reserve来调整有效的空间大小,然后从_size后开始写入字符ch直到有效长度达到n。修改_size的值为n。
push_back()函数 的模拟实现
push_back()函数就是在string类对象的有效长度的下一位插入元素。这里以插入一个字符的版本为例进行模拟实现。
实现思路:先判断空间是否足够,若不够就扩容。然后在第_size个位置插入字符。随后++_size,并在_size下标位置上放上’\0’。
append()函数的模拟实现
append()函数就是在string类对象的有效长度后追加数据,这里以常用的追加一个c字符串为例模拟实现。
实现思路:首先,判断堆区空间是否足够。然后,在_size下标出,将c字符串拷贝c字符串长度个数据到_size后的空间。最后更新string类对象的有效长度。
operator += 重载的模拟实现
由于上面我们已经实现了push_back()和append()接口,这里我们至今进行一波复用就可以啦。
insert()函数的模拟实现
这里我们分别模拟实现两个版本,一个是在pos位置插入n个字符,另一个是在pos位置插入一个c字符串。再此之前我要引入一个新的成员变量npos,他是一个静态的公用成员变量,它表示的是最大的无符号整型值,即-1(2^32-1)。
实现思路:首先,判断一下pos下标的合法性。然后,判断堆区空间是否足够,不够就扩容。接下来,从pos位置开始向后挪动数据(需要注意边界控制),挪动n位。在pos位置插入n个字符或者一个c字符串。最后,更新一下_size。
find()函数的模拟实现
find接口用于从pos下标处开始查找一个字符或者一个字符串在当前string类对象的下标,没有找到则返回npos,所以我们还是实现两个版本。
实现思路:先判断下标的合法性。字符串版本进行strstr匹配,成功找到的话返回strstr的返回值-_str的偏移量,没有找到则返回npos。字符版本,直接使用一个for循环遍历string类对象,找到了就返回对应下标,否则返回npos。
erase()函数的模拟实现
erase函数用于删除从pos下标处开始,删除长度为len个数据。
实现思路:首先,判断pos下标的合法性。然后,根据len来判断删除的方式,若len = npos或者pos + len > _size,即从pos位置开始直接删完。若pos + len < _size,则需要挪动数据覆盖。
两个string类比较运算符重载的实现
这里就重点讲 重载< 和 重载== 的实现。只要实现这两个运算符,其余的比较运算符只需要复用它们俩就行。
实现思路:先讲 < 重载的实现。我们先找到两个string类有效长度短的那一个作memcmp的num参数。然后,根据memcmp的返回值进行判断。若返回值等于0,只需要判断_size是否小于s._size。若小于返回真,否则返回假。其他情况下,ret只要小于0,就返回真,否则返回假。而 == 重载需要我们先比较两个string类对象的有效长度是否一样,不一样直接返回假。一样的情况下,再进行memcmp,返回值为0才为真,否则为假。
其余情况复用上面两个运算符重载函数即可。
流插入运算符重载的模拟实现
实现思路:通过循环将string类对象的内容一个字符一个字符的流入到ostream类对象中,最后返回这个类对象。
流提取运算符重载的模拟实现
实现思路:第一步,需要清空string类的数据。紧接着,将缓冲区的空格和换行给清掉。我们定义一个buffer数组来存放从键盘上输入的字符,这样可以减少扩容所带来的性能消耗。然后将buffer追加到string类对象中即可。需要注意的是由于io流里面默认是以空格和换行进行分割的。所有在模拟实现流提取运算符重载时,我们需要用到一个接口就是get()。
深浅拷贝
浅拷贝的介绍
首先,下面请看一段代码
这里由于我们没有实现拷贝构造,编译器默认生成的拷贝构造导致了同一块内存被析构函数释放了两次。为什么呢?因为编译器默认生成的拷贝构造是浅拷贝,即值拷贝。其实就是将s1的三个成员变量的内容直接赋值给s2。main函数生命周期即将结束时,调用析构函数清理动态申请内存时,同一块动态申请的内存被释放了两次,导致的程序错误。此时,就需要使用深拷贝进行解决此问题。
深拷贝的介绍
深拷贝是指在拷贝对象时,不仅拷贝对象的值,还要拷贝对象所指向的内存空间,即重新分配一块内存空间,并将原对象的值复制到新的内存空间中。这样,原对象和拷贝对象就互不干扰,修改一个对象的值不会影响另一个对象的值。深拷贝通常需要自定义拷贝构造函数和赋值运算符重载函数来实现。
拷贝构造的模拟实现
这里我依次介绍传统写法以及现代写法的拷贝构造。
传统写法实现思路:先开辟根据被拷贝对象的大小申请空间并给_str,随后将被拷贝对象的数据内容拷贝到_str中,最后将被拷贝对象的_size和_capacity的值赋值给拷贝对象的_size和_capacity中。
现代写法实现思路: 先定义一个局部string对象tmp并用被拷贝的string类对象的内容去构造他,然后调用swap函数来将tmp对象的内容交换给拷贝对象。随后除了作用域tmp对象会被析构。就完成了拷贝构造的现代写法。这就像你假期在家时,让你的弟弟妹妹给你当工具人,吃饭让他们给你端进房间,吃完了他们还给你把碗筷收拾干净了端走。这里的tmp临时对象起到的就是这个作用。
operator = 重载的模拟实现
其实有了上面现代写法为基础,operator = 的实现思路大致也是一致,利用swap将形参的临时对象tmp内容交换给我们的拷贝对象即可。
substr()函数的模拟实现
有了对深浅拷贝的初步理解,接下来就带大家模拟实现一个常用的string类的接口substr()。substr()其实就是在当前string类对象的pos下标处构造一个长度为len的子串。
实现思路:首先,需要判断pos位置的有效性。然后,定义一个临时对象tmp。判断一下需要构造的子串的长度并修正。提前给tmp开辟好合适的容量,直接拷贝数据给tmp。最后,返回临时对象tmp。
总结
对string类的模拟实现主要是为了能够让我们更进一步的掌握string类。会使用string类和掌握string类底层逻辑,两者还是有很大的区别的。知其然,知其所以然。在学习语言时,适当的”造轮子“可以让你的水平更进一步。点击获取完整代码