【C++】STL之string类模拟-1

简介: 【C++】STL之string类模拟

string的模拟实现

对STL中的string类有了一个基本的认识后,本模块,我会带着你从0 ~ 1去模拟一下s库中string的这些接口,当然是比较常用的一些,代码量大概600行左右

1、前情提要

  • 首先第一点,为了不和库中的string类发生冲突,我们可以在外层包上一个名称为bit的命名空间,此时因为作用域的不同,就不会产生冲突了,如果这一块有点忘记的同学可以再去看看 namespace命名空间
namespace bit
{
  class string {
  public:
    //...
  private:
    size_t _size;
    size_t _capacity;
    char* _str;
  };
}

接下去呢,就在测试的test3.cpp中包含一下这个头文件,此时我们才可以在自己实现的类中去调用一些库函数

#include <iostream>
#include <assert.h>
using namespace std;
#include "string.h"

2、Member functions —— 成员函数

构造函数

好,首先第一个我们要来讲的就是【构造函数】

  • 首先我们从无参的构造函数开始讲起,看到下面的代码,你是否有想起了 C++初始化列表,我们默认给到 _size_capacity 的大小为,然后给字符数组开了一个大小的空间,并且将其初始化为\0
// 无参构造函数
string()
  :_size(0)
  , _capacity(0)
  ,_str(new char[1])
{ 
  _str[0] = '\0';
}
  • 然后我们立即来测试一下,因为我们自己实现的 string类 是包含在了命名空间bit中的,那么我们在使用这个类的时候就要使用到 域作用限定符::
bit::string s1;

然后打印一下这个string对象发现是一个空串

image.png

  • 有无参,那一定要有带参的,可以看到这里我们在初始化_size的时候先去计算了字符串str的长度,因为_size取的就是到 \0 为止的有效数据个数(不包含\0),那么【strlen】刚好可以起到这个功能
  • 然后在_str这一块,我们为其开出的空间就是 ==容量的大小 + 1==,最后的话还要在把有效的数据拷贝到这块空间中,使用到的是【strcpy】
// 有参构造函数
string(const char* str)
  : _size(strlen(str))
  , _capacity(_size)
  ,_str(new char[_capacity + 1])
{
  // 最后再将数据拷贝过来
  strcpy(_str, str);  
}
  • 同样地来进行一个测试

image.png💬 不过呢,我这里再给出一个改进的版本

  • 此处没有使用到初始化列表,而是在直接写在函数体内,注意观察这里的形参部分,这里运用到的知识点为 C++缺省参数,如果忘记了的同学记得去回顾一下
  • 如果外界在构造对象的时候不进行传参,此时使用的便是这个默认的参数,“”代表的是一个空的字符串,但是无论怎样,对于一个字符串来说末尾是一定有\0,此刻你可以将它带入下面的表达式,发现算出后的结果与前面无参是一样的
// 构造函数
string(const char* str = "")
{
  _size = strlen(str);
  _capacity = _size;
  _str = new char[_capacity + 1];
  memcpy(_str, str, _size + 1);
}
  • 可能有的读者注意到了这个 memcpy(),如果有度过 字符串函数与内存函数 一文的话就可以清楚它们的区别在哪里了,对于strcpy()来说拷贝到\0就会发生终止而不会拷贝了,这是我在测试一些极端场景的时候考虑到的

可以看到换回【strcpy】的时候\0后面的内容就不会去进行一个拷贝了,不过这里其实体现得不是很明显,我们在下面的 拷贝构造、赋值运算符重载 中会继续提到这个

image.png💬 有同学觉得上面的缺省参数很是奇妙,于是提出能不能写成下面这样

  • 这肯定是不可以的,从运行结果我们可以看出虽然运行出来也是空串的结果,但是这么写的话总归不太好
string(const char* str = "\0")

image.png

  • 但是呢对于下面这种就更不可以了,因为这在调用【strlen】的时候就会触发 ==空指针异常== 的问题
string(const char* str = nullptr)

