【C++】STL学习——string模拟实现


上篇我们介绍了STL, STL(standard template libaray-标准模板库):是C++标准库的重要组成部分,在C++的学习中,STL的学习是必不可少的。对于STL的学习,我们采用模拟实现——造轮子的方法来更好的了解和学习STL。在学习STL时,由于各个容器的接口众多,所以学会查看文档是很重要的,这里我们推荐一个 cplusplus网站来查看容器的相关接口。

string类介绍

string类本质是一个顺序表,用来存放字符,字符串,有了string我们就可以方便地对字符进行相关操作了。
string类文档介绍可以帮助我们更好学习( string在底层实际是:basic_string模板类的别名)。string类作为第一个容器(但cplusplus网站没有将其放入Containers中,单独列了一个string),接口有点繁杂,函数功能有点重复,只需掌握常用的即可。

总结

  1. string是表示字符串的字符串类。
  2. 该类的接口与常规容器的接口基本相同,再添加了一些专门用来操作string的常规操作。
  3. string在底层实际是:basic_string模板类的别名,typedef basic_string<char, char_traits, allocator>
    string;
  4. 不能操作多字节或者变长字符的序列。

使用时包含头文件string

string类函数接口总览

#pragma once
#include<iostream>
#include<assert.h>
#include<string>

using std:: cout;
using std:: cin;
using std:: endl;
using std:: ostream;
using std:: istream;

namespace djs
{
	class string
	{
	public:
	    typedef char* iterator;
		typedef const char* const_iterator;
		static const size_t npos = -1;
		//默认成员函数
		string(const char* str = "");//常用
		string(const string& str);//拷贝构造
		~string();
		string& operator= (const string& str);//赋值重载

		//迭代器相关函数
		iterator begin();//
		const_iterator begin() const;//const对象并且只可读(const 对象本就不可改;返回值也应该不能改)
		iterator end();
		const_iterator end() const;

		//容积相关函数
		size_t size() const;
		size_t capacity() const;
		bool empty() const;
		void reserve(size_t n = 0);
		void resize(size_t n, char c='\0');//将库的二合一
	
		
		//内容修改(增删)相关函数
		string& insert(size_t pos, const char* s);
		string& insert(size_t pos, char ch);//插入一个字符,库里没有该函数
		void push_back(char c);
		string& operator+= (const char* s);
		string& operator+= (char c);
		string& append(const char* s);
		string& erase(size_t pos = 0, size_t len = npos);//全缺省,不指定删除区间则全部删除
		void pop_back();

		//字符串相关操作
		const char* c_str() const;
		void swap(string& str);
		size_t find(const char* s, size_t pos = 0) const;
		size_t find(char c, size_t pos = 0) const;
		
		//访问
		char& operator[] (size_t pos);
		const char& operator[] (size_t pos) const;
		void clear();//不缩容
		
		//比较关系运算符重载
		bool operator>(const string& s)const;
		bool operator>=(const string& s)const;
		bool operator<(const string& s)const;
		bool operator<=(const string& s)const;
		bool operator==(const string& s)const;
		bool operator!=(const string& s)const;

	private:
		char* _str;
		size_t _capacity;
		size_t _size;
	};
    //流出入和输出
	ostream& operator<< (ostream& os, const string& str);
	istream& operator>> (istream& is, string& str);
	istream& getline(istream& is, string& str, char delim='\n');//改为默认以'\n'作为结束标志
}

为了不与库里的string冲突,我们将模拟实现的string放入自己的命名空间里。

结构介绍

string结构:
string类由于STL的版本不一样其结构(类成员)也有所不同。
VS下(PG版)的string的结构(了解):
注意:下述结构是在32位平台下进行验证,32位平台下指针占4个字节。
string总共占28个字节,内部结构稍微复杂一点,先是有一个联合体,联合体用来定义string中字符串的存储空间:

  • 当字符串长度小于16时,使用内部固定的字符数组来存放。
  • 当字符串长度大于等于16时,从堆上开辟空间。
