string的增删改查模拟实现(简单版)【C++】
目录
1. string的私有成员
2. 构造函数和析构函数
2.1 构造函数
2.1.1 无参构造函数 和 带参构造函数
2.1.2 全缺省构造函数
2.2 析构函数
3. string遍历
3.1 size()、length() 和 operator[ ]
3.2 迭代器遍历
3.3 遍历测试
4. string的增删改查
4.1 insert
4.2 push_back 和 append
4.3 operator+=
4.4 erase
4.5 resize
4.6 swap
4.7 find
4.8 substr
5. 深浅拷贝问题
6. 比较关系运算符重载
6.1 operator==
6.2 operator<
6.3 其他关系运算符重载
7. 流插入、流提取和getline
7.1 流插入
7.2 流提取
7.3 getline
8. 源码
string的底层就是一个动态增长字符数组。首先在自己创建的mystring的命名空间中创建一个头文件string.h,再定义一个string类,同时创建一个test.cpp文件来调用测试。
1. string的私有成员
namespace mystring {class string {public://成员函数private://string的底层就是动态增长的字符数组char* _str; //指向一个动态分配的字符数组size_t _size; //字符串的实际长度(不包括'\0')size_t _capacity; //分配的内存容量};//测试函数void test_string1() {}
}
其中size_t是无符号整型。
2. 构造函数和析构函数
2.1 构造函数
使用构造函数来进行初始化,同时使用初始化列表
class string {public://成员函数//2.1 构造函数//无参构造函数string():_str(nullptr),_size(0),_capacity(0){}//带参构造函数string(const char* str) :_str(str),_size(strlen(str)),_capacity(strlen(str)){}private://string的底层就是动态增长的字符数组//1 私有成员char* _str; //指向一个动态分配的字符数组size_t _size; //字符串的实际长度(不包括'\0')size_t _capacity; //分配的内存容量};
这时会出现一个问题 如图:
- const char* 不能改成 char* 因为这样可能会导致参数字符串被改变,得不到原来想要的参数。
- 也不能把 私有成员 char* 改成 const char* ,因为我们想去修改字符串的时候,发现无法修改。
所以应该去开辟一个新空间,拷贝数据。正确方法:
//带参构造函数string(const char* str) :_str(new char[strlen(str)+1])//加1原因是为了存储'\0',与C语言风格字符串匹配,_size(strlen(str)),_capacity(strlen(str)){strcpy(_str,str); //拷贝数据}
这里发现调用了三次strlen,优化版本
错误示例:
//错误示例:string(const char* str):_size(strlen(str)),_str(new char[_size+1]), _capacity(strlen(str)){strcpy(_str, str); //拷贝数据}
所以 在构造函数上的初始化列表上的初始化顺序 最好和私有成员定义的顺序一致。
正确写法:
string(const char* str):_size(strlen(str)){_capacity = _size;_str = new char[_capacity + 1];strcpy(_str,str);}
2.1.1 无参构造函数 和 带参构造函数
//无参构造函数string():_str(nullptr),_size(0),_capacity(0){}string(const char* str):_size(strlen(str)){_capacity = _size;_str = new char[_capacity + 1];strcpy(_str,str);}
但是,当定义无参对象的时候仍有问题
string s1;
这里借助获取字符串的c_str()成员函数来检测
#include<iostream>namespace mystring {class string {public://成员函数//2.1 构造函数//无参构造函数string():_str(nullptr),_size(0),_capacity(0){}string(const char* str):_size(strlen(str)){_capacity = _size;_str = new char[_capacity + 1];strcpy(_str,str);}const char* c_str() {return _str;}private://string的底层就是动态增长的字符数组//1 私有成员char* _str; //指向一个动态分配的字符数组size_t _size; //字符串的实际长度(不包括'\0')size_t _capacity; //分配的内存容量};//测试函数void test_string1() {string s1;std::cout << s1.c_str() << std::endl;}
}
可以将无参构造函数和带参构造函数合并为一个全缺省构造函数(即所有参数都有默认值的构造函数)
2.1.2 全缺省构造函数
在写全缺省构造函数的时候也要注意:
string(const char* str = nullptr):_size(strlen(str)){_capacity = _size;_str = new char[_capacity + 1];strcpy(_str,str);}
strlen(nullptr) 这会导致程序崩溃,strlen的参数必须指向合法的C字符串(以 \0
结尾)
正确写法:
string(const char* str = "") //也可以"\0":_size(strlen(str)){_capacity = _size;_str = new char[_capacity + 1];strcpy(_str,str);}
2.2 析构函数
~string() {delete[] _str;_str = nullptr;_size = _capacity = 0;}
3. string遍历
3.1 size()、length() 和 operator[ ]
在成员函数中进行实现
//遍历size_t size() const{return _size;}size_t length() const{return _size;}//返回pos位置的字符 并且要可读可写char& operator[](size_t pos) //char& 返回引用 说明,{//断言检查有没有越界assert(pos < _size); //开销不大,因为会变成内联函数return _str[pos];//_str[pos]访问的是堆上面的空间,出了作用域还在,就说明可以使用引用返回// 返回值类型是引用,返回的不是拷贝,返回的是别名,就说明可读可写}
传统的c语言的越界检查是一种给抽查(结束位置进行,看变量有没有被修改) 而C++函数封装的时候,可以加上越界判断,使用assert(#include<assert.h>) |
3.2 迭代器遍历
迭代器是像指针一样,有可能是指针,也有可能不是指针,这里借助指针。
//成员函数//指针版本的迭代器typedef char* iterator;typedef const char* const_iterator;//普通对象版本iterator begin() {return _str;}iterator end(){return _str + _size;}//const对象版本const_iterator begin() const //第二个const是防止修改_str{return _str;}const_iterator end() const {return _str + _size;}
3.3 遍历测试
//测试访问void test_string1() {//string s1;//cout << s1.c_str() << endl;//测试遍历string s1 = "hello world";for (size_t i = 0; i < s1.size(); i++){cout << s1[i] << " ";}cout << "\n";for (size_t i = 0; i < s1.size(); i++){cout << ++s1[i] << " ";}cout << "\n";//普通对象迭代器遍历+修改string::iterator it1 = s1.begin();while (it1 != s1.end()) {cout << *it1 << " ";it1++;}cout << endl;it1 = s1.begin();while (it1 != s1.end()){cout << ++(*it1) << " ";it1++;}cout << endl;//const对象迭代器遍历const string s2("xxxx");string::const_iterator cit2 = s2.begin();while (cit2 != s2.end()) {cout <<*cit2<< " ";++cit2;}cout << "\n";//范围for,底层是替换成迭代器,从编译器角度只增加一个替换规则for (auto e : s1){cout << e << " ";}cout << '\n';//范围for根据对象类型 对应//在这里发现 当s是普通对象的时候会替换成普通迭代器,是const对象的时候就会替换成const迭代器const string s("xxxxx");for (auto e : s){cout << e << endl;}}
测试结果;
4. string的增删改查
在实现下方函数时,因为增加数据要开空间,所以先实现reserve。
//预分配好内存void reserve(size_t n) {//当n > _capacity 需要单独开空间if (n > _capacity) {//开新空间char* tmp = new char[n + 1];//拷贝数据strcpy(tmp,_str);//释放旧空间delete[] _str;//修改指针指向_str = tmp;//修改容量_capacity = n;}}
4.1 insert
这里模拟实现在pos位置插入字符串。
//在pos位置插入单个字符void insert(size_t pos , char ch) {//首先判断位置是否合法assert(pos <= _size); //这里pos == _size 相当于尾插//判断是否需要扩容if (_size == _capacity){reserve(_capacity == 0 ? 4 :2* _capacity);}//移动数据//先移动_size位置的'\0',之后在向前移动,直到移动完pos位置for (size_t i = 0; i != _size - pos + 1; i++){_str[_size + 1 - i] = _str[_size - i];}//插入数据_str[pos] = ch;//调整大小++_size;}//在pos位置插入字符串void insert(size_t pos , const char* str) {//先判断位置是否合法assert(pos <= _size);//判断是否需要进行扩容size_t len = strlen(str);if (len + _size >_capacity) {reserve(len + _size);}//移动数据// 错误例子//size_t end = _size;//while (end >= pos) //{// _str[end+len] = _str[end];// --end;//}//正确写法size_t end = _size + 1;while (end > pos) {_str[end-1 + len] = _str[end-1];--end;}//拷贝数据strncpy(_str+pos,str,len);_size += len;}
上述在移动插入单个字符的实现移动数据实现上 通过移动的数据个数来控制,不会出现头插的bug,而在 插入在字符串移动数据使用end指针来控制的时候,需要格外关注头插问题。
4.2 push_back 和 append
push_back 和 append都是在字符串的末尾进行追加。
//追加单个字符void push_back(char ch){//尾插,首先要判断是否需要扩容if (_size == _capacity) {reserve(_capacity == 0 ? 4 : 2*_capacity); //这样写是为了避免_capacity == 0的情况}//追加数据_str[_size] = ch;++_size;_str[_size] = '\0';}//追加字符串void append(const char* str) {size_t len = strlen(str);if (len + _size > _capacity) {reserve(len+_size);}//拷贝数据strcpy(_str+_size,str);_size += len;}
优化:当然也可以借助insert函数来实现
void push_back(char ch){insert(_size,ch);}void append(const char* str){insert(_size,str);}
4.3 operator+=
对于增加字符串,operator+=重载是经常用的。
string& operator+=(char ch){//这里可以直接调用push_backpush_back(ch);return *this;}string& operator+=(const char* str) {//直接调用appendappend(str);return *this;}
4.4 erase
这里简单实现一下erase常用的函数,在pos位置删除len个字符。
这里的nops是一个共有的静态const成员变量,属于整个类,属于每个对象 ,类内声明,类外初始化。
注意在实现erase函数的时候要注意参数
如果 len = npos 或者 pos+ len >= _size的时候,直接在pos位置加 \0,否则直接删除部分串即可。
但是 pos + len 会有溢出风险,len = npos的时候,就会出现的。
所以最好改写成 _size - pos <= len 。
//在pos位置删除len个字符void erase(size_t pos , size_t len = npos) {//判读删除位置是否合法assert(pos < _size);//pos位置后面全部删除if (len == npos || _size - pos <= len ) {_str[pos] = '\0';_size = pos;}else {//部分删除strcpy(_str+pos,_str+pos+len);_size -= len;}}
4.5 resize
调整字符长度,需要注意的就是调整小空间和调大的情况
- 调小:直接在字符串 n 的位置给 \0 表示该串结束
- 调大:先扩容,再进行填充字符ch,注意最后n位置要加\0表示结束
void resize(size_t n,char ch = '\0'){//调小的情况if (n < _size){_str[n] = '\0';}else {//调大的情况//先扩容,再在原串的末尾填充chreserve(n);for (size_t i = _size; i < n;i++) {_str[i] = ch;}_str[n] = '\0';}_size = n; //注意修改}
4.6 swap
swap具体有三种,算法库里面的swap,手动模拟实现的swap,string里面重载的swap。
标准库里面的swap:
//库里面的swap对象交换的底层是/*template<class T> void swap(T&a,T&b) {T c(a);a = b; b = c;}这里的代价就是:三次拷贝+一次析构*/
这使用整个模板有一定的代价,三次拷贝+一次析构。
手动实现的swap:
为了避免上述的代价,最简单的方式就是把对象里面的成员进行交换。
void swap(string & s) {//命名空间展开//using namespace std; 先去局部找,在类中和函数找,没有找到就去全局找//一般不会直接去命名空间找(除非 命名空间展开)//编译器只会向上找 , 先局部-> 全局,如果命名空间展开就是命名空间找std::swap(_str,s._str);std::swap(_size,s._size);std::swap(_capacity,s._capacity);}
有什么方式避免误用swap?
我们发现string里面 重载了一个 void swap(string& x,string&y){x.swap(y)}
有现成就用现成,没有就去调用模板的。
4.7 find
实现 查找在pos位置出现的字符或者字符串
- 查找字符:先判断pos位置是否合法,然后从pos位置开始查找,找到返回下标,找不到返回npos。
- 查找字符串:先判断pos位置是否合法,然后从pos位置开始查找( 这里使用strstr()函数进行查找。strstr( ) 找不到就会返回空,找到了就会返回指向原字符串中第一次出现的子串的指针找到。)找到返回下标(这里返回的下标通过 两个指针来实现),找不到返回npos。
//find chsize_t find(char ch,size_t pos = 0) const{//判断位置是否合法assert(pos < _size);//查找字符,从pos位置开始查找for (size_t i = pos; i < _size; i++){if (_str[i] == ch) {return i;}}return npos;}//find substrsize_t find(const char* substr, size_t pos = 0) const{//判断位置是否合法assert(pos < _size);//查找字串const char* p = strstr(_str+pos,substr);if (p) {//不为空就返回下标//指针-指针返回的就是两指针之间的距离,也就是下标return p - _str;}else {return npos;}}
4.8 substr
获取部分字串,根据标准库来模拟实现。也就是获取从pos位置后且长度为len的字串。
先判断pos位置是否合法。
当 len 为npos的时候或者 pos+len >=_size的时候,获取从pos位置开始所有字符。
否则获取 pos开始长度为len的字符串。
//获取部分字串string substr(size_t pos = 0,size_t len = npos) const{string sub;assert(pos < _size);if (len == npos || _size - len <= pos) {获取pos位置后的全部for (size_t i = pos; i < _size; i++){sub += _str[i];}}else {//获取从pos位置开始的len个for (size_t i = pos; i < pos + len;i++) {sub += _str[i];}}return sub;}
优化的地方:可以去复用append函数
5. 深浅拷贝问题
拷贝构造函数
在没有手动实现拷贝构造函数的情况下,进行拷贝构造,发现程序出现问题,从调试可以发现问题:
- 在没有写拷贝构造的情况下,编译器会自动默认生成的拷贝构造是浅拷贝。
- 浅拷贝会指向相同的空间
- 而且会析构同一空间两次。
- 当s1修改的时候,发现s也被修改了。
所以编译器生成的拷贝构造函数不合适,所以需要手动生成一个拷贝构造函数。
采用深拷贝的方式
//拷贝构造函数//s1(s)string(const string& s) {//深拷贝//另开一个空间,再把数据拷贝过来_str = new char[s._capacity + 1];strcpy(_str,s._str);_size = s._size;_capacity = s._capacity;}
再次调试验证一下:
拷贝的对象不再指向相同空间了。
赋值运算符重载
当我们按照上图的方式赋值的时候,发现也会出现浅拷贝的问题。
所以需要手动实现赋值运算重载。
传统写法版本
//赋值运算符重载//如果不手动实现就会出现浅拷贝的问题//传统写法string& operator=(const string & s) {//开新空间char* tmp = new char[s._capacity + 1];//拷贝数据strcpy(tmp,s._str);//释放旧空间delete[] _str;//指向tmp_str = tmp;_size = s._size;_capacity = s._capacity;return *this;}
现代写法
//拷贝构造函数的现代写法string(const string& s) {string tmp(s._str);swap(tmp);}
6. 比较关系运算符重载
6.1 operator==
一般会直接在类中来实现该成员函数
bool operator==(const string & s){int ret = strcmp(_str,s._str);//这里借助strcmp函数来实现return ret == 0;}
但是在比较的时候会出现问题:成员函数的左端必须是对象才可以。
解决方法:可以重载为 全局的关系运算符。
bool operator==(const string& s1,const string& s2){int ret = strcmp(s1.c_str(), s2.c_str());return ret == 0;}
6.2 operator<
这里借助strcmp函数来实现,strcmp(str1,str2) 当str1 < str2 的时候,会返回一个小于0的值。
bool operator<(const string& s1,const string& s2) {int ret = strcmp(s1.c_str(),s2.c_str());return ret < 0;}
6.3 其他关系运算符重载
可以复用上述的关系运算符,来实现其他关系运算符。
bool operator>(const string& s1,const string& s2) {return !(s1 == s2 && s1 < s2);}bool operator>=(const string& s1,const string& s2) {return !(s1 < s2);}bool operator<=(const string& s1,const string& s2){return !(s1 > s2);}bool operator!=(const string& s1,string& s2) {return !(s1 == s2);}
7. 流插入、流提取和getline
7.1 流插入
当直接给对象输入字符串的时候和直接用对象输出字符串的时候,发现程序报错。需要我们手动来模拟实现。这里使用标准输出流类来实现。
//流插入必须是全局的ostream& operator<<(ostream& out , const string& s) {//使用范围for拿到字符,在使用out来输出for (auto ch : s) {out << ch;}return out; //这里返回希望连续cout<< s << s1...}
发现这里没有使用友元,因为这里只是打印字符,不需要去访问私有成员,打印字符可以使用operator[ ]、范围for等来访问 。
所以,运算符流插入的重载 不一定 必须为是全局函数且为类的友元函数。
7.2 流提取
这里对一个对象输入一个新的字符串。
需要注意的是先提前清空缓冲区,或者提前清空原来的串,也就是新的字符串覆盖原来的串,这样才可以保证打印出来的是新的串,这里通过复用模拟实现的clear来实现。
void clear(){//内容清空,空间不变_size = 0;_str[_size] = '\0';}
标准库中的cin提取不到空格和换行,但是这里string中的流提取需要读到。这里使用istream类中的重载函数get。
//流提取istream& operator>>(istream& in , string& s) {s.clear();//把提取的字符放到s里面去char ch;ch = in.get();while (ch != ' ' && ch != '\n') {s += ch;ch = in.get();}return in;}
问题:
当输入比较长的字符串的时候,上述代码会频繁的进行扩容,字符换越长,扩容次数就越多,就会导致一系列的消耗。
如果使用reserve,虽然可以减少扩容,但是不确定要开多少的内存
上述如果给了128,但是提取字符串较少的时候,内存空间就开大了,有些浪费。
优化:
s.clear()//为避免频发库容 和 reserve的不合适char ch;ch = in.get();char buff[128];size_t i = 0;while (ch != ' ' && ch != '\n') {buff[i++] = ch;if (i == 127) {buff[127] = '\0';s += buff;i = 0;}ch = in.get();}if (i > 0){buff[i] = '\0';s += buff;}return in;
所以这里没有把提取的数据直接用string += 而是先放到一个buff数组里面,如果字符串比较短,例如3个字符遇到空格或者换行就结束,string对象直接+=buff 就可以。
如果提取的字符串大 _capacity就大,值得注意的是,字符拆较大的时候,借助buff一段一段加。
7.3 getline
getline是获取一行的字符串,包括中间的空格。这里只需要把流提取的 ch != ‘ ’ 去掉就可以。
istream& getline(istream& in,string& s) {s.clear();char ch;ch = in.get();while (ch != '\n'){s += ch;ch = in.get();}return in;}
getline同样也可以优化
istream& getline(istream& in,string& s) {//优化//避免频繁扩容 和 reserve的不合适s.clear();char ch;char buff[128];ch = in.get();size_t i = 0;while (ch != '\n') {buff[i++] = ch;if (i == 127){s += buff;i = 0;}ch = in.get();}if (i > 0){buff[i] = '\0';s += buff;}return in;}
8. 源码
详细源码 请猛戳 此处