image.png

拷贝构造函数

马上,我们就来聊聊有关【拷贝构造函数】的内容

  • 深度探索类的六大天选之子 中我们有提到过若是一个类在没有显示定义拷贝构造对于内置类型不做处理,而对于自定义类型会去调用 类中默认提供的拷贝构造函数 此时就会造成浅拷贝的问题

image.png

  • 我们可以通过调试来浅浅地看一下,便可以看出浅拷贝所带来的危害,光是在调用析构这一块就出现了 ==二次析构== 的问题

  • 所以我们要自己去做一个实现,可以看到我们这里在进数据的拷贝时也是使用到了memcpy()
string(const string& s)
{
  _str = new char[s._capacity + 1];
  memcpy(_str, s._str, s._size);
  _size = s._size;
  _capacity = s._capacity;
}
  • 通过调试再去观察的话,我们可以发现,此时 对象s1 和 对象s2 中的数据存放在不同的空间中,此时去修改或者是析构的话都不会受到影响

image.png

下面呢还有一个新的版本,这一块我放到【赋值重载】去进行讲解

// 拷贝构造函数(新版本)
string(const string& s)
  : _str(nullptr)
  , _size(0)
  , _capacity(0)
{
  string tmp(s._str);
  // tmp出了当前函数作用域就销毁了,和this做一个交换
  this->swap(tmp);
}

赋值运算符重载

对于赋值运算符重载这一块我们知道它也是属于类的默认成员函数,如果我们自己不去写的话类中也会默认地生成一个

  • 但是呢默认生成的这个也会造成一个 ==浅拷贝== 的问题。看到下面图示,我们要执行s1 = s3,此时若不去开出一块新空间的话,那么s1s3就会指向一块同一块空间,此时便造成了下面这些问题
  • 在修改其中任何一者时另一者都会发生变化;
  • 在析构的时候就也会造成二次析构的;
  • 原先s1所指向的那块空间没人维护了,就造成了内存泄漏的问题

image.png

  • 那么此时我们应该自己去开出一块新的空间,将s3里的内容先拷贝到这块空间中来,然后释放掉s1所指向这块空间中的内容,然后再让s1指向这块新的空间。那么这个时候,也就达成了我们所要的【深拷贝】,不会让二者去共同维护同一块空间
  • 最后的话不要忘记去修改一下s1的【_size】和【_capacity】,因为大小和容量都发生了改变

image.png下面是具体的代码,学习过 类的六大天选之子 的同学应该不陌生

string& operator=(const string& s)
{
  if (this != &s)
  {
    char* tmp = new char[s._capacity + 1];
    memcpy(tmp, s._str, s._size + 1);
    delete[] _str;
    _str = tmp;
    _size = s._size;
    _capacity = s._capacity;
  }
  return *this;
}

但是呢,就上面这一种写法并不是最优的,我们来看看下面的这种写法

  • 很多同学非常地震惊,为何这样子就可以做到【深拷贝】呢?
// 赋值重载(pua版本)
string& operator=(const string& s)
{
  if (this != &s)
  {
    string tmp(s);
    this->swap(tmp);
  }
  return *this;
}
  • 有关这个swap()函数,本来是应该下面讲的,既然这里使用到了,那就在这里讲吧,这个接口我在上面并没有介绍到,但是在讲 C++模版 的时候有提到过库中的这个 swap() 函数,它是一个函数模版,可以 根据模版参数的自动类型推导去交换不同类型的数据
  • 可以看到在我们自己实现的这个swap(string& s) 函数中就去调用了std标准库中的函数然后交换一个string对象的所有成员变量
void swap(string& s)
{
  std::swap(_str, s._str);
  std::swap(_size, s._size);
  std::swap(_capacity, s._capacity);
}
  • 接下去来解释一下这里的原理,我们在这个赋值重载的函数内部调用了拷贝构造去获取到一个临时对象tmp,然后再通过swap()函数去交换当前对象和tmp的指向,此时s1就刚好获取到了赋值之后的内容,而tmp呢则是一个临时对象,出了当前函数的作用域后自动销毁,那么原本s1所维护的这块空间刚好就会销毁了,也不会造成内存泄漏的问题