union _Bxty
{ // storage for small buffer or pointer to larger one
	value_type _Buf[_BUF_SIZE];
	pointer _Ptr;
	char _Alias[_BUF_SIZE]; // to permit aliasing
} _Bx;

这种设计也是有一定道理的,大多数情况下字符串的长度都小于16,那string对象创建好之后,内部已经有了16个字符数组的固定空间,不需要通过堆创建,效率高。
其次:还有一个size_t字段保存字符串长度,一个size_t字段保存从堆上开辟空间总的容量
最后:还有一个指针(不是_str)做一些其他事情。
故总共占16+4+4+4=28个字节。(x64平台下为40)
VS下string结构
我们模拟实现的时候就不需要这么麻烦,因其本质为一个储存字符的舒徐容器,所以我们将其简化为一个维护字符串的指针_str,一个记录字符个数的_size,和一个记录容量大小的_capacity即可

private:
        char* _str;//维护字符串
		size_t _capacity;//容量大小
		size_t _size;//字符个数

当我们使用string类的一些查找功能或一些长度值时,会经常看到一个值npos:npos是公共静态成员常量,此常量使用值 -1 定义,由于成员类型 size_type 是无符号整数类型,因此它是此类型的最大可能表示值。可作为返回值,它通常用于指示不匹配。

public:
		static const size_t npos = -1;

设为static const变量,为string类共有且不可修改。

实现文件结构:
我们分为三个文件,将声明和定义分离实现:
string.h:string类成员变量和函数的声明。
string.cpp:string类的实现。
test.cpp:测试各接口。

默认成员函数

构造函数

构造函数我们设为全缺省,用户不传参则使用默认值,默认值为""——空串,因为字符串会自动追加’\0’;使用初始化列表先计算传入的字符串的大小,并初始化_size,再由此设置_capacity,最后使用strcpy将字符串拷进_str中。

string::string(const char* str)//const char* str = ""
	:_size(strlen(str))//注意初始化列表的顺序
{
	_capacity = 1 + _size;//当_size为0时,保证可以开空间
	_str = new char[_capacity + 1];//预留'/0'的位置
	strcpy(_str, str);
}

注意:

  • 初始化列表是按声明顺序初始化的,_size,和_capacity都放在初始化列表初始化时要注意。
  • 开空间时要确保_capacity不为0。
  • 开空间时即使开一个字符大小的空间也要使用new char[1],这是为了析构时不用区分delete,delete[],也是为了更好的统一。

深浅拷贝

在实现拷贝构造前,再讲讲深浅拷贝的区别:
默认成员函数如果我们不写,编译器默认实现的都是浅拷贝,如下:

#define _CRT_SECURE_NO_WARNINGS 1 
#include<assert.h>
#include<string.h>
class String
{
public:
	String(const char* str = "")
	{
		// 构造String类对象时,如果传递nullptr指针,可以认为程序非
		if (nullptr == str)
		{
			assert(false);
			return;
		}
		_str = new char[strlen(str) + 1];
		strcpy(_str, str);
	}
	~String()
	{
		if (_str)
		{
			delete[] _str;
			_str = nullptr;
		}
	}
	//没有实现拷贝构造,使用编译器默认生成的
private:
	char* _str;
};
// 测试
void TestString()
{
	String s1("hello csdn");
	String s2(s1);
}

int main()
{
	TestString();
	return 0;

}

深浅拷贝
崩溃原因:

上述String类没有显式定义其拷贝构造函数与赋值运算符重载,此时编译器会合成默认的,当用s1构造s2时,编译器会调用默认的拷贝构造。最终导致的问题是,s1、s2共用同一块内存空间,在释放时同一块空间被释放多次而引起程序崩溃,这种拷贝方式,称为浅拷贝

崩溃原因
所以string类的对象应该有一块独立的空间,管理自己的数据。这样的方式即称为深拷贝。

拷贝构造函数

有了以上对深浅拷贝的认识,所以拷贝构造函数里也应该自己开一块新的空间维护改对象的数据。实现方式与构造类似。

string::string(const string& str)
{
	_str = new char[str._size + 1];
	_size = str._size;
	_capacity = str._capacity;
	strcpy(_str, str._str);
}

