C++ STL初阶(2):string 的模拟实现

此文的背景是自己实现库中的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循环的覆盖写为:

                  

关于erase的返回值:

返回的是指向最后一个被删除元素的后一个有效元素,如果已经到最后则指向vec.end() 


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之前的要清空。

综上所述,

流插入和流提取不能写作成员函数   (正确)

流插入和流提取需要写成友元函数   (错误)

12.resize和reserve 

基于string的基础,我们认为:

resize会缩小现有的元素个数,而reserve不会。

例如:

下面程序的输出结果正确的是( )

int main()

{

int ar[] = {1,2,3,4,5,6,7,8,9,10};

int n = sizeof(ar) / sizeof(int);

vector<int> v(ar, ar+n);

cout<<v.size()<<":"<<v.capacity()<<endl;

v.reserve(100);

v.resize(20);

cout<<v.size()<<":"<<v.capacity()<<endl;

v.reserve(50);

v.resize(5);

cout<<v.size()<<":"<<v.capacity()<<endl;

}

 

分析:vector<int> v(ar, ar+n);

cout<<v.size()<<":"<<v.capacity()<<endl; //大小为数组元素个数,因此size=10 capacity=10

v.reserve(100); //预留空间100

v.resize(20);  //调整元素为20个,此时元素的size会改变,由于个数小于容量,因此容量不会变小

cout<<v.size()<<":"<<v.capacity()<<endl;// 故size=20 capacity=100

v.reserve(50);//期望预留空间为50,可是现在的空间已经有100个,所以空间不会减小

v.resize(5); //元素个数调整为5

cout<<v.size()<<":"<<v.capacity()<<endl;// 故size=5 capacity=100

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值