image.png💬 那有同学就说:这个妙啊!太妙了!

  • 哈哈,不知读者有没有听过最近很火的一个词叫做【PUA】
“PUA”的原理,就是打击你的自尊,摧毁你的独立思考能力,让你觉得自己一无所事,
然后对方趁虚而入,让你产生依赖,让你觉得只有对方才能帮助自己,从而被对方操控。
  • 泡面🍜的话相信大家都有吃过,假设说呢有这么一个场景:你呢是家里的哥哥,你还有一个弟弟,这一天的中午你很想吃冰箱里的那桶泡面,但是呢妈妈又不让吃,于是你就和你弟弟说:“冰箱里有一桶很好吃的泡面,你快去泡一下试试看”。那此时你傻傻的弟弟就立马去做了,当他泡完的时候呢你再去找你的妈妈告状,于是这个时候弟弟就被狠狠地骂了一顿(╯▔皿▔)╯
  • 此时这碗泡面就没人吃了,于是这个时候你就乘虚而入把这碗泡面给吃了,但是呢又不想洗碗,于是又把你弟弟给叫了过来,说:“要不你把这个碗去洗了,晚上我给你买雪糕吃🍦”。听到雪糕后你的弟弟又精神起来了,马上就把碗去给洗了

image.png

  • 透过上面这个小例子读者应该对新的这种拷贝构造有了一定的理解:反正你这个tmp对象出了作用域也要销毁的,你手上呢刚好有我想要的东西,那我们换一下吧,此时我得到了我想要的东西,你呢拿到了我的东西,这块地址中的内容刚好就是要销毁的,那tmp在出了作用域后顺带就销毁了,这也就起到了【一石二鸟】的效果

好,我们通过这个调试来观察一下,可以看到就是这个“PUA技术”,很好地达成了我们的目标

💬 但是呢,我觉得上面的这种PUA还不够,还可以再 “精妙” 一些,我们一起来看一下下面这个版本

  •  可以看到,真的是非常简洁,两行代码就足够了,那为什么可以起到这样的效果呢?原因其实就在于这个形参部分,可以看到我并没有使用像上面那样的【引用传参】,而是直接使用的传值传参
  • 那仔细学习过【类和对象】的同学一定可以知道对于【传值传参】的话会先去调用拷贝构造拷贝出一个临时的对象,那么这不就是我们在写上面一个版本的时候在函数内部去调用拷贝构造所做的事吗?那么当外界在给这个函数传递参数对象的时候,此时这个tmp便是外面这个对象的一个临时拷贝,我们直接去操作这个对象的时候也可以到达同样的效果
// 赋值重载(究极pua版本)
string& operator=(string tmp)
{
  this->swap(tmp);
  return *this;
}

一样,我们通过调试来看就可以看得很清晰,一开始按F11的时候我们可以看到进入到了拷贝构造函数内部,这个时候其实就是因为传值传参去调用拷贝构造的缘故


💬 此时我们就可以去谈谈在一模块所讲到的这个【新版本的拷贝构造函数】

  • 这里我把代码再放一遍,读者在看到赋值重载之后再来看这个应该就没有那么陌生了
// 拷贝构造函数(新版本)
string(const string& s)
  : _str(nullptr)
  , _size(0)
  , _capacity(0)
{
  string tmp(s._str);
  // tmp出了当前函数作用域就销毁了,和this做一个交换
  this->swap(tmp);
}
  • 我在这边主要想讲的还是这个初始化列表的问题,读者一定知道如果我们没有手动地去初始化成员变量的话,对于内置类型编译器是不做处理的,对于自定义类型则会去调用默认生成的拷贝构造,那交给编译器去做安全吗?当然是极度地不安全
