【C++要笑着学】深浅拷贝 | string 模拟实现 | 传统写法与现代写法(二)

简介: 本章将正式介绍深浅拷贝,在模拟实现 string 的同时带着去理解深浅拷贝。我们模拟实现 string类不是为了造更好的轮子,而是为了去学习它,理解它的本质!你自己造一次,心里会更清楚,也有利于加深对 string 的理解。

Ⅳ.  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 开一个空间。

cbead7865085821f2e51f220f66cea75_d7f4cfc2b1e642a382bd5d7998f880ac.png


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;
  }

🚩 运行结果如下:

c1608e3fc4447fcea7d2da20a1ab0d69_97691753bc254c389ec260afca267cd4.png


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,够用就行。

09dae515012bc188c1cda5de6c3c1d40_57634f33f182448587e1e705245c5e04.png

这里我们甚至都不需要用 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;
  }

🚩 运行结果如下:

327cfaf33396c501de24a421481e3765_3c54dd2e7b2644cbbf76e401d64fa5d8.png

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;
  }

🚩 运行结果如下:

293c1a9016df8a7de54405d1edabd4a8_65f69790677a4675acc696fe47d4895c.png

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;
  }


🚩 运行结果如下:

image.png

0x05 resize() 的实现

我们为了扩容,先实现了 reverse,现在我们再顺便实现一下 resize。


这里再提一下 reverse 和 resize 的区别:


resize 分给初始值和不给初始值的情况,所以有两种:

6224215e4dc299df4686c5ef0294c5bb_fe85879a2cc84f6dadb3a70d33c17f1a.png

他们也是这么实现的。


但是我们上面讲构造函数的时候说过,我们可以使用全缺省的方式,这样就可以二合一了。


resize 实现的难点是要考虑种种情况,我们来举个例子分析一下:

886bf2e68cd27140f8a436a92b69ca73_33017672b77049d89976225cb829e3c2.png

如果欲增容量比 _size 小的情况:

a1ef99487c0fce67e2cc240b2c028482_465c385101634fd5bb7cf933d927e5b1.png

因为标准库是没有缩容的,所以我们实现的时候也不考虑去缩容。我们可以加一个 \0 去截断。


如果预增容量比 _size 大的情况:


resize 是开空间 + 初始化,开空间的工作我们就可以交给已经实现好的 reserve,


然后再写 resize 的初始化的功能,我们这里可以使用 memset 函数。

9d1cefccef14abbab30f0f44d54c5f91_3c9e63d4a3634dd5b129ebf445db2a70.png

💬 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;  // 减开头
  }
}

547979cd98f1b1845a215436812aefbc_0155aa43c71e43fb8081acdd46cc2a90.png

这里我们可以用 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;
  }

🚩 运行结果如下:

7ad7c138731a53c5526922f849f197f9_0556054bd5774e93b512865ae8f80d86.png


Ⅴ. 传统写法和现代写法


0x00 拷贝构造的传统写法

c5309c9765865ab04978576250a5e539_864b87e3ec59478c91fcb244a4856bbb.png

对于深拷贝,传统写法就是本本分分分地去完成深拷贝。


💬 我们刚才实现的方式,用的就是传统写法:


/* 拷贝构造函数: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);  // 交换
}

8091494996c4c173d5883df07b83a4d9_5b238f7ff977400f87d402cb977d3366.png

现代写法的本质就是复用了构造函数。


我想拷贝,但我又不想自己干,我把活交给工具人 swap 来帮我干。妙啊!资本家看了都说好!


❓ 我们为什么要在初始化列表中,给 _str 个空指针:

string(const string& s)
  : _str(nullptr)

我们可以设想一下,如果我们不对他进行处理,那么它的默认指向会是个随机值。

603e37a32fe260fb4eb4b8bad2e4b93c_cd94cae9e645484da17111e6d2d1debe.png

这样交换看上去没啥问题,确实能完成深拷贝,但是会引发一个隐患!


tmp 是一个局部对象,我们把 s2 原来的指针和 tmp 交换了,那么 tmp 就成了个随机值了。


tmp 出了作用域要调用析构函数,对随机值指向的空间进行释放,怎么释放?


都不是你自己的 new / malloc 出来的,你还硬要对它释放,就可能会引发崩溃。


但是 delete / free 一个空,是不会报错的,因为会进行一个检查。


所以是可以 delete 一个空的,我们这里初始化列表中把 nullptr 给 _str,


是为了交换完之后, nullptr 能交到 tmp 手中,这样 tmp 出了作用域调用析构函数就不会翻车了。


🐞 我们来看看效果如何:

5ec15c9ad0ef2802e0c12d0e5fc9980f_ce567ac514ea4e2d87069472daca81c5.png

💬 如果还是不放心,我们还可以在析构函数那进行一个严格的检查:


/* 析构函数 */
~string() {
  if (_str != nullptr) {
  delete[] _str;
  _str = nullptr;
  }
  _size = _capacity = 0;
}

0x02 赋值重载的现代写法

0780389bac11a04ee97797af7f92ba84_99348f95ac52429ab51061824750337a.png

💬 传统写法:


/* 赋值重载: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;
}

4178030775d0838b0fd73c88c2e0440f_fb815c99368c436faebb9258276657dc.png

我们先通过 s3 拷贝构造出 tmp,这样 tmp 就是 _str 的工具人了。


tmp 里的 "pig" ,s1 看的简直是垂涎欲滴,我们让 tmp 和 s1 交换一下

95880747fdf58cb3bf374d5cf53ce520_65e4897894564d63ad04fea0caca6f4d.png

交换完之后,正好让 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 这里优势体现的不明显罢了,我们后面可以慢慢体会。

相关文章
|
7天前
|
编译器 C++
【C++进阶(三)】STL大法--vector迭代器失效&深浅拷贝问题剖析
【C++进阶(三)】STL大法--vector迭代器失效&深浅拷贝问题剖析
|
7天前
|
算法 Linux C语言
【C++进阶(一)】STL大法以及string的使用
【C++进阶(一)】STL大法以及string的使用
|
7天前
|
C++
【C++】std::string 转换成非const类型 char* 的三种方法记录
【C++】std::string 转换成非const类型 char* 的三种方法记录
5 0
|
8天前
|
C++
c++的学习之路:11、string(3)
c++的学习之路:11、string(3)
16 0
|
8天前
|
编译器 C++
c++的学习之路:10、string(2)
c++的学习之路:10、string(2)
26 0
|
8天前
|
存储 算法 C语言
c++的学习之路:9、STL简介与string(1)
c++的学习之路:9、STL简介与string(1)
22 0
|
11天前
|
编译器 C++
【C++】模拟实现string
【C++】模拟实现string
|
11天前
|
存储 安全 C语言
【C++】string类
【C++】string类
|
13天前
|
C语言 C++ Windows
标准库中的string类(下)——“C++”
标准库中的string类(下)——“C++”
|
存储 编译器 Linux
标准库中的string类(中)+仅仅反转字母+字符串中的第一个唯一字符+字符串相加——“C++”“Leetcode每日一题”
标准库中的string类(中)+仅仅反转字母+字符串中的第一个唯一字符+字符串相加——“C++”“Leetcode每日一题”