学习C++,特别是C++中的STL部分,重点不是学习如何去使用STL,而是知道其底层原理是怎么样的,是怎么去实现的。因此,本篇文章带来的是对C++中的string的模拟实现。废话不多说,让我们去了解string是如何实现的吧!
一.模拟实现构造函数
对于构造函数,在官方库中,C99有下面种类:
我们主要实现的是
string();
string(const char* s);
string(const string& str);
前两个称为构造函数,第三个称为拷贝构造
①无参构造函数string();
//无参构造
string()
{
_str = new char[1];
_str[0] = '\0';
_size = _capacity = 0;
}
解析:由于是无参构造,在创建string对象时,字符串是空的,需要一个'\0',所以_str开辟一个字符的空间,用来存放'\0',然后将字符串有效个数和字符串空间赋值为0.
②带参构造函数string(const char* str);
//带参构造
string(const char* str)
{
_size = strlen(str);
_capacity = strlen(str);
_str = new char[_capacity + 1];
strcpy(_str, str);
}
解析:先对_size和_capacity进行初始化,而且_capacity没有额外+1,而是在_str开辟空间的时候才加1,加1的目的是放'\0',而先不加1的原因是在后面实现插入数据的时候,我们需要用到if(_size==_capacity)这个条件来判断容量是否满了,需要扩容,如果先加1,就不好操作了。最后将str的内容拷贝到_str中去。
温馨提示:带参构造函数没有使用初始化列表,原因有其下:如果使用初始化列表,在初始化_str,_size和_capacity的时候,有以下方法:
方法①:
string(const char* str)
:_str(str)//不能这样初始化:因为这样给,给的常量字符串,常量字符串存在常量区,常量区不能被修改
{//......
}
方法②:
//将上面写法改成下面这种,使用new来开辟空间,空间的大小为str的大小+1
string(const char* str)
:_str(new char[strlen(str)+1])//+1存放'\0',strlen不包含'\0'
,_size(strlen(str))
,_capacity(strlen(str))
{}
//但我们发现,如果使用上面的方法,我们需要使用3次strlen,这样的方法不好
//其实我们可以不使用初始化列表,这样就能只是用一次strlen
对于带参构造函数,上面那种方法还不够好,因为我们还可以使用缺省参数!
看下面的实现代码:
string(const char* str = "")
{
_size = strlen(str);//0
_capacity = _size;//0
_str = new char[_capacity + 1];//0+1=1
strcpy(_str, str);
}
这里的缺省值为什么是""?
解析:""代表的是空字符,里面就隐藏着一个'\0',由于strlen是以第一个'\0'为终点的,所以当我们给的缺省值是""的时候,strlen计算出来的长度是0。到这里,肯定会有人想,为什么不直接给nullptr或'\0'? 因为nullptr给了str后,str为空指针,strlen不能在空指针上使用,就会报错。而给'\0','\0'是一个字符,其ascll码值为0,直接给指针类型的变量,也相当于是空指针了。所以着两种都不可以。如果给"\0",这个是可以的,这个跟""类似,""是带一个'\0',而"\0"是字符串,里面有两个"\0",计算出来的长度都为0.
③拷贝构造函数
拷贝构造函数,在C++中,有两种版本的写法,称为传统版本和现代版本。
先来看传统版本的:
//拷贝构造的传统写法
string(const string& s)
{
_str = new char[s._capacity + 1];
_capacity = s.capacity();
_size = s._size;
strcpy(_str, s._str);
}
解析:拷贝构造跟构造函数的实现方法差不多,区别就在于拷贝构造是将参数s的属性内容全部拷贝到this中,所谓this,就是调用拷贝构造的string类对象的指针。
对于拷贝构造函数,最重点的是深拷贝和浅拷贝!
这里重点解析一下深拷贝和浅拷贝。
浅拷贝:说实在的,浅拷贝虽然带着个拷贝,但是其空间内存是共用的。看下图:
这会导致什么呢?当我们调用完s2,但后面的程序还需要用到s1的时候,析构函数会将调用完,不需要再使用的s2释放掉,将其空间内存还给操作系统。到了这一步,问题就出现了,我的s2的内存是还给操作系统了,但是s1也是指向这个内存空间的啊!s1还需要用到,这不就导致内存泄漏了吗!报错就报错了在这里了!
所以,我们需要用到深拷贝。
深拷贝:给s2独自开辟一块空间,用来存放"hello world",这样以后,s1和s2是独立的,有自己各自的房间,谁也不会影响谁。所以我们看到上面的代码,给_str开辟了一块与s1同样大的空间,这就是深拷贝。
这里我们额外结合之前的知识总结一下,什么时候用到深拷贝,什么时候不需要深拷贝,只需浅拷贝即可。
非自定义类型,比如int、char、double等等的类型,不需要用到深拷贝,同样的它们也不需要用到析构函数。
对于自定义类型:比如string就需要用到深拷贝了,因为每个对象都需要有自己的小房间。
然后来看看现代版本的,对于现代版本,追求是代码简介,不是效率。看下面代码:
string(const string& s)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
string tmp(s._str);//调用构造函数
swap(_str, tmp._str);
swap(_size, tmp._size);
swap(_size, tmp._capacity);
//tmp不能指向随机值,所以要初始化
}
解析:现代版本的构造函数,使用了初始化列表来初始化s2。因为我们是创建了tmp对象,来为s2打工,让tmp和s2交换,交换后的tmp不能指向随机值,而是指向nullptr,所以要初始化s2。通俗的来讲,tmp相当于s2的打工人,为s2获取s1的属性,然后将其交给s2,而s2把工资(nullptr)发给tmp。
但是这样的写法不够简介,而且需要调用swap,调用3次拷贝构造(传值传参嘛)。在改写之前,我们来区分一下string自带的swap和C++库自带的swap的区别:
string自带的swap:
C++库自带的swap:
我们可以看到,C++库自带的swap函数,是模板类型的,每一次调用,都需要推演出参数的类型,在上面的代码中,就需要推演int、char*两种类型,需要拷贝3次,比较花时间。而使用string类自带,已经定义好string类,并且是引用,不需要拷贝。所以我们来实现一下string类的swap。
其实,我上面讲的,虽然我们模拟实现了string类的swap,调用了string的swap,但效率是一样的,因为实现的代码是这样的:
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
我上面说了,一切都是为了代码的简洁哈哈哈哈哈哈哈哈哈,上面说了那么多,是为了让我们了解一下底层原理。
最后,我们可以将现代版本改写成这样子:
string(const string& s)
:_str(nullptr)
,_size(0)
,_capacity(0)
{
string tmp(s._str);
//this->swap(tmp);
swap(tmp);
}
④析构函数
析构函数对于自定义类型也是很重要的。因为需要释放掉内存空间。
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
⑤operator=()赋值
operator=()跟拷贝构造一样,也有传统和现代写法
先来个传统的写法:string s2(s1);
string& operator=(const string& s)
{
if (this != &s)//不能给自己赋值
{
char* tmp = new char[s._capacity + 1];
strcpy(tmp, s._str);
delete[] _str;//需要把s2原本的空间给释放掉
_str = tmp;
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
解析:思路很简单,就是创建一个新的char* ,来接收s,然后释放掉s2原本的内存空间,再指向tmp的内存空间即可。
然后来看现代写法:
跟拷贝构造的现代版本一样,使用交换,找个打工人。
string& operator=(const string& s)
{
if (this != &s)
{
string tmp(s);//直接调用拷贝构造
swap(tmp);
}
return *this;
}
二.模拟实现string类的容量操作
①size() 和 len(),还有capacity()
这size() 和 len()的功能是相同的,都是返回字符的有效个数。这里就写size()。
size_t size() const
{
return _size;
}
为什么要加个const?因为size是只读,加const保护起来吧。
capacity()是返回总空间的大小。可以理解为:size是水的体积容量,capacity是水杯瓶子的容量
size_t capacity() const
{
return _capacity;
}
②clear()
清除掉有效字符,但是不改变空间大小。把水瓶里面的水喝完吧。这个实现的方法是直接将_size置为0,然后将0位置的_str[0]赋值'\0',就行了
void clear()
{
_size = 0;
_str[0] = '\0';
}
③resize(size_t n = 0)/resize(size_t n = 0, char c = '\0');
这个函数重要。我们先来分析这个函数的功能如何:
1.这个函数的功能是对有效字符的个数进行操作。它有三种情况。①当n大于字符串的长度并且小于capacity的时候,就是进行插入数据的处理②当n大于capacity,并且大于字符串长度的时候,是进行插入数据+扩容的处理。③当n小于字符串长度的时候,便是删除数据。
2.对于resize(size_t n)/resize(size_t n,char c)这两种重载,需要知道的是,resize(size_t n)是将多余的空间用0来填充,而resize(size_t n,char c)用字符c来填充。
了解到这里,我们就可以去模拟实现它了。当然啦,我们发现我们这里实现的时候,用到了reserve和operator[],这两个我们还没实现,但这样说明了C++在设计的时候,很多功能都是互相辅助的,没你没我都不行。
void resize(size_t n = 0, char val = '\0')
{
//当n大于_size
if (n > _size)
{
reserve(n);//判断是否需要扩容,即n是否大于capacity
for (size_t i = _size; i < n; i++)
{
_str[i] = val;
}
_size = n;
_str[n] = '\0';
}
else//小于 删除
{
_str[n] = '\0';
_size = n;
}
}
解析这段代码:我们通过上面的情况,当n>_size,自然是要插入数据,然后再看看是否需要扩容,这一步交给reserve(n)来完成。插入数据这一步,从第_size这个位置开始,这个位置是值是'\0',自然会被覆盖掉,然后一直到n-1,在第n个位置补上'\0',最后将_size的长度变为n。完成!当n<_size,自然是相当于删除数据,直接在第n个位置补'\0',然后_size改成n,完成!当n==_size,什么都不用干。
④reserve(size_t n)
这个函数的功能,是增大水杯的容量,一升的水杯不够我喝,那我就用两升的,两升的不够,那就十升。所以,对于这个函数,它的功能是扩容,为字符串预留空间,是把空间(capacity)增大,不会缩小空间,而且不会改变有效字符的个数或长度。具体是①当n大于当前的空间的时候,那么reserve会将空间扩大。②当n小于等于当前空间的时候,那么不会对原有的空间做什么事。
看下面实现的代码:
void reserve(size_t n)
{
if (n > _capacity)//判断是否需要扩容
{
char* tmp = new char[n + 1];//多加1,给'\0'
strcpy(tmp, _str);//将_str的内容拷贝过去
delete[] _str;//释放原本的空间
_str = tmp;//拿到tmp的内存空间
_capacity = n;//最后将容量改为n
}
}
三.模拟实现访问及遍历的函数
①operator[](size_t pos);
这个比较简单实现,在返回前最后断言一下。当然,需要写两个重载类型,一个是可读可写,一个是只读不写。
//可读可写
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
//只读
const char& operator[](size_t pos) const
{
assert(pos < _size);
return _str[pos];
}
②begin()+end()
实现begin()和end(),用到迭代器。迭代器就是一种用法跟指针差不多,但不一定是指针的东西。现阶段,我们可以只知道它的用法,就是把它当成指针用就行了,但是不一定是指针。
代码如下:
typedef char* iterator;
iterator begin() const
{
return _str;
}
iterator end() const
{
return _str + _size;
}
解析:对于begin(),返回的_str,因为_str是指针类型, 指向的是首元素的地址,便是最开始的那个位置,即begin。对于end(),返回的是_str+_size,指向的是最后一个位置,即'\0'这个位置。
③范围for
其实范围for,看着好像很高大上一样,我们在用的时候,不知道它为什么能够识别到循环的起点和重点,为什么有这样的功能。这里揭秘:范围for其实就是迭代器的分身!它相当于宏替换一样,当我们写出范围for后,在编译代码的时候,编译器就会将其展开,变成了使用迭代器来实现循环的代码一样了。看下面代码:
string s("12345");
//使用迭代器
string::iterator it = s.begin();
while (it != s.end())
{
(*it)++;
++it;
}
cout << s.c_str() << endl;
//范围for
for (auto& ch : s)
{
ch--;
}
cout << s.c_str() << endl;
结果分别为:23456和12345
然后,如果我们将迭代器中的begin()改为Begin(),也就是将函数名称改一改,接下来我们就会发现,虽然while循环依然可以使用,范围for用不了了。
报错的内容为:
咦?不对啊,我都没有在范围for循环中用到begin()这个函数,怎么会在报错信息中出现这个?这也就很好地解释了我上面所讲的,范围for类似于宏替换,将使用迭代器的循环替换成了范围for。
四.模拟实现string类对象修改操作
①push_back()
push_back的实现,相当于数据结构中的顺序表差不多,如果我们对顺序表的实现熟悉的话,实现push_back一点问题都没有。
在插入数据前,判断一下容量是否满了,如果满了就进行扩容。然后在_size处插入数据,然后将_size++,最后在_size处添上'\0'。代码如下:
void push_back(char ch)//可以二倍扩容,但是要注意,如果是个空字符串,也就是说capacity是0
{
if (_size == _capacity)
{
size_t newCapacity = _capacity == 0 ? 4 : _capacity * 2;
reserve(newCapacity);
}
_str[_size] = ch;
_size++;
_str[_size] = '\0';
}
加个小知识:
我们来看看每次是如何扩容的?
使用下面代码来测试一下:
void TestPushBack()//每次扩容代价都比价大,提前把空间开好
{
string s;
size_t sz = s.capacity();
cout << "making s grow:" << endl;
for (int i = 0; i < 1000; i++)
{
s.push_back('c');
if (sz != s.capacity())
{
sz = s.capacity();
cout << "capacity changed: " << sz << endl;
}
}
}
在windows下,vs2019编译器:
在Linux下:
结论:在vs2019的编译器下,每一次扩容的容量是一点五倍左右,而在Linux系统下便是两倍。
②append();
append()的实现也差不多,不过当容量满了,扩容的时候,扩二倍就一定够吗?肯定不是的,比如当原来的内容是"hello\0",而我要插入"world hello world\0",这个字符串就已经比本身的二倍还要长,所以是不能直接扩二倍。而是扩容到需要追加的字符串的长度再加1,这个长度才行。
void append(const char* str)
{
int len = strlen(str);
if (_size + len > _capacity)//判断原有的长度加上要追加的字符串长度是否大于现有的容量
{
reserve(_size + len);
}
strcpy(_str + _size, str);//size下标的位置开始,拷贝_str,结尾有'\0',不需要额外加'\0'
_size += len;
}
③operator+=();
这个需要模拟实现两种情况,因为它既可以追加一个字符,也能追加一个字符串。而且,它的模拟实现,我们可以直接调用push_back和append。
//追加字符串
string& operator+=(const char* str)
{
append(str);
return *this;
}
//追加字符
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
④c_str()
这个函数,是返回C格式的字符串。何为C格式的字符串?在c里面,一个字符指针拿到的是字符串的首元素的地址。使用这个函数,就可以拿到这个地址了。
代码如下:
const char* c_str() const
{
return _str;
}
⑤find()
find()函数返回的是目标字符的位置下标,默认从0开始,也就是说缺省值为0,是半缺省函数。我们通过循环来找。当然,find()函数可以找字符,也可以找字串。对于找某个字符来说,直接使用循环遍历,找到就返回下标。对于找子串,我们可以使用strstr函数来找,返回的是子串的首元素的地址,用char*类型的变量来接收,最后返回首元素的下标。代码如下:
//找字符
size_t find(char ch, size_t pos = 0) const
{
assert(pos < _size);
while (pos < _size)
{
if (_str[pos] == ch)
{
return pos;
}
++pos;
}
return npos;
}
//找子串
size_t find(const char* str, size_t pos = 0) const
{
assert(pos < _size);
const char* ptr = strstr(_str + pos, str);
if (ptr == nullptr)//判空,如果没找到,就返回npos
{
return npos;
}
else
{
return ptr - _str;//找到,就ptr-_str,即为首元素的下标
}
}
⑥insert()
inset()函数相当于是顺序表里面的任意位置插入,所以我们就知道了它的效率不太行,如果字符串很长很长,但我们想要在字符串里面插入字符或字符串,那么就需要挪动很多字符。这也是我们为什么必须学习其底层的实现方法,只有了解了,学习了才能知道哪些接口好用,哪些不好用。
在插入之前,老办法,先判断容量是否满了,如果满了,那就扩容。同样了,insert重载了两种方法,一个是插入字符,一个是插入字符串。
注意,这部分有坑,所以要重点讲解。
插入字符:先来看下面代码:
string& insert(size_t pos, char ch)
{
assert(pos < _size);
if (_size == _capacity)
{
int NewCapacity = _capacity == 0 ? 4 : _capacity * 2;
reserve(NewCapacity);
}
size_t end = _size;
while (end >= pos)
{
_str[end + 1] = _str[end];//往后挪
end--;
}
_str[pos] = ch;
_size++;
return *this;
}
这一看,是没问题。如果我们给出的例子是这样的
根据上面代码,最后将第一个位置的w挪到第二个位置后,x就会插入到下标为0的这个位置,但实际上.......我们一运行,陷入了死循环。
为什么?原因就在于end我们的类型是无符号的,不会有负数,当end等于0的时候,将第一个w字符挪动后,不会变成-1,而是变成一个很大的值,直接进入下一个循环。
下面给出调试的结果:
那么,就有同学会想,我们可以把size_t改为int呀!
好的,我们现在来改一下:
当我们的end变为-1的时候,我们会发现一个问题,怎么回事?明明end为-1,pos为0,而循环条件是end>=pos,还会进入循环?其实原因就在于int隐式提升,int会提升为size_t。在C/C++中,当小的类型于相较大的类型做运算时,小的类型会向大的类型提升,比如int跟double做运算时,int会提升为double。
其解决方法就是,将pos强制转换成int类型。还有就是,在C++的string类的库中,end的类型就是size_t的,我们既然要模拟实现string,我们就遵循规则。那么我们该如何取解决这个问题呢?
好办!让end再往后挪一格,此时,我们循环挪动数据的目标,就是挪动数据,不是放数据,此时循环条件变成end>pos,不能等于。
看下面代码:
string& insert(size_t pos, char ch)
{
assert(pos < _size);
if (_size == _capacity)
{
int NewCapacity = _capacity == 0 ? 4 : _capacity * 2;
reserve(NewCapacity);
}
//size_t end = _size;
//int end = _size;
//while (end >= (int)pos)
//{
// _str[end + 1] = _str[end];//往后挪
// end--;
//}
size_t end = _size+1;
while (end > pos)
{
_str[end] = _str[end-1];//数据挪的位置而不是放的位置
end--;
}
_str[pos] = ch;
_size++;
return *this;
}
插入字符串:先看下面代码:
根据上面的插入字符的代码,我们很容易就写出下面这样的代码:
string& insert(size_t pos, const char* str)
{
int len = strlen(str);
if (_size + len > _capacity)
{
reserve(_size + len);
}
size_t end = _size + len;
while (end > pos)
{
_str[end] = _str[end - len];
end--;
}
strcpy(_str + pos, str);
_size += len;
return *this;
}
我们测试下面这个代码:
一运行,发现,只打印了hello,怎么回事?其实我们不能使用strcpy,因为它会将hello 里面的'\0'也拷贝了过去,所以也只打印了hello出来。
所以我们改一下这个代码,将strcpy改成strncpy;
运行,发现结果对了!好啦,就这样了!错!错哪?我们先来调试瞅一瞅
我们其实可以好好想想,看看这个调试的结果
当pos等于6,而end也等于6的时候,应该就停止了,直接将最近的字符串拷贝过去, 覆盖掉,但是循环并没有停止,反而进行进行,这会导致什么结果?end小于pos了,减掉是负数,在无符号下,是一个很大的数,所以我们接着调试运行看看结果:
它会将随机数给挪过去了!这显然是不可以的。所以我们必须控制好循环条件,将循环条件改为end>=pos+len,或者是end > pos+len-1.
⑦erase()
erase()函数是个半缺省函数,如果我们不写需要删除的字符串的长度,那么就会默认使用npos长度,也就是说从pos位置开始删,删完全部。又或者说是给出的长度加上pos,这个长度大于_size,也会将所有的字符串删掉
而当长度小于_size,那么我们就需要往前挪动数据即可。代码如下:
string& erase(size_t pos, size_t len = npos)
{
//当不给长度len时,默认是npos,或者需要删除的长度大于_size,那么就是删除从pos开始到结尾
if (len == npos || pos + len > _size)
{
_str[pos] = '\0';
_size = pos;
}
else
{
size_t begin = pos + len;
while (begin<=_size)
{
//往前挪动数据
_str[pos] = _str[begin];
pos++;
begin++;
}
_size -= len;
}
return *this;
}
⑧operator<<()
要实现string的流输出,我们要明确一下格式,是cout<<s<<endl;所以我们不能将operator<<()函数写在类域中,因为类域中的函数,默认第一个参数是this。但我们又必须拿到string类中的私有变量,那就使用友元吧,但也不一定需要友元,我们可以直接在类域外写。
ostream& operator<<(ostream& out, const string& s)
{
for (size_t i = 0; i < s.size(); i++)
{
out << s[i];
}
return out;
}
⑨operator>>()
实现流输入,跟流输出一样,写在类域外。重点在于,我们有几个细节需要考虑。第一个,在输入的时候,可能已经有内容了,是重新输入的,所以在输入之前,先将原本的内容情况。第二个,为了避免不断扩容,我们可以定义一个字符数组,用来存放输入的字符,当字符数组满了,再追加到string对象中,然后重新输入。第三个,由于流输入cin跟scanf一样,遇到空格或换行就会停下来。最后,代码如下:
istream& operator>>(istream& in, string& s)
{
s.clear();
char buff[128] = { '\0' };
size_t i = 0;
char ch = in.get();//一个字符一个字符地拿
while (ch != ' ' && ch != '\n')
{
if (i == 127)//127这个位置留给'\0'
{
s += buff;
i = 0;
}
buff[i++] = ch;
ch = in.get();
}
if (i >= 0)//当i!=127但是又有空格了,那么跳出循环,将当前i这个位置给上'\0'表示字符串结束位置
{
buff[i] = '\0';
s += buff;//最后追加给s
}
return in;
}
vs和g++下string结构的说明
注意:下述结构是在32位平台下进行验证,32位平台下指针占4个字节
vs下string的结构:
string总共占28个字节,内部结构稍微复杂一点,先是有一个联合体,联合体用来定义string中字
符串的存储空间:
①当字符串长度小于16时,使用内部固定的字符数组来存放
②当字符串长度大于等于16时,从堆上开辟空间
union _Bxty
{
char _buff[16];
char* _ptr;
size_t _size;//保存字符串长度
size_t _capacity;//保存从堆上开辟空间的总容量
} _Bx;
这种设计是有一定道理的,大多数情况下字符串的长度都小于16,那string对象创建好之后,内
部已经有了16个字符数组的固定空间,不需要通过堆创建,效率高,本质上是以空间换时间。
g++下string结构:
G++下,string是通过写时拷贝实现的,string对象总共占4个字节,内部只包含了一个指针,该指
针将来指向一块堆空间,内部包含了如下字段:
空间总大小
字符串有效长度
引用计数
struct _Rep_base { size_type _M_length; size_type _M_capacity; _Atomic_word _M_refcount; };
比如有两个string类的对象s1和s2,然后进行s1(s2)这样的拷贝构造时,会默认是浅拷贝,然后在析构的时候,根据引用计数的个数来判断是否释放空间。那什么时候会进行写时拷贝呢?那就是s2要修改数据的时候,就会额外给s2一个空间。这就跟操作系统中的父子进程概念类似!
本篇文章结束~这就是模拟实现string的详细过程,如果有什么不懂的可以下方评论留言~喜欢的朋友可以点个收藏~