在之前的 【C++】深入理解String类(一)里,我们讲解了string类的相关知识与其中部分库函数的使用方法。
这次我们要根据string的用法,模仿实现写一个string类。
注:我们模拟实现这个类,不是为了完美复制源码,而是熟悉string框架,加深对string的理解,我会用我们已经学习过的有限知识,来简单还原string.
1. 创建一块自定义的命名空间
我们平常在写c++的时候,在开头都会写:
using namespace std;
这段代码的意思就是展开std命名空间,展开后,我们就可以随时使用std里的库函数。
所以,我们也可以模仿自定义一段命名空间:
namespace yyk
{
//string 模拟实现 区域
}
2. 确定基本框架
string 实际上就是一个类,我们在使用时,实例化这个类,并且调用其中的函数。
我们将成员变量私有化,成员函数公有化,留作接口,供外部使用:
class string
{
public:
//成员函数
private:
char*_str;
size_t _size;
size_t _capacity;
static const size_t npos;
};
这里有两个需要解释的点:
- 什么是size_t类型? 为什么要使用 size_t 类型?
size_t其实就是一个8字节的长整数
在32位架构中被普遍定义为:
typedef unsigned int size_t;而在64位架构中被定义为:
typedef unsigned long size_t;size_t在32位架构上是4字节,在64位架构上是8字节,在不同架构上>>进行编译时需要注意这个问题。而int在不同架构下都是4字节,与size_t不>同;且int为带符号数,size_t为无符号数
我们之所以使用 size_t 而不是 int ,是因为使用size_t可能会提高代码的可移植性、有效性或者可读性
与int固定四个字节不同有所不同,size_t的取值range是目标平台下最大可能的数组尺寸,一些平台下size_t的范围小于int的正数范围,又或者大于unsigned int. 使用Int既有可能浪费,又有可能范围不够大
- npos 是什么? 为什么需要npos?
string::npos参数 —— npos 是一个常数,用来表示不存在的位置
可以看出,npos =-1,但由于size_t是无符号整形,所以npos 等于size_t的上限,也就是4294967295。在使用string 时,我们的_size不可能达到这个数,最多为4294967294,也就是“不存在的位置”
至于为什么要设置这个常量,暂时先不讲,我们在用到的时候再讲解。
3. 完善功能
1. 重载 [ ]
我们知道字符串的底层实际就是一个字符数组,既然是数组,我们就可以通过以下方式去访问:
string a="hello world"
cout<<a[1]; //e
所以,要实现这种访问方式,我们要进行运算符重载:
char& operator[](size_t pos)
{
assert(pos< _size);
return _str[pos]; //等价于 *(_str+pos)
}
上面的是可读可写的,我们还可以设置一个只可读的:
const char& operator[](size_t pos)const //只读
{
assert(pos < _size);
return _str[pos]; //*(_str + pos)
}
ps: 为了接下来访问字符串方便,我们先实现这个功能
2.构造函数 与 析构函数
对于任何类,使用构造器初始化都是第一步
string (char* str="")
:_size(strlen(str)
:_capacity(_size)
:_str(str)
{}
这样写对吗?
不对
我们使用一段代码来看一下:
string s1 ("hello");
s1[0] = 'x';
我们运行这段代码,发现s1并没有被改变,为什么?
因为 “hello” 这段字符串 是常量字符串,存在常量区。我们初始化的时候将str赋给了_str,那么_str也就指向了这段常量区,可以访问但无法改变。
所以我们要想对字符串进行增删查改,得先另外开辟一段空间(在堆上),再将str指向的字符串拷贝到这段空间:
string (const char* str="")
:_size(strlen(str))
:_capacity(_size)
{
_str = new char[_capacity+1];
strcpy(_str , str);//将str指向的字符串拷贝到_str里
}
这里我们要注意两个点:
- 我们要对形参设置缺省值。当我们没有给string对象赋值,我们默认初始化为空,但这里str实际上里还有一个’\0’.
- 我们在给_str 分配空间的时候,要分配_capacity个,因为此时_capacity=_size=strlen(str). strlen()计算字符串长度的时候不包含’\0’,所以我们实际开空间要多开一个留给’\0’
析构函数比较简单:
~string()
{
delete[] _str;
}
3. str迭代器
对于string 迭代器,实际上就是指针。
- begin() 返回首字符的地址
- end() 返回最后一个字符,即’\0’的地址
但是,注意,不是所有的迭代器都是指针(比如list),因为不是所有容器的存储都是空间连续的。
同时,我们要设置两种迭代器,一种是可读可写,另一种const_iterator只能读。
public:
typedef char* iterator;
typedef const char* const_iterator;
iterator begin()
{
return _str;
}
iterator end()
{
return _str+_size;
}
const_iterator begin()const
{
return _str;
}
const_iterator end()const
{
return _str + _size;
}
测试:
//对于print函数,我们希望对字符串只读,所以调用const迭代器
void print(const string& s)
{
string::const_iterator it = s.begin();
while (it != s.end())
{
cout << *it << endl;
++it;
}
cout << endl;
}
4. 拷贝(重难点)
现在 在我们之前程序的基础上 测试这一段代码:
string s1("hello");
//使用s1去初始化s2,也就是“拷贝构造”
string s2(s1);
很显然,根据我们的构造函数,这是写法是浅拷贝,s1和s2 中的_str指向同一块堆上的空间:
浅拷贝会导致严重的错误:
- 当我们执行完程序之后,我们需要调用析构函数 释放堆上的这段空间,s2先调,空间被释放,s1后调,同一块空间再次被释放,很显然发生了错误,一段空间被析构两次。
- 同时,由于指向同一段空间,当我改变 s1或s2 的时候,另外一个也会被修改。
如何解决?
很显然,使用深拷贝。
深拷贝就是 拷贝对象,新开一块和原对象一样大的空间,再把原对象空间上的值拷贝过来。
讲完原因,现在我们来实现深拷贝
string (const string& s)
:_str(new char [strlen(s._str)+1])
{
strcpy(_str, s._str);
}
这里有两个点需要注意:
- 当涉及开空间的时候,我们都可以使用初始化列表
- 传参的时候我们传s的引用。如果我们直接传 s,实际上是把s1深拷贝给临时对象s,与直接传s 的别名(引用)相比,效率太低。同时我们为了防止s1引用被改变而导致s1的改变,还加了const
以上是深拷贝的传统写法,其实我们还有其他写法:
string (const string& s)
:str(nullptr)
{
string tmp(s._str);
swap(_str , tmp._str);
}
这段代码是啥意思?我们可以理解为“坐享其成”。
我们先创建一个对象tmp,调用构造函数,使用s._str去初始化tmp,也就是_str开辟的空间的内容与s相同。(注意:这里不是拷贝函数,构造的行参是指针,拷贝的行参是对象)
那么这个时候 tmp指向的空间就是我们 s2 想要的,所以我用swap 将s2._st(也就是this ->_str),和tmp._str交换,此时s2就指向了tmp原来指向的空间,效果上实现了深拷贝。
此时,tmp就指向了s2._str原来指向的空间,而s2在之前被我们初始化为nullptr了,所以在之后析构tmp的时候不会报错(delete nullptr 是被允许的)
但是,同学们会发现,这个写法好像没什么优化。
s2是偷懒了,调包了指向的空间。但整体上我们并没有占什么便宜,与深拷贝差不多。其实不是的,如果我们拷贝的是 list(链表) 或者map 之类的容器,此时使用s2自身去深拷贝是很“累”的。而且,在未来的学习中,这种写法会体现更大的价值,这里暂时无法体现。
5.赋值
对于赋值语句来说,简单来说就是对 = 的重载:
string s1="hello";
string s2="hello yyk";
s1=s2;
我们能将s2指向的字符串直接赋值到s1指向的空间吗?
显然不对
因为会出现下面两种情况:
- s2中的字符串长度超出s1的_capacity,造成越界。我们在赋值前要对s1进行扩容。
- s1中的空间很大,我们将s1的内容拷贝过来会有大量闲置空间余留,可能造成大量浪费
所以,我们这样解决:我们先释放s1的原有空间,再重新开一块和s1一样大的新空间。此时我们再将s2的数据拷贝到s1指向的新空间中。
string & operator = (const string& s)
{
//内容相同不赋值
if(this !=&s)
{
delete[] _str;
_str = new char[_size+1];//_szie等价于strlen(s._str)
strcpy(_str , s._str);
}
return *this;
}
但是其实我们最好先开新空间,再释放原空间。因为如果我们开空间失败(虽然不常见),程序抛异常,这个时候我们原空间也找不到了,可以说是“赔了夫人又折兵”。
所以这样写比较好:
string & operator = (const string& s)
{
//内容相同不赋值
if(this !=&s)
{
char*tmp = new char[_size+1];//_szie等价于strlen(s._str)
delete[] _str;
_str=tmp;
strcpy(_str , s._str);
}
return *this;
}
拷贝有 其他写法,其实赋值也有其他的写法。
string &operator = (const string& s)
{
if(this!= &s)
{
string tmp(s._str);
swap(_str,tmp._str);
}
return *this;
}
原理和拷贝差不多,也是”坐享其成“,我们对下面这个例子画图来看一下
string s1="hello";
string s2="hello yyk";
s1=s2;
我们还可以再简化一些:
string& operator(string s)
{
swap(_str , s._str);
return *this;
}
其实换汤不换药:我们不传引用,而是直接传值,此时的形参s是由s2深拷贝而来的(s是一个临时对象),所以此时s._str 就是我们s1想要的,我们直接“偷梁换柱”就行了。
6. reserve
reserve() 函数是我在上一篇文章中讲解比较详细的接口。这里我就不再赘述了。
void reserve (size_t n)
{
if(n>_capacity)
{
char*tmp=new char[n+1];
strcpy(tmp,_str);
delete[] _str;
_str = tmp;
}
_capacity =n ;
}
7. resize
resize 和 reserve 的区别在于在开辟空间的时候会对空间初始化。所以我们需要在形参列表中加上一个char型字符,用来填充我们新开的空间。
当然,如果n小于_size,我们要将字符串截取到n长度。具体可以看上一篇文章。
void resize(size_t n, char ch='\0')
{
if(n<=_size)
{
_size = n;
_str[_size]= '\0';
}
else
{
if(n > _capacity)
{
reserve(n)
}
for(size_t i=_size; i<n;i++)
{
_str[i] = ch;
}
_size = n;
_str[_size] ='\0';
}
}
8. push_back
push_back 尾插函数,这个函数比较简单,我们只需要注意需不需要扩容就行了。
void push_back(char ch)
{
if(_size >=_capacity)
{
size_t newcapacity = _capcity==0?4: _capacity*2 ;
reserve(newcapacity*2);
}
_str[_size]=ch;
++_size;
_str[_size]='\0';
}
9. append
append 也是一个尾插函数,但尾插的内容是字符串。
void append(const char*str)
{
size_t len =strlen(str);
if(_size+len>_capacity)
{
reserve(_size+len);
}
strcpy(_str+_len,str);//将数据拷贝到str之后的空间
_size += len;
}
10.insert
insert 函数既可以 往字符串任意位置插入字符,也可以插入字符串,所以我们要写两种insert.
string& insert(size_t pos ,char ch)
{
assert(pos <= _size);
if(_size == _capacity)
{
size_t newcapacity =_capacity==0?4 :_capacity*2 ;
reserve(newcapacity);
}
int end=_size+1;
while(end >=(int)pos)
{
_str[end] = _str[end-1];
--end;
}
_str[pos] =ch;
++_size;
retunr *this;
}
string& insert(size_t pos ,const char* str)
{
assert(pos < _size);
size_t len = strlen(str);
if(len == 0)
{
return *this ;
}
if(len+_size > _capacity)
{
reserve(len+_size);
}
size_t end = _size+len;
while(end >= pos+len)
{
_str[end] =_str[end-len];
--end;
}
for(size_t i=0;i<len ;i++)
{
_str[pos+i] =str[i];
}
_size +=len ;
return *this;
}
11.erase
对于erase函数来说,我们确定pos位置之后,如果不传缺省参数,那么就把pos之后的字符全部删完,如果我们指定了缺省参数len,我们会把pos位置后的len个字符删除。当len>pos之后的长度,我们就认为删除pos之后的所有值。
对于缺省参数的默认值,我们选择npos,也就是size_t类型的上限,这样写,也就等价于删除所有数字。
string& erase(size_t pos,size_t len=npos)
{
assert(pos<_size);
if(len ==npos || pos+len >_size)
{
_str[pos] ='\0';
_size=pos;
}
else
{
strcpy(_str+pos,str+pos+len);
_size -=len;
}
}
12 c_str
c_str()函数很简单,就是返回对象地址。
有时候使用c_str很方便。
const char* c_str()
{
return _str;
}
13. size() 和 capacity()
size_t size()const
{
return _size;
}
size_t capacity()const
{
return _capacity;
}
14. 一些运算符的重载
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
string& operator+=(const char* str)
{
append(_str);
return *this;
}
string& operator+=(const string& s)
{
*this += s._str;
return *this;
}
//存在深拷贝对象,尽量少用
string operator+(const string& s1, char ch)
{
string ret = s1;
ret += ch;
return ret;
}
string operator+(const string& s1, const char* str)
{
string ret = s1;
ret += str;
return ret;
}
//不涉及私有成员,不用加友元
ostream& operator <<(ostream& out, const string& s)
{
for (size_t i = 0; i < s.size(); ++i)
{
out << s[i];
}
return out;
}
istream& operator >>(istream& in, string& s)
{
s.clear();
char ch;
ch = in.get();
while (ch != ' ' && ch != '\n')
{
s += ch;
ch = in.get();
}
return in;
}
istream& getline(istream& in, string& s)
{
s.clear();
char ch;
ch = in.get();
while (ch != '\n')
{
s += ch;
ch = in.get();
}
return in;
}
//s1 > s2
bool operator > (const string& s1, const string& s2)
{
size_t i1 = 0, i2 = 0;
while (i1 < s1.size() && i2 < s2.size())
{
if (s1[i1]>s2[i2])
{
return true;
}
else if (s1[i1] < s2[i2])
{
return false;
}
else
{
++i1;
++i2;
}
}
if (i1 == s1.size())
{
return false;
}
else
{
return true;
}
}
bool operator == (const string& s1, const string& s2)
{
size_t i1 = 0, i2 = 0;
while (i1 < s1.size() && i2 < s2.size())
{
if (s1[i1] != s2[i2])
{
return true;
}
else
{
++i1;
++i2;
}
}
if (i1 == s1.size() && i2 == s2.size())
{
return true;
}
else
{
return false;
}
}
inline bool operator != (const string& s1, const string& s2)
{
return !(s1 == s2);
}
inline bool operator >= (const string& s1, const string& s2)
{
return s1 > s2 || s1 == s2;
}
inline bool operator < (const string& s1, const string& s2)
{
return !(s1 >= s2);
}
inline bool operator <= (const string& s1, const string& s2)
{
return !(s1 > s2);
}
String 类里其实还有许多接口,这里限于篇幅只能实现一些常用的,有兴趣的小伙伴可以参考源码实现以下其他的接口。