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

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

💭 写在前面



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


Ⅰ.  深浅拷贝


0x00 引入:

我们先来实现 string 的构造和析构:

💬 string.h

namespace chaos {      // 命名空间
  class string {   
  public:
  string(const char* str) {
            // ...
  }
  ~string() {
            // ...
  }
  private:
  char* _str;
  };
}

这里为了和原有的 string 进行区分,我们搞一个命名空间给它们括起来。


❓ 思考一个问题,构造函数能不能这样初始化呢?


string(char* str)
  : _str(str) {}

这是不行的,因为你初始化这个 string 时,比如我们通常情况会这么写:

void test_string1() {
  string s1("hello world");
}

这是一个常量字符串,退一万步来讲,就算它不是常量字符串,它也是一个指针,


是不能被修改的,那我们后面要实现修改、插入删除,怎么扩容嘛?


你就只能对堆上的空间扩容了,所以是不能这么写的!那该怎么写呢?


💬 我们可以这么写:


string(const char* str)
    : _str(new char[strlen(str) + 1]) {    // 开strlen大小的空间
    strcpy(_str, str);
}

值得注意的是,这里要 strlen(str) + 1,因为 strlen 算的是有效字符的长度,没算 \0 。


💬 然后我们实现析构,用 new[] 对应的 delete[] 来析构:

~string() {
    delete[] _str;    // 释放空间
  _str = nullptr;   // 置空
}

我们来测试一下:


💬 string.h

#include <iostream>
using namespace std;
namespace chaos {
  class string {
  public:
  string(const char* str)
    : _str(new char[strlen(str) + 1]) {
    strcpy(_str, str);
  }
  ~string() {
    delete[] _str;
    _str = nullptr;
  }
  private:
  char* _str;
  };
  void test_string1() {
  string s1("hello world");
  }
}

💬 test.h

#include "string.h"
int main(void)
{
  chaos::test_string1();
  return 0;
}

🚩 运行结果如下:

da1f27467faf0fa6d7260e6b98bac9b9_97a742d6139c4b0086a9b4e66c7de5f2.png


💬 此时我们改一下测试用例 test_string1,如果我们要用 s1 拷贝构造一下 s2:


void test_string1() {
  string s1("hello world");
  string s2(s1);
}

🚩 运行结果如下:

2314bbbcda46d05bb58899ee285c5626_7b70c28f60144b2981ad4dd8781b5338.png

🔑 详细解析:

7b2c5bfbec61d60aae412ecad9d1e0c9_b399bd46ae414d5f9105f0dc35ffd2fd.png

f551c0673f6bbcd9faedb18bae981476_5821ff1eccb24910913b41f2e4f87bfe.png

❓ 如何解决这样的问题呢?


我们 s2 拷贝构造你 s1,本意并不是想跟你指向一块空间!


我们的本意是想让 s2 有一块自己的空间,并且能内容是 s1 里的 hello world

f7e0a90bbce65eeed4a476f81127e8bf_4bec3b550be246279b2c5b9ea1efbd79.png


所以这里就涉及到了深浅拷贝的问题,我们下面就来探讨一下深浅拷贝的问题。


0x01  深浅拷贝问题

举个最简单的例子 —— 拷贝就像是在抄作业!


浅拷贝:直接无脑照抄,连名字都不改。


           (直接把内存无脑指过去)


深拷贝:聪明地抄,抄的像是我自己写的一样。


           (开一块一样大的空间,再把数据拷贝下来,指向我自己开的空间)

308388d5ec2a26e7b29f9abe5fd7546c_9c4bff41ac5a4cb0808a646e24ab5985.png

浅拷贝就是原封不动地把成员变量按字节依次拷贝过去,


深拷贝就是进行深一个层次的拷贝,不是直接拷贝,而是拷贝你指向的空间。


0x02 拷贝构造的实现

我们之前实现日期类的时候,用自动生成的拷贝构造(浅拷贝)是可以的,


所以当时我们不用自己实现拷贝构造,让它默认生成就足够了。


但是像 string 这样的类,它的拷贝构造我们不得不亲自写。


💬 string 的拷贝构造:

/* s2(s1) */
string(const string& s)
  : _str(new char[strlen(s._str) + 1]) {
  strcpy(_str, s._str);
}