以上是传统写法,借助string的swap函数可以更加简洁高效,等到swap函数再介绍。

赋值重载函数

赋值重载与拷贝构造是类似的,但是要区别出赋值重载是对已存在的对象而言;

	string s5("HELLO CSDN");
	string s6(s5);//拷贝构造
	string s7 = s5;//拷贝构造
	string s8;
	s8 = s5;//赋值重载

先使用tmp来确保获取了新的数据,再删除原有的数据,最后让_str指向tmp,完成赋值操作。最后确保一下不会发生自己给自己赋值这种扯蛋操作。

string& string::operator=(const string& str)
{
	if (this != &str)//防止自己给自己赋值
	{
		char* tmp = new char[str._capacity + 1];
		strcpy(tmp, str._str);
		delete[] _str;//删除原有空间
		_str = tmp;
		_size = str._size;
		_capacity = str._capacity;
	}
	return *this;
}

赋值重载同样也可以使用string的swap写得更简洁的写法。

析构函数

析构函数主要负责对构造时在堆区开辟的空间进行释放。使用delete[] 进行空间的释放。

string::~string()
{
	delete[] _str;
	_str = nullptr;
	_size = 0;
	_capacity = 0;
}

迭代器

string类是顺序容器,所以string的迭代器本质上就是指针:char*,const char*,使用typedef重命名为iterator,const_iterator。
注意:

  • 并不是所有的迭代器都是原生指针。
  • 迭代器的区间范围是左闭右开[first,last)
typedef char* iterator;
typedef const char* const_iterator;

begin

迭代器的begin也就是返回字符串给首地址,即_str即可;这里我们重载两个版本的begin

string::iterator string::begin()
{
	return _str;//返回字符串中第一个字符的地址
}

string::const_iterator string::begin() const
{
	return _str;//返回字符串中第一个字符的const地址
}

end

迭代器的end为有效字符的下一个位置,即_str+_size的位置;这里我们也重载两个版本的end

string::iterator string:: end()
{
	return _str + _size;
}

string::const_iterator string:: end() const
{
	return  _str + _size;
}

注意迭代器的区间[first,last),使用迭代器时需要声明是谁的迭代器;

string s9("HELLO CSDN");
//string::iterator it = s9.begin();
auto it = s9.begin();//当然你也可以使用auto自动识别

范围for本质

如今又学习了迭代器,那么我们总共有三种方式访问数据了

	string s9("HELLO CSDN");
	//下标访问
	for (int i = 0;i<s3.size(); i++)
	{
		cout << s9[i];
	}
	cout << endl;
	//迭代器访问
	string::iterator it = s9.begin();
	while (it != s9.end())
	{
		cout << *it;
		it++;
	}
	cout << endl;
	//范围for范文
	for (auto e : s9)
	{
		cout << e;
	}
	cout << endl;

对于范围for,曾今的你觉得很神奇,但现在也将不值得一提了,因为其本质就是通过迭代器来访问数据,只不过是编译器将范围for替换成迭代器了。

我们将(自己模拟实现的string)迭代器一关,范围for也用不了了
范围for
通过反汇编也能看到范围for也确实是调用了迭代器,其本质是一样的。
汇编范围for

容量相关函数

因为string类的成员变量是私有的,我们并不能直接对其进行访问,所以string类设置了size和capacity这两个成员函数,用于获取string对象的大小和容量。

size

返回有效字符的大小——_size

size_t string::size() const
{
	return _size;
}

capacity

返回容量大小

size_t string::capacity() const
{
	return _capacity;
}

reserve

功能:

  • 如果 n 大于当前字符串容量,则该函数会导致容器将其容量增加到 n 个字符(或更大)
  • 在所有其他情况下(n小于当前容量),它被视为一个非约束性请求,不做响应,也就是说不进行缩容

只对n大于_capacity的情况进行处理,逻辑与赋值重载类似,要先确保原有数据已经拷贝到tmp里再释放原有空间,再把_str指向tmp,记得更新容量_capacpity

