此文的背景是自己实现库中的string,由于string的模版实现较为困难,我们只实现最简单char版本。
1.命名空间分割
为了避免与库中的string冲突,我们使用一个自己的命名空间中来分离并实现所有内容,并且将所有的声明和定义相分离,因此需要使用相同名字的命名空间。申明和定义都在同一个命名空间中,就会自动合并。
(用一个命名空间来隔离。避免冲突)
为什么 # include " string.h"不会与库冲突?
在编译一讲提到过,双引号下的头文件名字先搜索自己的本地文件。又由于我们自己实现了
string.h在项目文件夹中,所以会优先使用我们自己实现的string
2.构造、析构函数
先在.h文件中声明:
(VS下的string其实还包含一个16个字节的buffer数组,此处我们简化掉该buffer数组)
再到.cpp中去实现:
namespace lsnm {
string::string(const char* str)
:_str(str),
_size(strlen(str)),
_capacity(strlen(str))
{}
string::~string() {
}
}
为什么不用sizeof而是用strlen?
szieof(_str)相当于计算一个指针的大小,因为这是一个常量字符串的指针,而不是数组名,因此不会计数整个数组的大小。
但是发现出现了报错:
为什么在初始化列表中不能直接用参数str来初始化?
由内存管理中的知识可知,如果用一个常量字符串赋值来初始化
(str和_str都是char* 类型的变量,我们没有拷贝,而是一直都在传指针,相当于传了一个常量区的指针去可读可写,扩大了权限)
常量字符串是存在于常量区的并且不可被修改的,直接
:_str(str),
会使我们按照string s1="abcd"初始化的s1无法改变内容(无法插入删除修改等)
正确使用方法:
namespace lsnm {
string::string(const char* str)
:_str( new char[strlen(str)+1]),
_size(strlen(str)),
_capacity(strlen(str))
{
strcpy(_str, str);
}
string::~string() {
}
}
提醒:类函数定义需要指定类域,所以每一个函数前面都有一个 string ::
此时的函数有一个问题:
上文构造函数中,strlen要跑三次,效率较低,能不能按照下文方法写?
先在初始化列表中写size,只执行一次strlen呢?
这是经典错误。因为初始化列表会按照在private中的声明的顺序初始化。
解决方案:
1.在private中改声明顺序为适合的顺序:
但是这样不妥,如果一不小心改了private中的顺序就会出现报错。
解决方案2:
初始化列表虽然好,但是也不能死板的一直使用,此时就建议放在函数体中去定义。
复习关于初始化列表:
string::string(const char* str)
:_size(strlen(str))
{
_str = new char[strlen(str) + 1];
strcpy(_str, str);
_capacity = _size;
}
我们再快速实现一个c_str(因为现在还没有实现流提取的重载,所以c_str之后可以便于打印和检查),建议用后置const修饰this指针,也就是:
const char* c_str() const;
这样的话const的string和非const修饰的string就都可以调用这个c_str() ,当然,同时也都不能修改由c_str返回的char形数组。
(此处的string都指的是我们自己实现的string)
注意:在.c文件中分离实现时,返回类型是写在域名的前面的。
namespace lsnm {
string::string(const char* str)
:_size(strlen(str))
{
_str = new char[strlen(str) + 1];
strcpy(_str, str);
_capacity = _size;
}
string::~string() {
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
const char* string :: c_str() const {
return this->_str;
}
}
在之前的学习中,我们说到构造函数(尤其是包含自定义类型)最好实现默认构造。
因此我们再实现一个无参的string构造函数。
这样写又是很经典的错误。错误原因:与库中的功能不符合。库中直接string s1;
紧接着,s1可以被自由使用、打印,其里面只包含了一个'\0'
但是报错的原因不是delete, 因为free和delete的底层都是可以操作nullptr的.....
正确的做法:
(new出来的自定义类型在后面用花括号赋值):
string::string() {
_str = new char[1] {'\0'};
_size = _capacity = 0;
}
//也可以写成这样
string::string()
: _str(new char[1]{""})
{
_size = _capacity = 0;
}
然后合并无参和带参为全缺省:
不写\0是因为作为char类型的数组,本身自带\0
声明和定义分离时,参数写在声明处。
3.方括号遍历与size函数
size_t string :: size() const{
return this->_size;
}
char& string:: operator[](size_t pos) {
assert(pos<_size);
return _str[pos];
}
const char& string :: operator[](size_t pos) const {
assert(pos<_size);
return _str[pos];
}
4.实现用于遍历的迭代器
除了方括号遍历, 最常用的还有范围for循环。
我们如果想实现范围for,就需要先实现迭代器版本的遍历。(范围for循环的底层是编译为迭代器版本的循环)
因为范围for的底层是迭代器;
我们此处只实现原生指针版本的迭代器
先typedef一下
注意,返回类型和函数名都属于类域中,都需要单独用类域展开一下:
iterator属于 char* ,所以此处的实现直接按照指针来就可以了
string::iterator string::begin() {
return this->_str;
}
string::iterator string::end() {
return this->_str + _size;
}
我们操作的都是加了一层皮的char* , begin()和end()返回的都是指针
切记,iterator是我们自己定义的。
但是,倘若我们把自定义的iterator全部换回char*
范围for还能通过吗?
答案是可以的,因为范围for的底层是去找begin()和end(),只要实现了begin()和end(),就都可以实现了。auto又能自动推导类型,将e作为char类型
但是如果我们把begin改成Begin,范围for就又不能通过了,因为找不到begin()
iterator的作用:
用iterator的方法是完成一种对底层逻辑的封装。因为iterator其实不确定到底是哪种类型,自定义类型还是内置类型都有可能作为iterator。
因为iterator的原生类型都不一样,不同的平台实现也可能不一样,所以规定都叫做iterator,便于使用。比如reverse算法函数,不关心你的访问方法是自定义、还是char*、还是int*,只管使用iterator
这样就能将分离的算法和数据结构相结合,也统一了不同的数据结构的使用方法。
所有的访问方式都能通过迭代器进行。对使用者更加方便。
这一点也能体现类和对象中的特点之一:封装。
除此之外,还有const修饰的iterator:
string ::iterator string:: begin(){
return _str;
}
string::iterator string::end(){
return _str+_size;
}
string::const_iterator string::begin() const{
return _str;
}
string::const_iterator string::end() const {
return _str+_size;
}
5.增添、删除、修改
string作为一个相对复杂的顺序表
5.1 push_back和append
前者用来插入字符,后者用来插入字符串。
前者在扩容时可以直接扩二倍,但是增加字符串的时候可以只增加二倍吗?
因此,我们需要先引入扩容函数:
实现如下:
void string :: reserve(size_t n) {
if (n <= this->_size) return;
char* tmp = new char[n+1];//多开一个预留给\0
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
只要我们希望reserve出的空间大于等于 _size+1 (还需要给'\0'留一个位置), 就都是合法的,可以在大于等于_size+1的情况下进行缩容。
再实现两个填充内容的函数:
关于开出空间的大小:如果需要n个空间,永远开n+1个空间,因为要预留一个给\0
自己开空间,拷贝内容,改变指针指向,再将原空间释放掉。
同时,对push_back和append是否需要扩容作出判断:
在push_back汇总同时处理\0:
对于append,我们可以使用最简单的for循环一个一个放进去:
void string::append(const char* str) {
size_t len = strlen(str);
reserve(_size + len);
for (int i = 0; i < len; i++) {
_str[_size++] = str[i];
}
_str[_size] = '\0';
}
也可以用C语言中的字符串函数如strcat去实现,(strcat能自主覆盖destination的\0并且移植新的\0)
strcat有什么弊端?
strcat的底层是从头开始找'\0',然后从\0的位置开始覆盖。这样固然没有问题,但是操作效率变低。我们清晰\0的位置 : (_str+_size) ,那直接使用strcpy,跳过寻找\0的过程,提高效率。
再实现类似功能(并且最好用)的 +=
string& string::operator+=(char ch) {
push_back(ch);
return *this;
}
string& string::operator+=(const char* str) {
append(str);
return *this;
}
此处是实现类的内部函数,所以默认所有的push_pack或者append都是直接对this对应的元素使用。
记得传引用返回,提升效率,避免传值返回时重复复制。
5.2 insert和erase
先声明三个函数:
为什么不给pos加缺省参数?
要给pos加缺省参数就必须先给ch或者str加,因为缺省参数只能从右边开始赋值。
需要用到npos,我们自己定义一个static的npos.
static需要在第一次使用时就既声明又定义,但如果就像上图那样使用,string.cpp和string.h都会包含一次这个npos,导致重复定义,从而链接出错。
关于静态成员在不需要链接时候的使用如下:
C++:类与对象(2)-CSDN博客
static修饰的成员变量没有被保存在类中,而是保存在静态区中。
本文中,应当将npos的声明和定义相分离:在.h中声明,在.cpp中定义 ,在.test中可以直接使用。
链接时,类似于函数一样,因为先在头文件中声明过了所以编译能通过,最后去生成.o中找这个值。
补充一点很奇怪的知识:
可以用const修饰之后给缺省值,并且只有整型可以。
很奇怪,了解即可。
实现insert:
注意,要将\0一并移走,所以从end+1(\0所在位置)开始移动。
void string :: insert(size_t pos, char ch) {
assert(pos <= _size);
if (_size == _capacity) {
size_t newcapacity = _capacity == 0 ? 4 : 2 * _capacity;
reserve(newcapacity);
}
size_t end = _size;
for (int i = _size - pos; i >= 0; --i) {
_str[end + 1] = _str[end];
end--;
}
_str[pos] = ch;
}
注意顺序表阶段的一个小问题,往哪边挪就得从哪边开始挪。
不用担心\0,\0也被一起挪动了。
不过如果不引进变量i, 写成以下形式 ,并且进行头插(在pos=0的位置插入):
end作为一个无符号整形,不管如何加减,是不可能小于0的。
因此此时无法头插,会死循环:
那如果将end改为int呢?
依然死循环。
5.2.1无符号小于等于零都是坑
为什么将end的类型改成int之后依然会死循环呢??
对于一个双目操作符,当两侧数据的类型不一样时,会发生隐式类型转换。
其中的原则就是有符号的都会变为无符号的。因此在判断end >= pos时,会因为_size的类型是无符号整形,所以end也会被转换成无符号整形。
解决方法:
1. while的条件中进行强转。
2. pos直接写成int,但是这样与库中不一样。
3. 将end指向更后面的一位,等于0的时候就会跳出循环。
4. 引入新变量int
插入字符串的insert:
移动部分的逻辑同上:
紧接着我们利用库函数将传入的参数str直接插入*this
但是插入部分不能用strcpy,因为strcpy会自己补/0,提前结束字符串
因此使用strncpy或者memcpy来避免自动补\0的问题
(关于C语言字符串函数中的弊端,我们稍加总结:strcat会从头开始找\0,效率较低;而strcpy会在插入结束后自动在末尾补\0,因此strcpy不能用于在一个字符串的中间插入;memcpy就是一个字节一个字节的拷贝,非常“朴实无华”)
void string :: insert(size_t pos, const char* str) {
assert(pos <= this->_size);
size_t len = strlen(str);
if (_capacity < _size + len)
reserve(_size + len);
size_t end = _size;
//_str[_size + len + 1] = '\0';
//"abcdefg\0" "qwe\0"
while (end >= pos) {
_str[end + len] = _str[end];
--end;
}
memcpy(_str + pos, str, len);
_size += len;
}
依然有死循环的问题,我们改变end的类型并且在while条件处强转:
5.2.2erase
当触发len==npos,需要全部删完的时候:
不要考虑用delete,因为delete不能只删除部分空间。
直接将pos位置变成\0即可(pos位置后面的都不要了),同时改变_size
或者要删除的长度len大于可以被删除的部分(就像官网定义中的is too short的那样)
不全部删完:
直接平移覆盖即可。
void string::erase(size_t pos, size_t len) {
assert(pos < _size);
if (len > _size - pos) {
_str[pos] = '\0';
}
else {
for (int i = pos + len; i <= _size; i++) {
_str[i - len] = _str[i];
}
}
}
也可以将for循环的覆盖写为:
6.find函数
寻找字符:
默认找不到的时候不可能是无符号整形的最大值(42亿多,一个字符串不可能有四个G)
size_t string::find(char ch, size_t pos) const {
assert(pos < _size);
for (int i = pos; i < _size; i++) {
if (_str[i] == ch)
return i;
}
return npos;
}
匹配子串:
用strstr(底层是BF算法)即可。因为KMP的算法在实际运用中效率并没有非常出色,非常依赖自身的重复性(需要自身的重复性来体现效率)。
7.拷贝构造
如果我们执行这样一个代码:
由于没有实现拷贝构造,所以自动生成一个浅拷贝。
但是此处浅拷贝就会在析构的时候报错,因为对同一块堆上的数组空间析构了两次。
同时,也存在修改s1就会修改到s2的尴尬情况。
因此,我们换一个新逻辑:直接重新开一个一样的空间,进行strcpy即可。
string::string(const string& s)
{
_str = new char[s._capacity + 1];
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;
}
8.运算符重载
8.1 赋值运算符重载:
先试试系统默认生成的:
为什么发生报错呢?
原因和之前的拷贝构造一样,默认生成的是浅拷贝,浅拷贝是一个字节一个字节的拷贝,会将_str的指针拷贝过去,在释放时会对同一个数组delete[]两次,因此报错。
除了两次delete[]会发生报错,还有以上两种情况证明浅拷贝是不够的:
情况1空间不够,情况2空间浪费严重
解决方法:
我们简单粗暴的处理,直接开一个新空间调用strcpy,再释放掉原空间。
不过倘若执行 s1=s1就亏了,再加一个判断条件:
此时再执行s2=s1就不会报错了:
此时能使用swap完成s1和s2的交换吗?
8.2 swap
答案是可以的,因为swap是模版函数。
但是这个swap代价很大,通过观察swap的源码,我们发现要完成swap需要三次深拷贝。
所以我们自己在类中实现一个消耗小的:
直接改指针即可,并且使用库中的swap调换相应的数据:
C++标准库自然也想到了这个问题,string作为一个容器,有专属于自己的swap,来避免深拷贝问题。
为了避免使用者不小心调用库中的标准swap,c++考虑的非常周全,利用匹配原则(如上):直接调用swap(string)版本,并且还是全局实现的。
"the strings exchange references to their data, without actually copying the characters "
只交换指针,没有交换实质里的内容。
9.substr
获取子串依然是一个涉及“len和pos”的问题,依然分两种情况讨论。
(直接从pos的位置去构造一个)
会报错,此处的问题在于不能传引用返回,因为sub会被销毁。
string string::substr(size_t pos, size_t len) { if (pos + len >= _size) { string sub(_str + pos); return sub; } else { string sub; sub.reserve(_size + 1); for (int i = 0; i < len; i++) { sub += _str[pos + i]; } return sub; } }
10.其他常用运算符
如+ - += -=等等
我们借助strcmp来实现小于和等于。
实现小于和等于之后·,其他都可以直接复用。
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) || (*this == s);
}
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);
}
11.流插入和流提取
由于运算符中操作数顺序的问题(cout<<s1),流插入不适于写在类内部(类内部函数的第一个参数是this)。
但是此处不需要写成友元函数,因为可以不访问类内部的数据(访问一个_size和使用一个public函数 operator[ ]),就可以直接访问公有元素。
因为[ ]运算符在重载之后的本质是一个返回char&的函数,而该函数是在public中的,所以可以直接使用
最后return的目的是为了便于连续输出。
留提取:
我们此时只输入4个x试试:
这是因为只提取了一次,,,,
需要多次提取:
依然拿不到换行。
看看测试函数:
因为cin拿不到空格和换行。cin会将空格和换行默认当作操作者这次输入与下次输入之间的隔阂。
正确使用(能拿到换行):用is.get()
还需要一个clear,cin之前的要清空。
综上所述,
流插入和流提取不能写作成员函数 (正确)
流插入和流提取需要写成友元函数 (错误)