🔍 我们监视看一下效果:

e570492c988e70d22ff6395a7c449bcb_9dd17f7f0ed340388aa62f389ac1a6e5.png


0x03 赋值的深拷贝

47307ed262aa8c6a1d94fc8bc002058b_adea8fefc49945809f0c7ebcf8bf135f.png

💬 现在有一个 s3,如果我们想把 s3 赋值给 s1:


void test_string1() {
  string s1("hello world");
  string s2(s1);
  string s3("pig");
  s1 = s3;
}

如果你不自己实现赋值,就和之前一样,会是浅拷贝,也会造成崩溃:

b09d657da524676b882de54434a49528_17b1a0342a734f13944920d8c485575d.png


所以,我们仍然需要自己实现一个 operator= ,实现思路如下:

7bb4f7c65a35e0b12e7eafeaf6e71ac2_f9499a93657245b5a932d3a81846d58b.png


💬 代码实现 operator=


/* s1 = s3 */
string& operator=(const string& s) {
    if (this != &s) {  // 防止自己给自己赋值
  delete[] _str;                        // 释放原有的空间
  _str = new char[strlen(s._str) + 1];  // 开辟新的空间
  strcpy(_str, s._str);                 // 把s3的值赋给s1
  }
  return *this;
}

🔑 代码解析:


根据我们的实现思路,首先释放原有空间,然后开辟新的空间,


最后把 s3 的值赋值给 s1。为了防止自己给自己赋值,我们可以判断一下。


这时我们还要考虑一个难以发现的问题,如果 new 失败了怎么办?


抛异常!抛异常!抛异常!


失败了没问题,也不会走到 strcpy,但问题是我们已经把原有的空间释放掉了,


神不知鬼不觉地,走到析构那里二次释放可能会炸,所以我们得解决这个问题!


⚡ 我们可以试着把释放原有空间的步骤放到后面:

ffc006d30fbb6669f5b3680778d1d45d_513708b253d84d019c0bbd776b137aef.png

/* s1 = s3 */
string& operator=(const string& s) {
  if (this != &s) {  // 防止自己给自己赋值 
  char* tmp = new char[strlen(s._str) + 1];  // 开辟新的空间到tmp中
  strcpy(tmp, s._str);                       // 把s3的值赋给 tmp
  delete[] _str;                             // 释放原有的空间
  _str = tmp;                                // 把tmp的值赋给 s1
  }
  return *this;
}

🔑 代码解析:


这样一来,就算是动态内存开辟失败了,我们也不用担心出问题了。


这是更标准的实现方式,我们先去开辟空间,放到临时变量 tmp 中,


tmp 没有翻车,再去释放原有的空间,最后再把 tmp 的值交付给 s1,


这是非常保险的,有效避免了空间没开成还把 s1 空间释放掉的 "偷鸡不成蚀把米" 的事发生。


Ⅱ.  string 的实现


0x00 引入

刚才我们为了方便讲解深浅拷贝的问题,有些地方所以没有写全,


是没有考虑增删查改的问题的,所以我们现在要增加一些成员:

private:
  char*  _str;
  size_t _size;
  size_t _capacity;   // 有效字符的空间数,不算\0

0x01 成员函数 _size 和 _capacity

💬 加上 _size 和 _capacity 后,在刚才实现的 string 基础上修改完善:

#include <iostream>
using namespace std;
namespace chaos 
{
  class string {
  public:
  string(const char* str) 
  : _size(strlen(str)) 
  , _capacity(_size) {
  _str = new char[_capacity + 1];     // 多开一个空间给\0
  strcpy(_str, str);
  }
  /* s2(s1) */
  string(const string& s)
  : _size(s._size)
  , _capacity(s._capacity) {
  _str = new char[_capacity + 1];
  strcpy(_str, s._str);
  }
  /* s1 = s3 */
  string& operator=(const string& s) {
  if (this != &s) {                              // 防止自己给自己赋值
    char* tmp = new char[s._capacity + 1];     // 开辟新的空间到tmp中
    strcpy(tmp, s._str);                       // 把s3的值赋给 tmp
    delete[] _str;                             // 释放原有的空间
    _str = tmp;                                // 把tmp的值赋给 s1
    _size = s._size;
    _capacity = s._capacity;
  }
  return *this;
  }
  ~string() {
  delete[] _str; 
  _str = nullptr;
  _size = _capacity = 0;
  }
  private:
  char*  _str;
  size_t _size;
  size_t _capacity;   // 有效字符的空间数,不算\0
  };
}