void string::reserve(size_t n)
{
	//小了不处理
	if (_capacity < n)
	{
		char* tmp = new char[n + 1];
		_capacity = n;
		strcpy(tmp, _str);
		delete[] _str;
		_str = tmp;
	}
}

resize

功能:

  • 将字符串的大小调整为 n 个字符的长度。如果 n 小于当前字符串长度,则当前值将缩短为其前 n 个字符,并删除第 n个字符之外的字符。
  • 如果 n 大于当前字符串长度,则通过在末尾插入所需数量的字符来扩展当前内容,以达到 n 的大小。如果指定了 c,则新元素将初始化为 c 的副本,否则,它们是值初始化的字符(‘\0’)。

我们这里使用半缺省参数(‘\0’)将库里的两个函数合二为一了;
首先要检测指定的个数n是否大于_capacity来检测是否需要扩容,接着使用一个循环,通过_size<n 的条件看看是否需要添加新字符,_size=n则是定位resize后字符串的结束位置。

void string::resize(size_t n, char c)//size_t n, char c='\0'
{
	//大于size:是否需要扩容
	if (n > _capacity)
	{
		reserve(n);
	}
	//大于我就重新设置内容,小于则不进循环但size就没有改变
	while (_size < n)
	{
		_str[_size] = c;
		_size++;
	}
	//
	_size = n;//必须再次设置,小于时_size不会变,在此设置
	_str[_size] = '\0';

}

empty

返回_size==0,判断是否为空

bool string::empty() const
{
	return _size == 0;
}

clear

功能:将字符串清空
将_size改为0,再将此时的起始位置_size的值设为’\0’即可。

void string::clear()
{
	_size = 0;
	_str[_size] = '\0';
}

访问相关函数

operator[]

功能:
该函数是为了让string类对象能够像数组一样通过[]+下标访问字符串,并且支持修改对应的值;所以我们重载operator[]来实现这一功能。

由于能够直接修改字符串内容,所以对于位置需要严加小心,assert一下;我们的字符串是用_str维护的,而_str是我们在堆区开辟的连续空间,是可以通过下标访问的,所以直接_str[]的形式返回即可。由于是引用返回,所以能够修改对应的字符。对于只读不修改的operator[]我们再重载一个const版。

char& string::operator[](size_t pos)
{
	assert(pos < _size);//size为'\0'
	return _str[pos];
	
}

const char& string::operator[](size_t pos) const
{
	assert(pos < _size);//size为'\0'
	return _str[pos];
}

字符串修改相关函数

insert

先自己实现一个插入字符的insert函数(库里没有使用下标一个插入字符的insert);
对于insert,可以在任意位置插入一个字符:先检查插入位置是否合规,大于_size的pos才属于非法位置,接着检查是否需要扩容,需要的话使用reserve进行扩容,一般以二倍或一点五倍的速度扩容。要注意我们使用的类型为size_t——无符号整型。 将pos开始的数据向后移动一位,最后将字符插入pos位置,记得_size++

  • 数据从后先前往后移。
  • 以上移动方法是带’\0’的。
string& string::insert(size_t pos, char ch)
{
	//1.检查pos是否合法
	assert(pos <= _size);//'\0'也是该串可插入的范围
	//2.检查是否需要扩容
	if (_size + 1 > _capacity)
	{
		reserve(_capacity * 2);//;两倍速扩容
	}
	//开始插入
	size_t end = _size + 1;//移动到的新位置
	while (end > pos)//pos==end即可
	{
		_str[end] = _str[end - 1];//'\0'也移动
		end--;
	}
	_str[end] = ch;//此时pos==end
	_size++;
	return *this;

}

对于插入字符串的insert,需要知道插入字符串的长度len,我们的方法是将end设为插入字符后的最后位置,间隔len个字符向后移,end–不断将前面的字符往后移,直到end-len==pos时即为最后一次移动,所以end > len + pos - 1可作为循环结束的条件。

