c++STL——string学习的模拟实现
文章目录
- string的介绍
- 学习的意义
- auto关键字和范围for
- string中的常用接口
- 构造和析构
- 对string得容量进行操作
- string的访问
- 迭代器(Iterators):
- 运算符[ ]重载
- string类的修改操作
- 非成员函数
- string的模拟实现
- 不同平台下的实现
- 注意事项
- 模拟实现部分
- 所有的模拟实现函数
- 预先处理的函数
- 构造函数和析构函数
- 简单迭代器
- 容量操作
- 访问操作
- 修改操作
- string的比较关系
- 操作函数
- 流插入/提取运算符重载
- 对于深拷贝的改进
string的介绍
本章节将重点介绍string的模拟实现,其内部的函数使用则需要自行查看文档使用:
string类的使用
本文将对string类中重点的函数进行模拟实现。在使用string类时,必须包含#include string这个头文件以及using namespace std;
学习的意义
C语言中,字符串是以’\0’结尾的一些字符的集合,为了操作方便,C标准库中提供了一些str系列的库函数,但是这些库函数与字符串是分离开的,不太符合OOP的思想,而且底层空间需要用户自己管理,稍不留神可能还会越界访问。
所以c++引入了string这个类进行管理这个字符串,并且把其对应的操作函数写成其成员函数。这样子十分方便使用。
string其实是c++库中实现的一个类,我们可以理解为其是字符串。只不过底层使用类似于之前学习过的顺序表进行实现的。在OJ中,有关字符串的题目基本以string类的形式出现,而且在常规工作中,为了简单、方便、快捷,基本都使用string类,很少有人去使用C库中的字符串操作函数。
auto关键字和范围for
这是c++11引入的概念,为了方便学习后续内容需要先进行了解。
auto关键字其实可以理解为类型的自动识别:
在早期C/C++中auto的含义是:使用auto修饰的变量,是具有自动存储器的局部变量,后来这个不重要了。C++11中,标准委员会变废为宝赋予了auto全新的含义即:auto不再是一个存储类型指示符,而是作为一个新的类型指示符来指示编译器,auto声明的变量必须由编译器在编译时期推导而得。
1.用auto声明指针类型时,用auto和auto*没有任何区别,但用auto声明引用类型时则必须加&
2.当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错,因为编译器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量。
3.auto不能作为函数的参数,可以做返回值,但是建议谨慎使用
4.auto不能直接用来声明数组
5.auto声名变量的同时必须进行初始化
这些概念我们来简单介绍一下:
即我们在声名变量时可以这么写:
#include<iostream>
using namespace std;
int main() {
auto a = 10;
auto b = 'a';
auto c = 10.5;
auto d = &a;
cout << typeid(a).name() << endl;
cout << typeid(b).name() << endl;
cout << typeid(c).name() << endl;
cout << typeid(d).name() << endl;
return 0;
}
可以使用typeid中的name函数打印出变量的类型名称:
我们发现确实是自动识别了。
但是注意千万不能不进行初始化,因为auto关键字其实是编译器经过特殊处理的,需要通过赋值的内容进行自行推到。如果没有值赋予,就无法推导出类型。
同一行的声名如auto a = 10, b = 5必须是同一个类型的数据,如果前后有不相同的数据,会导致类型推导失败。
函数的参数部分是不能使用auto关键字的,但是返回值却可以。但是需要慎用。如果有多个函数嵌套调用且返回值均是auto,需要一直往调用本函数的上一个函数进行推导是何类型的返回值。这非常麻烦,且会导致代码逻辑混乱。
当然用的最多的地方是范围for的使用:
int array[] = { 1, 2, 3, 4, 5 };
// C++98的遍历
for (int i = 0; i < sizeof(array) / sizeof(array[0]); ++i){
array[i] *= 2;
}
for (int i = 0; i < sizeof(array) / sizeof(array[0]); ++i){
cout << array[i] << endl;
}
// C++11的遍历
for (auto& e : array)
e *= 2;
for (auto e : array)
cout << e << " " << endl;
也就是说,auto关键字在for中使用的时候,会自动识别访问的类型并且从头进行访问,不需要我们判断访问范围,这是十分方便的。
对于string类中也是可以使用范围for,但是是使用其内部迭代器进行操作的,具体的会在概念部分进行重点的讲解。
对于auto的使用,最大的特点就是在于对于某些类型代码长度比较长的时候,是可以进行一定程度上简化的,还有就是在范围for中的使用。其余情况下,能确定类型还是尽量写成确定类型,也是为了代码的可读性。
string中的常用接口
现在我们来讲一下string中的一些重要概念。其实严格意义上来说,string出现的比STL库要早,并不是STL库中的一员,但是由于其性质和使用方式和STL中其他的类都被封装成类似的方式和对应的接口进行调用,所以是可以把string当作STL中的一员进行学习的。
只不过由于string出现的更早,内部成员函数会更多也更冗杂(也是为了向前兼容),所以只需要重点掌握几个重点函数即可,其余的使用的时候查一下文档即可。重点函数即需要模拟实现的部分。
string可以理解为字符串,字符串常见的操作即:访问、修改、查找、反转、交换等。其内部各种函数又被重载成针对不同中参数的时候对应得操作。下面我们来看看重点的接口:
构造和析构
既然是一个类,又因为其类似于顺序表得实现,所以是需要自行编写构造函数和析构函数的:
(constructor)函数名称 | 功能说明 |
---|---|
string() (重点) | 构造空的string类对象,即空字符串 |
string(const char* s) (重点) | 用C-string来构造string类对象 |
string(size_t n, char c) | string类对象中包含n个字符c |
string(const string&s) (重点) | 拷贝构造函数 |
~string()(重点) | 析构函数 |
析构函数操作系统会自行调用,但是在创建字符串的时候,就需要使用其构造函数。
对string得容量进行操作
函数名称 | 功能说明 |
---|---|
size(重点) | 返回字符串有效字符长度 |
length | 返回字符串有效字符长度 |
capacity | 返回空间总大小 |
empty (重点) | 检测字符串释放为空串,是返回true,否则返回false |
clear (重点) | 清空有效字符 |
reserve (重点) | 为字符串预留空间 |
resize (重点) | 将有效字符的个数该成n个,多出的空间用字符c填充 |
这里需要说明一点:为了兼容c的用法,string的结尾处也是会加一个‘\0’的。而capacity计算的是不包括‘\0’这个位置以外其他空间的数量,这点需要十分注意。也就是说,假设满容状态下有50个字符,但是为了存储‘\0’会多开一个空间,即总共51个空间,但是真正计算容量的时候其实是50个,需要始终多开一个存储‘\0’。
还有就是字符串有效长度的问题,也是因为string出现的较早,为了兼容c的用法加入了length这个函数,其实在STL库中更多的使用还是size这个函数,计算的就是不包括’0’的字符个数。
clear函数只会把string中的字符数变为0,即清空字符串,但是不会改变其容量。
reserve函数在不同平台下的实现是不一样的,如果传入一个比当前空间数还要小的数给reserve函数,c++并没有明确规定此种行为应该如何操作。在g++编译器下会选择缩容,而vs2022下是不做任何改变的。
resize函数则会改变容量,是可以缩容的,扩容需要填充字符,默认填充的是‘\0’。
string的访问
迭代器(Iterators):
begin和end返回的其实是string的开始位置和结束位置(正向迭代器iterator),这是类似于指针的东西:
注意end()的位置就是‘\0’的位置。
而rbegin和rend是反向迭代器(reverse_iterator):即rbegin指向的是反向开头位置,rend是反向结尾数据:
即对反向迭代器++就是往前走。rend其实指向的是串中第一个元素的前一个位置。
剩下的四个带字母c的迭代器其实是上述四个迭代器对应的常量迭代器,也就是说,使用常量迭代器时,不能对string中的数据进行修改。当然在string类中迭代器用的不算多,因为有更方便的方式,但是在STL其他的容器中(如链表)就会用的比较多。
运算符[ ]重载
毕竟是字符串,在c语言中字符串可以当作数数组使用,也源于[]这个运算符的特性。所以我们希望能够像数组那样访问字符串。
为了方便修改,该运算符重载函数返回该位置的引用。
这里有两种形式,一种是可以修改的,另外一种返回的是不可修改的。如果传入的pos越界会触发断言报错。
string类的修改操作
函数 | 功能说明 |
---|---|
push_back | 在字符串后尾插字符c |
append | 在字符串后追加一个字符串 |
operator+= (重点) | 在串后追加串str或追加字符c |
c_str(重点) | 返回C格式字符串 |
find + npos(重点) | 从字符串pos位置开始往后找字符c,返回该字符在字符串中的位置 |
rfind + npos | 从字符串pos位置开始往前找字符c,返回该字符在字符串中的位置 |
substr + npos | 在str中从pos位置开始,截取n个字符,然后将其返回 |
我们先来解释一下何为npos,对string类执行修改操作时,会不可避免地涉及到长度的事情。但是长度究竟要多长需要用户输入。但是由于c++中缺省参数的存在,是可以设定默认值的。所以c++在string类中加入静态常量成员变量static const size_t npos,且初始化为-1。由于是size_t类型变量,是无符号整形,所以-1赋值给它其实是赋值的补码,即全1。即size_t对应的是无符号int整形的最大值。
函数讲解:
对于push_back还是很好理解的,就和顺序表的尾插一个字符一样。但是对于尾插其实用的更多的是operator+=的运算符重载,因为更简介也好理解。operator+=可以尾插字符,也可以尾插字符串。
而c_str返回的是string中存储串的c格式的字符串,因为要兼容c的用法。
find和refind其实很好理解,就是从pos位置(不能越界)找传入符号的第一次出现位置。(最常用),只不过一个是向后找,一个向前找。
而substr则是返回string中从pos位置开始长度为len的串,构造一个string并且返回。当然这个len的默认长度是npos。如果后续长度够,则返回len个。如果后续长度不够,则后续的字符全返回。
非成员函数
函数 | 功能说明 |
---|---|
operator+ | 尽量少用,因为传值返回,导致深拷贝效率低 |
operator>> (重点) | 输入运算符重载 |
operator<< (重点) | 输出运算符重载 |
getline (重点) | 获取一行字符串 |
relational operators (重点) | 大小比较 |
对于+的运算符重载,其实可以理解为拼接两个字符串,将+后的string拼接在前面一个位置上后传值返回。
而流插入流提取符号则是方便输入,使得我们直接输入字符串就能构造一个string类。
但是这样为什么还要getline函数呢?因为对于标准输入流cin,如果在缓冲区中识别空格会忽略掉。而getline不会:
还有就是一些比较关系的运算符重载,这些我们会在模拟实现部分讲解。
string的模拟实现
不同平台下的实现
在这里我们得先知道一个事情就是:在不同平台下,对于string类的实现是不一样的。
对于vs2022来讲,其string类在实现的时候多加入了一个大小为16的buffer数组。也就是当构造的字符串长度小于等于16时,就存储在buffer中,反之才会存储在指针指向的开辟空间上。而对于gcc编译器,则是直接存储在指针指向的空间上。
gcc编译器在扩容的时候严格村寻二倍扩容原则。而vs2022则复杂一些,扩容的时候可能会考虑容量对齐的方式。
对于模拟实现,其实是为了更好的理解这个类的使用,理解其背后的思想。而不是要写出一些更好的实现,所以在后续的模拟实现部分将写最简单的形式,即采用顺序表进行实现,只不过要考虑字符串的一些特殊问题。
注意事项
由于是我们自己写的string类,但是标准库中也有string类。很容易起到命名冲突的问题。在学习命名空间的时候我们就讲到,为了尽可能防止命名冲突,写项目的时候最好将自己部分的代码写入自己的命名空间。所以我设定了命名空间Mystring。
模拟实现部分
所有的模拟实现函数
#define _CRT_SECURE_NO_WARNINGS
#pragma once
#include<iostream>
#include<assert.h>
#include<string.h>
using namespace std;
namespace Mystring {
class string {
public:
friend ostream& operator<<(ostream& _cout, const Mystring::string& s);
friend istream& operator>>(istream& _cin, Mystring::string& s);
typedef char* iterator;
//constructor
string(const char* s = "");
string(const string& s);
string& operator=(const string& s);
//Destructor
~string();
//iterator
iterator begin();
iterator end();
// modify
void push_back(char c);//尾插一个字符
string& operator+=(char c);//也是尾插一个字符 只不过是使用运算符重载 还会返回插入后的字符串的引用
string& operator+=(const char* str);//尾插字符串
void clear();//只进行清空数据,不清空容量
void swap(string& s);//交换操作
void append(const char* str);//追加字符串
// capacity
size_t size() const;//返回字符串长度
size_t capacity() const;//返回当前字符串空间数
bool empty() const;//判断是否长度为0(空)
void resize(size_t n, char c = '\0');//调整字符串长度 加长就补字符c
void reserve(size_t n);//扩容(不修改内容) 不缩容
// access
//预算符重载 为了像数组一样获取第index坐标的内容
char& operator[](size_t index);
const char& operator[](size_t index)const;
//relational operators
//关系比较运算符
bool operator<(const string& s);
bool operator==(const string& s);
bool operator!=(const string& s);
bool operator<=(const string& s);
bool operator>(const string& s);
bool operator>=(const string& s);
//string operations
const char* c_str()const;//返回字符串的首地址 兼容c使用
size_t find(char c, size_t pos = 0) const;//返回c在string中第一次出现的位置 没有就返回npos
size_t find(const char* s, size_t pos = 0) const;//返回字串第一次出现的位置 没有也是返回npos
string& erase(size_t pos, size_t len);//删除pos位置开始的,往后数共len个元素 不够就全删
string& insert(size_t pos, char c);//在pos位置上插入字符c
string& insert(size_t pos, const char* str);//在pos位置插入字符串
//get_npos
size_t get_npos();//获取成员变量npos
private:
char* _str;
size_t _size;
size_t _capacity;
static const size_t npos;
};
//流插入提取运算符重载
ostream& operator<<(ostream& _cout, const Mystring::string& s);
istream& operator>>(istream& _cin, Mystring::string& s);
void TestString_Constructor_Destructor();//测试构造和销毁
void TestIterator();//测试迭代器(指针版本)
void TestCapacity();//测试容量的成员函数
void TestAccess();//测试获取元素操作
void TestModify();//测试修改逻辑
void TestRelationalOperators();//测试比较逻辑
void TestStringOperations();//测试串操作函数
void TestMyStringIOstream();//测试IO输入
}
注意的是,我们习惯的将经常调用且代码量短小的函数放在类中定义,因为类中函数默认为inline,这样能提高效率。而其余的函数最好声名与定义分离,放在另外一个文见的同一个命名空间中进行定义,同时定义时需要指定类域。这些都是命名空间以及类域的知识,需要熟悉。
预先处理的函数
有一些函数可能会被频繁的被复用,所以可以预先进行实现。这一点后续会体验到。
1.reserve函数
reserve函数是预留容量的函数,因为使用的是vs2022平台进行编译,所以我就学习这个编译器的实现,只扩不缩。还需要注意的是,为了兼容c的用法,末尾应该是要有‘\0’这个字符作为终止字符的。否则一些c库中的函数可能用不了。传入reserve函数的参数n代表为n个有效字符,所以需要多开一个空间,存储‘\0’:
void string::reserve(size_t n) {
if (n <= _size) return;
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
由于只是处理容量,所以只需要改动_capacity的值。又由于c++中没有realloc这样类似功能的函数,所以需要自行调整空间然后赋值。这里复制直接使用c库函数即可,可读性高、方便。
2.get_npos函数
当然可以把这个值变为公有的成员变量,就不需要通过函数的方式获取。
size_t get_npos() {
return npos;
}
这样是更加规范的(个人认为)。
3.c_str函数
为了方便查看某些功能的结果,往往是需要打印出来的。很多人有疑问,使用string类的时候不是可以直接使用标准输入输出流吗?为什么不用那个呢?
我们得直到,那个是string库中实现的,里面包含了对流插入和提取运算符的重载。而我们当前实现的string是我们自己写的,和std中的是不一样的。但是又由于对流插入和运算符重载的知识点比较陌生,通常都是放在后面来讲。而对于字符串类型的数据,c++早已经实现重载,可以直接使用。所以我们可以先使用string返回的字符串类型进行打印查看
const char* string::c_str() const {
return _str;
}
构造函数和析构函数
我们打开文档查看会发现,构造函数有很多种形式,但其实很冗余。真正使用的就那么一两个,所以我们只对常用的进行实现。
又由于构造函数和析构函数会被频繁的调用,所以不妨就把构造函数和析构函数写在类中。
1.输入字符串进行构造,不输入默认为空串
我们可以把这个认为不传参时这个就是默认构造函数,传参了就是调用这个构造函数。
string(const char* s = "") {
_size = strlen(s);
_str = new char[_size + 1];
strcpy(_str, s);
_capacity = _size;
}
对于此处的缺省参数,其实就是一个空串。c字符串只有一个‘\0’的时候就是空串,长度为0。
使用strcpy函数就可以直接复制了,因为strcpy会把‘\0’一起复制过来。
2.拷贝构造函数
当然拷贝构造也是很常见的,所以也得写一下:
string(const string& s) {
_size = s._size;
_str = new char[_size + 1];
strcpy(_str, s._str);
_capacity = _size;
}
3.赋值运算符重载函数
string& operator=(const string& s) {
//防止有自己给自己赋值出问题
if (*this != s) {
delete[] _str;
_size = s._size;
_str = new char[_size + 1];
strcpy(_str, s._str);
_capacity = _size;
}
return *this;
}
赋值重载就是针对于两个已存在的对象进行赋值操作,那么 被赋值的那个可能有很多种情况。所以得先清空资源,再来开空间进行深拷贝。
注意这里的所有赋值和构造都是深拷贝,因为值拷贝会导致析构两次,程序会崩溃。且不符合要求。这点在类和对象的只是讲解就已经提到了。
4.析构函数
因为这是有开辟资源的类,所以默认的析构函数肯定是会导致内存泄露的,所以必须自行写析构函数。
~string() {
delete[] _str;
_str = nullptr;
_capacity = _size = 0;
}
然后就可以自行编写void TestString_Constructor_Destructor();这个函数的实现。这个因人而异,但总体就是检查是否构造成功,是否完成深拷贝,容量大小等是否正确即可。
简单迭代器
虽然前面说了迭代器是类似指针的东西,但实际上并不是。特别是对于STL中其他容器来讲,如果是用指针实现,那指针向后走一步都不是下一个节点的位置。只不过基于string这个类毕竟是用顺序表实现的,所以用指针来实现迭代器也未尝不可:
typedef char* iterator;
//iterator
iterator begin() {
return _str;
}
iterator end() {
return _str + _size;
}
在自己模拟实现的时候直接可以认为就是char*指针即可。
然后就是编写void TestIteraor();这个函数,测试一下范围for等功能即可。
容量操作
我们可能需要直到当前串的长度,容量为多少,又或是是否为空:
size_t string::size() const {
return _size;
}
size_t string::capacity() const {
return _capacity;
}
bool string::empty() const {
return (_size == 0);
}
这些逻辑都十分简单。就不多说了。
重点来看一下resize函数:
void string::resize(size_t n, char c) {
if (n < _size) {
_size = n;
}
else if (n > _size) {
reserve(n);
for (size_t i = _size; i < n; i++) {
_str[i] = c;
}
_size = n;
}
_str[_size] = '\0';
}
如果传入的n小于当前长度,那么直接缩小长度即可,并且记得处理‘\0’的问题。又或是当n大于当前长度,那就需要扩容,所以reserve函数就有用了,直接扩容即可。然后将后续扩容的所有位置都赋值为字符c。默认为‘\0’,修改长度。也是要处理‘\0’问题。鉴于两种情况都要处理,所以就合并写。
而reserve函数已经实现过了,就不再多说。
然后编写void TestCapacity();函数,测试一下reserve和reszie是否正确操作,是否能正确返回对应长度容量等。
访问操作
访问操作有很多种,但是真正用的多的还是对[]的运算符重载:
//access
char& string::operator[](size_t index) {
assert(index < _size);//不能越界
return _str[index];
}
const char& string::operator[](size_t index) const {
assert(index < _size);//不能越界
return _str[index];
}
只不过一个是能修改,一个不能修改。
然后编写void TestAccess();函数,测试一下是否能正常访问和修改。如果是const的变量是否做到了只能访问。
修改操作
修改操作即为清空、尾插、头插、任意位置插入等。
清空比较简单,直接把字符串变为空串,长度为0即可。在此我们不进行缩容。
void string::clear() {
_size = 0;
_str[_size] = '\0';
}
然后就是尾插函数push_back,尾插一个字符char c:
当然是需要判断是否需要扩容的:
void string::push_back(char c) {
//判断是否需要扩容
if (_size == _capacity) {
reserve(_size * 2);
}
_str[_size++] = c;
_str[_size] = '\0';
}
我们总体还是执行二倍扩容的原则,所以满容的情况下直接将当前容量翻倍即可。这件事情直接交给reserve函数完成就好了。内部会完成的复制,容量修改操作。pushback函数就直接修改大小即可,然后需要处理‘\0’的问题。
当然尾插更常用的还是运算符+=的重载,有两种版本,一种是尾插一个字符,一种是尾插字符串:
//尾插一个字符
string& string::operator+=(char c) {
push_back(c);
return *this;
}
返回的是string的引用,减少拷贝。
string& string::operator+=(const char* str) {
size_t len = _size + strlen(str);
if(len >= _capacity) reserve((len > 2 * _capacity) ? len : 2 * _capacity);
strcpy(_str + _size, str);
_size = len;
return *this;
}
对于尾插字符串,就得判断一下尾插字符串后的有效长度是否超出当前容量。如果超出就需要进行扩容。但是为了防止过度扩容,可以考虑一下容量对齐,如果长度超出了当前容量的2倍就扩至需要长度容量。反之扩大到当前容量的2倍。
使用strcpy进行复制即可,‘\0’会自动处理。然后再修改容量处理返回值即可。
还有一个追加操作append,就是在字符串末尾追加一个字符串:
void string::append(const char* str) {
*this += str;
}
直接复用前面写的operator+=函数即可
最后一个是交换两个字符串操作:
对于交换,很多人认为要向以往那样找个中间量,也就是开辟一个string类对象,作为中转接收。但是这样要一直调用构造函数和拷贝构造,会非常影响效率。
而我们又知道,两个string指向的串实际上是在堆上的,只不过它们在栈区上有一个指针指向这个空间。那么让他们两个指向的空间的地址交换不就好了吗?然后再让大小容量交换不就完成交换了。
而且对于交换这个函数,标准库中是有模板的。直接调用即可,只不过要指定是std标准命名空间中的那个交换函数。
void string::swap(string& s) {
std::swap(_str, s._str);
std::swap(_capacity, s._capacity);
std::swap(_size, s._size);
}
还是一样的,需要自行编写void TestModify();函数进行测试相关功能是否正确。
string的比较关系
其实就是字符串的比较大小(如strcmp的功能)。这个部分很简单,在日期类模拟实现的时候早有类似情况,只需要写出判断是否相等和大于或者小于,就可以复用逻辑:
//relational operators
bool string::operator<(const string& s) {
return (strcmp(_str, s._str) < 0);
}
bool string::operator==(const string& s) {
return (strcmp(_str, s._str) == 0);
}
bool string::operator!=(const string& s) {
return !(*this == s);
}
bool string::operator<=(const string& s) {
return (*this < s) || (*this == s);
}
bool string::operator>(const string& s) {
return !(*this <= s);
}
bool string::operator>=(const string& s) {
return !(*this < s);
}
只需要调用c库中的strcmp函数就可以了。剩下的就是逻辑的复用。
自行编写void TestRelationalOperations();函数进行测试相关功能是否正确。
操作函数
1.返回c串形式的指针
即c_str函数,这个已经实现过了,就不再多说。
2.查找操作find
查找操作find是从pos位置开始向后查找第一个需要查找的字符或者字符串的位置。倒着找就是rfind。但是逻辑基本相同,只不过是查找方向的问题。所以只实现一下正向查找就可。
查找字符:
size_t string::find(char c, size_t pos) const {
assert(pos < _size);
size_t i = pos;
while (i < _size) {
if (_str[i] == c) return i;
++i;
}
return npos;
}
逻辑很简单,一个一个找,找到就返回即可。
查找字符串:
size_t string::find(const char* s, size_t pos) const {
assert(pos < _size);
int sublen = strlen(s);
if (sublen > _size) return npos;
for (size_t begin = pos; begin <= _size - sublen; begin++) {
//匹配过程
int i = 0;
while (s[i] == _str[begin + i] && s[i] != '\0' && _str[i] != '\0') {
++i;
}
if (i == sublen) return begin;
}
return npos;
}
可以使用c库中的函数strstr寻找字串。但是其原理也不是很难就自行实现也可以。
当字串长度大于被查找串长度,这肯定找不到的,所以直接返回npos即可。反之需要查找,一直到剩余长度小于字串长度就停止查找即可。每匹配成功一个字符,i就自增,直到i的值与查找的子串长度相同的时候就返回当前位置。但是需要注意的是,匹配过程不能包括字符‘\0’,否则匹配效果会出错。
2.删除操作erase
erase操作主要实现的功能就是从pos位置开始,删除长度为len的字符。(pos不能越界)
当前默认的长度len = npos,即从pos位置开始全删(因为字符串一般达不到那么长)。
所以分两种情况,一种是删除长度len > _size - pos(从pos位置开始的有效元素个数),则将后续的全删。
反之则需要挪动数据:
string& string::erase(size_t pos, size_t len) {
assert(pos < _size);
if (len >= _size - pos) {
_size = pos;
_str[_size] = '\0';
}
else {
int poslen = pos + len;
while (poslen < _size) {
_str[pos] = _str[poslen];
++pos;
++poslen;
}
_str[pos] = '\0';
_size = pos;
}
return *this;
}
具体的操作流程可以通过画图来感受。
2.插入操作insert
和erase相对,在pos位置插入字符或者字符串。
一旦涉及到插入操作,就需要进行判断是否需要扩容,所以reserve函数就又派上用场了。
插入一个字符:
string& string::insert(size_t pos, char c) {
assert(pos <= _size);
if (_size == _capacity) {
reserve(_capacity * 2);
}
if (pos == _size) *this += c;
//挪动数据
else {
for (size_t i = _size + 1; i > pos; i--) {
_str[i] = _str[i - 1];
}
_str[pos] = c;
++_size;
}
return *this;
}
注意i开始的位置,从插入字符后‘\0’放在的位置开始往前走,将前一个位置的值赋值到当前i的位置。这样子到pos位置就能停下。
如果从_size位置开始,将当前的值赋值到后面去,那就要走过pos这个位置才能停下。这会出问题。因为假设pos的位置是0,那么i - 1 的值不是1,而是无符号整形最大值。因为i是size_t类型,不可能小于0。所以从_szie + 1位置开始向后移动数据。
对于pos的位置如果是当前‘\0’的位置,则使用尾插。
插入一个串:
string& string::insert(size_t pos, const char* str) {
assert(pos <= _size);
if (pos == _size) *this += str;
else {
size_t sublen = strlen(str);
size_t len = sublen + _size;
if (len >= _capacity) {
reserve((len >= _capacity * 2) ? len : _capacity * 2);
}
for (size_t i = _size + sublen; i > pos + sublen - 1; i--) {
_str[i] = _str[i - sublen];
}
memcpy(_str + pos, str, sublen);
_size = len;
}
return *this;
}
也是从新串的‘\0’放在的位置开始,不断地将数据向后移动。我们发现其实和上面的过程是相似的,只不过移动数据的位置之间差了一个插入串的长度sublen,当sublen == 1的时候其实就是插入一个字符。然后使用memcpy函数将str的sublen个字节赋值给要插入的位置即可。
对于pos的位置如果是当前‘\0’的位置,则使用尾插。
然后就是自行编写void TestStringOperations();函数进行测试相关功能是否正确。
流插入/提取运算符重载
因为流插入和流提取运算符c++中只对内置类型进行了重载,对于自定义类型是没有的。虽然标准库中确实进行了重载,但那是标准库的。
而我们在自己的命名空间内写的string还没有进行重载,没有办法做到直接将string对象插入到标准输出流,也没有办法直接从标准输入流中提取内容构造string对象。所以我们需要自行进行重载。
在之前模拟实现日期类的时候就说到了,流插入和提取运算符应该重载为全局函数,并且在类中声明为友元函数,因为重载成成员函数第一个参数必须是类对象,这样子会与平常的使用相反。
对于流插入是很简单的,因为只需要打印字符串。那直接将指向串空间的那个指针插入流中就好了,这个标准库中是已经完成重载的了:
ostream& operator<<(ostream& _cout, const Mystring::string& s) {
_cout << s._str;
return _cout;
}
返回的是流的引用,为了连续赋值。
而流的提取就需要注意的是:
在上面我们已经讲到了,标准输入流是会自动忽略空格的,从而导致不进行输入空格到串中。所以一旦输入空格就会导致串读取错误,不是想要的串。所以需要使用另外一个函数。
即istream中的一个函数get,这个除了‘\n’都能提取到流中。
然后就可以通过这个函数一直从缓冲区内读取,直到‘\n’,不断地尾插到string中即可。
但是如果字符串比较长的情况下,一个一个尾插效率还是非常低地,所以可以考虑自行设置一个缓冲区buffer,大小为256个元素,设置内部所有元素全部为‘\0’。
然后将读取到地内容放在buffer中,直到放够255个后,就一次性尾插到串中。重新开始放在第一个位置。
然后出循环后buffer中可能还剩下一些元素没有插入,个数正好是i个,所以可以直接使用memcpy函数复制i个字节到指定位置即可。然后处理’\0’。
istream& operator>>(istream& _cin, Mystring::string& s) {
s.clear();//先清空 要不然读取会错乱
int i = 0;
char buffer[256] = { '\0' };
char ch;
ch = _cin.get();
while (ch != '\n') {
buffer[i++] = ch;
if (i == 255) {
s += buffer;
i = 0;
}
ch = _cin.get();
}
s.reserve(s._size + i);
memcpy(s._str + s._size, buffer, i);
s._size += i;
s._str[s._size] = '\0';
return _cin;
}
最后还是一样,写一个void TestMyStringIOstream();函数测试一下即可。重点是测试当输入字符串较长的情况下是否能正常输出一样的结果。
对于深拷贝的改进
深拷贝主要就是拷贝构造部分和赋值运算符重载部分,每次都要自己开空间还是很麻烦的,也灭有办法能够让其他地方开空间呢?
答案是有的,需要用到string的交换操作。
对于拷贝构造:
我们调用构造函数,将tmp构造为一个和s有着一样大空间、容量、内容的对象。然后这是个动态成员函数,有this指针,直接将this指向的地址,容量,大小进行交换即可。
但是需要注意的是,由于拷贝构造是构造,当前this指针的三个内容其实是为定义的。所以最好给定缺省值。防止交换后,tmp要调用析构函数释放的是随机值。
即在类定义处给定缺省值。
代码实现:
string(const string& s) {
string tmp(s._str);
swap(tmp);
}
代码逻辑就简化了很多,不用自己开空间。
而对于赋值重载,也是可以使用这个逻辑的,我们把参数改为string的对象,而不是引用。这样子参数可以直接构造出一个对象,那么直接让this和其交换即可:
string& operator=(string tmp) {
swap(tmp);
return *this;
}
这样子写虽然效率上没有太多提升,但是写起来会简洁很多。