Ⅳ. string的增删查改
0x00 reserve() 的实现
💬 我们先实现一下 reserve 增容:
/* 增容:reverse */ void reserve(size_t new_capacity) { if (new_capacity > _capacity) { char* tmp = new char[new_capacity + 1]; // 开新空间 strcpy(tmp, _str); // 搬运 delete[] _str; // 释放原空间 _str = tmp; // 没问题,递交给_str _capacity = new_capacity; // 更新容量 } }
这里可以检查一下是否真的需要增容,万一接收的 new_capacity 比 _capacity 小,就不动。
这里我们之前讲数据结构用的是 realloc,现在我们熟悉熟悉用 new,
还是用申请新空间、原空间数据拷贝到新空间,再释放空间地方式去扩容。
我们的 _capacity 存储的是有效字符,没算 \0,所以这里还要 +1 为 \0 开一个空间。
0x01 push_back() 的实现
💬 push_back:
/* 字符串尾插:push_back */ void push_back(char append_ch) { if (_size == _capacity) { // 检查是否需要增容 reserve(_capacity == 0 ? 4 : _capacity * 2); } _str[_size] = append_ch; // 插入要追加的字符 _size++; _str[_size] = '\0'; // 手动添加'\0' }
首先检查是否需要增容,如果需要就调用我们上面实现的 reserve 函数,
参数传递可以用三目操作符,防止容量是0的情况,0乘任何数都是0从而引发问题的情况。
然后在 \0 处插入要追加的字符 append_ch,然后 _size++ 并手动添加一个新的 \0 即可。
我们来测试一下效果如何:
void test_string4() { string s1("hello world"); cout << s1.c_str() << endl; s1.push_back('!'); cout << s1.c_str() << endl; s1.push_back('A'); cout << s1.c_str() << endl; }
🚩 运行结果如下:
0x02 append() 的实现
💬 append:
/* 字符串追加:append */ void append(const char* append_str) { size_t len = strlen(append_str); // 计算要追加的字符串的长度 if (_size + len > _capacity) { // 检查是否需要增容 reserve(_size + len); } strcpy(_str + _size, append_str); // 首字符+大小就是\0位置 _size += len; // 更新大小 }
append 是追加字符串的,首先我们把要追加的字符串长度计算出来,
然后看容量够不够,不够我们就交给 reserve 去扩容,扩 _size + len,够用就行。
这里我们甚至都不需要用 strcat,因为它的位置我们很清楚,不就在 _str + _size 后面插入吗。
用 strcat 还需要遍历找到原来位置的 \0,太麻烦了。
0x03 operator+= 的实现
这就是我们一章说的 "用起来爽到飞起" 的 += ,因为字符和字符串都可以用 += 去操作。
所以我们需要两个重载版本,一个是字符的,一个是字符串的。
我们不需要自己实现了,直接复用 push_back 和 append 就好了。
💬 operator+=
/* operator+= */ string& operator+=(char append_ch) { push_back(append_ch); // 复用push_back return *this; } string& operator+=(const char* append_str) { append(append_str); // 复用append return *this; }
测试一下看看:
void test_string5() { string s1("hello world"); cout << s1.c_str() << endl; s1 += '!'; cout << s1.c_str() << endl; s1 += "this is new data"; cout << s1.c_str() << endl; }
🚩 运行结果如下:
0x04 insert() 的实现
💬 insert:字符
/* 插入:insert */ string& insert(size_t pos, char append_ch) { assert(pos <= _size); // 检查是否需要增容 if (_size == _capacity) { reserve(_capacity == 0 ? 4 : _capacity * 2); } // 向后挪动数据 //size_t end = _size; //while (end >= (int)pos) { // _str[end + 1] = _str[end]; // end--; //} size_t end = _size + 1; while (end > pos) { _str[end] = _str[end - 1]; end--; } // 插入 _str[pos] = append_ch; _size++; return *this; }
💬 insert:字符串
string& insert(size_t pos, const char* append_str) { assert(pos <= _size); size_t len = strlen(append_str); // 检查是否需要增容 if (_size + len > _capacity) { reserve(_size + len); } // 向后挪动数据 size_t end = _size + len; while (end > pos + len - 1) { _str[end] = _str[end - len]; end--; } // 插入 strncpy(_str + pos, append_str, len); _size += len; return *this; }
测试一下:
void test_string6() { string s1("hello world"); cout << s1.c_str() << endl; s1.insert(0, 'X'); cout << s1.c_str() << endl; s1.insert(0, "hahahaha"); cout << s1.c_str() << endl; }
🚩 运行结果如下:
insert 都实现了,那 push_back 和 append 直接复用,岂不美哉?
⚡ 修改 push_back 和 append:
/* 字符串尾插:push_back */ void push_back(char append_ch) { //if (_size == _capacity) { // 检查是否需要增容 // reserve(_capacity == 0 ? 4 : _capacity * 2); //} //_str[_size] = append_ch; // 插入要追加的字符 //_size++; //_str[_size] = '\0'; // 手动添加'\0' insert(_size, append_ch); } /* 字符串追加:append */ void append(const char* append_str) { //size_t len = strlen(append_str); // 计算要追加的字符串的长度 //if (_size + len > _capacity) { // 检查是否需要增容 // reserve(_size + len); //} //strcpy(_str + _size, append_str); // 首字符+大小就是\0位置 //_size += len; // 更新大小 insert(_size, append_str); }
测试一下 push_back 和 append,和复用它们两实现的 operator+= 有没有问题:
void test_string4() { string s1("hello world"); cout << s1.c_str() << endl; s1.push_back('!'); cout << s1.c_str() << endl; s1.push_back('A'); cout << s1.c_str() << endl; s1.append("this is new data"); } void test_string5() { string s1("hello world"); cout << s1.c_str() << endl; s1 += "!"; cout << s1.c_str() << endl; s1 += "this is new data"; cout << s1.c_str() << endl; }
🚩 运行结果如下:
0x05 resize() 的实现
我们为了扩容,先实现了 reverse,现在我们再顺便实现一下 resize。
这里再提一下 reverse 和 resize 的区别:
resize 分给初始值和不给初始值的情况,所以有两种:
他们也是这么实现的。
但是我们上面讲构造函数的时候说过,我们可以使用全缺省的方式,这样就可以二合一了。
resize 实现的难点是要考虑种种情况,我们来举个例子分析一下:
如果欲增容量比 _size 小的情况:
因为标准库是没有缩容的,所以我们实现的时候也不考虑去缩容。我们可以加一个 \0 去截断。
如果预增容量比 _size 大的情况:
resize 是开空间 + 初始化,开空间的工作我们就可以交给已经实现好的 reserve,
然后再写 resize 的初始化的功能,我们这里可以使用 memset 函数。
💬 resize:
/* resize */ void resize(size_t new_capacity, char init_ch = '\0') { // 如果欲增容量比_size小 if (new_capacity <= _size) { _str[new_capacity] = '\0'; // 拿斜杠零去截断 _size = new_capacity; // 更新大小 } // 欲增容量比_size大 else { if (new_capacity > _capacity) { reserve(new_capacity); } // 起始位置,初始化字符,初始化个数 memset(_str + _size, init_ch, new_capacity - _size); _size = _capacity; _str[_size] = '\0'; } }
0x06 find() 的实现
💬 find:查找字符
/* find */ size_t find(char aim_ch) { for (size_t i = 0; i < _size; i++) { if (aim_ch == _str[i]) { // 找到了 return i; // 返回下标 } } // 找不到 return npos; }
遍历整个字符串,找到了目标字符 aim_ch 就返回对应的下标。
如果遍历完整个字符串都没找到,就返回 npos(找到库的来)。
💬 这个 npos 我们可以在成员变量中定义:
... private: /* 成员变量 */ char* _str; size_t _size; size_t _capacity; // 有效字符的空间数,不算\0 public: static const size_t npos; }; /* 初始化npos */ const size_t string::npos = -1; // 无符号整型的-1,即整型的最大值。 ... }
💬 find:查找字符串
size_t find(const char* aim_str, size_t pos = 0) { const char* ptr = strstr(_str + pos, aim_str); if (ptr == nullptr) { return npos; } else { return ptr - _str; // 减开头 } }
这里我们可以用 strstr 去找子串,如果找到了,返回的是子串首次出现的地址。如果没找到,返回的是空。所以我们这里可以做判断,如果是 nullptr 就返回 npos。如果找到了,就返回对应下标,子串地址 - 开头,就是下标了。
0x07 erase() 的实现
💬 erase:
/* 删除:erase */ string& erase(size_t pos, size_t len = npos) { assert(pos < _size); if (len == pos || pos + len >= _size) { _str[pos] = '\0'; // 放置\0截断 _size = pos; } else { strcpy(_str + pos, _str + pos + len); _size -= len; } return *this; }
测试一下:
void test_string7() { string s1("hello world"); cout << s1.c_str() << endl; s1.erase(5, 2); // 从第五个位置开始,删两个字符 cout << s1.c_str() << endl; s1.erase(5, 20); // 从第五个位置开始,删完 cout << s1.c_str() << endl; }
🚩 运行结果如下:
Ⅴ. 传统写法和现代写法
0x00 拷贝构造的传统写法
对于深拷贝,传统写法就是本本分分分地去完成深拷贝。
💬 我们刚才实现的方式,用的就是传统写法:
/* 拷贝构造函数:s2(s1) */ string(const string& s) // 拷贝构造必须使用引用传参,一般用const修饰 : _size(s._size) // 将s1的size给给s2 , _capacity(s._capacity) // 将s1的capacity给给s2 { _str = new char[_capacity + 1]; // 开辟空间 strcpy(_str, s._str); // 将s1字符串给给s2 }
这就是传统写法,非常的老实。
0x01 拷贝构造的现代写法
现在我们来介绍一种现代写法,它和传统写法本质工作是一样的,即完成深拷贝。
现代写法的方式不是本本分分地去按着 Step 一步步干活,而是 "投机取巧" 地去完成深拷贝。
💬 直接看代码:(为了方便讲解,我们暂不考虑 _size 和 _capacity)
// 现代写法 string(const string& s) : _str(nullptr) // 为tmp置空做准备 { string tmp(s._str); swap(_str, tmp._str); // 交换 }
现代写法的本质就是复用了构造函数。
我想拷贝,但我又不想自己干,我把活交给工具人 swap 来帮我干。妙啊!资本家看了都说好!
❓ 我们为什么要在初始化列表中,给 _str 个空指针:
string(const string& s) : _str(nullptr)
我们可以设想一下,如果我们不对他进行处理,那么它的默认指向会是个随机值。
这样交换看上去没啥问题,确实能完成深拷贝,但是会引发一个隐患!
tmp 是一个局部对象,我们把 s2 原来的指针和 tmp 交换了,那么 tmp 就成了个随机值了。
tmp 出了作用域要调用析构函数,对随机值指向的空间进行释放,怎么释放?
都不是你自己的 new / malloc 出来的,你还硬要对它释放,就可能会引发崩溃。
但是 delete / free 一个空,是不会报错的,因为会进行一个检查。
所以是可以 delete 一个空的,我们这里初始化列表中把 nullptr 给 _str,
是为了交换完之后, nullptr 能交到 tmp 手中,这样 tmp 出了作用域调用析构函数就不会翻车了。
🐞 我们来看看效果如何:
💬 如果还是不放心,我们还可以在析构函数那进行一个严格的检查:
/* 析构函数 */ ~string() { if (_str != nullptr) { delete[] _str; _str = nullptr; } _size = _capacity = 0; }
0x02 赋值重载的现代写法
💬 传统写法:
/* 赋值重载:s1 = s3 */ string& operator=(const string& s) { if (this != &s) { // 防止自己给自己赋值 char* tmp = new char[s._capacity + 1]; // Step1:先在tmp上开辟新的空间 strcpy(tmp, s._str); // Step2:把s3的值赋给tmp delete[] _str; // Step3:释放原有的空间 _str = tmp; // Step4:把tmp的值赋给s1 // 把容量和大小赋过去 _size = s._size; _capacity = s._capacity; } return *this; // 结果返回*this }
传统写法,全都自己干,自己开空间自己拷贝数据。
💬 现代写法:复用拷贝构造
/* 赋值重载:s1 = s3 */ string& operator=(const string& s) { if (this != &s) { string tmp(s); // 复用拷贝构造 swap(_str, tmp._str); } return *this; }
我们先通过 s3 拷贝构造出 tmp,这样 tmp 就是 _str 的工具人了。
tmp 里的 "pig" ,s1 看的简直是垂涎欲滴,我们让 tmp 和 s1 交换一下
交换完之后,正好让 tmp 出作用域调用析构函数,属实是一石二鸟的美事。
把 tmp 压榨的干干净净,还让 tmp 帮忙把屁股擦干净(释放空间)。
⚡ 还有更简洁的写法:
/* 赋值重载:s1 = s3 */ string& operator=(string s) { swap(_str, s._str); return *this; }
和上面的写法本质是一样的。这种写法不用引用传参,它利用了拷贝构造。
这里的形参 s 就充当了 tmp,s 就是 s3 的拷贝,再把 s1 和 s 交换。简直是物尽其用!
📌 注意:但是这种写法也有小缺点,可能会导致自己给自己赋值时地址被换。
你会发现我们这里没有加个 if 去判断自己给自己赋值的问题了。
因为这里没办法判断自己给自己赋值了。之前 s 就是 s3,this 就是 s1。
现在 this 还是 s1,但是 s 已经不是 s3 了,所以判断不到自己
if (this != &s) ?????? 👆 👆 s1 s1
所以这里加上 if 判断也没用。但是其实也没太大问题,谁会自己给自己赋值啊。
0x03 整体代码改进
我们现在再去考虑 _size 和 _capacity,我们来把之前写的传统写法都改成现代写法。
💬 拷贝构造函数:s2(s1)
/* 拷贝构造函数:s2(s1) */ string(const string& s) : _str(nullptr) // 为tmp置空做准备 , _size(0) , _capacity(0) { string tmp(s._str); swap(_str, tmp._str); swap(_size, tmp._size); swap(_capacity, tmp._capacity); }
💬 赋值重载函数:s1 = s3
/* 赋值重载:s1 = s3 */ string& operator=(string s) { swap(_str, s._str); swap(_size, s._size); swap(_capacity, s._capacity); return *this; }
这里也是进行交换的,真是跟 tmp 交换改成了跟 s 交换。
我们不如写一个 Swap 函数:
void Swap(string& s) { swap(_str, s._str); swap(_size, s._size); swap(_capacity, s._capacity); }
这样就很简单了 ——
/* 拷贝构造函数:s2(s1) */ string(const string& s) : _str(nullptr) // 为tmp置空做准备 , _size(0) , _capacity(0) { string tmp(s._str); Swap(tmp); // this->Swap(tmp); } /* 赋值重载:s1 = s3 */ string& operator=(string s) { Swap(s); return *this; }
0x04 总结
现代写法在 string 中体现的优势还不够大,因为好像和传统写法差不多。
但是到后面我们实现 vector、list 的时候,你会发现现代写法的优势真的是太大了。
现代写法写起来会更简单些,比如如果是个链表,传统写法就不是 strcpy 这么简单的了,
你还要一个一个结点拷贝过去,但是现代写法只需要调用 swap 交换一下就可以了。
现代写法更加简洁,只是在 string 这里优势体现的不明显罢了,我们后面可以慢慢体会。