string& string::insert(size_t pos, const char* s)
{
	//1.检查pos是否合法
	assert(pos <= _size);//'\0'也是该串可插入的范围
	size_t len = strlen(s);
	//2.检查是否需要扩容
	if (_size + len > _capacity)
	{
		reserve(_capacity +len);//具体大小扩容
	}
	//插入
	size_t end = _size + len;//移动到的新位置
	while (end > len + pos - 1)//end==len+pos为最后一次移动 //对比上面插入一个字符,len==即插入一个字符
	{      //end>=len+pos也行
		_str[end] = _str[end - len];
		end--;
	}
	strncpy(_str + pos, s, len);//指定个数
	//strcpy(_str + pos, s);//连'\0'也拷,后面有内容的要使用strncpy
	_size += len;
	return *this;
}

push_back

功能:尾插一个字符。
有了insert,插入操作就比值得一谈了,直接使用insert尾插

void string::push_back(char c)
{
	insert(_size, c);
}

+=

功能:尾插一个字符或字符串。
该运算符重载函数较为常用,同样复用insert实现

string& string::operator+=(const char* s)
{
	insert(_size, s);
	return *this;
}

string& string::operator+=(char c)
{
	insert(_size, c);
	return *this;
}

append

功能:尾插一个字符串
这里就能体现string类的接口过于冗余了。依旧复用insert

string& string::append(const char* s)
{
	insert(_size, s);
	return *this;
}

erase

功能:删除任意位置的指定个字符
有两种情况:

  1. 从pos开始全删.
  2. 从pos开始删除部分

为区分这两种状况,用一个range表示可串中删除的个数,如果指定删除的个数len大于等于可删除的个数range,说明从pos开始全删,只需要将_size(‘\0’下标)改为pos,并将该位置的字符改为结束标志’\0’即可;否则即为删除pos后的部分字符,使用strcpy将剩余部分即 _str + pos + len开始到’\0’部分,拷贝到pos处覆盖要删除的内容。

string& string::erase(size_t pos, size_t len)//size_t pos = 0, size_t len = npos,
{                                            //全缺省,不指定删除区间则全部删除
	assert(pos <= _size);//可以在pso位置,(开区间),但该位置不删除
	assert(!empty());
	size_t range = _size - pos;//可删除的个数
	//两种情况
	//1.从pos开始全删
	if (len >=range)//npos的情况只会在这里
	{
		_size = pos;
		_str[_size] = '\0';
	}
	else//从pos开始删除部分
	{
		strcpy(_str + pos, _str + pos + len);
		_size -= len;
	}
	return *this;

}

pop_back

功能:尾删一个字符
复用erase,删除的时有效字符,即_size的前一位。

void string::pop_back()
{
	erase(_size - 1, 1);
}

string相关操作

c_str

功能:返回一个字符串指针。
C++兼容C语言,在部分场景中,需要获取指针字符串的指针,但此时 _str 为私有成员,所以需要通过函数间接获取指针 _str。

const char* string::c_str() const
{
	return _str;
}

find

功能:从指定位置(默认从下标0开始找)所要查询的字符。
确保pos在有效位置,遍历字符串,找到则返回有效位置pos,否则返回无效位置npos。

size_t string::find(char c, size_t pos) const
{
	assert(pos < _size);
	while (pos < _size)
	{
		if (_str[pos] == c)
		{
			return pos;
		}
		pos++;
	}
	return npos;
}

功能:查找一个字符串。
使用库函数strstr,该函数找到则返回起始位置的指针,通过与_str(起始位置)相减,得到坐标。

size_t string::find(const char* s, size_t pos) const
{
	assert(pos < _size);
	const char* find = strstr(_str + pos, s);
	if (find != NULL)
	{
		return find - _str;
	}
	//到此处说名find为NULL
	return npos;
}

swap

功能:交换两个string对象
借助函数库的swap,交换string的成员变量。

void string::swap(string& str)
{
	std::swap(_str, str._str);
	std::swap(_size, str._size);
	std::swap(_capacity, str._capacity);

}

前面说了string的拷贝构造和赋值重载借助string的swap可以写得更加简洁。

拷贝构造现代写法

拷贝构造就是拷贝一个一模一样的string对象,所以可以使用源字符串借助构造函数构造一个临时的tmp对象,该tmp对象与源string对象一模一样,这时再使用swap与tmp交换,就可以达到拷贝的效果了。

