欢迎光临
我们一直在努力

string 模拟实现

在这里插入图片描述


  • 点击表格内对应链接跳转对应内容⬇️⬇️⬇️
作者主页吃透C语言专栏数据结构Gitee仓库C++Linux算法

文章目录

  • 一,namespace bit 命名空间
  • 二,构造函数
    • 无参构造
    • 带参构造
  • 三,析构函数
  • 四,拷贝构造
    • 1 拷贝构造传统写法
    • 2 拷贝构造现代写法
  • 五,operator=(赋值运算符重载)
    • 1 operator = 传统写法
    • 2 operator= 现代写法
    • 3 operator = 最简便写法
  • 六,Swap 交换函数
    • 1 Swap 交换函数
  • 七,c_str (返回地址)
  • 八,size()
  • 九,operator[ ]
  • 十,迭代器 begin( ) 和 end()
  • 十一,reserve(扩容)
  • 十二,push_back(尾插字符)
  • 十三,append(追加字符串)
  • 十四,operator+=(运算符重载)
  • 十五,insert 插入(任意位置插入,字符,或者字符串)
  • 十六,erase(指定位置开始删除指定长度)
  • 十七,find(找字符,找字符串,设定查找位置,返回找到的起始位置)
  • 十八,substr(指定位置,截取指定长度字符串,返回对象)
  • 十九,clear()(清除)
  • 二十,operator<< (流插入运算符重载)
  • 二十一,operator>>(流提取运算符重载)
  • 二十二,判断表达式运算符重载

一,namespace bit 命名空间

.h文件

namespace bit
{
class string
{
void find(string& s);//成员函数声明
}
}

.c文件

namespace bit
{
string::find(string& s)//成员函数定义
}

  • 我们是模拟实现string,而STL中自带了string,所以类名string会冲突,所以我们把自己模拟实现的类stirng用命名空间 namespace bit封装,这样就解决的冲突,但是类被放进命名空间中,类中的成员函数声明和定义分离的话,函数定义需要指定类域,指定是哪个类的成员函数,同时被命名空间封住的类,需要指定命名空间才能知道是哪个域里面的类,对于成员函数必须 bit::sting::函数,指定命名空间拿到对应的类,再指定类域定义对应的成员函数

  • 同名的命名空间最后会自动合并的,所以在.c文件中对于成员函数的定义,不需要一个个指定命名空间,把成员函数定义写在同名命名空间中就可以了,最后命名空间自动合并,对于成员函数就不需要一个个指定命名空间了,只需要指定类域就行了

二,构造函数

无参构造


错误写法

string::string()
{
_str = nullptr;
_size = _capacity = 0;
}

  • 无参构造把_str置为nullptr,_size = _capacity = 0。STL中的sting,如果调用无参构造的话,不初始化对象,打印对象的话是不会有任何值的。但是如果我们把_str置为nullptr的话,打印对象的的时候,对_str进行解引用,就是对nullptr解引用

  • 正确写法

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

}

  • 无参构造需要保证_size = _capacity = 0,但是又保证打印的时候不会报错但是又可以打印出来东西,所以我们需要new一个1字节的空间存放\\0,这样的话,保证_str不为nullptr同时里面没有有效数据,打印\\0不会报错也不会有任何显示。" "双引号引用代表字符串,而字符串默认以\\0结尾,为空的话结尾默认自带一个\\0

带参构造


普通写法

  • 成员变量声明 (.h文件)

char* _str;
size_t _size;
size_t _capacity;

  • 带参构造(.c文件)

