目录
一、 为什么学习string类?
二、了解string类
三、string类的常用接口说明
四、模拟实现string类(常用接口)
1.成员变量
2.构造函数
3.c_str函数
4.size和[ ]
5.迭代器
6.reserve
7.push_back,+=和append
8. insert
9.erase
10.find
11.resize
12. 流插入和流提取
13.clear
14.拷贝构造函数
15. 析构函数
16.赋值重载
17.swap
拓展:拷贝构造函数的现代写法和赋值重载的现代写法
C语言中,字符串是以'\0'结尾的一些字符的集合,为了操作方便,C标准库中提供了一些str系列的库函数, 但是这些库函数与字符串是分离开的,不太符合OOP的思想,而且底层空间需要用户自己管理,稍不留神可能还会越界访问。
而且很多有关字符串的题目基本以string类的形式出现,而且在常规工作中,为了简单、方便、快捷,基本都使用string类,很少有人去使用C库中的字符串操作函数。
下面一起来看看标准库库里面的string类。
1. 字符串是表示字符序列的类
2. 标准的字符串类提供了对此类对象的支持,其接口类似于标准字符容器的接口,但添加了专门用于操作单字节字符字符串的设计特性。
3. string类是使用char(即作为它的字符类型,使用它的默认char_traits和分配器类型(关于模板的更多信息,请参阅basic_string)。
4. string类是basic_string模板类的一个实例,它使用char来实例化basic_string模板类,并用char_traits 和allocator作为basic_string的默认参数(根于更多的模板信息请参考basic_string)。
5. 注意,这个类独立于所使用的编码来处理字节:如果用来处理多字节或变长字符(如UTF-8)的序列,这个类的所有成员(如长度或大小)以及它的迭代器,将仍然按照字节(而不是实际编码的字符)来操作。
总结:
1. string是表示字符串的字符串类
2. 该类的接口与常规容器的接口基本相同,再添加了一些专门用来操作string的常规操作。
3. string在底层实际是:basic_string模板类的别名,typedef basic_string string;
4. 不能操作多字节或者变长字符的序列。
在使用string类时,必须包含#include头文件以及using namespace std;
为了避免和标准库里的string冲突,这里用了一个命名空间。直接把成员函数在类里实现,测试用例在命名空间里。
namespace bit
{
class string
{
private:
char* _str;
size_t _size;
size_t _capacity;
};
}
这里定义一个s,用字符串初始化,可以通过这个测试样例去定义构造函数。首先最好不要用初始化列表直接初始化,因为这里跟日期类不一样,如果这里像日期类那样直接用初始化列表初始化就有可能更改这个常量字符串,这里是不允许的。首先求出字符串的长度,然后new一个空间,这里可能会有人很疑惑,为什么开空间还要+1。那是因为strlen求出的字符串长度是不加 '\0' 的,在这里提前预留好 '\0' 的空间避免后面进行大量的计算。开好空间后直接把内容拷贝过去,构造函数就完成了。( 在C++里推荐用new,不建议使用malloc,在这里还勉强可以,在vector里就不能用了。)这里的参数为什么会等于一个空字符串,那是因为定义一个string对象不一定都会初始化,这里给一个全缺省的参数,这样即使不初始化,string对象里也会有一个 '\0' ,这样插入数据的时候才不会出错。
//构造函数
string(const char* str = "")
{
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
void test1()
{
string s("hello world");
}
这里因为没写打印函数,所以用调试的方法看一看。可以看到内容确实被初始化到 s 里了。
当然这里还有一个成员函数可以用一用,这个函数的作用是返回C格式的字符串,其实就是返回底层的成员变量str,用这个可以把内容打印出来。
const char* c_str()const;
{
return _str;
}
这里趁热打铁再写几个成员函数来用一用,获取到size的值,在定义一个[ ],就可以通过循环直接修改字符串的内容了,看下图是不是已经被修改了。这里重载一个只读的[ ],如果是const对象编译器会直接调用这个。
//返回字符串长度
size_t size()const;
{
return _size;
}
//返回index位置的字符(可读可写)
char& operator[](size_t index);
{
assert(index < _size);
return _str[index];
}
//重载一个只读的
const char& operator[](size_t index)const;
{
assert(index < _size);
return _str[index];
}
那么这里可以用循环了以后,自己实现一个迭代器,,begin就是字符串开头的位置,end就是'\0'的位置。从下面图片可以看到迭代器也可以使用了。(这里只是一种迭代器的实现,迭代器可以是指针,也可以不是指针。)
typedef char* iterator;
//iterator 迭代器
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
迭代器写好后可以看到范围for这里也支持了。那有人问了,不是没实现范围for吗?怎么能用了,其实在底层,范围for就是迭代器,编译器编译一遍过后会自动的把范围for替换成迭代器。把迭代器一屏蔽范围for就不能用了。
这里因为尾插会判断要不要扩容,所以先把reserve写了。reserve是为字符串预留空间,因为C++这里不像C,有malloc,还有realloc可以用,就只能new一个新空间把内容拷贝过去,这样就好了。
//为字符串预留n个空间
void reserve(size_t n);
{
if (n > _capacity)
{
char* ret = new char[n + 1];
strcpy(ret, _str);
delete[]_str;
_str = ret;
_capacity = n;
}
}
那么尾插字符,首先要判断一下是否需要扩容,为了提防给的是一个空字符串,做无用功,没有内容即使乘了二倍也还是0。判断完毕后把 '\0' 的位置插入字符,让size++,而后把size的位置附上 '\0' ,这样尾插就完成了。+=一个字符和尾插一个字符是一样的,这里我就直接复用一下。
这里追加字符串也比较简单,求出字符串的长度,判断一下源对象的空间能不能存的下字符串,存不下就扩容。判断完成后,这里直接用一个C语言的追加字符串函数,完成后再把追加的字符串的长度加到size里就可以了。+=字符串函数复用append。
//尾插一个字符
void push_back(char c)
{
//扩容
if (_size == _capacity)
{
size_t new_capacity = _capacity == 0 ? 4 : _capacity * 2;
reserve(new_capacity);
}
_str[_size] = c;
++_size;
_str[_size] = '\0';
}
//+=一个字符
string& operator+=(char c)
{
push_back(c);
return *this;
}
//追加字符串
void append(const char* str)
{
size_t len = strlen(str);
if (len + _size > _capacity)
reserve(len + _size);
strcat(_str, str);
_size += len;
}
//+=一个字符串
string& operator+=(const char* str)
{
append(str);
return *this;
}
插入字符:首先要保证pos位置是合理的,即pos要小于等于size。其次判断是否要扩容,为了防止传空的string对象,造成预留空间失败这种现象,判断一下capacity是否为0。让end等于size的后一位('\0'的后一位),然后从size位置到pos位置的字符依次往后挪一位(从后往前依次往后挪),挪完字符后,把字符 c 放到pos位置,让size+1,返回string对象。这里是传引用返回,可以直接对string对象进行修改。这么写的话不会产生一些边界的问题,类似于指针越界,这里很容易出现这样的问题,要小心!
插入字符串:这里也是要判断pos的位置是否合理,先求出要插入的字符串的长度,判断容量够不够,即capacity能不能插入len个字符,不能就得扩容。完成后这里的挪动字符跟上面的有点不一样,因为插入的是字符串,所以每次要往后挪动len个位置,一直挪到pos的位置,如果不+1那么第0个位置的字符就不会被挪动,这里也就会出现问题,所以这里的边界要特别小心!数据挪动完成后再把字符串拷贝到pos的位置就可以了,插入字符串记得把长度加上。
// 在pos位置上插入字符c/字符串str,并返回该字符的位置
string& insert(size_t pos, char c)
{
assert(pos <= _size);
if (_size == _capacity)
{
size_t new_capacity = _capacity == 0 ? 4 : _capacity * 2;
reserve(new_capacity);
}
size_t end = _size + 1;
while (pos < end)
{
_str[end] = _str[end - 1];
--end;
}
_str[pos] = c;
++_size;
return *this;
}
string& insert(size_t pos, const char* str)
{
assert(pos <= _size);
size_t len = strlen(str);
if (_size + len > _capacity)
{
reserve(_size + len);
}
size_t end = _size + len;
while (pos < end - len + 1)
{
_str[end] = _str[end - len];
--end;
}
strncpy(_str + pos, str, len);
_size += len;
return *this;
}
这里比较特殊,len给了个缺省值npos,所以我们加上一个静态的成员变量,这里的静态成员变量原本是要在类外面给值的,但C++在这里有个特例就是const的静态成员变量可以直接给缺省值。(只针对整形可以用)
这里要么用引用返回,要么自己写一个拷贝构造函数,如果不用引用返回会涉及到浅拷贝的问题,系统默认生成的拷贝构造函数,对自定义类型的拷贝达不到你想要的结果,而且浅拷贝只是把要拷贝的目标对象底层的str指针指向了源对象底层指针指向的空间。析构函数释放空间时,会重复释放同一个空间两次,这就是为什么明明函数内部逻辑没有问题却一直报错的原因。
要删除数据首先pos的位置要合法不能大于size,然后len给上缺省值npos,如果长度大于或等于pos到size的位置,那么就代表要把pos位置到末尾的数据全部删除,这里删除比较好删,直接把pos位置给上 '\0' 即可,再把pos赋值给size,把长度减下来。反之则从pos+len的位置拷贝给pos位置,szie再减len,这样数据就删除完成了。
// 删除pos位置上的元素,并返回该元素的下一个位置
string& erase(size_t pos, size_t len = npos)
{
assert(pos < _size);
if (len == npos || len >= _size - pos)
{
_str[pos] = '\0';
_size = pos;
}
else
{
strcpy(_str + pos, _str + pos + len);
_size -= len;
}
return *this;
}
private:
char* _str;
size_t _size;
size_t _capacity;
//加一个静态的成员变量,给上缺省值
static const size_t npos = -1;
};
查找函数这里也比较好写,查找字符这里,保证pos的位置是合法的,然后从pos位置开始遍历字符串(这里的pos给缺省值为0,默认从0位置开始),如果字符相同就返回下标i,如果找不到就返回npos。
查找子串,同样要保证pos位置是合法的,其次用一个查找子字符串的函数进行查找,如果没找到那么tmp为空,返回npos,如果找到了,tmp指向str里面子串的开头位置,因为要返回一个整数,所以这里用一个指针减指针的方法。(用tmp位置减开头位置即为pos位置的下标)这里的find和前面的pos位置插入、删除一起用更好。
// 返回c在string中第一次出现的位置
size_t find(char c, size_t pos = 0)const
{
assert(pos < _size);
for (size_t i = pos; i < _size; ++i)
{
if (_str[i] == c)
return i;
}
return npos;
}
// 返回子串s在string中第一次出现的位置
size_t find(const char* s, size_t pos = 0)const
{
assert(pos < _size);
const char* tmp = strstr(_str + pos, s);
if (tmp == nullptr)
return npos;
else
return tmp - _str;
}
这里的resize有三种情况:
一是n < szie,那么就是删除数据,直接把n给size,再把size位置给 '\0' ,数据就删除了。
二是size < n < capacity,那么就把size到n的位置用字符填充,把长度size改成n,后面size位置记得给 '\0' ,如果c是其他字符,不给 '\0'可能会出问题。
三是capacity < n,那么就需要扩容,空间扩容后继续第二种情况插入字符即可。
因为测试需要capacity函数,,所以就一起实现了。一般只读的函数加上const会好一点,防止被修改内容。
//将有效字符的个数该成n个,多出的空间用字符c填充
void resize(size_t n, char c = '\0')
{
if (n > _size)
{
if (n > _capacity)
{
//扩容
reserve(n);
}
for (size_t i = _size; i < n; ++i)
{
_str[i] = c;
}
_size = n;
_str[_size] = '\0';
}
else
{
_size = n;
_str[_size] = '\0';
}
}
//容量
size_t capacity()const
{
return _capacity;
}
流插入和流提取必须要是全局的函数,不能写在类里,但是不一定是友元,
这里的流插入直接用[ ] ,就可以读取到每个字符的内容。这里我上面实现了一个const的[ ] ,所以可以直接用,但是如果没有实现const的[ ],这里会有报错。
流提取这里为了避免扩容次数过多影响效率,所以提前开一个字符数组,然后获取一个字符放到ch里,只要获取到的不是空格或换行就进入循环,如果数组里的数据没有放满,那么就继续获取,如果满了就插入到s里并把i赋为0,这里需要注意如果没有放满,但是遇到空格或者换行出了循环,还需要加一个判断条件,就是放了字符但是没放满,就把 i 位置插入'\0',而后插入s里,这样读取比较少的字符,也不会开很大的空间,读取到很多的字符也不会频繁扩容。这里有个清理数据函数clear,我下面说实现,这里主要是防止读取的string对象里有字符,后面插入会出错,直接把数据清理掉比较好。
//流插入
ostream& operator<<(ostream& _cout, const bit::string& s)
{
for (size_t i = 0; i < s.size(); ++i)
{
_cout << s[i];
}
return _cout;
}
//流提取
istream& operator>>(istream& _cin, bit::string& s)
{
s.clear();
char buff[128] = { '\0' };
size_t i = 0;
char ch = _cin.get();
while (ch != ' ' && ch != '\n')
{
if (i == 127)
{
s += buff;
i = 0;
}
buff[i++] = ch;
ch = _cin.get();
}
if (i > 0)
{
buff[i] = '\0';
s += buff;
}
return _cin;
}
清理数据这里比较简单,因为不用释放空间,所以直接把起始位置赋成'\0'就可以了。
//清理数据
void clear()
{
_size = 0;
_str[0] = '\0';
}
拷贝构造不自己写的话,编译器会默认生成一个,编译器生成的拷贝构造函数是浅拷贝,对内置类型可以用,自定义类型就不一定好用了,那么就自己写吧。
拷贝构造说白了就是以一个string对象复制一个一模一样的出来,但是注意,一模一样不代表指向的空间也一样。
//拷贝构造
string(const string& s)
{
_size = s._size;
_capacity = s._capacity;
_str = new char[_capacity + 1];
strcpy(_str, s._str);
}
析构函数也比较简单,把开辟的空间释放掉就可以了,从好习惯的角度讲,继续把指针置空,变量赋为0。析构函数有了就可以了,编译器会自动帮我们调用的。
//析构函数
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
这里首先判断是否是自己给自己赋值,如果不是那么就把s这个对象的size和capacity的值赋给要赋值的对象,然后创建一个tmp指针指向新开辟的空间,把s的内容拷贝给tmp指针指向的空间,先释放掉str指向的空间,然后让str指针指向tmp指针指向的空间。返回string对象,赋值完成。
//赋值重载
string& operator=(const string& s)
{
if (this != &s)
{
_capacity = s._capacity;
_size = s._size;
char* tmp = new char[_capacity + 1];
strcpy(tmp, s._str);
delete[] _str;
_str = tmp;
}
return *this;
}
这里这个交换函数我讲下面扩展知识的时候会比较好用,首先这里如果不加std:: 那么这个函数会报错,因为它会先在局部域去找,找不到再去全局域找,这里不加明显调用的就是自己。然后只需要将两个的所有成员交换就可以了。
//交换
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
拷贝构造就是先构造一个tmp,然后用交换函数交换这两个对象就可以了。这里要把str指针初识化为空,如果不赋空,那么交换完了以后,因为tmp是局部对象,结束后会调用析构函数。这里str不赋空的话那么它就指向了随机值,交换完了以后tmp成野指针了,释放野指针空间当然会报错咯,但是赋空以后析构函数它默认对空不进行操作。
//拷贝构造,现代写法s(s1)
string(const string& s)
:_str(nullptr)
,_size(0)
,_capacity(0)
{
string tmp(s._str);
swap(tmp);
}
赋值重载这里一样先判断是否给自己赋值,如果不是,拷贝构造一个tmp,交换这两个对象的内容,返回拷贝后的*this就完成了。这里还是一样*this的内容给tmp后,tmp结束会调用析构函数,交换后的tmp空间也会被释放。
这里第二种写法,它的参数不一样,首先s就是一个拷贝构造,我们直接用现成的一交换,就可以啦,怎么样是不是非常简单。
//赋值重载,现代写法
string& operator=(const string& s)
{
if (this != &s)
{
string tmp(s);
swap(tmp);
}
return *this;
}
string& operator=(string s)
{
swap(s);
return *this;
}
完整版代码:作业库: 一些课后作业 - Gitee.com