string(const string& s)
{
  string tmp(s._str);
  // tmp出了当前函数作用域就销毁了,和this做一个交换
  this->swap(tmp);
}
  • 通过调试我们可以观察到,当直接去调用拷贝构造的时候,编译器对当前的对象做了一个初始化的工作,于是在析构的时候就没有出现问题,但是继续执下去到达我们上面的赋值=的时候,因为传值传参的缘故首先会去调用这个拷贝构造拷贝一份临时对象,但是呢在调试的时候可以发现编译器并没有去对当前对象中的成员变量做一个初始化的工作,在执行swap()函数后这个没被初始化的对象就交给tmp来进行维护了,但是呢tmp在出了作用域之后又要销毁,那么此时在执行析构函数的时候便会出问题了,去释放了一块并没有初始化的空间,一定会出现问题的!

💬 所以我们还是不能去相信编译器所做的一些工作,而是要自己经手去做一些事,避免不必要的麻烦

析构函数

最后的话就是析构函数这一块,前面在调试的过程中我们已经看到很多遍了,此处不再细述

~string()
{
  delete[] _str;
  _str = nullptr;
  _size = _capacity = 0;
}

2、Element access —— 元素访问

基本的成员函数我们已经讲完了,string对象也构造出来了,接下去我们来访问一下对象里面的内容吧

operator[ ]

  • 首先最常用的就是这【下标 + [ ]】的形式去进行一个访问,那很简单,我们通过当前所传入的下标值去访问对应的数据即可
  • 下面的话有两种实现形式,一个是可读可写的,一个则是可读不可写
// 可读可写
char& operator[](size_t pos)
{
  assert(pos < _size);
  return _str[pos];
}
// 可读不可写
const char& operator[](size_t pos) const
{
  assert(pos < _size);
  return _str[pos];
}
  • 里面我们就通过循环来访问一下,这里的size()函数和流插入我们会在下面讲到

image.png

  • 此时我们去调用的时候可读可写的版本,是可以在边访问的时候去做一个修改的,效果如下

image.png

  • 但是呢,如果我在定义这个对象的时候在前面加上一个const的话此时这个对象就具有常性了,在调用operator[]的时候调用的便是 可读不可写 的那一个,所以此刻我们去做一个修改操作的话就会出问题了
const bit::string s2("world");

image.png

  • 通过调试我们可以观察到编译器在调用这一块会默认去匹配最相近的重载函数非常得智能

3、Iterator —— 迭代器

那经过上面的学习我们可以知道,要去遍历访问一个string对象的时候,除了【下标 + []】的形式,我们还可以使用迭代器的形式去做一个遍历

  • 而对于迭代器而言我们也是要去实现两种,一个是非const的,一个则是const的

cpp

复制代码

typedefchar* iterator;
typedefconstchar* const_iterator;
  • 这里的话我就实现一下最常用的【begin】和【end】,首位的话就是_str所指向的这个位置,而末位的话则是_str + _size所指向的这个位置
typedef char* iterator;
typedef const char* const_iterator;
  • 实现了普通版本的迭代器之后,我们再来看看常量迭代器。很简单,只需要修改一下返回值,然后在后面加上一个【const成员】,此时就可以构成函数重载了
const_iterator begin() const
{
  return _str;
}
const_iterator end() const
{
  return _str + _size;
}
  • 首先我们来看一下这个普通的迭代器,成功地遍历了这个string对象

image.png

  • 那么对于常对象来说的话,就要使用常量迭代器来进行遍历,但你是否觉得这个迭代器的长度过于长了呢?

image.png

  • 这一点我们在上面也讲到过了,使用C++11中的auto关键字进行自动类型推导即可
auto cit = s2.begin();

之前我们有讲过,一个类只要支持迭代器的话那一定支持范围for,马上我们来试试看吧

  • 分别去遍历一下这两个 string对象 ,可以看到都不成问题
for (auto ch : s1)
{
  cout << ch << " ";
}
cout << endl;
for (auto ch : s2)
{
  cout << ch << " ";
}

