目录
9)resize将字符数组的有效字符个数扩展到n,多余部分全都赋值ch
一、为什么引入string类?
我们知道在C语言中,字符串就是一些以‘\0’结尾的字符的集合,在C语言中为了方便我们对字符串进行操作,在其标准库中也给出了一些str系列的库函数,如strlen、strcpy、strncpy、strcpy等等,但是对于面向对象语言C++来说这些库函数和字符串是分离的,且其底层空间是需要用户自己维护的,稍不注意的话还会有越界的风险,基于这种情况,C++提出了string类,其可以看作是一个专门管理字符串的数据结构。
二、什么是string类?
string就是字符串的意思,是用来表示字符序列的类,是C++用来代替char 数组的数据结构,里面封装了一些关于操作字符串的方法,该string类的接口与常规容器的接口基本相同,同时再添加了一些专门用来操作string的常规操作。但是不能操作多字节或者变长字符的序列。
所以综上所述,我们就可以将string类理解为一个数组,只不过存储在该数组中的元素类型为char类型,且其大小可以实现动态增长,而且在数组末尾隐藏了'\0'
三、 string类的常用接口即实现
我们在使用string的接口时,必须要包含头文件#include<string>和using namespace std
接下来我们就要一步一步边介绍string一些常用类的功能并实现它。
1、准备工作
我们要自己尝试实现一个string类,为了避免与C++标准库中的string类冲突,我们创建一个自己的命名空间lrk。
接着,我们要复刻一个string类,首先它是一个字符数组,且要求其有增删查改功能,所以我们还需要有一个变量来表示数组中有效字符的个数,还有需要一个变量来表示string类的容量大小。
namespace lrk
{
class string
{
public:
private:
char* _str;
size_t _size;//有效字符的个数
size_t _capacity;//存储有效字符个数的容量
};
}
当我们完成上述的代码之后,我们就默认了编译器为我们生成了其默认的构造、拷贝构造、拷贝赋值,析构函数,但是在这种情况下生成的默认函数是会出现问题的,因为数组是一个动态增长的数组,如果牵扯到开辟新空间和拷贝数据的操作,在最后调用析构函数时会将同一块空间析构多次,会出现问题。这是是深浅拷贝问题,所以这里的涉及到开辟新空间的操作,我们都要自己写构造函数。
基于上面描述,我们已经知道了为什么需要对动态的string类进行扩展,所以接下来就开始操作吧。
2、默认成员函数
如果我们声明了一个类,类里面什么成员都没有,这个类就叫空类,那么空类里面什么都没有吗?并不是,任何类在什么都没有写的时候,编译器会自动生成6个默认的成员函数。
1)构造函数
定义
构造函数是一个特殊的成员函数,在对象构造时调用的函数,这个函数完成初始化工作。其名字与类相同,在创建类类型对象时由编译器自动调用以保证每一个数据成员都有一个合适的初始值,并且在对象整个生命周期内只调用一次。
需要注意的是其名字虽然叫构造,但是它的主要任务并不是开空间创建对象,而是初始化对象。
构造函数特征
- 函数名与类名相同。
- 无返回值
- 对象实例化时编译器自动调用对应的构造函数
- 构造函数可以重载
- 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义则不会生成。
- 对于类生成的默认的无参构造函数这里还有一个“语法坑”,它针对C++内置类型的成员变量不会做处理,但是针对自己定义的成员变量,调用它的构造函数进行初始化。
- 无参的构造函数和全缺省的构造函数都被称为默认构造函数,并且默认构造函数只能有一个。
自定义实现
string(const char* str = "")
{
_size = strlen(str);//size_t _size=strlen(str)这是错误写法,会导测试cout不出来东西
_capacity = _size;
_str = new char[_capacity + 1];//这里多开辟一个空间给\0,因为这是字符数组且其不算做有效字符
strcpy(_str, str);
}
string构造函数的参数传的是const char* str=" ",这里因为const是只读的,无论是只读的还是可读可写的都可以传进来,如果不加const的话,传参的时候只能传非const的,const的不能传过来,只读的不能传给可读可写的。
其次这里的缺省值我们不能给空指针nullptr,如果给了空指针在第一步strlen(str)的这一步就会报错。所以我们给一个空的字符串,它里面只包含一个‘\0’。
那么在构造函数里面我们需要完成什么操作呢?
因为我们实现的是一个动态增长的string类, 在传参构造之后,它的字符串处于代码段,无法更改且当后面容量不够时,无法增容,增容只能在堆上。为了方便扩容,我们想的当然是在堆上开辟一块空间,接着处理传递进来的字符串参数,如上述代码所示,我们先计算出参数的有效字符个数,再到堆上开辟一块比它多1的空间(用来存储'\0',不算做有效字符个数),接着将原来空间上的数据拷贝到新的空间上。
2)析构函数
定义
析构函数与构造函数功能相反,析构函数并不是完成对对象本身的销毁,局部对象的销毁工作是由编译器完成的,而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
析构函数特征
- 析构函数名是在类名前加上~
- 无参数无返回值类型
- 一个类只能有一个析构函数,若未显示定义系统会默认生成,且析构函数不能重载(构造函数可以)
- 对象生命周期结束,C++编译系统会自动调用析构函数
自定义实现
~string()
{
if (_str)
{
delete[]_str;
_str = nullptr;
_size = _capacity = 0;
}
}
3)拷贝构造函数
定义
在创建对象的时候,怎么才能创造一个与已存在对象一摸一样的对象?——拷贝构造函数
拷贝构造函数只有单个形参,该形参是对本类类型对象的引用(一般用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
特征
- 拷贝构造函数时构造函数的一个重载形式
- 拷贝构造函数的参数只有一个且必须时类类型对象的引用,使用传值方式编译器会直接报错,因为此时会引发无穷递归调用。
- 若未显示定义,编译器会生成默认的拷贝构造函数。但是默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。注意:类中如果没有涉及到资源申请时,拷贝构造函数写不写都行,一旦涉及到资源申请时,则拷贝构造函数时一定要写的,否则就是浅拷贝。
拷贝构造函数典型调用场景
- 使用已存在的对象创建新对象
- 函数参数类型为类类型对象(传值)
- 函数返回值类型为类类型对象(传值)
所以为了提高程序的运行效率,一般对象传参时,尽量使用引用类型,返回时根据使用实际场景,能用引用就用引用。
自定义实现
传统写法
string(const string& s)
:_str(new char[strlen(s._str) + 1])
, _size(s._size)
,_capacity(s._capacity)
{
strcpy(_str, s._str);
}
上述代码是传统的拷贝构造写法,使用初始化列表,用传递进来的字符串参数对调用拷贝构造的对象的成员变量进行初始化操作,包括开辟新空间和数据拷贝。
现代写法
为了方便,下面我们给出新的写法
string(const string& s)
:_str(nullptr)
, _size(0)
,_capacity(0)
{
string tmp(s._str);
this->swap(tmp);
}
上述代码的思想是先 利用初始化列表对调用该函数的对象进行简单初始化,再调用构造函数创建一个临时的对象tmp,该对象是利用传递过来的拷贝对象创建的,所以它就是我们想要的,接着我们调用swap函数对临时对象和我们简单初始化后的对象进行交换这样就得到了我们想要的拷贝对象。
这样做的好处是除了简单方便外,我们创建一个临时对象做完交换后,出了作用域编译器会自动调用它的析构函数,一举两得。
这里我们还需要自己实现一个swap函数,因为如果调用库里的swap函数时,可能会涉及到三个深拷贝,代价会比较大。swap函数如下
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
两个swap函数重名,在函数里面我们调用的是库里面的函数,是全局的函数所以加上限定符::
4)赋值重载
定义
1.赋值运算符重载格式
- 参数类型:const T&,传递引用可以提高传参的效率,const为了传过来的参数可以是const的也可以是非const的
- 返回值类型:T&,返回引用可以提高返回的效率,有返回值的目的是为了支持连续赋值(如s1=s2=s3)
- 检测是否自己给自己赋值:否则会出现本对象自己已经调用析构函数后数据都被析构掉还进行其他操作报错的场景昂。
- 返回*this:方便连续赋值
2.赋值运算符只能重载成类的成员函数不能重载成全局函数(赋值运算符如果不显示实现,编译器会生成一个默认的,此时用户在类外自己实现一个全局的赋值运算符重载就和编译器在类中生成的默认的重载函数冲突了)
3. 用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值得方式逐字节拷贝。注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。
自定义实现
传统写法
知道了赋值运算符的基本格式和方法,接下来我们要自己实现一个string类的赋值运算符重载。
string& operator=(const string& s)//返回是一个string类为了方便连续赋值
{
if (this != &s)
{
char* tmp = new char[strlen(s._str) + 1];//开辟一段与s.str一样大的空间
strcpy(tmp, s._str);//拷贝过去
delete[]_str;//释放掉原来的空间
_str = tmp;
return *this;
}
}
- 首先防止自己给自己赋值
- 在堆上开辟一块比传参对象大一的空间
- 释放掉原来的旧空间
- 返回本身
现代写法
string& operator=(string s)
{
this->swap(s);
return *this;
}
可以看出赋值重载这里的现代写法比 之前的传统写法简单得多,主要得益于传参这里是传值拷贝,例如这里需要s1=s2;s2传值给s,这里s就成为s2,我们再将s和s1交换,s1就变成了s2,s变成了s1,因为是传值调用,在出作用域得时候就会调其析构函数,这样就会省下来好多事。
5)
6)迭代器
迭代器是一种检查容器内元素并遍历元素的数据类型,通常用于对C++中各种容器内元素访问,不同的容器有着不容的迭代器,可以暂时理解为指针。
typedef char* iterator;//定义一个迭代器
//迭代器
iterator begin()//获取第一个字符的迭代器
{
return _str;
}
iterator end()//获取最后一个字符的下一个位置的迭代器
{
return _str + _size;
}
有了迭代器之后我们就可以给出以下三种遍历方式
遍历方式
1、重载[ ],利用下标进行遍历
for (size_t i = 0; i < s.size(); ++i)
{
s[i] += 1;
cout << s[i] << " ";
}
2、利用迭代器进行遍历
lrk::string::iterator it = s.begin();
while (it != s.end())
{
*it -= 1;
cout << *it << " ";;
it++;
}
3、也是由隐式迭代器支持的
for (auto ch : s)//这里是由迭代器支持的
{
cout << ch << " ";
}
将s中的元素不断赋值给ch
7)reserve将字符数组扩展到指定大小
void reverse(size_t n)
{
char* newstr = new char[n + 1];
strcpy(newstr, _str);
delete[]_str;
_str = newstr;
_capacity = n;
}
8) 在字符串尾部增加字符/字符串
//增加字符
void push_back(char ch)
{
if (_size == _capacity)//如果满了就要增容
{
size_t newcapacity = _capacity == 0 ? 2 : _capacity * 2;//开二倍空间,如果原来空间为0,则开2个
reverse(newcapacity);
}
_str[_size] = ch;
_size++;
}
//增加字符串
void append(const char* str)
{
size_t len = strlen(str);
if (_size + len > _capacity)
{
size_t newcapacity = _size + len;//这里开空间不能以几倍来开,因为可能不够用
reverse(newcapacity);
}
strcpy(_str + _size, str);//这个_str是头指针位置加上有效字符个数的位置
_size += len;
}
9)resize将字符数组的有效字符个数扩展到n,多余部分全都赋值ch
void resize(size_t newsize,char ch='\0')//将字符数组的有效数字个数扩到n,假使扩容后多余部分全都补ch
{
//判断newsize与容量_size的大小
if (newsize >_size)
{
if (newsize > _capacity)//newsize比容量还大时需要增容
{
reverse(newsize);
}
for (size_t i = _size; i < newsize; i++)
{
_str[i] = ch;
}
_size = newsize;
_str[_size] = '\0';
}
else
{
_str[newsize] = '\0';
_size = newsize;
}
}
10)insert 在指定位置插入字符/字符串
string& insert(size_t pos,char ch)//在pos处插入一个字符
{
assert(pos <= _size);//断言
if (_size == _capacity)//容量不够时扩容
{
size_t newcapacity = _capacity == 0 ? 2 : _capacity * 2;
reverse(newcapacity);
}
//挪动数据
int end = _size;
while (end >=(int) pos)//这里也包含了把\0向后移动,所以后面不用赋值\0
{
_str[end+1] = _str[end];
--end;
}
_str[pos] = ch;
_size++;
return *this;
}
string& insert(size_t pos,const char* str)//在pos处插入一个字符串
{
assert(pos <= _size);
size_t len = strlen(str);//要插入字符串的长度
if (_size + len > _capacity)//合起来的长度大于容量时,需要增容
{
reverse(_size + len);
}
//挪动数据
int end = _size;
while (end >= (int)pos)//有符号整形和无符号整型进行比较时会涉及隐式的符号转化,将有符号的转换成无符号的,end此时会变成无限大,所以这里需要一个强制转换
{
_str[end + len] = _str[end];
--end;
}
//将str字符copy到对应位置,strcpy拷贝时会将\0也拷贝过去,所以用strncpy控制复制字符串的长度
strncpy(_str + pos, str, len);
_size += len;
return *this;
}
11) find在指定位置向后查找字符字符串
size_t find(char ch,size_t pos=0)//在字符数组中从pos开始向后查找对应的字符
{
for (size_t i = pos; i < _size; ++i)
{
if (_str[i] == ch)
{
return i;
}
}
return npos;
}
size_t find(const char* str,size_t pos=0)//在字符数组中查找对应的字符串
{
//利用strstr函数
char* p = strstr(_str, str);
if (p == nullptr)
{
return npos;
}
return (p - _str);
}
12)erase删除指定位置后长度为len的字符
void erase(size_t pos, size_t len = npos)//删除从pos位置开始的长度为len的字符,如果没有给长度,则默认删除到最后
{
if (len > _size - pos)//直接删到尾
{
_str[pos] = '\0';
_size = pos;
}
else
{
size_t i = pos + len;
while (i <= _size)
{
_str[i - len] = _str[i];//挪动数据
++i;
}
_size -= len;
}
}
这里我们还需要定义一个静态的全局变量npos ,静态成员变量允许类的所有对象共享一份数据,对于那些不依赖特定对象实例,而是与类本身相关的数据非常有用
static size_t npos;
静态成员变量一定要在类外进行初始化
size_t string::npos = -1;
13)其他简单接口和重载
size_t size()const
{
return _size;
}
size_t capacity()const
{
return _capacity;
}
void clear()
{
_size = 0;
_str[_size] = '\0';
}
bool empty()const
{
return 0 == _size;
}
char& operator[](size_t i)//操作符重载
{
assert(i < _size);//要保证取的数的位置小于实际有效数字
return _str[i];
}
const char& operator[](size_t i) const //const对象版本,用来解决const对象访问的问题
{
assert(i < _size);
return _str[i];
}
const char* c_str()const
{
return _str;
}
bool operator<(const string& s)
{
int ret=strcmp(_str, s._str);//strcmp(str1,str2),str1<str2时返回小于0的数等等
return ret < 0;
}
bool operator==(const string& s)
{
int ret = strcmp(_str, s._str);
return ret == 0;
}
bool operator>(const string& s)
{
int ret = strcmp(_str, s._str);
return ret > 0;
}
bool operator<=(const string& s)
{
return *this < s._str || *this == s._str;
}
bool operator>=(const string& s)
{
return *this > s || *this == s;
}
bool operator!=(const string& s)
{
return !(*this == s);
}
14)输入输出的重载
友函数法
现在尝试去重载operator<<,然后发现没办法将operator<<重载成成员函数。因为cout的 输出流对象和隐含的this指针在抢占第一个参数的位置。this指针默认是第一个参数也就是左操作数了。但是实际使用中cout需要是第一个形参对象,才能正常使用。所以要operator<<重载成全局函数。但又会导致类外没办法访问成员,此时就需要友元来解决。operator>>同理。
private:
friend ostream& operator<<(ostream& _cout, const bit::string& s);
friend istream& operator>>(istream& _cin, bit::string& s);
ostream& operator<<(ostream& _cout, const bit::string& s)
{
// 不能使用这个, 因为string的字符串内部可能会包含\0
// 直接cout时, 是将_str当成char*打印的,遇到内部的\0时后序内容就不打印了
//cout << s._str;
for (size_t i = 0; i < s.size(); ++i)
{
_cout << s[i];
}
return _cout;
}
其他
这里我们不需要友元函数,因为其调用的是类里面的公有函数,且就是一个个输出。
ostream& operator<<(ostream& out, const string& s)//const对象只能调用const的成员对象,所以将类里需要调用的函数都改为const类型
{
for (size_t i = 0; i < s.size(); ++i)
{
cout << s[i];
}
return out;
}
//重载输入>>
istream& operator>>(istream& in, string& s)
{
while (1)
{
char ch;
/*in >> ch;*///系统实现的,只要遇到换行或者空格就会认为本次输入停止,所以这里不能用
ch = in.get();
if (ch == ' ' || ch == '\n')
{
break;
}
else
{
s += ch;
}
}
return in;
}