string类的简单实现
- 前言
- 1、基本成员
- 2、构造方法和析构方法
- 2.1无参构造
- 2.2有参构造
- 2.3析构函数
- 2.4拷贝构造函数
- 3、遍历方式
- 3.1operator [ ]
- 3.2iterator
- 3.2.1正向迭代器
- 3.2.2const正向迭代器
- 3.3范围for
- 4、常用方法,运算符重载
- c_str()
- size()
- reverse()
- push_back()
- pop_back()
- append()
- operator+=
- operator<<
- insert()
- eryes()
- find()
- substr()
- 关系运算符重载
- operator=
- string::swap()
- swap()
前言
本文结合C语言的函数,对其C++的STL中string类的常用接口进行简单实现,以更好的通过底层来理解string类。
本文实现的方法都是进行了声明定义分离。
1、基本成员
通过对于string类的学习,我们知道string类的基本成员变量是包含一个指向数组的指针变量,一个记录字符串大小的变量。一个记录字符串容量大小的变量。
代码如下:
namespace dyj
{class string{// 成员变量private:char* _str; // 指向数组的指针int _size; // 记录字符串大小int _capacity; // 记录字符串容量// 成员函数public:// ......};
}
上述代码片段使用了命名空间,这里使用命名空间怕和库中的string冲突,或者和外部其他代码命名冲突,所以使用命名空间将我自己的string圈起来,这样就不会产生命名冲突了。
2、构造方法和析构方法
2.1无参构造
在使用无参构造的时候,size与capacity都是0,并且大小都是不包含\0字符的,所以初始化成0;但是str有一点不一样,str是指向数组的指针,这个字符数组和C语言的字符数组一样,末尾是存在一个\0的,用来标识字符串的结束,故而不能来用nullptr来初始化。
代码如下:
// 无参构造string::string():_str(new char[1]{'\0'}),_size(0),_capacity(0){}
2.2有参构造
这里有参构造,_str(char*)若直接使用传递过来的str(const char*)进行初始化,则是一个权限的放大,所以不能直接进行初始化,所以思路是先动态申请和str相同大小的空间,申请空间后使用strcpy函数进行拷贝,从而完成初始化工作;_size和_capacity使用的是传递过来的str的大小来初始化。
代码如下:
// 有参构造string::string(const char* str):_size(strlen(str)){_str = new char[_size + 1];_capacity = _size;// strcpy函数是会靠拷贝 \0 的strcpy(_str, str);}
上述代码使用了初始化列表和函数体内部初始化混合使用,并且_str和_capacity的大小由_size替换,这样可以只使用一次strlen函数,从而提高效率。
创建一个由有参构造生成的对象:
也验证了string类的对象串的末尾也和C语言一样存在一个\0字符。
其实有参和无参的构造可以合并成一个,使用缺省值即可。
合并代码:
// 无参有参相结合 --- 缺省参数string(const char* str = ""); // 在这里加一个空字符即可
这里的缺省值不能是\0字符,\0字符是一个char类型的,不能转换成const char*类型。
2.3析构函数
代码如下:
// 析构函数string::~string(){delete[] _str; // 配对使用_str = nullptr;_size = 0;_capacity = 0;}
这里_str使用的是动态开辟数组的形式进行初始化,所以这里析构的时候要配对使用。
2.4拷贝构造函数
代码如下:
// 拷贝构造string::string(const string& str){// 传统写法:_str = new char[str._capacity + 1];memcpy(_str, str._str, str._size + 1);_size = str._size;_capacity = str._capacity;// 现代写法:string temp(str._str); swap(temp); }
使用构造函数创建中间temp对象,再进行交换,只是表达更加简洁,没有进行效率的提升。
而且这里的swap函数并不是库里重载的全局函数,而是自己写的swap函数。
3、遍历方式
3.1operator [ ]
功能:此重载(有两个版本)是返回底层数组第i个位置所在的字符。
代码如下:
// operator[] --- 两种版本// 此版本可读可写,服务于普通对象char& string::operator[](size_t i) {assert(i < _size); // 可以检查越界操作return _str[i];}// 此版本只可读不可写,服务于const对象const char& string::operator[](size_t i) const {assert(i < _size);return _str[i];}
测试:
3.2iterator
功能:此为迭代器,STL中主流的遍历方式。
别看名字高大上,其实底层就是像指针(形态之一)一样的东西。
3.2.1正向迭代器
按照像指针的形式,所以前面有一个重命名:typedef char* iterator;
,这个是放在自己实现的string类里面重命名。
代码如下:
// iteratorstring::iterator string::begin(){// 即返回初始位置的迭代器 --- 下标为0的元素的位置return _str;}string::iterator string::end(){// 即返回末尾位置的迭代器 --- 最后的\0的位置return _str + _size;}
测试:
3.2.2const正向迭代器
const正向迭代器套路和上述差不多,需要重命名一个:typedef char* const_iterator;
,同样定义在自己实现的string类里面。
代码如下:
// const_iteratorstring::const_iterator string::begin() const{return _str;}string::const_iterator string::end() const{return _str + _size;}
测试:
3.3范围for
功能:也是一种遍历方式,C++11引入。
其底层是由迭代器实现的。
代码如下:
// 范围forfor (auto ch : s2) {std::cout << ch;}std::cout << std::endl;
测试:
4、常用方法,运算符重载
c_str()
功能:返回其底层指向数组的指针。
代码如下:
// c_str() --- 返回底层指向数组的指针const char* string::c_str() const{return _str;}
测试:
size()
功能:返回字符串的大小(不包含\0)。
代码如下:
// size() --- 返回字符串的大小size_t string::size() const{return _size;}
测试:
reverse()
功能:进行扩容或者缩容操作,但是这里只实现其扩容功能,缩容的不实现。
代码如下:
void string::reverse(size_t n){if (n > _capacity){// 手动扩容 --- 手动申请一块新空间char* str = new char[n + 1]; // 加一为了预留\0的空间memcpy(str, _str, n + 1);// 释放旧空间delete[] _str;// 指向新空间_str = str;_capacity = n;}}
第一步的n > _capacity为了进行判断,如果需要的空间大于_capacity,则进行扩容操作,不过C++没有像C语言配套的扩容(realloc)接口,所以就只能自己来手动扩容。
三步骤:
(1)申请一块扩容后的新空间,注意预留 \0 的空间。
(2)将旧空间释放,注意配套使用delete[ ]。
(3)再将指针指向新空间,容量改为新容量。
注意点:拷贝旧字符串到新字符串不要直接使用strcpy函数,因为strcpy函数进行拷贝时检测到 \0 字符后就停止拷贝,我们都知道插入的字符可以是 \0 ,即字符串中间也可能出现 \0,这样一来,若后续还需插入新的字符串或者字符,那后续的内容就拷贝不进去了,所以说这里需要进行一个字符一个字符的拷贝,以免出现上述情况;同样也别忘记末尾的 \0 字符。
push_back()
功能:在字符串末尾插入一个字符。
代码如下:
// push_back()void string::push_back(char ch){// 判断是否需要扩容 --- size与capacity进行比较if (_size >= _capacity){size_t newcapacity = _capacity == 0 ? 4 : 2 * _capacity;reverse(newcapacity);}_str[_size++] = ch;_str[_size] = '\0'; // 别忘记\0}
上述代码和我们之前的使用C语言实现基本的数据结构的思路是一样的,先判断是否需要扩容,再将数据插入进串尾,不过需要注意的是串本身带有一个\0,别忘记将其添加在末尾。
pop_back()
功能:尾删一个字符。
代码如下:
// pop_back()void string::pop_back(){// 不能对空串进行尾删操作assert(_size > 0);--_size;_str[_size] = '\0';}
append()
功能:在字符串末尾插入一字符串,这里只实现插入常量字符串的重载。
代码如下:
// append()void string::append(const char* str){// 首先记录插入的字符串的长度size_t len = strlen(str);// 再判断是否需要扩容 --- 有插入短串,长串的区别if (_size + len > _capacity){size_t newcapacity = 2 * _capacity > _size + len ? 2 * _capacity : _size + len;reverse(newcapacity);}// (1)// 将插入的串一个字符一个字符的插入for (size_t i = 0; i < len; i++){_str[_size++] = str[i];}_str[_size] = '\0'; // 最后别忘记 \0// (2)// 或者使用memcpy函数进行拷贝memcpy(_str + _size, str, len + 1);_size += len; // 添加完字符串后_size会改变大小}
如果插入的是一个短串,push_back()里面的2倍扩容机制是适用的,但是插入长串旧不一样了,若长串特别特别大,那就需要N多次的2倍扩容,效率不高。
所以这里重新设计一个扩容机制:
对插入串之后的大小跟容量进行比较,大于则需要进行扩容,若2倍扩容后大于了插入串之后的大小,则表明插入的是一个较短的串,进行2倍扩容即可;若2倍扩容后小于了插入串之后的大小,则表明插入的是一个较长的串,就扩容插入串之后的大小。
拷贝方法代码中任选其一。
operator+=
功能:字符串末尾插入一个字符或字符串。
代码如下:
// operator+=string& string::operator+=(char ch){push_back(ch);return *this;}string& string::operator+=(const char* str){append(str);return *this;}
实质上是对于append和push_back的复用。
operator<<
功能:流插入,用于输出不同类型的对象,其只能重载成为一个全局函数。
代码如下:
// operator<<ostream& operator<<(ostream& out, const string& str){//out << str.c_str(); // 错误的输出,因为c_str是返回指向的数组,数组一遇到\0就会停止,所以中间若添加// \0字符,则会输出不完全。// 正确的是一个一个输出:for (size_t i = 0; i < str.size(); i++){out << str[i];}return out;}
因为<<运算符的操作数为两个,若重载成成员函数,则this指针会占用左操作数的位置,而左操作数只能是ostream类的对象,不能是string类的对象,第二个参数才是string类的对像,所以这里的<<重载只能是全局函数。
insert()
功能:在pos位置插入字符或者字符串,这里只实现插入单个字符的字符串的重载。
(1)插入单个字符
代码如下:
// insertvoid string::insert(size_t pos, char ch){assert(pos <= _size); // 判断pos位置是否合法// 先判断扩容 --- 单个字符两倍扩容足够if (_size >= _capacity){size_t newcapacity = _capacity == 0 ? 4 : 2 * _capacity;reverse(newcapacity);}// 挪动数据 --- 腾出pos位置size_t end = _size + 1;while (end > pos){_str[end] = _str[end - 1];--end;}// 找到pos位置 --- 插入ch_str[pos] = ch;++_size;}
插入单个字符的图解:
(2)插入字符串
代码如下:
void string::insert(size_t pos, const char* str){// p判断pos位置是否合法assert(pos <= _size);size_t len = strlen(str);// 再判断是否需要扩容 --- 插入短串(两倍扩容),长串(需要多少扩多少)if (_size + len > _capacity){size_t newcapacity = 2 * _capacity > _size + len ? 2 * _capacity : _size + len;reverse(newcapacity);}// 挪动数据 --- 找到pos位置size_t end = _size + len;while (end > pos + len -1){_str[end] = _str[end - len];--end;}// 找到了pos位置 --- 插入strfor (size_t i = 0; i < len; i++){_str[pos + i] = str[i];}_size += len; // 一样的别忘记了更新大小}
插入字符串的图解:
eryes()
功能:删除指定位置开始,len长度的字符或者字符串。
代码如下:
// erase()void string::erase(size_t pos, size_t len){// pos要合法 --- 删除时_size没意义assert(pos < _size);// 当len取缺省值npos,或者len大于pos位置开始后续的串的长度(左闭右开区间,// 直接右减左就是区间长度),此时直接删完。if (len == npos || len >= (_size - pos)){_size = pos;_str[pos] = '\0';}else{size_t i = pos + len;memmove(_str + pos, _str + i, _size + 1 - i);_size -= len;}}
memmove函数图解:
这里使用memmove函数也可以防止内存重叠。
find()
功能:从字符串中查找满足给定字符或者字符串的起始下标
代码如下:
// find()// 查找字符size_t string::find(char ch, size_t pos) const{// pos位置要合法assert(pos < _size);for (size_t i = pos; i < _size; i++){// 找到返回下标if (_str[i] == ch){return i;}}// 没找到返回nposreturn npos;}// 查找子串size_t string::find(const char* str, size_t pos) const{// pos位置要合法assert(pos < _size);// 暴力匹配const char* ptr = strstr(_str + pos,str);if (ptr == nullptr){return npos;}else// 指针减指针返回其之间的元素个数,也就是find到的子串的起始下标return ptr - _str; }
测试:
substr()
功能:从pos位置开始返回len个字符。
代码如下:
// substrstring string::substr(size_t pos, size_t len) const{if (len == npos || len >= (_size - pos)){// 取到尾len = _size - pos;}string ret;ret.reverse(len);for (size_t i = 0; i < len; i++){ret += _str[pos + i];}return ret;}
此函数是一个传值返回,所以返回的是ret的拷贝(临时对象),而此string类中还未实现拷贝构造,所以这里使用的是编译器默认生成的拷贝构造,但是,满足不了需求,因为是浅拷贝,会导致ret的拷贝(临时对象)也指向了ret指向的数组,出函数,ret销毁,ret的拷贝指向的数组也销毁了,所以这里成为了野指针的情形,故我们要重新自己写一个深拷贝的构造函数(此函数在上文2.4),来实现需求。
测试:
关系运算符重载
功能:对string类型实例化的对象进行比较大小,只需要实现(1)和(5)即可,剩下的直接使用这两个来复用。
(1)operator <
代码如下:
// s1 < s2 --- 比较同一位字符的ASCII值大小bool string::operator<(const string& str) const{size_t i1 = 0, i2 = 0;while (i1 < _size && i2 < str._size){// 一般情况:if (_str[i1] < str[i2]){return true;}else if (_str[i1] > str[i2]){return false;}else{++i1, ++i2;}}// 特殊情况://(1) "hello" "hello" --->false//(2)"helloD" "hello" --->false//(3) "hello" "helloD" --->truereturn i2 < str._size;}
特殊情况下,i2小于自身字符串的长度时,即代表真,也就是_str < str,因为当为特殊情况1时两串前面字符都相同,i1,i2同时结束记录,此时i2等于自身字符串的长度,为假,返回false;当为特殊情况2时,i2先结束记录,此时i2还是等于自身字符串的长度,为假,返回false;当为特殊情况3时,i1先结束记录,此时i2还要字符没有记录,所以i2小于自身字符串的长度,为真,返回true。
(2)operator <=
代码如下:
bool string::operator<=(const string& str) const{return *this < str || *this == str;}
(3)operator >
代码如下:
bool string::operator>(const string& str) const{return !(*this <= str);}
(4)operator >=
代码如下:
bool string::operator>=(const string& str) const{return !(*this < str);}
(5)operator ==
代码如下:
bool string::operator==(const string& str) const{size_t i1 = 0, i2 = 0;while (i1 < _size && i2 < str._size){if (_str[i1] != str[i2]){return false;}else{++i1, ++i2;}}// i1,i2都走到尾了才为真return i1 == _size && i2 == str._size;}
只有当i1,i2同时记录结束,也就是都等于自身字符串长度时才为真,其余则为假。
(6)operator !=
代码如下:
bool string::operator!=(const string& str) const{return !(*this == str);}
operator=
功能:对string类型实例化的对象进行赋值操作。
代码如下:
// operator=string& string::operator=(const string& str){// 不能自己给自己赋值if (this != &str){// 传统写法:char* temp = new char[str._capacity + 1];memcpy(temp, str._str, str._size + 1);delete[] _str;_size = str._size;_capacity = str._capacity;// 现代写法:string temp(str._str);swap(temp);}return *this;}
这里现代写法道理同拷贝构造的现代写法。
string::swap()
功能:交换两个串,这是string类成员函数swap()。
代码如下:
// swapvoid string::swap(string& str){// 这里的内部swap是库里的全局函数std::swap(_str, str._str);std::swap(_size, str._size);std::swap(_capacity, str._capacity);}
swap()
功能:交换两个串,这是全局函数swap()。
代码如下:
// swap()void swap(string& x,string& y){x.swap(y);}
上述两种swap功能上一样,不过定义出来都有意义的,首先算法库里面也是有swap函数的,不过其实现使用了很多的深拷贝以及模板,同常情况下不会使用算法库里的swap,所以就有了成员函数以及全局函数的swap,而且这两个的定义也是为了使用者在不了解有成员函数的情况下写成了全局函数,前面不是说算法库里的swap是模板吗,那么全局函数的swa就是指定了参数,更适合调用,因为有模板和指定参数的情况下,肯定会带调用那个更具体的函数。所以这里重载两种swap都是为了不去调用算法库里面的swap函数。