C++ —— STL容器 —— string的模拟实现
1. 前言
了解 string 的各个常用的接口之后,可以尝试模拟实现 string 类。模拟实现string 类并不是为了实现一个与库中一样的 string ,而是为了让我们更好的了解 string 类的底层,在之后使用 string 类出现错误时,能快速的找到问题,并解决问题,同时能够加深对 C++ 知识的掌握。这里的模拟实现函数的声明与定义分离。在模拟实现的过程中,为了避免命名冲突的问题,三个文件中都可以使用关键字 namespace 。
2. string 类的模拟实现
2.1 无参,带参构造函数,以及 c_str 函数
在实现构造函数之前,先实现 string 的底层结构,在介绍 string 的时候,提到string的底层其实就是一个存储字符的顺序表,顺序表在数据结构中已经接触到了,它的底层结构是指向数组的指针,有效数据个数,当前数组的容量大小。如下代码所示:
namespace AY
{class string{public:private:char* m_str;size_t m_size;size_t m_capacity;};
}
底层的结构实现完毕后,接下来先实现无参的构造函数 string() 。首先面对的问题是对成员变量初始化,一开始我们可能会想,这里是否与数据结构中的顺序表的初始化函数中的思路一致呢?先这样尝试将指针初始化为空指针,其余两个变量初始化为0:
namespace AY
{// 指明类域string::string(): m_str(nullptr), m_size(0), m_capacity(0){}
}
无参的构造函数实现完毕后,接下来实现 c_str 函数。该函数的作用是返回 string 的底层指针,即 m_str。代码如下所示:
// c_str 函数
const char* string::c_str() const
{return m_str;
}
c_str 函数实现完毕后,下面来检验之前实现的无参的构造函数是否存在问题:
#include "string.h"using namespace std;namespace AY
{void func1(){string str;cout << str.c_str() << endl;}
}int main()
{AY::func1();return 0;
}
当运行上述程序时会发现程序崩溃,接下来使用库中 string 来运行同一段程序,观察结果如何?
当使用库中 string 时程序却正常运行,这是怎么一回事呢?c_str 函数是没有问题的,问题出现在无参的构造函数的初始化。m_str 被初始化为 nullptr ,那么 c_str 函数返回的就是空指针,相当于返回了 const char* 类型的空指针。需要注意的是 const char* 类型的指针与其他类型的指针有些不同,其它类型的指针都会按照十六进制去打印地址,而 const char* 类型的指针不会那样去打印地址。为什么呢?因为 const char* 的第一原则并不是去打印地址,而是先去访问该指针指向的数组的内容。访问指针指向的数组的内容需要解引用,这里的 m_str 是空指针,所以程序会崩溃,是因为对空指针进行了解引用操作。优化后的代码如下所示:
string::string(): m_str(new char[1]{""}), m_size(0), m_capacity(0)
{}
先开辟一个char类型大小的空间,为了能够表示数组中没有数据,用空字符串来填充这个空间。当然也可以这样 m_str(new char[1]{'\0'}) 。无参的构造函数实现完毕后,接下来开始实现带参的构造函数,即 string(const char* s) 。
首先面临的问题仍然是对成员变量初始化,这里不能直接将形参 s 赋值给成员变量 m_str ,因为 m_str 指向的字符串是可以改变的,而这里的形参 s 是常量串,这里应该开辟一块空间,将字符串 s 中的数据拷贝到 m_str 中,开辟的空间大小为字符串 s 的大小,并且要加1,即开辟 strlen(str) + 1 大小的空间(strlen 求得的字符串长度并不包含’\0’,这里的加1是为了存储 ’\0’ ),其余的两个成员变量初始化为 strlen(str) 。如下代码所示:
// 带参的构造函数
string::string(const char* s): m_str(new char[strlen(s) + 1]), m_size(strlen(s)), m_capacity(strlen(s))
{// 记得引用头文件 <string.h>memcpy(m_str, s, strlen(s) + 1);
}
其实这样写还是存在一些不足之处 —— strlen 函数的多次使用。众所周知,strlen 和 sizeof 都可以求取字符串的长度,但是它俩有些不同,sizeof 求得的结果包含 '\0' ,而 strlen 不包含;除此以外,sizeof 求取字符串的长度是在编译时计算的,而strlen 是在运行时计算的。这里使用了三次 strlen 函数,会降低程序的运行效率,所以这样初始化有些不足。那么怎么优化呢?既然多次使用 strlen 函数不好,那么少使用 strlen 函数或者只使用一次 strlen 函数就可以了呀!让 m_size 用 strlen(s) 来初始化,其余的两个变量的初始化都复用m_size的结果,代码如下所示:
// 带参的构造函数
string::string(const char* s): m_size(strlen(s)), m_str(new char[m_size + 1]), m_capacity(m_size)
{// 记得引用头文件 <string.h>memcpy(m_str, s, strlen(s) + 1);
}
讲解了 strlen 的不足之处后,很容易想到这样的方法,但是这种方法是正确的吗?并不正确,需要注意初始化列表的初始化顺序是按照成员变量的声明顺序来初始化的,按照现在所写的成员变量的声明,m_str 是第一个初始化的,这时 m_size 还未初始化,只是一个随机值或者是 0 ,最终会产生意料之外的结果。既然是成员变量的声明顺序导致的这个结果,那么调整成员变量的声明顺序不就可以了?调整结果如下所示:
size_t m_size;
char* m_str;
size_t m_capacity;
这样写也并不正确,这样会增加代码之间的耦合。也就是说,只要改了这里的成员变量的声明的顺序,那么就会导致程序的崩溃。代码之间的耦合太高并不好,修改这串代码影响那串代码,修改那串代码影响这串代码。所以这种思路也存在不足,将成员变量的声明顺序改为原来的顺序,开始思考更好的解决办法。
既然初始化列表这里有很多坑,那么不使用初始化列表来初始化成员变量或者部分成员变量使用初始化列表来初始化问题不就解决了吗?虽然所有的成员变量都会通过初始化列表来初始化,但并不意味着这些成员变量只能通过初始化列表来初始化,还可以在函数体内初始化。所以可以让 m_size 通过初始化列表来初始化,其余的两个成员变量在构造函数的函数体内初始化。代码如下所示:
// 带参的构造函数
string::string(const char* s): m_size(strlen(s))
{m_str = new char[m_size + 1];m_capacity = m_size;// 记得引用头文件 <string.h>memcpy(m_str, s, strlen(s) + 1);
}
2.2 全缺省的构造函数和析构函数
2.2.1 全缺省的构造函数
无参和带参的构造函数实现完毕后,将而二者合一,实现一个全缺省的构造函数,全缺省的构造函数的函数体与带参的构造函数的函数体中的代码差不多,只是在函数参数上有些不同,代码如下所示:
// 函数的声明
string(const char* s = "")// 函数的定义
// 全缺省的构造函数
string::string(const char* s): m_size(strlen(s))
{m_str = new char[m_size + 1];m_capacity = m_size;// 记得引用头文件 <string.h>memcpy(m_str, s, strlen(s) + 1);
}
将全缺省的构造函数实现完毕后,将可以将无参和带参的构造函数注释掉了。
2.2.2 析构函数
既然有资源的申请,那么就有资源的释放,接下来实现析构函数,即 ~string() ,该函数实现起来并不复杂,与数据结构中顺序表的 Destory 函数差不多,只是这里释放资源是使用 delete 。代码如下所示:
// 析构函数
string::~string()
{delete[] m_str;m_str = nullptr;m_size = m_capacity = 0;
}
2.3 size 函数和 operator[ ] 函数
2.3.1 size 函数
size 函数的作用的是返回字符串的有效字符长度(不包含'\0'),在 string 的底层中 m_size 的大小就表示字符串的有效字符长度,所以 size 函数的实现直接返回 m_size 即可,代码如下所示:
// size 函数
size_t string::size() const
{return m_size;
}
2.3.2 operator[ ] 函数
operator[ ] 函数有两个版本,一个是普通版本,一个是 const 版本。但是它俩的函数体的代码都是一样的,返回 pos 位置的数据。在返回 pos 位置的数据之前需要检验 pos 的合法性,即是否大于当前字符串的有效字符串长度 —— m_size),代码如下所示:
// operator[] 函数 —— 普通版本
char& string::operator[] (size_t pos)
{// 记得引用头文件<assert.h>assert(pos < m_size);return m_str[pos];
}// operator[] 函数 —— const 版本
const char& string::operator[] (size_t pos) const
{// 记得引用头文件<assert.h>assert(pos < m_size);return m_str[pos];
}
2.4 迭代器和范围 for
之前提到过,范围 for 的底层就是迭代器。这里要实现的迭代器有两个版本 —— 普通正向迭代器和 const 正向迭代器。
先来实现普通迭代器,在此之前先实现 begin 和 end 函数的普通版本。begin 和 end 函数的返回值类型为 iterator ,但是内置类型中并没有 iterator 类型呀?观察 string 的源码可以发现,其实 iterator 是某个类型 typedef 得到的,所以我们也可以这么干。而迭代器又是像指针一样的东西,所以不妨将 char* typedef 为 iterator 。
解决了函数的返回值类型的问题之后,接下来分析 begin 和 end 函数怎么实现,我们可以知道 begin 和 end 函数的作用分别是:返回指向字符串的第一个字符迭代器 和 返回指向容器末尾(最后一个元素之后,即‘\0')的迭代器。m_str 指向的就是字符串的首字符的地址,所以 begin 函数直接返回 m_str 即可;怎么得到指向容器末尾的迭代器呢? m_str 加上 m_size 指向的就是字符串最后一个字符的地址,这样一来就得到了指向容器末尾的迭代器。
之所以可以这样实现,是因为将 char* 重命名为 iterator 。分析完毕,具体代码如下所示:
// 重命名 —— 在.h文件中
typedef char* iterator;// 函数的实现
// begin 函数
string::iterator string::begin()
{return m_str;
}// end 函数
string::iterator string::end()
{return m_str + m_size;
}
普通版本的 begin 和 end 函数实现完毕后,接下来使用这两个函数来实现迭代器和范围 for 。运行结果如下图所示:
普通迭代器实现完毕后,接下来再实现 const 迭代器,在此之前先实现 const 版本的 begin 和 end 函数。与普通迭代器一致,将 const char* typedef 为 const_iterator 。函数体的实现与普通迭代器的一模一样,这里就不过多赘述。分析完毕,具体代码如下所示:
// 重命名 —— 在.h文件中
typedef const char* const_iterator;// 函数的实现
// const 版本的 begin 函数
string::const_iterator string::begin() const
{return m_str;
}// const 版本的 end 函数
string::const_iterator string::end() const
{return m_str + m_size;
}
const 迭代器是不可以修改字符串的字符的大小的,只能遍历字符串的数据,不能修改字符串的数据。我们来尝试修改字符串中的数据,观察编译器是否会报错,若报错则说明我们实现的函数没有问题;反之,则有问题。若想要范围 for 使用的 const 迭代器,可以在类型的前面加上 const 。测试结果如下图所示:
2.5 reserve 函数,push_back 函数和 append 函数
2.5.1 reserve 函数
reserve 函数的作用是:扩容,为字符串预留空间,将字符串的 capacity 的大小改变到 n 。需要注意的是 reserve 函数的扩容是异地扩容,既然是异地扩容,就免不了开辟新空间,拷贝旧数据,释放旧空间,更新变量的数据这几个步骤。这里的扩容逻辑与数据结构顺序表的逻辑并无二异。只是这里的扩容需要手动扩,new 一块空间。具体代码如下所示:
// reserve 函数 —— 扩容,异地扩容
void string::reserve(size_t n)
{if(n > m_capacity){// 开辟新空间 —— 为'\0'预留一块空间char* str = new char[n + 1];// 拷贝旧数据memcpy(str, m_str, m_size + 1);// 释放旧空间delete[] m_str;// 更新变量的数据m_str = str;m_capacity = n;}
}
2.5.2 push_back 函数
push_back 函数的作用是:尾插字符。既然要插入数据,那么就得考虑是否需要扩容,即需要调用 reserve 函数。什么情况下需要扩容呢?当有效数据个数大于或等于当前容器的空间大小时,就需要扩容。执行完扩容操作后,接下来插入数据,往字符串的末尾插入数据,即 m_str[m_size] 赋值为字符 c,然后有效数据个数再加1,之后记得手动加上 '\0' (因为m_size 处的数据由 '\0' 变成 字符 c 了)以表示是字符串。分析完毕,代码如下所示:
// push_back 函数
void string::push_back(char c)
{// 判断是否需要扩容if (m_size >= m_capacity){size_t newcapacity = m_capacity == 0 ? 4 : 2 * m_capacity;reserve(newcapacity);}// 插入数据m_str[m_size] = c;// 有效数据个数 +1m_size++;// 手动加上 '\0'm_str[m_size] = '\0';
}
2.5.3 append 函数
这里我们只实现追加字符串,函数原型为:string& append(const char* s) 。插入字符串也意味着需要扩容,那么怎么扩容呢?扩多大呢?不能再像之前那样无脑2倍扩容了,这里在扩容之前先计算要追加的字符串的长度 len ,之后再判断 size + len 与 2 * capacity 之间的关系,若是小于,则就2倍扩容;若是大于,则扩的空间为 size+ len 大小。当然,也可以简单一点需要多少,开辟多少,直接开 size + len 大小的空间。分析完毕,具体代码如下所示:
// append 函数
string& string::append(const char* s)
{//先求出待插入的字符串的长度size_t len = strlen(s);// 判断是否需要扩容if (m_size + len >= m_capacity){size_t newcapacity = m_size + len > 2 * m_capacity? m_size + len : 2 * m_capacity;reserve(newcapacity);}// 插入数据 —— +1 是算上了'\0'memcpy(m_str + m_size, s, len + 1);// 有效数据个数 + lenm_size += len;return *this;
}
append 函数实现完毕,检验实现的函数是否能达到该函数本来的效果,检验结果如下图所示:
2.6 operator+= 函数和流插入运算符函数
2.6.1 operator+= 函数
operator+= 函数的作用与 push_back 和 append 函数的实现并无二异,都是在字符串的尾部操作,这里只实现 += 字符和 += 字符串,这两个函数的函数原型分别为:string& operator+= (char c) 和 string& operator+= (const char* s) 。+= 字符可以直接复用 push_back 函数,+= 字符串可以直接复用 append 函数。具体代码如下所示:
// operator+= 函数 —— += 字符
string& string::operator+= (char c)
{push_back(c);return *this;
}// operator+= 函数 —— += 字符串
string& string::operator+= (const char* s)
{append(s);return *this;
}
2.6.2 流插入运算符函数
流插入运算符函数的原型为:ostream& operator<< (ostream& os, const string& str) 。该函数并不是 string 类的成员函数,所以在声明的时候将其声明至 string 类的外面。那么怎么实现流插入运算符函数呢?在之前没实现流插入运算符时,打印字符串都是使用 c_str 函数,那么这里流插入函数的实现是否可以复用 c_str 函数呢?我们先试试看,代码如下所示:
// 流插入运算符 << 函数
ostream& operator<< (ostream& os, const string& str)
{os << str.c_str() << endl;return os;
}
我们来使用自己实现的流插入运算符函数来打印字符串,运行结果如下所示:
为什么没有将 '*' 打印出来?换成库里面的流插入运算符函数试试,看看它的结果是怎样的?运行结果如下图所示:
咦?为什么库里面的流插入运算符却可以将 '*' 打印出来?这个不同的结果说明了我们实现的流插入运算符函数存在问题,流插入运算符函数的实现是基于 c_str 函数, c_str 函数的返回值是 m_str 。打印数据的时候遇到 ’\0’ 就会终止打印,这才出现了上述的问题。所以这里的流插入函数不能基于 c_str 函数来实现,而是将字符串 str 中的数据依次输出。代码如下所示:
// 流插入运算符 << 函数
ostream& operator<< (ostream& os, const string& str)
{// 依次输出for (size_t i = 0; i < str.size(); i++){os << str[i];}return os;
}
将之前的程序再运行一遍,观察其结果是否和库中的流插入运算符函数的结果一致,运行结果如下所示:
2.7 insert 函数和 erase 函数
2.7.1 insert 函数
insert 函数的作用是:在指定位置 pos 处插入数据。这里我们只实现插入字符和插入字符串,这两个函数的原型为:string& insert(size_t pos, char c) 和 string& insert(size_t pos, const char* s) 。
先实现插入字符函数,既然要在 pos 位置处插入数据,就需要考虑是否需要扩容以及 pos 位置是否合法,这里的扩容逻辑与 push_bcak 函数的逻辑一致;另外还要考虑挪动数据,在数据结构的顺序表中演示过了,这里就不过多的赘述。具体代码如下所示:
// insert 函数 —— 插入字符
string& string::insert(size_t pos, char c)
{// 判断 pos 位置是否合法assert(pos < m_size);// 判断是否需要扩容if (m_size >= m_capacity){size_t newcapacity = m_capacity == 0 ? 4 : 2 * m_capacity;reserve(newcapacity);}// 挪动数据size_t end = m_size;while (end >= pos){m_str[end + 1] = m_str[end];end--;}// 插入数据m_str[pos] = c;m_size++;return *this;
}
接下来调用该函数,检验实现的函数是否能达到该函数本来的效果,检验结果如下图所示:
往中间插入数据没有问题,那么往头部插入数据呢?结果如下图所示:
非正常状态退出,说明我们实现的插入字符函数有问题,问题出现在哪呢?我们来调试观察:
从上图可以发现当数据挪完之后 end-- ,并没有变成 -1 ,而是变成了一个很大的数据,导致循环永远不会终止,为什么 end 为 0 的时候 -1,没有变成 -1 呢?这是因为 end 是 size_t 类型的数据,size_t 类型的数据没有负数。那么怎么解决这个问题呢?这时有人可能会想,既然因为 end 为 size_t 类型才出现的错误,那将 end 的数据类型改为 int 类型不就好了吗?其实这样改变也不好,因为这里就涉及整型提升了,int 类型向 size_t 类型提升,先提升,再比较大小。当然对于这种问题也有解决办法,再将 end 的数据类型改为 int 类型的基础上,将 pos 强制类型转换成 int 类型就可以解决了,但是我个人并不推荐这种方法。
接下来介绍较为好的方法,之所以会出现上述的问题,是因为 while 循环中的终止条件中的 >= 号的等于存在问题,要想将源字符串中 pos 位置的数据向后挪动,就需要遍历至 pos 位置处,就避免不了 >= pos,但是将 end的初始值改变一下,就可以使用 > pos 的条件了,将 end 的 的初始值改为 m_size + 1(这里之所以 +1 ,是因为只插入一个字符,若插入的是字符串,加的则是字符串的长度大小)。如此一来,数据的挪动也需要改动,改为 m_str[end] = m_str[end - 1] 。下面通过画图来理解:
分析完毕,具体代码如下所示:
// insert 函数 —— 插入字符
string& string::insert(size_t pos, char c)
{// 判断 pos 位置是否合法assert(pos < m_size);// 判断是否需要扩容if (m_size >= m_capacity){size_t newcapacity = m_capacity == 0 ? 4 : 2 * m_capacity;reserve(newcapacity);}// 挪动数据size_t end = m_size + 1;while (end > pos){m_str[end] = m_str[end - 1];end--;}// 插入数据m_str[pos] = c;m_size++;return *this;
}
调用该函数,检验实现的函数是否能达到该函数本来的效果,检验结果如下图所示:
无论是中间插入,还是头部插入,都没有问题,说明我们实现的 insert 插入字符函数没有问题。 insert 插入字符函数实现完毕,接下来实现 insert 插入字符串函数。这里插入字符串的扩容逻辑与append 函数的扩容逻辑一致。挪动数据所用到的思想与插入字符的思想一致,在扩容步骤中求得待插入字符串的长度 len ,所以 end 的初始值为 m_size + len,那么循环的条件为 end > pos + len - 1 ,数据的挪动为 m_str[end] = m_str[end - len] 。下面通过画图来理解:
挪动数据的思路分析清楚之后,接下来分析插入数据,先找到开始插入的位置,即 pos 位置,再寻找结束插入的位置,即 pos + len 位置,起始位置和终止位置确定之后,用 for 循环插入字符。所有的步骤都分析完毕,接下来编写代码:
// insert 函数 —— 插入字符串
string& string::insert(size_t pos, const char* s)
{// 判断 pos 位置是否合法assert(pos < m_size);//先求出待插入的字符串的长度size_t len = strlen(s);// 判断是否需要扩容if (m_size + len >= m_capacity){size_t newcapacity = m_size + len > 2 * m_capacity? m_size + len : 2 * m_capacity;reserve(newcapacity);}// 挪动数据size_t end = m_size + len;while (end > pos + len - 1){m_str[end] = m_str[end - len];end--;}// 插入数据for (size_t i = 0; i < len; i++){m_str[pos + i] = s[i];}// m_size 的大小增加 lenm_size += len;return *this;
}
调用该函数,检验实现的函数是否能达到该函数本来的效果,检验结果如下图所示:
2.7.2 erase 函数
erase 函数的作用是:删除字符串中从字符位置pos开始len个字符的部分,若 len 的大小大于从 pos 位置开始到 '\0' 结束之后的字符串的长度,那么有多少删除多少。该函数的原型为:string& erase(size_t pos = 0, size_t len = npos) 。下面来分析 erase 函数怎么实现,首先检验 pos 位置的合法性,接着由 erase 函数的作用可以看出,存在两种情况的结果,一是当 len 的长度大小或者等于大于从 pos 位置开始到 '\0' 结束之后的字符串的长度,则将 pos 位置后的字符全部删除,二是 len 的长度小于的情况,这种情况就需要挪动数据了。
第一种情况,先将 m_size 的大小变为 pos ,再将 m_size 位置的数据置为 '\0' ,这样就将 pos 位置后的字符全部“删除”了;第二种情况,数据的挪动逻辑与 insert 插入字符串的逻辑一致,先确定插入的起始位置即 pos,再确定插入的结束位置即 pos + len ,中间的数据的插入使用 for 循环;接下来再改变 m_size 的大小,记得将 m_size 处的数据赋值为 '\0' 。分析完毕,具体代码如下所示:
// 函数声明
string& erase(size_t pos = 0, size_t len = npos);// 需要添加 npos 成员函数
// 声明 —— 在.h 文件中
static const size_t npos;
// 定义 —— 在.cpp 文件中
const size_t string::npos = -1;// 函数的实现
string& string::erase(size_t pos, size_t len)
{// 判断 pos 位置是否合法assert(pos < m_size);if (len >= m_size - pos || pos >= npos){m_size = pos;m_str[m_size] = '\0';}else{// 挪动数据for (size_t i = pos; i < pos + len; i++){m_str[i] = m_str[i + len];}// m_size 的大小 - lenm_size -= len;m_str[m_size] = '\0';}return *this;
}
调用该函数,检验实现的函数是否能达到该函数本来的效果,检验结果如下图所示:
2.8 pop_back 函数和 find 函数
2.8.1 pop_back 函数
先实现 pop_back 函数,pop_back 函数的作用是:尾删字符。需要实现的函数原型为 void pop_back() 。既然是删除字符,那么有效数据的个数不能为0,否则就没有字符要删除了。具体代码如下所示:
// pop_back 函数
void string::pop_back()
{assert(m_size != 0);m_size--;m_str[m_size] = '\0';
}
2.8.2 find 函数
find 函数是用来查找字符串中的数据,这里实现的函数是查找字符和查找字符串,函数原型为:size_t find(char c, size_t pos = 0) const 和 size_t find(const char* s, size_t pos = 0) const 。找到了则返回对应的下标,找不到则返回 npos 。
先实现查找字符,实现起来很简单,从pos 位置开始遍历字符串,看是否有与字符 c 相等的字符。分析完毕,具体代码如下所示:
size_t string::find(char c, size_t pos) const
{for (size_t i = pos; i < m_size; i++){if (m_str[i] == c){return i;}}return npos;
}
调用该函数,检验实现的函数是否能达到该函数本来的效果,检验结果如下图所示:
find 查找字符函数实现完毕,接下来实现 find 查找字符串函数。可以使用 strstr 函数,函数原型为 char * strstr ( char * str1, const char * str2 ) ,作用为:如果在str1中找到了str2子字符串,则返回指向str2在str1中首次出现位置的指针;如果未找到str2子字符串,则返回 NULL。
解决完怎么查找字符串之后,开始考虑返回值的问题,找不到返回 npos ,找到了返回对应的下标,下标是整型,这里全是指针,怎么表示呢?用指针- 指针的方法,在strstr 函数找到子串时,会返回一个指针,让该指针减去指向字符串首字符的指针即 m_str ,就是子串第一次出现的位置的下标。分析完毕,代码如下所示:
// find 函数 —— 查找字符串
size_t string::find(const char* s, size_t pos) const
{char* ret = strstr(m_str + pos, s);if (ret != nullptr){return ret - m_str;}else{return npos;}
}
调用该函数,检验实现的函数是否能达到该函数本来的效果,检验结果如下图所示:
2.9 substr 函数
substr 函数的函数原型为:string substr (size_t pos = 0, size_t len = npos) const , 作用为:在字符串中从pos位置开始,截取len个字符,然后将其返回;若 len 的大小大于从pos位置开始直到 '\0' 结束的字符串的长度,那么将从 pos 位置后的所有字符都返回。
涉及 pos ,可以先检查 pos 位置的合法性,也可以不检查。由该函数的作用可知,这里的 len 的大小需要根据情况的不同进行调整,若 len 的大小大于从 pos 位置开始直到'\0'结束的字符串的长度,那么要返回的字符串只有从 pos 位置开始到之后的字符串了,返回的长度并不是 len ,而是 m_size - pos(左闭右开,相减就是该区间字符串的长度)。
该函数的返回值是 string 类型,可以先创建一个 string 类对象作为返回值,将要返回的字符串拷贝到该对象中。分析完毕,代码如下所示:
// substr 函数
string string::substr(size_t pos, size_t len) const
{// 判断 pos 的位置是否合法assert(pos < m_size);// 调整 len 的大小if (len >= m_size - pos || len >= npos){len = m_size - pos;}// 先创建一个 string 类对象用来返回string tmp;// 提前预留好空间tmp.reserve(len);for (size_t i = 0; i < len; i++){tmp += m_str[pos + i];}return tmp;
}
调用该函数,检验实现的函数是否能达到该函数本来的效果,检验结果如下图所示:
也许有人会有疑问,这里的不是传值返回吗?传值返回不是会产生临时对象吗?那么不就需要调用拷贝构造函数吗?这里没有实现拷贝构造函数,使用的是编译器自己生成的拷贝构造函数,而编译器自己生成的拷贝构造函数是浅拷贝/值拷贝,这里需要的是深拷贝,需要自己实现拷贝构造函数,但是并没有实现,编译器却不会报错,这是为什么呢?因为编译器针对这些情况进行了优化,即便自己没有实现拷贝构造函数,也没有问题,并且这里的返回 tmp 时,也不会调用析构函数,这也是因为编译器自己优化了。
2.10 六种比较函数
先实现 < 与 == 这两个比较函数,其余四个函数的实现直接复用这两个函数。
先来实现 < 比较函数,比较两个字符串的大小,比较的是对应位置的 ASCII 码值,所以先定义两个遍历变量 i1 和 i2 ,一个遍历左对象,一个遍历右对象,循环遍历,遍历条件为小于对象的 m_size 。当左对象对应的字符大于或等于右对象对应的字符时,返回 false ,反之返回 true。
当跳出循环时,说明左右两个对象中,有其中一个或者两个对象遍历完毕了,此时应当返回什么?在小于比较中,无非就以下三种情况:
观察上述三种情况的结果,返回 true 时,右对象还未遍历完毕,即 i2 小于右对象的 m_size ,其余的情况都是返回 false 。所以跳出循环后,返回的表达式为 i2 < 右对象的 m_size。分析完毕,代码如下所示:
bool string::operator< (const string& str)
{size_t i1 = 0;size_t i2 = 0;// 遍历两个字符串while (i1 < m_size && i2 < str.m_size){if (m_str[i1] < str[i2]){return true;}else if (m_str[i1] > str[i2]){return false;}else{i1++;i2++;}}return i2 < str.m_size;
}
调用该函数,观察程序的运行结果,运行结果如下图所示:
小于比较函数实现完毕后,接下来实现等于比较函数。与小于比较函数的逻辑一致,只是循环体内部的代码需要改动,只要左右相对应的字符不相等,则直接返回 false 。跳出循环后,说明左右两个对象中,有其中一个或者两个对象遍历完毕了,此时应当返回什么?既然是等于比较函数,只有跳出循环后,左右对象都遍历完毕了,才返回 true ;其余的情况都返回 false 。分析完毕,具体代码如下所示:
bool string::operator== (const string& str)
{size_t i1 = 0;size_t i2 = 0;// 遍历两个字符串while (i1 < m_size && i2 < str.m_size){if (m_str[i1] != str[i2]){return false;}i1++;i2++;}return i1 == m_size && i2 == str.m_size;
}
调用该函数,观察程序的运行结果,运行结果如下图所示:
小于比较函数和等于比较函数实现完毕后,其余四个比较函数实现的代码如下所示:
bool string::operator<= (const string& str)
{return (*this < str) || (*this == str);
}bool string::operator> (const string& str)
{return !(*this < str || *this == str);
}bool string::operator>= (const string& str)
{return !(*this < str);
}bool string::operator!= (const string& str)
{return !(*this == str);
}
2.11 clear 函数,流提取运算符函数和 getline 函数
2.11.1 clear 函数
clear 函数的作用为: 清除当前字符串中的所有字符,即将 size 变为 0 ,但是不改变 capacity 的大小。该函数的原型为:void clear() 。与 erase 函数删除字符的逻辑类似,直接将有效数据个数 m_size 的大小赋值为 0 ,然后在 m_size 位置处赋值为 '\0' 。分析完毕,代码如下所示:
// clear 函数
void string::clear()
{m_size = 0;m_str[m_size] = '\0';
}
2.11.2 流提取运算符函数
流提取运算符函数的原型为:istream& operator>> (istream& is, string& str) 。流提取运算符函数是非 string 成员函数,所以该函数的声明写在 string 类的外部。可以不断的向对象一个一个的输入字符,当输入的字符等于空格字符或者换行字符时,就终止输入并且跳出循环。分析完毕,代码如下所示:
// 流提取运算符 >> 函数
istream& operator>> (istream& is, string& str)
{char c;// 依次输入字符while (is >> c){if (c == ' ' || c == '\n'){break;}str += c;}return is;
}
当我们实际使用该函数时,发现即便是输入空格字符或者换行字符,循环也不会停止,这是因为因为 is >> c 默认会跳过空格和换行符。那该怎么实现流提取运算符函数呢?在 istream 中提供了一个 get 函数,该函数的作用是:从流中提取单个字符,该函数的函数原型有多个,这里只介绍我们需要使用的:istream& get(char& c) 。接下来使用 get 函数来实现流提取运算符函数,代码如下所示:
// 流提取运算符 >> 函数
istream& operator>> (istream& is, string& str)
{char c;// 依次输入字符while (is.get(c)){if (c == ' ' || c == '\n'){break;}str += c;}return is;
}
调用该函数,观察程序的运行结果,运行结果如下图所示:
观察上图程序的运行结果,可以发现,为什么 str2 字符串中的原数据也打印出来了呢?std 库中也是这样的吗?如下图所示:
使用 std 库中的 string ,运行该代码时,并没有将字符串中的原数据打印出来,说明我们实现的流提取运算符存在问题。之所以会将 str2 中原数据打印出来,是因为没有将这些原数据进行清除,清除字符串中的数据可以使用 clear 函数。代码如下所示:
// 流提取运算符 >> 函数
istream& operator>> (istream& is, string& str)
{// 清除 str 中的数据str.clear();char c;// 依次输入字符while (is.get(c)){if (c == ' ' || c == '\n'){break;}str += c;}return is;
}
调用该函数检验实现的函数是否能达到该函数本来的效果,检验结果如下图所示:
程序的运行结果与库中一致,说明现在实现的函数没有问题。但是当输入的字符串非常长时,会不断的扩容,会浪费空间,降低效率。那么有没有什么优化方案呢?如下代码所示:
// 流提取运算符 >> 函数
istream& operator>> (istream& is, string& str)
{// 清除 str 中的数据str.clear();char c;size_t i = 0;char buff[128];// 依次输入字符while (is.get(c)){if (c == ' ' || c == '\n'){break;}if (i == 127){buff[i] = '\0';str += buff;i = 0;}buff[i++] = c;}if (i > 0){buff[i] = '\0';str += buff;}return is;
}
既然不足出现在字符串较长时,会多次扩容,那么我提前开辟好一块较大的空间,即buff;将输入的字符都存储到 buff 中,等到空间满了之后,再将 buff 中的字符存储到 string 对象 str 中,然后再将 buff 中的数据清空,清空数据之前将 buff 的末尾字符赋值为 '\0' ,以表示是字符串。以此往复,直到 c 为终止字符。跳出循环,对 buff 中剩余未满的字符进行处理,执行 while 循环中,同样的操作。这里 buff 的初始空间大小可以根据自己需求来规定。
2.11.3 getline 函数
getline 函数的原型为:istream& getline(istream& is, string& str, char delim = '\n') 。 getline 函数是非 string 成员函数,所以函数的声明需要写在 string 类的外部该函数与流提取运算符函数差不多,只是终止字符串可以自己定义,未定义时默认为 '\n' ,所以给 delim 缺省值'\n'。分析完毕,代码如下所示:
// getline 函数
istream& getline(istream& is, string& str, char delim)
{// 清除 str 中的数据str.clear();char c;size_t i = 0;char buff[128] = "";// 依次输入字符while (is.get(c)){if (c == delim){break;}if (i == 127){buff[i] = '\0';str += buff;i = 0;}buff[i++] = c;}if (i > 0){buff[i] = '\0';str += buff;}return is;
}
调用该函数,检验实现的函数是否能达到该函数本来的效果,检验结果如下图所示:
2.12 swap 函数,拷贝构造函数和赋值重载函数
2.12.1 swap 函数
swap 函数的函数原型为:void swap(string & x, string & y) ,作用为:交换字符串对象 x 和 y 的值。需要注意的是 std 中也有 swap 函数,这里 swap 函数的实现就是借助库中的 swap 。swap 是将两个对象的值交换,也就是将 m_str,m_size,m_capacity 都交换。具体代码如下所示:
// swap 函数
void string::swap(string& str)
{// 调用 std 库中的 swap 函数std::swap(m_str, str.m_str);std::swap(m_size, str.m_size);std::swap(m_capacity, str.m_capacity);
}
调用该函数,检验实现的函数是否能达到该函数本来的效果,检验结果如下图所示:
2.12.2 拷贝构造函数
这里介绍拷贝构造函数的两种实现方法。拷贝构造函数的原型为:string(const string& str) ,
(1) 第一种实现方法
拷贝构造函数的作用是将 str 对象中的数据拷贝到 *this 对象中。既然是拷贝,那么步骤为开辟新空间,拷贝旧数据,这里不需要释放旧空间,这里的旧空间指的就是str 的空间,拷贝构造函数并不会影响被拷贝对象,随即在更新 *this 对象的成员变量的数据。分析完毕,第一种实现方法的代码如下所示:
// 拷贝构造函数 —— 第一种实现方法
string::string(const string& str)
{// 开辟新空间m_str = new char[str.m_capacity + 1];// 拷贝旧数据memcpy(m_str, str.m_str, str.m_size + 1);// 更新变量的数据m_size = str.m_size;m_capacity = str.m_capacity;
}
(2) 第二种实现方法
第一种实现方法是自己开辟空间,自己拷贝数据,自己更新数据。其实不需要这么麻烦,可以借助其它的函数来实现这些操作,第二种实现方法的代码如下所示:
// 拷贝构造函数 —— 第二种实现方法
string::string(const string& str)
{string tmp(str.m_str);// 这里的 swap 函数是 this 在调用// 实际上是这样的 this->swap(tmp)swap(tmp);
}
定义一个 tmp 对象,让它调用构造函数,这里实际上是让 tmp 去开辟新空间和拷贝数据,然后*this 对象再调用 swap 函数,将 *this 与 tmp 对象中的数据交换,实现了更新成员变量数据的效果,当拷贝构造调用结束后,tmp 就被析构了。
对于这两种实现方法,推荐使用第二种。这两种方法在效率上并没有区别,只是写起来代码更简洁,该执行的操作一个都没有少,只是这些操作是由其它函数来实现的。
2.12.3 赋值重载函数
这里介绍赋值重载函数的两种实现方法。赋值重载函数的原型是:string& operator= (const string& str)
(1) 第一种实现方法
赋值重载函数的作用是将 str 对象的数据赋值给 *this 对象,改变 *this 对象原有的数据。这里的步骤与拷贝构造函数的差不多,只是多了一步,释放旧空间,将 *this 对象中的 m_str 的空间给释放掉,避免造成内存的泄漏。分析完毕,第一种实现方法的代码如下所示:
// 赋值运算符重载
string& string::operator= (const string& str)
{ // 避免自己给自己赋值if (*this != str){// 开辟新空间char* tmp = new char[str.m_capacity + 1];// 拷贝旧数据memcpy(tmp, str.m_str, str.m_size + 1);// 释放旧空间delete[] m_str;// 更新变量的数据m_str = tmp;m_size = str.m_size;m_capacity = str.m_capacity;return *this;}
}
(2) 第二种实现方法
与拷贝构造函数的第二种实现方法一致,借助其它的函数来实现开辟空间,拷贝数据,释放空间的操作,第二种实现方法的代码如下所示:
// 赋值重载函数
string& string::operator= (const string& str)
{if (*this != str){string tmp(str);swap(tmp);}return *this;
}
定义一个 tmp 对象,让它调用拷贝构造函数,开辟新空间,拷贝数据都是拷贝构造函数完成的,然后 *this 对象再调用 swap 函数,将 *this 与 tmp 对象中的数据交换,实现了更新成员变量数据的效果,而旧空间的释放就交给 tmp 对象来处理,反正当赋值构造函数调用结束时,会析构 tmp 。
对于这两种实现方法,推荐使用第二种。这两种方法在效率上并没有区别,只是写起来代码更简洁,该执行的操作一个都没有少,只是这些操作是由其它函数来实现的。
3. 结言
在博客的末尾需要提到一个问题,在拷贝数据的时候,为什么不能使用 strcpy 函数呢?其实有些函数的拷贝数据使用 strcpy 函数没有问题,但是有些函数使用 strcpy 函数来拷贝数据会有大问题,而所有的函数使用 memcpy 函数来拷贝数据没有问题,为了保持代码的一致性,所有的函数在拷贝数据时,都使用 memcpy 函数。为什么有些函数使用 strcpy 函数会出现问题呢?strcpy函数遇到’\0’,就会停止拷贝;而 memcpy 函数直接从源头拷贝到结尾才会停止,不会因为其它原因而停止拷贝。