文章目录
前言
本文主要介绍对C++中的string容器的简单模拟实现,在实现的过程中主要参考SGI版本STL库,模拟实现之后我们将会对string加深印象理解更为深刻。
1.实现的大致思路
在实现之前我们可以先去看看stl库的源码大致了解一下。之前介绍string容器的时候就提到过string是模板类的其中一种实例化。我们自己在实现的时候不写模板之类的了,string容器的底层其实类似于之前实现的顺序表,成员变量大致就3个c_str capcacity size。这3个变量我们很容易猜到它们用途,c_str肯定是指向存储字符串的空间,size表示存储字符的个数,capacity表示存储字符串空间的容量。然后就是围绕字符串的一些增删改查之类的,外加一些涉及一些空间容量的修改。以上便是我们实现string类大致方向了。
2.代码的具体实现
1.string类的简单定义
这里为了和库中保持一致还加了一个成员变量 npos,这个npos一定要设置成共有的,不然在类外部使用的时候就访问不了。
这里简单提一下被const修饰的静态整形成员变量被允许给缺省值,但是为了统一这里还是在类外初始化。
为了避免和类中的string起冲突,我们将自己实现的stirng封在命令空间中。这里还说明一下capacity是指存储有效字符的空间容量,也就是说\0是不被计算在内的,后续实现的需要注意一下。
代码示例
namespace Ly
{
class string
{
private:
char* _cstr;//指向存储字符串的空间
size_t _sz;//记录存储字符个数
size_t _capacity;//记录有效字符可存储的空间容量
public:
static const size_t npos;
};
const size_t string::npos = -1;
}
2.构造函数和析构函数
1.字符串构造(默认构造)
string(const char* str="")
:_sz(strlen(str))
{
_capacity = _sz==0?1:_sz;
_cstr = new char[_capacity + 1];//多出来的一个是用来存储\0
strcpy(_cstr, str);
}
这里在实现的有个细节需要注意
当使用默认的构造的时候,实际上string中存储的是个空串。这个时候sz的个数是0,因为空串不是字符,但是这个时候capacity不能为0,假如capcaity为0了,后续出现问题的,比如我实例化一个string对象,这个时候我用reserve进行空扩容的时候就会有问题,所以这里哪怕是空串capacity也给一个不为0的数,我这里给了1
.还有一点需要注意这个strcpy函数进行字符串拷贝的时候\0也会被拷贝进去。
2.拷贝构造
string(const string& str)
:_sz(str._sz),
_capacity(str._capacity)
{
_cstr = new char[str._capacity + 1];
strcpy(_cstr, str._cstr);
}
拷贝这里也很简单,代码也很容易看懂,这里就不过多赘述了。
3.析构函数
~string()
{
delete[] _cstr;
_cstr = nullptr;
_capacity = _sz = 0;
}
析构函数这个很简单释放掉申请的存储字符的空间,该置0的置0,该置空的置空。
3.关于空间扩容
1.reserve
void reserve(int n)
{
if (n <= _capacity)
{
return;
}
char* tem = new char[n + 1];
strcpy(tem, _cstr);
delete[] _cstr;
_cstr = tem;
_capacity = n;
}
这个reverse之前介绍过,当申请的空间小于原有的空间的时候是不做任何处理的,当申请的空间大于原有的空间的时候才会扩容,这里扩容很简单我们先用一个临时变量保存申请的空间地址,之后将原有的数据拷贝到申请的空间中,最后释放掉原有的空间,让_cstr指向新空间即可。
这样就不需要使用realloc,只用new即可,而且c++中也没有realloc。临时变量也不用处理,它的生命周期只在函数体内,出了作用域就会被销毁。用临时变量还有个好处,如果申请空间失败会抛出异常,原有的数据不会丢失。
因为扩容了,这个capacity也需要及时更新。
2.resize
void resize(int n, char ch = '\0')
{
if (n <= _sz)
{
_cstr[n] = '\0';
_sz = n;
}
else
{
if (n > _capacity)
{
reserve(n);
}
for (size_t i = _sz; i < n; i++)
{
_cstr[i] = ch;
}
_sz = n;
_cstr[_sz] = '\0';
}
}
这个resize中传入的参数n如果小于存储有效字符的个数,这个时候resize的作用就像是清理字符,我们直接在_cstr指向的空间n位置出放置\0,sz更新成n即可,如果等于存储有效字符的个数不用处理即可,按照上述代码逻小于等于n可以一起处理。
当n大于sz会有两种情况需要考虑,一个是n是小于capacity,这个时候就直接往_cstr中插入字符即可,当n大于capacity的时候就需要我们进行扩容处理,这个时候我们直接调用之前写好的reverse即可。
4.插入数据
这里插入数据主要实现在任意插入字符和字符串以及追加字符串(append函数)。
1.任意位置插入字符
string& insert(size_t pos, char ch)
{
assert(pos <= _sz);
if (_sz + 1 > _capacity)
{
reserve(_sz + 1);
}
/*这样做在头插入的时候会有问题,我们无符号整形往后减的时候
会变成int最大值
*/
/*size_t end = _sz;
while (pos <= end)
{
_cstr[end + 1] = _cstr[end];
end--;
}*/
//错开一位这样不用取等了,避免上述的情况
size_t end = _sz+1;
while (pos < end)
{
_cstr[end] = _cstr[end-1];
end--;
}
_cstr[pos] = ch;
_sz++;
return *this;
}
我们从\0开始的位置开始后移,这样的好处就是\0也被挪动到了应该在的位置上不用单独处理\0了,同时主要的就是如果我们从\0前面的位置开始挪动那么循环条件就会写成end>=pos,这样其实是会有隐患的,当我们需要在头部插入的时候这个end是无符号整形减到0的时候就会出现问题它下一次就会更新成整形的最大值,哪怕end被写成in类型,但是pos是无符号整形比较的时候会发生整形提升这也是有问题的。
所以我们从\0开始挪动。插入的时候还需要判断一下空间容量是否需要进行扩容。
2.任意位置插入字符串
string& insert(size_t pos, const char* str)
{
assert(pos <= _sz);
int len = strlen(str);
if (_sz + len > _capacity)
{
reserve(_sz + len);
}
size_t end = _sz + len;
while (end > pos)
{
_cstr[end] = _cstr[end - 1];
end--;
}
//不用拷贝\0,只用将字符串中有效字符拷贝进去即可,采用strncpy
strncpy(_cstr + pos, str,len);
_sz = _sz + len;
return *this;
}
这个插入字符串也是需要考虑扩容问题的,之后就是和插入单个字符一样进行数据的挪动,这个挪动数据其实和上面挪动一个字符是一样的过程,无非就是把1换成了len而已。还有就是我们挪动的时候\0其实也被挪动了这个时候就不要把\0拷贝进行字符空间,所以我们采用strnpcy进行拷贝。
尾插头插这些就不实现了,这些都可以通过复用insert来实现
3.追加字符串
void append(const char* str)
{
int len = strlen(str);
if (_sz + len > _capacity)
{
reserve(_sz + len);
}
strcpy(_cstr + _sz, str);
_sz += len;
}
这个最加字符串也很好实现,这都不涉及数据的挪动。我们直接在从_cstr位置往后偏移sz个位置处开始进行字符串的拷贝即可。
5.删除数据
string& erase(size_t pos, size_t len = npos)
{
assert(pos <= _sz);
//这里npos单独判断 因为npos是整形最大值 相加会溢出
if (pos+len >= _sz || len == npos)
{
_cstr[pos] = '\0';
_sz -= len;
}
else
{
strcpy(_cstr + pos, _cstr + pos + len);
_sz -= len;
}
return *this;
}
这里如果没有指定从pos处起往后删除多少个字符默认是全部删除,或者给出的len过大超出从pos位置起往后的有效字符个数,也是同样的处理方式,我们直接将pos处置为0,这样删除数据之后sz也需要更新处理。
这里有个处理细节npos是整形最大值是不能直接相加的不然会溢出,这里单独处理一下。
接下来直接len小于从pos位置到\0的长度,还是利用strcpy进行拷贝覆盖。
6.查找和交换
size_t find(char ch, size_t pos = 0)
{
assert(pos < _sz);
for (size_t i = pos; i < _sz; i++)
{
if (ch == _cstr[i])
{
return i;
}
}
return npos;
}
查找到了就返回对应位置下标,如果没找到就返回npos,这个支持下标访问需要重载[],下面后讲到先不急。
void swap( string& str)
{
std::swap(_cstr, str._cstr);
std::swap(_capacity, str._capacity);
std::swap(_sz, str._sz);
}
这个交换函数我直接复用库里的,把对应的类中成员变量进行交换即可。
7.操作符重载
1.赋值重载和[ ]重载
string& operator =(const string str)
{ //避免自己给自己复制重载
if (this != &str)
{
char* tem = new char[str._capacity + 1];
strcpy(tem, str._cstr);
delete[] _cstr;
_cstr = tem;
_capacity = str._capacity;
_sz = str._sz;
}
return *this;
}
char& operator[](int pos)
{
assert(pos < _sz);
return _cstr[pos];
}
这个赋值重载的时候要避免自己给自己赋值不然就会问题的,同时也可以避免产生不必要的消耗。[ ]重载没啥好说的,返回引用即可,如果考虑到const变量的情况,我们可以可以在写一个const修饰是 [ ]重载函数。
2.比较操作符重载
bool operator>(const string str)const
{
return strcmp(_cstr, str._cstr) > 0;
}
bool operator==(const string str)const
{
return strcmp(_cstr, str._cstr) == 0;
}
这里给出>和==的重载剩下的>= < <= !=复用这两个即可。
3.+=重载
string& operator+=(const char ch)
{
push_back(ch);
return *this;
}
string& operator +=(const char* str)
{
append(str);
return *this;
}
这里的+=重载实现了两个一个单个字符的拼接一个是字符串的拼接,这里直接复用之前实现的尾删和append即可。
4.输入输出流重载
ostream& operator<<(ostream& out, const string str)
{
for (auto ch : str)
{
out << ch;
}
return out;
}
istream& operator>>(istream& in, string & str)
{
str.clear();
char ch = in.get();
char buf[128];
size_t i = 0;
while (ch != ' ' && ch != '\n')
{
buf[i++] = ch;
if (i == 127)
{
buf[127] = '\0';
str += buf;
i = 0;
}
}
if (i != 0)
{
buf[i] = '\0';
str += buf;
}
return in;
}
流插入运算符比较好实现,因为之前实现了迭代器,我们直接使用auto来进行遍历访问即可。get() 是 istream 类的成员函数,它有多种重载形式。 get 函数是内置在 cin 对象中的,所以可称之为 cin 的一个成员函数。get 成员函数读取单个字符,包括特殊字符.
为了确保能将输入的字符串中的空格也被读到,我们使用get函数读取从键盘上输入的字符。同时为了避免频繁单个字符+=产生不必要的消耗,我们定义一个buf用来保存字符,当buf满了以后就把buf拼接给string对象,最后为了保证将buf中全部数据都拼接给string对象我们最后需要加一个额外判断再次拼接。
8.迭代器
因为这里是用char*指针来保存存储字符串的空间的,所以天然的就可以使用原生指针作为迭代器类行访问。
typedef char* iterator;
typedef const char* const_iterator;
iterator begin()
{
return _cstr;
}
iterator end()
{
return _cstr + _sz;
}
const_iterator begin()const
{
return _cstr;
}
const_iterator end()const
{
return _cstr + _sz;
}
因为库中标准规定了迭代器类型名称的iterator 或者const_iterator,这里typedef一下对类型重定义。
注意迭代器的区间的都是左开右闭[ )
9.其他成员函数
void clear()
{
_cstr[0] = '\0';
_sz = 0;
}
const char* c_str()const
{
return _cstr;
}
const size_t size()const
{
return _sz;
}
const size_t capacity()const
{
return _capacity;
}
clear函数没啥好说的直接将存储字符空间的第一个位置设为\0,_sz也置为0。剩下的capacity size函数之类的太简单了就没啥好说的。
3.总结补充
在实现string的时候其拷贝构造函数与赋值运算符重载都这些涉及到空间资源的申请分配的时候都是采用的深拷贝,每个对象的存储空间都是独立的。关于深浅拷贝之前提到过这里再次简单说一下:
浅拷贝:也称位拷贝,编译器只是将对象中的值拷贝过来。如果对象中管理资源,最后就会导致多个对象共 享同一份资源,当一个对象销毁时就会将该资源释放掉,而此时另一些对象不知道该资源已经被释放,以为 还有效,所以当继续对资源进项操作时,就会发生发生了访问违规。
深拷贝是指 拷贝对象时,每个对象都有一份独立的资源,不要和其他对象共享,拷贝结束之后俩个对象虽然存的值是一样的,但是内存地址不一样,两个对象互相不影响,互不干涉。
string有很多不同的版本,vs中在上述成员变量的基础上了增加了一个16字节大学字符数组用于存储数据,当需要存储的字符串较短时就会用这个数组存储数据,当字符串长度超过这个数组存储容量时候,就会使用上述我们实现的string的方式申请动态空间进行存储。这种做法就是相当于以空间换时间减少不必要的性能消耗。
在Liunx下G++下,string是通过写时拷贝实现的,string对象总共占4个字节,内部只包含了一个指针,该指针将来指向一块堆空间,内部包含了如下字段:空间总大小 符串有效长度 引用计数。假如两个string数据都是一样的,它们俩的数据可以存储同一个空间内相当于资源共享,这个引用计数就为2,表示有两个对象的数据存储在同一个空间中,当需其中一个对象被销毁的时候引用计数减1,不会调用析构函数,如果当另一个也要销毁的时候,引用计数为1这个时候就会调用析构函数如果对象需要对数据进行写操作才会为对象开辟新空间,Liunx的这做法就是在赌对象作用对数据进行读操作,一旦赌对了就是赚到了,赌错就分配空间也没啥损害。
以上内容如有问题,欢迎指正!