string::string(const char* str)
:_str(new char[strlen(str) + 1),
_size(strlen(str)),
_capacity(strlen(str))
{
strcpy(_str,str);
}

  • 带参构造的普通写法,1:_str接收new char[strlen(str)+1]开辟的空间的地址,new 根据[ ]中字符串的大小来开辟对应大小的空间,而strlen(str)通过传入的字符串来计算,字符串的长度,而标准的string中,存储空间中一直预留着一个\\0的空间,所以[ ]中除了strlen(str)计算字符串长度之外,还要+1给\\0预留一个位置,因为C语言中的字符串函数都是根据\\0来判断字符串是否结束,所以不会把\\0计入,strlen计算的也就是\\0之前字符串的长度,所以必须+1给\\0预留一块空间。2:_size(strlen(str))和_capacity(strlen(str))也是一样,通过strlen(str)计算str的长度初始化_size 和 _capacity 3:上面做的仅仅是构造函数中给_str开空间,给_size 和 _capacity 初始化的步骤,但是并没有在开辟空间中进行初始化。只是对成员变量初始化,strcpy(_str,str)才是把字符串中的内容拷贝到_str的空间中,这时候才算完成构造(缺点:频繁的使用strlen计算)

  • 优化写法

string::string(const char* str)
:_size(strlen(str))
{
_str(new char[_size + 1]);
_capacity(_size);
strcpy(_str,str);
}

  • 只需要_size初始化的时候调用strlen,_str 和 _capacity 初始化的时候复用_size就可以了,不需要重复调用strlen,把_str 和 _capacity 在函数体中初始化,而不是在初始化列表中初始化是因为,三个成员变量的初始化是有依赖关系,在初始化_size 前提下,才能进行 _str 和 _capacity 的初始化,而初始化列表的初始化顺序是根据声明顺序初始化,而我们的声明顺序是_str _size _capacity,所以初始化列表会先初始化_str,再初始化_size 和 _capacity,而 _str 的初始化是依赖于_size,而_size此时没有初始化,里面是随机值,所以就会导致_str 开辟的空间大小是随机值,过大崩溃。但是我们可以保持声明顺序和初始化列表顺序一致来解决依赖关系成员变量初始化问题,但是如果有人不小心改动了声明的顺序,就又会出问题。所以最优的解决依赖性关系成员变量初始化的方式是_size在初始化列表,其他的在函数体中初始化,这样就保证了初始化列表只能初始化我们需要优先初始化的成员变量,不会因为声明顺序而影响初始化列表初始化顺序,因为初始化列表中只有一个成员变量,所以只能初始化这个唯一的成员变量,而执行完初始化列表的初始化才会去执行函数体,这时候我们通过初始化列表初始化好了的成员变量再在函数体中初始化别的成员变量即可。

  • 错误写法:声明和初始化列表顺序不匹配

string::string(const char* str)
: _size(strlen(str))
,_str(new char[_size+1])
,_capacity(_size)
{
strcpy(_str,str);
}


三,析构函数

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

  • 释放_str指向的空间,把_str置为nullptr,再把_size 和 _capacity 置为 0

四,拷贝构造

1 拷贝构造传统写法

string& string::string(const string& s)
{
_str = new char[s._capacity + 1];
strcpy(_str,s._str);
_size = s._size;
_capacity = s._capacity;
}

  • 拷贝构造属于构造函数,作用就是初始化对象,只不过初始化的内容固定好了是同类对象,所以拷贝构造的传统写法是,为了满足构造和拷贝两个条件,所以先new char[s._capacity + 1]开辟一个和构造对象一样大的空间,+1是给\\0预留空间,然后把开辟好了的同类空间的地址赋值给_str,这样就给对象初始化了一个同类一样大小的空间,再strcpy把构造对象的值拷贝进行,_size 和 _capacity 拷贝给对象,就完成了拷贝构造

2 拷贝构造现代写法

string& string::string(const string& s)
{
string tmp(s._str);
Swap(tmp);
}

  • 明确构造函数目的就是初始化成员变量,也就是初始化_str和 _size 和 _capacity,传统方式就是new一个拷贝对象一样大的空间给_str,有了空间以后再把值拷贝进去,最后给_size 和 _capacity 初始化成拷贝对象一样的_size 和 _capacity。而现代的写法,我们直接借助构造函数,初始化构造出一个相同的同类对象string tmp(s._str),也就是把s._str作为参数进行构造,就构造出了一个同类对象,再借助Swap把两个对象中的成员变量进行交换,这样就相当于对象指向了我们刚刚构造好的tmp,完成了构造。出了作用域后,析构的也是交换后的tmp

  • 总结:现代写法的拷贝构造就是借助构造函数构造同类对象tmp,再通过Swap交换函数,交换对象,这样对象就拿到了我们借助构造函数构造好了的空间了,简便在于调用构造创建临时对象,再调用Swap把临时对象和对象交换,这样对象就拿到了拷贝,不用我们自己去开空间和赋值

五,operator=(赋值运算符重载)

1 operator = 传统写法

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


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

  • 对于赋值运算符重载,传统写法是开一块相同大小的tmp临时空间,把s._str strcpy 到临时空间tmp里面,再释放原来对象的旧空间,再把_str 指向 tmp空间,最后把_size = s._size 和 _capacity = s._capacity。赋值运算符重载和拷贝构造本质上都是把拷贝对象或者赋值对象的值复制过去,区别在于拷贝构造属于构造,是初始化,原本的对象中是没有值的,而赋值运算符重载是赋值,原本的对象已经存在,并且有值。1 和2 的区别在于,1 是直接new一块空间让_str指向,而 2 是new一块空间给tmp,把s._str先放到tmp中,再让_str指向,效果都是一样的,都是让_str指向新的空间,只不过这一块新的空间是直接new出来给你的还是new出来先放在tmp中保存一下再给你。第2种写法更有健壮性,因为先把s._str放在临时空间中,可以保证这一块空间没问题,再让_str指向这一块空间,而如果直接让_str指向new的空间的话,怕new出问题一起出错。

2 operator= 现代写法

string& string::operator=(const string& s)
{
string tmp(s._str);
Swap(tmp);
return *this;
}

  • 传统写法的流程就是开一块新空间放s._str的值,然后让_str指向这一块开好的空间,最后把s._size 和 s._capacity 赋给 _size 和 _capacity,中途要析构对象原来的空间。而现在写法是,将s._str作为参数构造一个完全相同的对象tmp,然后直接Swap把对象和tmp进行交换,这样对象就拿到了s._str构造的对象,完成了赋值,而对象原本的成员变量给了tmp,tmp作为函数中临时创建的对象,出了作用域自动调用析构函数销毁,并随着函数栈帧的结束空间被回收。

  • 总结:现代写法:1:调构造函数完成s的拷贝,避免的自己去开空间,去初始化空间 2:直接调用Swap和临时构造对象交换,避免了手动把_str指向我们开辟的空间,避免了手动去初始化_size 和 _capacity

3 operator = 最简便写法

string& string::operator=(string tmp)
{
Swap(tmp);
return *this;
}

  • 现代写法的核心在于:用参数:引用的赋值对象的s._str调用构造函数构造一个相同的构造对象,然后Swap交换对象和构造对象的成员变量,但是s._str作为参数调用构造,构造对象的过程是可以在参数部分完成的,string tmp作为参数,赋值对象传参拷贝构造,tmp拷贝成了赋值对象,在参数部分就完成了现代写法中的构造环节,不同的是简便写法是直接调用拷贝构造替代了现代写法中的构造,最终的结果都是一样的,得到了一个赋值对象相同的对象,无论是通过赋值对象s._str构造来的,还是赋值对象拷贝构造来的,都是一样的,最后进行Swap,交换这个临时的相同对象即可,最后return *this

  • 总结:1:Swap交换的是对象中的成员变量,_str 和 _size 和 _capacity,交换了成员变量,两个对象空间中所有内容就完成了交换,但是两个对象的地址是没有变动的,交换的仅仅是地址对应空间的内容,所以最后return *this,返回的依然是需要被赋值,或者被拷贝的对象,但是对象中_str指向的空间和 _size 和 _capacity 都已经完成了交换,*this不是operator=函数中开辟的空间,所以不会因为函数结束而随着栈帧被回收,回收的是tmp

六,Swap 交换函数

Swap 类型所在位置 / 头文件底层实现机制效率 / 复杂度功能与效果说明适用场景与注意事项
1. 通用泛型模板版本std::swap(T& a, T& b) <utility>(C++11前在 <algorithm>) 借助第三方临时变量 tmp,执行三次移动(或深拷贝)构造/赋值。 低O(N),若发生深拷贝则开销极大 交换任意两个同类型对象 a 和 b 的值。 适用于内置类型(如 int)或没有自定义高效 swap 的小对象。痛点:面对大对象会引发灾难性的多次拷贝。
2. string 成员函数版本s1.swap(s2) <string>(作为 std::string 的成员) 仅交换对象内部的指针(_str)、大小(_size)和容量(_capacity)。 极高O(1),恒定时间 交换两个 string 对象底层的堆内存资源。完全不碰实际的字符数据。 当你明确知道操作对象是 std::string 时,显式调用此成员函数。优点:完美避免字符数据的拷贝。
3. string 全局重载版本std::swap(s1, s2) <string>(作为 std 命名空间内的非成员函数) 内部直接转调成员函数 s1.swap(s2)。 极高O(1),恒定时间 提供符合通用习惯的全局调用接口,同时保证 std::string 的高效交换。 日常写 std::swap(s1, s2) 时的首选。原理:编译器会优先匹配这个非模板的精确重载,从而避开第1种低效的泛型模板。

1:通用泛型模板版本

template <class T>
void swap(T& a, T& b) {
T tmp = a; // 触发拷贝构造(如果 T 是 string,这里会深拷贝大块内存)
a = b; // 触发拷贝赋值(再次深拷贝)
b = tmp; // 触发拷贝赋值(又一次深拷贝)
}


2. string 成员函数版本

void string::swap(string& s) {
// 注意:这里调用的 std::swap 是专门针对内置类型(指针和整数)的
std::swap(_str, s._str); // 仅交换 8 字节的指针地址
std::swap(_size, s._size); // 仅交换 8 字节的长度
std::swap(_capacity, s._capacity); // 仅交换 8 字节的容量
}


3.string 全局重载版本

// 这是一个全局函数,不是成员函数
void swap(string& x, string& y) {
x.swap(y); // 非常简单,直接把活丢给上面的成员函数
}


💡 核心防坑总结: 标准库为了兼顾泛型(支持所有类型)和性能(防止大对象拷贝),采用了成员函数做脏活,全局重载做门面的设计。 所以在使用 std::string 时,你直接写 std::swap(s1, s2) 即可,它背后会自动调用高效的指针交换逻辑。


  • swap交换函数其实有三个,1:库里面的swap,这个是效率最低的一个,交换两个对象需要借助一个中间对象tmp,自定义类型的拷贝是通过拷贝构造完成的,如果我们自己没实现拷贝构造,编译器调用自己的拷贝构造就是值拷贝,也就是浅拷贝,只有自己实现的拷贝构造并且是深拷贝构造,编译器才能进行深拷贝。不断的进行深拷贝开辟空间,如a b 交换的话,需要 a 深拷贝得到tmp,有了tmp之后就不怕a丢失了,深拷贝b,让a指向深拷贝的b,然后b指向tmp,中间进行了三次深拷贝。2:string成员函数的swap进行的是内置类型成员变量的浅拷贝,如_size 和 s._size的交换,_capacity 和 s._capacity 的交换,_str 和 s._str 的交换,进行的是内置类型,如整型和指针的交换,这样就保证了对象的空间位置没有变动,但是空间中的资源全部完成了交换。最巧妙的是,指针的交换完全避开了开辟空间的问题,直接把指针指向的空间交换即可。需要注意的是,string中成员函数作用是浅拷贝内置类型成员变量,对象中的资源,而不是把对象的地址交换了,对象指向的空间没有改变,是空间中的资源进行了交换 3:库里面的swap是效率底下的深拷贝,而string类中的成员函数swap是高效的浅拷贝,所以为了避免调用到库里面的swap,string重载了一个全局的swap,如果调用全局的swap,在包含了string类的前提下,调用的是string重载的全局的swap,而string重载的全局的swap底层调用的还是string的成员函数swap

  • 总结:string 成员函数swap 效率高于 std::swap 的原因是进行的是内置类型的交换,而内置类型的拷贝只有浅拷贝,也就是直接值拷贝,没有拷贝构造,也更没有深拷贝这一说法。而std::swap中进行的是自定义类型的交换,自定义类型的拷贝只能通过拷贝构造来完成,而拷贝构造怎么实现全靠我们自己,如果实现浅拷贝,那编译器进行的拷贝构造就是浅拷贝,如果实现深拷贝,那编译器实现的拷贝构造,但是如果实现浅拷贝,首先编译器自己的默认拷贝构造函数就是浅拷贝,第二指针类型的浅拷贝会导致多个指针指向同一块空间,修改空间会影响另一个,以及调用析构重复释放的问题。所以只能通过实现深拷贝构造来避免这个问题,这时候std::swap进行自定义类型的拷贝就是深拷贝,这时候自定义类型的交换就会重复调用深拷贝而导致效率底下,所以现代C++才通过string成员函数swap进行内置类型的交换,交换指针的方式来避免std::swap的效率低下,而内置类型的交换,是不会调用拷贝构造的,没有拷贝构造这一说法,是直接值拷贝,只有自定义类型的拷贝才会通过拷贝构造完成,自定义类型的拷贝才有拷贝构造这一说法。

1 Swap 交换函数

void string::Swap(string& s)
{
std::swap(_str,s._str);
std::swap(_size,s._size);
std::swap(_capacity ,s._capacity);
}

  • 模拟实现string中成员函数swap,成员函数swap进行的是内置类型的浅拷贝交换,所以我们实现的Swap内部使用std::swap进行内置类型的交换。1:内置类型的拷贝没有拷贝构造这一说法,只有浅拷贝,也就是值拷贝 2:自定义类型的拷贝,只能通过拷贝构造来完成,才会去调用拷贝构造来进行自定义类型的拷贝,而编译器自己的拷贝构造也是浅拷贝,深拷贝只能通过我们自己实现

七,c_str (返回地址)

const char* string::c_str()const
{
return _str;
}

  • c_str()的作用就是返回对象中存储字符串空间的地址,也就是return _str。const char* string::c_str()const中第一个const指返回值类型是const char* 类型,返回值类型是只读字符地址。第二个const修饰成员函数的作用是修饰形参*this指针,原本的this指针类型为类类型+*const,就是string *const this,this是对象的地址,类型为string *const ,意味则无法修改this中的地址,但是可以修改this指向的空间,而第二个const的作用就是修饰this指针,this指针的类型变成const string *const this,修饰的是*this,意味则无法通过this修改指向的内容,而我们需要return _str,一定要避免_str被不小心修改,所以用const修饰成员函数

  • cout遇到字符地址,会按照字符串打印,常量字符串本身在静态区就是一连串字符数组,数组名代表字符首元素的地址,常量字符串本身代表的是字符首元素的地址,所以cout遇到char* 类型数据就会按照字符串去打印,而const char* 也是一样的,都是char*

八,size()

size_t string::size()const
{
return _size;
}


九,operator[ ]

operator[ ]运算符重载

char& string::operator[]( size_t pos)
{

assert(pos<_size);
return _str[pos];
}


const版本 operator[ ]运算符重载

const char& string::operator[](size_t pos)const
{
assert(pos<_size);
return _str[pos];
}

  • [ ]下标引用运算符重载,作用是通过[pos]索引取出对象中pos位置的字符。所以返回值类型为char&,传引用返回对应位置的字符,参数部分为size_t pos(索引值),函数体中assert(pos<_size)确保不会越界访问,return _str[pos]用下标引用操作符移动pos。而const 版本两个const分别限定了返回值类型,变成了const char& 让返回的char&变成了只读的,不可修改返回的char,属于权限的缩小。而右边的const修饰对象地址,也就是修饰this指针,无法修改this指向的内容,也就是无法修改_str等成员变量,确保了_str的正确性

十,迭代器 begin( ) 和 end()

  • 迭代器作用是遍历和访问容器,链接容器和算法。容器中的数据别人是不能访问的,怎么办?通过迭代器,begin()得到首元素,end()得到最后一个有效元素的下一个位置,得到的是什么呢?对于不同的容器,迭代器的类型是不一样的,但是这个东西类似于指针,可以解引用来访问数据,可以++遍历,而begin()和end()取到了这个东西,用什么类型的变量来接收,C++中统一用iterator来接收,所以iteraotr是一种类型,这种类型用来接收的是迭代器begin()和end()中返回类似指针的东西,而iterator本身是什么,在不同的容器中不一样,在string中可能就是指针。了解这些,我们就会使用iterator类型创建变量,iterator it,去接收begin()的返回值 iterator it = begin()。这样我们就可以通过返回的迭代器访问容器了,如*it,解引用it拿到首元素。

  • 通过上面的描述我们清楚了iterator是迭代器的返回值类型,而iterator具体的类型是根据容器来实现的,但是统一的叫法和用法就是iterator,而begin()和end()获取的数据类型和iterator是一致的,不同容器下不一样,但是类似指针,可以遍历和访问容器

.h文件 iterator定义

typedef char* iterator

  • iterator类型底层在不同容器中不一样,在string中我们将其底层实现为char* 指针类型,所以typedef char* iterator ,将其统一命名为iterator,但是底层为char* 指针,这样就满足了迭代器返回值类型叫做iterator统一叫法,也满足了类似指针可以解引用和++来访问和遍历容器

.c文件 begin()和end()实现

string::iterator string::begin()
{
return _str;
}

string::iterator string::end()
{
return _str + _size;
}

  • 既然确定了iterator的底层是char*指针,所以begin()作用就是获取首元素的地址,end()作用就是获取最后一个元素下一个位置的地址,所以begin()中return _str,end()中return _str + _size。返回值是char*类型,但是返回值类型以及被我们typedef 成 iterator类型,所以返回值类型统一叫做iterator,但是iterator是定义在string类域中的,所以要指定类域string::iterator编译器才能知道iterator是啥

.h文件 const_iterator定义

typedef const char* const_iterator

  • const char* 中 const 的修饰让指针指向的内容无法被修改

.c文件const版本 begin() 和 const版本 end()实现

string::const_iterator string::begin()const
{
return _str;
}

string::const_iterator string::end()const
{
return _str + _size;
}

  • const_iterator让返回的指针被const修饰,这样返回的iteraotr权限变成只读,无法修改,同时const修饰成员函数,让对象指针,也就是this变成只读的,无法通过this修改成员变量

十一,reserve(扩容)

void string::reverse(size_t n)
{
if(n>_capacity)
{
char* tmp = new char[n + 1];
strcpy(tmp,_str);
delete[]_str;
_str = tmp;
tmp = nullptr;
_capacity = n;
}

  • c++中只有new,没有像c语言中的realloc扩容,所以扩容必须手动扩容,模拟realloc的过程。1:开辟一块新空间(扩容 )char* tmp = new char[n + 1]。2:有了新空间了把就空间的数据拷贝过来,strcpy(tmp,_str)。3:数据拷贝过来之后,释放就空间delete[ ]_str。4:让指针指向新空间_str = _tmp 5:把tmp置为nullptr 6:更新_capacity = n,最后扩容的前提是n>_capacity,如果n<_capacity就变成缩容了

十二,push_back(尾插字符)

void string::puch_back(char& ch)
{
if(_size == _capacity)
{
size_t newcapacity = _capacity == 0 ? 4 : 2 * _capacity;
reserve(newcapacity);
}
_str[_size] = ch;
_str[_size + 1] = '\\0';
++_size;
}

  • 尾插字符进行的是插入操作,插入就需要判断空间够不够,if(_size == _capacity)空间满了才去扩容,而newcapacity按照2倍扩容,但是_capacity可能是0,所以三目操作判断_capacity是否为0,如果为0,则先把_capacity = 4 去开空间,如果不为0则2倍扩,reserve接收newcapacity开空间

  • 开好空间以后_str[_size] = ch,下标引用操作符索引到\\0的位置插入ch,同时再_str[_size + 1] = '\\0'给字符串结尾补上\\0

十三,append(追加字符串)

void string::append(const char* str)
{
size_t len = strlen(str);
if(len + _size > _capacity)
{
reverse(len + _size);
}
strcpy(_str + len,str);
_size += len;
}

  • 追加字符串,也就是尾插字符串,插入就需要进行判断空间是否充足,是否需要扩容。if(len + _size > _capacity)如果插入字符串后,整体的长度大于_capacity就需要扩容,而扩容就直接扩容到插入字符串后需要的整体长度,插入后需要多少空间,就扩容到多大的空间。如果按照2倍扩容,而插入的长度都超过了2倍扩容后的空间就会出问题,所以插入后需要多大的空间,就开多大的空间

  • 空间问题解决了以后,直接用strcpy追加,起始位置_str + len,也就是对象\\0的位置。追加的字符串str开始到\\0,strcpy拷贝后会自动补一个\\0,最后更新_size大小:_size += len

十四,operator+=(运算符重载)

追加字符

string& string::operator+=(const char ch)
{
push_back(ch);
return *this;
}


追加字符串

string& string::operator+=(const char* str)
{
append(str);
return *this;
}

  • +=运算符重载,+=字符的话,函数体中可以调用push_back完成字符尾插,+=字符串的话,函数体中可以调用append完成字符串尾插,对象调用成员函数的时候会把自己的地址,对象地址,也就是this指针传进去,所以在成员函数中直接调用成员函数就可以了。push_back(ch),append(str),在底层是this->push_back(ch)和this->append(str),所以直接调用就行。最后+=运算符重载的返回值类型是string&,返回的是对象本身,所以return *this

十五,insert 插入(任意位置插入,字符,或者字符串)

任意位置插入字符:有bug

void string::insert(size_t pos,const char ch)
{
//扩容
if(_size == _capacity)
{
size_t newcapacity = _capacity == 0 ? 4:_capacity* 2;
reverse(newcapacity);
}
//pos位置插入字符,涉及挪动:从哪里开始挪,挪到哪里停下来
size_t end = _size;
while(end>=pos)
{
_str[end+1] = _str[end];
—end;
}
//挪完后腾出位置赋值
_str[pos] = ch;
++_size;
}

  • 任意位置插入字符,1:只要涉及插入,就需要判断容量是否需要扩容 2:插入就需要位置,而位置就涉及到挪位,把位置挪出来。插入一个地方,意味着后面的位置都需要进行挪位,所以size_t end = _size,把最后一个位置,也就是\\0的位置,设置成开始挪动的位置开始。所有的挪动开始挪动的位置都是最后一个位置,因为无论怎么插入都必须从最后一个开始一个个挪,如果最后一个位置不动,根本没法挪动。while循环的判断条件是用来设定最后停下来的位置的。而最后停下来的位置,一定是pos位置,一定要把pos位置挪开才能放数据,3:最后挪完数据就在pos位置插入数据,更新_size

  • 这套逻辑都是正确的,代码错误地方在于,size_t end是无符号整型永远不会<0,当pos == 0 的时候,就意味则,end 最后要把pos位置挪开,然后end<0的时候结束while循环,但是size_t类型永远不会小于0,所以while循环永远不会停止。这时候我们把size_t 换成 int end也是没用的,因为end >= pos 这个表达式中,end 是int 类型,而 pos 是 size_t类型,在表达式中,有符号整型遇到无符号整型,会自动隐式转换成无符号整型,所以int end最后还是变成size_t end,这时候为了int end 存在,只能(int)pos强制类型转换pos才有用

任意位置插入字符:不需要强转pos

void string::insert(size_t pos,const char ch)
{
//扩容
if(_size == _capacity)
{
size_t newcapacity = _capacity == 0 ? 4:_capacity* 2;
reverse(newcapacity);
}
//pos位置插入字符,涉及挪动:从哪里开始挪,挪到哪里停下来
size_t end = _size + 1;
while(end > pos)
{
_str[end] = _str[end – 1];
—end;
}
//挪完后腾出位置赋值
_str[pos] = ch;
++_size;
}

  • 1:永远记住挪动的起始位置只能是最后一个位置 2:停下来的条件 只能是把pos位置挪开。那刚刚必须强转的原因就是end需要<0,但是end又不能<0,而我们可以设定成end不需要<0就可以挪掉第一个位置。之前end要挪掉第一个位置就必须<0,那我们就把end初始值+1即可,也就是size_t end = _size + 1。而开始挪动的位置必须是最后一个位置,也就是_size的位置,而end = _size + 1 那我们开始挪动的位置就是end -1,end -1 挪动到end的位置,和上面的必须强转pos的版本一样的逻辑,依然是最后一个位置开始挪动,挪动到下一个位置,但是不同的是,我们改变了end表达式,一样的位置一样的挪动,但是前面是end挪动,这里是end -1挪动,表达式不一样了。最后停下来的位置依然是把pos挪开,所以就是end – 1 >= pos,这个表达式等价于end >= pos + 1 等价于 end > pos

  • 总结:挪动问题:1:挪动的起始位置一定是最后一个位置 2 :挪动的停止位置一定是把pos位置挪开了就停了。3:表达最后一个位置的方式有很多种,end – 1 ,end + 1 ,end ,都是没问题的。我们需要注意的是初始值和移动最后一个位置之间的关系,例如end初始值是size,那需要表达最后一个位置,直接就是end,end初始值是size + 1,那表达最后一个位置就是end -1,end只是表达最后一个位置的一个变量,无论end初始化为多少,最终的目的都是让end表达出最后一个位置,让最后一个位置作为起始挪动位置。最后根据end表达出的最后一个位置,挪动到pos位置即可,也就是把pos位置挪开即可

任意位置插入字符串

void string::insert(const char* str)
{
size_t len = strlen(str);
if(len + _size > _capacity)
{
reverse(len + str);
}
size_t end = _size + len;
while(end > pos + len – 1)
{
_str[end] = _str[end – len];
—end;
}
memcpy(_str + pos,str,len);
_size += len;
}

  • 任意位置插入字符串,1:扩容 2:初始化end,end初始啥都没关系,重要的是最后我们需要通过初始化的end值来表达需要挪动的数据的最后一个位置。避免end == _size,如果直接将end设置为最后一个位置的话,直接用end来表示最后一个位置,这样最后pos = 0插入的时候,size_t end 又必须<0才能解决。所以end = _size + len。3:初始化了end = _size + len 了以后,需要用end来表示最后一个位置,也就是end – len,从最后一个位置开始挪动,需要挪动到下一个位置,下一个位置就是需要挪动的长度,也就是_size + len 也就是 end。4:挪出位置之后需要进行插入,而我们挪动的时候顺便把\\0也挪过去了,所以使用memcpy从_str + pos作为起始位置拷贝,拷贝str开始len长度的字符串。5:最后更新_size += len

十六,erase(指定位置开始删除指定长度)

void string::erase(size_t pos,size_t len)
{
assert(pos < _size)
if(len >= _size – pos)
{
_str[pos] = '\\0'
_size -= len;
}
else
{
strcpy(_str + pos,_str + pos + len);
_size -= len;
}
}

  • erase效果是指定位置开始删除指定长度,assert(pos<_size)删除的位置不能越界,if (len >= _size – pos)代表需要删除的长度大于pos位置之后的长度,我们需要完成的效果就是pos之后全删,而c和c++中删除的做法就是添加\\0,\\0作为字符串结束的标志,代表\\0之后的字符无法读取。所以在pos位置添加\\0,更新_size长度:_size -= len 即可,else的话则代表pos之后的长度要大于len,意味着不用删完,strcpy(_str + pos,_str + pos + len)直接把pos位置开始,拷贝删除之后剩余字符串,str + pos + len,最后更新_size大小:_size -= len

十七,find(找字符,找字符串,设定查找位置,返回找到的起始位置)

  • std::string::npos 是 std::string 类中的一个静态成员常量,类型为size_t,整体是size_t npos = -1,表示无符号整型的-1 等价于 无符号整型的最大值。底层是-1有符号整型赋值size_t无符号整型,需要取模,转换后的结果就是无符号整型的最大值

  • 作用:未找到,无效位置,无长度限制直到末尾,因为如果用用一个最大值设置长度去找,就是一直去找的意思,要么就是没找到,要么就是找到尾找到了。

//标准库中的定义大致为
static const size_type npos = size_type(–1);


.h中声明npos

const static size_t npos;
const static size_t nops = –1//等价写法

  • 模拟npos,按照npos为静态成员变量来模拟,static size_t npos,静态成员变量空间在静态区不在类中,不是对象专属的,是公共的,所有对象共用一份,不走初始化列表,所以不能给缺省值,因为缺省值实际上走的是初始化列表,要在类外定义,因为空间都不在类中,在静态区,所以在类中无法定义。

  • const是确保npos是只读不被修改的,所以类中声明为 const static size_t npos。定义必须在类外。如果也在.h文件中定义的话,声明和定义在同一个文件中,当多个.c文件包含.h文件,每个.c文件都会拿到一份定义,就会导致重定义。所以声明定义要分离在不同文件中。

  • 最离谱的地方来了,静态成员变量声明在类中,定义要在类外,这是一定的,因为空间都在静态区,类中无法定义,但是如果是整型类型的静态成员,加了一个const 就可以在类中定义了,所以const static size_t nops = -1是没错的,但是只对整型和const 同时修饰,有效,如果是static size_t npos = -1依然报错,如果是const static double nops = 1.1依然报错,所以const 修饰整型静态成员的时候,可以在类中定义是特例

概念通俗比喻对编译器说的话内存分配
声明 (Declaration) 相当于“说明书” 告诉编译器:“这个东西是存在的,它叫什么名字,是什么类型,长什么样”。 不分配内存
定义 (Definition) 相当于“实物” 告诉编译器:“在这里给我分配真正的内存,并把值填进去”。 分配真实内存并赋值
  • 所以声明和定义分离的时候,包含.h文件中的声明之后就会,会有通过声明去找定义,而包含.h文件,就会把.h中的内容拷贝一份到.c文件中,如果声明和定义都在.h文件中的话,每一个.c文件都拿到一份定义,每个文件都定义一份,每个文件都开一个空间定义一个一样的东西,最后链接的时候就会发现有多个定义,造成重定义,而声明和定义分离在不同文件中,声明在.h文件中,定义在.c文件中,每一个.c文件拿到的只是声明,而定义,开空间的地方只在那一个.c文件中,所有的.c文件中拿到声明都会通过编码在链接的时候找到定义,找到开空间的地方,拿到声明的.c文件只会通过生成的对应编码去找定义,而不是自己在重定义一份。

.c中定义npos

const size_t string::npos = –1;

  • 类外定义成员变量要指定类域string::npos,static成员变量依然受到private 和 public 的限制,所以在类外依然不能使用private成员变量,但是定义又不是使用,只是给他赋值而已,编译器只会看在哪个类域中,不会管public 和 private

查找字符

size_t string::find(const char ch,size_t pos)
{
for(size_t i = pos;i<_size;i++)
{
if(_str[i] == ch)
{
return i;
}
}
return npos;
}

  • 用for循环遍历去查找,起始位置设置pos就行,找到了返回i,没有找到返回npos

查找字符串

size_t string::find(const char* sub,size_t pos)
{
const char* p = strstr(_str + pos,sub);
return p – _str;
}

  • 查找字符串我们可以使用strstr字符串匹配函数,在一个字符串中匹配另一个字符串,返回匹配到的字符串的起始位置,在_str + pos的开始开始匹配,sub字符串,返回匹配到的起始位置p,而p位置的下标就是p 和 _str之间的距离,也就是p – _str

十八,substr(指定位置,截取指定长度字符串,返回对象)

string string::substr(size_t pos,size_t len)
{
if(len > _size – pos)
{
string sub(_str + pos);
return sub;
}
else
{
string sub;
sub.reverse(len)
//for(size_t i = pos;i<=len;i++)
//{
// sub += _str[i];
//}
for(size_t i = 0;i < len;i++)
{
sub += _str[pos + 1];
}
return sub;
}
}

  • substr作用是从pos位置开始,截取len长度的字符串,返回对象。if (len >_size – pos)表示需要截取的长度len大于pos之后剩余的字符串长度,意味着pos之后全部截取,string sub(_str + pos)调用构造函数,把_str + pos之后的字符串做参数进行构造,然会return sub。else 的话说明len < _size – pos,需要截取的len长度小于pos后面剩余的长度,所以不能一次性从_str + pos位置把后面的全部截取完,无法直接构造,所以先创建sub,并预分配len空间,然后在for循环中从pos位置开始,到pos + len 长度的字符依次+=追加到sub中,最后return sub

十九,clear()(清除)

void string::clear()
{
_str[0] = '\\0';
_size = 0;
}

  • 清除的效果就是_size变成0,string中字符无法读取,所以将起始位置_str[0]置为\\0即可,\\0在C和C++字符串中代表分割符,代表字符串结束的标志。最后更新_size即可:_size = 0

二十,operator<< (流插入运算符重载)

std::ostream& operator<<(std::ostream& out,const string& s)
{
out<<s.c_str();
return out;
}

std::ostream& operator<<(std::ostream& out,const string& s)
{
for(size_t i = 0;i<s.size();i++)
{
out<<s[i];
}
return out;
}

  • operator<<,流插入运算符重载,1:重载成全局函数,如果重载成成员函数,调用的时候只能是string类的对象才能调用string类的成员函数,而ostream类的全局对象cout只能作为参数,所以调用的时候就是s << cout,和我们日常的写法矛盾。所以重载成全局函数,形参顺序和操作数顺序一致,第一个形参为ostream类,调用函数的时候操作数就可以写成 cout << s。

  • ostream类被封在std命名空间中,所以使用的时候需要指定命名空间,函数体中,传入了ostream全局对象cout,和string类对象s,cout只能打印内置类型,所以在for循环中遍历s中的字符,cout << s[i]循环打印s中的字符。

  • 调用成员函数,只能这个类实例化的对象才能调用类中的成员函数,流插入重载成string的成员函数,那就只能s调用,cout 是ostream类实例化的对象,无法调用string的成员函数,而运算符重载作为成员函数的时候,this指针是第一个默认形参,是类实例化对象的地址,位置是固定好了的。如果cout << s 那就必须cout 是 string实例化的对象,才能调用<<流插入运算符重载函数。

二十一,operator>>(流提取运算符重载)

std::istream& operator>> (std::istream& in,string& s)
{
//提取需要注意停止条件\\0 和 空格
char buff[128] = {};
char ch;
int i = 0;
ch = in.get()
while(ch != ‘\\0’ && ch != ‘ ’)
{
buff[i] = ch;
++i;
if(i == 127)
{
buff[127] = '\\0';
s+= buff;
i = 0;
}
ch = in.get();
}

if(i != 0 )
{
buff[i] = '\\0';
s += buff;
}
return in;
}

  • 思路:使用in流流提取不断提取键盘中输入的字符,我们循环判断输入的这些字符是不是空格或者\\0,空格和\\0代表字符结束的标志。

  • 按照思路,我们需要in.get()不断读取流中的字符(注意:get()是istream中的一个成员函数,可以读取\\0和空格,否则cin读取到空格和\\0代表字符串结尾,会立刻结束读取,空格和\\0是结束读取的标志,所以不会去读取空格和\\0,如果读取不了空格和\\0,那我们也无法判断字符串是否结束。我们需要做的事读取到\\0和空格进行判断。模拟实现流提取的逻辑)

  • 如果我们ch = in.get(),然后直接s += ch的话,频繁调用+=需要频繁开辟申请和开辟空间,所以我们可以模拟vs中 string 中的 buff 数组,先把读取到的数据放在buff中,等buff满了再+=到s中,这样模拟实现了一个缓冲区的功能,就不需要频繁+=申请和扩容。

  • 流程:创建char ch用来接收in.get()读取到的字符,用ch接收到的字符进行判断是否事\\0或者空格,来作为while循环的执行条件。int i = 0作为buff数组放满之后重头开始放的重置条件。while循环中,在buff中循环放入ch,如果buff放满了,128个空间,下标对应0到127,当i == 127说明放满了,这个位置是给\\0的,则把buff[127]放置\\0,然后buff整体插入到s中,也就是s+=buff,这只是buff放满了而已,又不是字符串读完了,继续i = 0 让buff重头开始放置。最后只有遇到空格或者\\0才代表字符串结束,才会出while循环

  • 剩下if ( i != 0 )是对应,在while循环中,buff追加到s中只有buff存满了才回去把buff追加到s中,而如果没读满的追加我们没有实现,所以while中出来的本质是读完了字符串,但是放在buff里面,还没有追加到s中,所以我们在while循环的下面把buff [ i ]读到\\0的位置放置\\0然后把buff整体追加到s中,最后return in

二十二,判断表达式运算符重载

bool string::operator>(string& s)
{
return strcmp(_str,s._str) < 0;
}

bool string::operator>=(string& s)
{
return (*this > s) || (*this == s);
}

bool string::operator<(string& s)
{
return !(*this >= s);
}

bool string::operator<=(string& s)
{
return !(*this > s);
}

bool string::operator==(string& s)
{
return strcmp(_str,s._str) == 0;
}

bool string::operator!=(string& s)
{
return !(*this == s);
}

  • 实现了> 和 == 或者 实现了 < 或者 == ,其他的就可以复用这两个运算符重载函数,函数体中的逻辑是调用strcmp进行两个字符串的字典序比较,得到的结果 和 0 进行对应的比较,如果满足则返回对应的bool值
  • 点击表格内对应链接跳转对应内容⬇️⬇️⬇️
作者主页吃透C语言专栏数据结构Gitee仓库C++Linux算法

在这里插入图片描述

赞(0)
未经允许不得转载:171主机测评 » string 模拟实现
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址