这个方式去遍历的话还是很方便的,必须安利一波✌

image.png

相关文章
|
11天前
|
算法 C语言 C++
【c++丨STL】list的使用
本文介绍了STL容器`list`的使用方法及其主要功能。`list`是一种双向链表结构,适用于频繁的插入和删除操作。文章详细讲解了`list`的构造函数、析构函数、赋值重载、迭代器、容量接口、元素访问接口、增删查改操作以及一些特有的操作接口如`splice`、`remove_if`、`unique`、`merge`、`sort`和`reverse`。通过示例代码,读者可以更好地理解如何使用这些接口。最后,作者总结了`list`的特点和适用场景,并预告了后续关于`list`模拟实现的文章。
27 7
|
28天前
|
存储 编译器 C语言
【c++丨STL】vector的使用
本文介绍了C++ STL中的`vector`容器,包括其基本概念、主要接口及其使用方法。`vector`是一种动态数组,能够根据需要自动调整大小,提供了丰富的操作接口,如增删查改等。文章详细解释了`vector`的构造函数、赋值运算符、容量接口、迭代器接口、元素访问接口以及一些常用的增删操作函数。最后,还展示了如何使用`vector`创建字符串数组,体现了`vector`在实际编程中的灵活性和实用性。
54 4
|
29天前
|
C语言 C++ 容器
【c++丨STL】string模拟实现(附源码)
本文详细介绍了如何模拟实现C++ STL中的`string`类,包括其构造函数、拷贝构造、赋值重载、析构函数等基本功能,以及字符串的插入、删除、查找、比较等操作。文章还展示了如何实现输入输出流操作符,使自定义的`string`类能够方便地与`cin`和`cout`配合使用。通过这些实现,读者不仅能加深对`string`类的理解,还能提升对C++编程技巧的掌握。
72 5
|
29天前
|
存储 编译器 C语言
【c++丨STL】string类的使用
本文介绍了C++中`string`类的基本概念及其主要接口。`string`类在C++标准库中扮演着重要角色,它提供了比C语言中字符串处理函数更丰富、安全和便捷的功能。文章详细讲解了`string`类的构造函数、赋值运算符、容量管理接口、元素访问及遍历方法、字符串修改操作、字符串运算接口、常量成员和非成员函数等内容。通过实例演示了如何使用这些接口进行字符串的创建、修改、查找和比较等操作,帮助读者更好地理解和掌握`string`类的应用。
51 2
|
14天前
|
存储 编译器 C语言
【c++丨STL】vector模拟实现
本文深入探讨了 `vector` 的底层实现原理,并尝试模拟实现其结构及常用接口。首先介绍了 `vector` 的底层是动态顺序表,使用三个迭代器(指针)来维护数组,分别为 `start`、`finish` 和 `end_of_storage`。接着详细讲解了如何实现 `vector` 的各种构造函数、析构函数、容量接口、迭代器接口、插入和删除操作等。最后提供了完整的模拟实现代码,帮助读者更好地理解和掌握 `vector` 的实现细节。
27 0
|
1月前
|
存储 编译器 C++
【c++】类和对象(下)(取地址运算符重载、深究构造函数、类型转换、static修饰成员、友元、内部类、匿名对象)
本文介绍了C++中类和对象的高级特性,包括取地址运算符重载、构造函数的初始化列表、类型转换、static修饰成员、友元、内部类及匿名对象等内容。文章详细解释了每个概念的使用方法和注意事项,帮助读者深入了解C++面向对象编程的核心机制。
104 5
|
1月前
|
存储 编译器 C++
【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)
本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。
92 4
|
1月前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
110 4
|
2月前
|
存储 编译器 对象存储
【C++打怪之路Lv5】-- 类和对象(下)
【C++打怪之路Lv5】-- 类和对象(下)
32 4
|
2月前
|
编译器 C语言 C++
【C++打怪之路Lv4】-- 类和对象(中)
【C++打怪之路Lv4】-- 类和对象(中)
32 4