string::string(const string& str)
{
	string tmp(str._str);
	swap(tmp);
}

赋值重载现代写法

与上面类似,不过这里是借助拷贝构造函数生成一个临时的tmp对象,再使用swap完成交换,达到赋值的效果;记得先判断是否自己给自己赋值这种无效操作。

string& string::operator=(const string& str)
{
	if (this != &str)//防止自己给自己赋值
	{
		string tmp(str);//构造一个临时对象,再将临时对象与对象使用swap交换
		swap(tmp);//string的swap
	}

	return *this;
}

比较运算符重载

这部分较为简单,主要使用了库的strcmp函数,以及比较运算符重载的复用与逻辑关系。

>

bool string::operator>(const string& s) const
{
	return  strcmp(_str, s._str) > 0;
}

>=

bool string::operator>=(const string& s) const
{
	return *this > s || *this == s;
}

<

bool string::operator<(const string& s) const
{
	return  strcmp(_str, s._str) < 0;
}

<=


bool string::operator<=(const string& s) const
{
	return *this < s || *this == s;
}

==

bool string::operator==(const string& s) const
{
	return  strcmp(_str, s._str) == 0;
}

!=

bool string::operator!=(const string& s) const
{
	return !(*this == s);
}

非成员函数

<<流提取

为达到cout<<的效果,我们在类外实现,由于有众多访问接口,我们不用将其设为友元函数。
string类的流提取是按_size打印的(字符串按’\0’打印),所以直接使用范围for获取打印。

ostream& djs::operator<<(ostream& os, const string& str)
{
	//按size打印
	for (auto e : str)
	{
		os << e;
	}
	return os;
}

流插入>>

向string类输入也是以空格’ ‘或’\n’为结束标志;为可接收空格或’\n’,使用istream类中的get()输入;为了更好的效率,先将输入的字符放入一个大小为128的字符数组buf,并用i记录存放的下标,当i==127时则说明数组可存放的有效字符满了,将下标为127处的字符设为’\0‘,使用+=尾插到_str中;结束输入时判断数组中是否还有字符,有则再次+=进_str。

//以空格或\n换行作为结束
istream& djs::operator>>(istream& is, string& str)
{
	str.clear();
	char buf[128];//最多存放127个字符
	int i = 0;
	char ch;
	ch = is.get();//用get获取
	while (ch != ' ' && ch != '\n')
	{
		buf[i++] = ch;
		if (i == 127)//一次加进str里效率高
		{
			buf[i] = '\0';
			str += buf;
			i = 0;//重置
		}
		ch = is.get();
	}
	//结束输入
	if (i != 0)//说明还有
	{
		buf[i] = '\0';
		str += buf;
	}
	return is;
}

getline

string的流插入无法接收空格,所以还有一个getline函数来解决:可以按指定的字符作为输入结束的标志,其逻辑与流插入一致,只是判断结束的标志不同。

  • 这里使用了半缺省参数delim默认为’\n’。
istream& djs::getline(istream& is, string& str, char delim)
{
	str.clear();
	char buf[128];//最多存放127个字符
	int i = 0;
	char ch;
	ch = is.get();//用get获取
	while (ch != delim)
	{
		buf[i++] = ch;
		if (i == 127)//一次加进str里效率高
		{
			buf[i] = '\0';
			str += buf;
			i = 0;//重置
		}
		ch = is.get();
	}
	//结束输入
	if (i != 0)//说明还有
	{
		buf[i] = '\0';
		str += buf;
	}
	return is;
}

总结

string作为我第一个学习的容器,可以帮助熟悉STL的用法,也加深了对string的理解;本篇string的模拟实现难度不大。主要讲了深浅拷贝的问题,开始接触迭代器,剩余的也只是对之前C++内容学习的一个大杂烩吧。当然我们也只是浅浅的模拟了一下,也还有很多不足,如使用strcpy/strncpy导致模拟实现的string无法完全达到库中string的效果,有兴趣的可以自己再改改。完整代码之后传上gitee。篇幅过长,错误请在评论区提醒,Thanks~

  • 6
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值