为了减少 strlen 的次数,我们在初始化列表里只处理 _size 和 _capacity。


0x02 c_str() 的实现

📚 c_str() 返回的是C语言字符串的指针常量,是可读不写的。


💬 c_str 的实现:


/* 返回C格式字符串:c_str */
const char* c_str() const {
  return _str;
}

const char*,因为是可读不可写的,所以我们需要用 const 修饰。


c_str 返回的是当前字符串的首字符地址,这里我们直接 return _str 即可实现。


我们来测试一下:

void test_string1() {
  string s1("hello world");
  string s2;
  cout << s1.c_str() << endl;
}

🚩 运行结果如下:

a6b378d066b9565e6386c5af6a5909c3_67898b2b13e44ed68c85b9a60cae8fe4.png


(c_str 是认 \0 的,下面我们探讨不带参全缺省值给什么值的时候需要知道这个点)


0x03 全缺省构造函数

我们还要考虑不带参的情况,比如下面的 s2:

void test_string1() {
  string s1("hello world");    // 带参
  string s2;                   // 不带参
}

💬 不带参初始化:

string()
  : _str(new char[1])
  , _size(0)
  , _capacity(0) {
  _str[0] = '\0';
}

这里我们开一个空间给 \0,既然都这么写了,我们不如直接在缺省值上动手脚:

string(const char* str = "")
  : _size(strlen(str)) 
  , _capacity(_size) {
  _str = new char[_capacity + 1];     // 多开一个空间给\0
  strcpy(_str, str);
}

一般的类都是提供全缺省的,值得注意的是,这里缺省值给的是 " "


有人看到指针 char* 就突发恶疾,这里缺省值就忍不住想给个空 nullptr:


string(const char* str = nullptr)

不能给!给了就崩。因为 strlen 是不会去检查空的,它是去找 \0 ,


void test_string2() {
  string s1("hello world");
  string s2;
  cout << s1.c_str() << endl;
  cout << s2.c_str() << endl;
  }

也就相当于直接对这个字符串进行解引用了,这里的字符串又是空,所以会引发空指针问题。


所以我们这里给的是一个空的字符串 " ",常量字符串默认就带有 \0,这样就不会出问题:

string(const char* str = "")

b36739a1a850a304521e5ec732f002ba_d7b91c64ebc9440db7b3c47954389656.png


0x04 size() 和 operator[] 的实现

💬 size() 的实现:

size_t size() const {
  return _size;
}

size() 只需要返回成员函数 _size 即可,考虑到不需要修改,我们加上 const。


💬 operator[] 的实现:

/* operator[] */
char& operator[](size_t pos) {
  return _str[pos];  // 返回字符串对应下标位置的元素
}

直接返回字符串对应下标位置的元素,


因为返回的是一个字符,所以我们这里引用返回 char。


我们来测试一下,遍历整个字符串,这样既可以测试到 size() 也可以测试到 operator[] :

void test_string1() {
  string s1("hello world");
  string s2;
  for (size_t i = 0; i < s1.size(); i++) {
  cout << s1[i] << " ";
  }
  cout << endl;
}

🚩 运行结果如下:

96950f440578014f185525b65efc7f01_c292772a596440518bbc00ad86a17a5b.png


我们再来测试一下 operator[] 的 "写" 功能:

void test_string1() {
  string s1("hello world");
  string s2;
  s1[0] = 'F';
  for (size_t i = 0; i < s1.size(); i++) {
  cout << s1[i] << " ";
  }
  cout << endl;
}

a908440d2355cbbcce73c59327d984d9_8dc895b0cdbc43eaaee642691f6f9a6c.png

普通对象可以调用,但是 const 对象呢?所以我们还要考虑一下 const 对象。


💬 我们写一个 const 对象的重载版本:

const char& operator[](size_t pos) const {
  return _str[pos];
}

因为返回的是 pos 位置字符的 const 引用,所以可读但不可写。


💬 最后我们还需要考虑一下越界的问题,这里我们使用断言暴力处理一下:


#include <assert.h>
...
char& operator[](size_t pos) {
  assert(pos < _size);
  return _str[pos];
}
const char& operator[](size_t pos) const {
  assert(pos < _size);
  return _str[pos];
}


测试一下效果如何:


void test_string1() {
  string s1("hello world");
  s1[30];
}

2e02e7ee9d68ba321c0182c8fea51f1f_e2965a2f7e674942827637f9950c23ad.png

Ⅲ. 实现迭代器


0x00 引入 - 再探迭代器

在上一章中,我们首次讲解迭代器,为了方便理解,我们当时解释其为像指针一样的类型。

5dfb8881f25635f6eb3ce58e54e142e7_082297cd0b9746d09804702e512d577d.png

实际上,有没有一种可能,它就是一种指针呢?


遗憾的是,迭代器并非指针,而是类模板。 只是它表现地像指针,模拟了指针的部分功能。


0x01 迭代器的实现

实际上迭代器的实现非常简单,它就是一个 char* 的指针罢了(但也不一定)。


后面我们讲解 list 的时候它又™不是指针了,又是自定义类型了。如何评价?


我的评价是 —— 似是而非。


它是一个像指针的东西,有可能是指针有可能不是指针。


💬 实现迭代器的 begin() 和 end() :

typedef char* iterator;
iterator begin() {
  return _str;          // 返回第一个字符位置
}
iterator end() {
  return _str + _size;  // 返回最后一个数据的下一个位置
}

💬 我们来测试一下:


         

🚩 运行结果如下:

226e9d95bf471538a8bcaccc05a6b6ae_8bbbe736c04140c088b5a8d30be82268.png

0x02 const 迭代器的实现

我们知道,const 迭代器就是可以读但是不可以写的迭代器。


💬 const 迭代器:

typedef const char* const_iterator;
const_iterator begin() const {
  return _str;          // 返回第一个字符位置
}
const_iterator end() const {
  return _str + _size;  // 返回最后一个数据的下一个位置
}

这里用 const 修饰,意味着解引用时可以读但不可以写。


0x03 再度思考迭代器

它的底层是连续地物理空间,给原生指针++解引用能正好贴合迭代器的行为,就能做到遍历。


但是对于链表和树型结构来说,迭代器的实现就没有这么简单了。


但是,强大的迭代器通过统一的封装,无论是树、链表还是数组……


它都能用统一的方式遍历,这就是迭代器的优势,也是它的强大之处。


0x04 再探范围 for

be69297a208c7b6b57025dbdecbf9ba1_7dd4748e77504c18a235373bf681c391.png


上一章讲 string 类对象的遍历时,我们讲的第三种方式就是范围 for,回忆一下 ——

d809a42f8d41af725663851955365f13_68c5dbdee20b4944b36df1537d019efb.png

(五毛特效)


我们上一章提到过,我们现在就来演示一下范围 for 的实现:

for (auto e : s1) {
  cout << e << " ";
}
cout << endl;

你会发现根本就不需要自己实现,你只要把迭代器实现好,范围 for 直接就可以用。


范围 for 的本质是由迭代器支持的,编译时范围 for 会被替换成迭代器。


这么一看,又是自动加加,又是自动判断结束的范围 for,好像也没那么回事儿。


📌 注意事项:


它的替换是认 begin 和 end 的,我们可以试着把我们实现的迭代器 begin 的 b 改成大写 B 试试:

typedef char* iterator;
  iterator Begin() {
    return _str;         
  }
  iterator end() {
    return _str + _size;  
  }
void test_string2() {
  string s1("hello world");
  string::iterator it = s1.Begin();
  while (it != s1.end()) {
  *it += 1;
  it++;
  }
  it = s1.Begin();   // 重置起点
  while (it != s1.end()) {
  cout << *it << " ";
  it++;
  }
    for (auto e : s1) {
  cout << e << " ";
  }
  cout << endl;
}


迭代器是可以正常用的,但是范围 for 就寄了。

b268e63972d2db936d93689f1791e048_3114b2d86aed49c5b8a5d992024e270c.png


因为它是按迭代器固定的名称去替换的,begin 和 end,


如果你自己实现迭代器时没有按固定的规范去实现,


比如 begin 取名为 start,那范围 for 就不支持了。


相关文章
|
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每日一题”