目录
引言
在之前的博客中我简单介绍了string的相关使用方法和接口,现在我们自己来模拟实现一下它的底层(注:不同编译器底层实现不同,这里只是其中一种的实现)。
外层包装
本来应该是在外层套个basic_string<char>的模板的,但因为模板不支持声明定义分离所以这里不用模板了,直接将string类放到命名空间sak中,测试函数放在头文件中,在命名空间里面,分两个文件链接。
namespace sak
{
class string
{
public:
//...
private:
//...
};
Test();
};
成员变量设计
我的 string类中成员变量设置三个:char* _str , size_t size(有效数据大小) , size_t capacity(容量)
因为字符串涉及到 '\0' 问题,要多给一个空间储存 '\0',所以在开辟空间时给_str 多开一个字节空间,capacity不包含 '\0' 的大小,表示有效数据的容量。
class string
{
public:
//...
private:
size_t capacity;
size_t size;
char*_str;
};
接口实现
1、构造函数接口
构造可以用字符串构造,也可以用string类型对象构造。注意成员变量声明的顺序,为了尽可能减少strlen函数的调用,先对_capacity初始化,再对_size和_str初始化,因为成员变量在类中声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后 次序无关,所以要先声明_capacity,再声明其他的。
string(const char* str)
:_capacity(strlen(str))
,_size(_capacity)
,_str(new char[_capacity+1])
{
strcpy(_str, str);
}
string(const string& s)
:_capacity(s._capacity)
,_size(s._size)
,_str(new char[s._capacity+1])
{
strcpy(_str, s._str);
}
这里不能直接将str 指针赋值给_str,虽然确实将str指向的内容赋给了_str,但是如果参数str是常量字符串,那么后面调用push_back接口需要扩容的话,无法对常量字符串操作,所以这里先给_str开辟空间(上面说了因为 '\0'的原因多开一个空间)。
还有一种情况:无参类型的构造
string()
{
_str = new char[1];
_str[0] = '\0';
_size = _capacity = 0;
}
或者这里可以通过字符串类型参数的缺省来实现无参类型的构造:
string(const char* str = "")
:_capacity(strlen(str))
,_size(_capacity)
,_str(new char[_capacity+1])
{
strcpy(_str, str);
}
string(const char* str = "")
这里为什么是"",需要先辨析清楚 '\0' , " \0" , "" 三者之间的不同。
'\0' :字符\0,ASCLL码值是0,即就代表了0
" \0" :字符串都以 '\0' 结尾,所以是\0\0,有2个
"" : 空字符串,里面有一个 \0
size_t strlen ( const char * str );
strlen函数的参数部分是const char* 类型的,会计算 '\0'之前有几个字符。因此这里不能是
string(const char* str = '\0'或者nullptr) 而是 string(const char* str = "")
2、析构函数接口
~string()
{
delete[]_str;
_str = nullptr;
_capacity = _size = 0;
}
3、c_str 接口
const char* c_str() const
{
return _str;
}
在尚未实现 流输出<< 和 流提取>> 操作符时,可以用c_str打印string类型对象。
4、size 接口
size_t size() const
{
return _size;
}
5、capacity 接口
size_t capacity() const
{
return _capacity;
}
这里capacity实现的和VS编译器下不同,所以表现结果也不同
6、operator[] 接口(重载[ ])
//可读可写
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
//只读
const char& operator[](size_t pos)const
{
assert(pos < _size);
return _str[pos];
}
7、迭代器实现(char*指针方式)
迭代器底层实现有很多方法,内部类、指针....... 这里我用指针实现。
typedef char* iterator;
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
string::iterator it = s2.begin();
while (it != s2.end())
{
(*it)++;
it++;
}
cout << s0.c_str() << endl;
范围for底层其实就是迭代器,是直接用迭代器替换的,所以范围for并没有想象的那么神奇。
底层是将对象赋给 it变量,再将*it 赋给e,其实很简单。
8、reserve接口
当要改变的空间大小n < capacity,对容量没有变化,当n < capacity ,reserve实质上就是扩容的实现。在C语言中扩容有realloc,C++这里我用的new,需要自己实现一下扩容。
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[]_str;
_str = tmp;
_capacity = n;
}
}
9、push_back、append、+= 接口
void push_back(char c)
{
if (_size == _capacity) {
size_t newcapacity = _capacity == 0 ? 4 : 2 * _capacity;
reserve(newcapacity);
}
_str[_size] = c;
_size++;
_str[_size] = '\0';
}
void append(const char* s)
{
size_t len = strlen(s);
if (len + _size > _capacity) {
reserve(_size + len);
}
strcpy(_str + _size, s);
_size += len;
}
string& operator+=(char c)
{
push_back(c);
return *this;
}
string& operator+=(const char* s)
{
append(s);
return *this;
}
10、insert、erase接口
insert函数方便我们在任何位置插入数据。和上面一样,分为插入1个字符或字符串。
在插入之前要先挪动数据(尾插除外),挪动的过程中要注意边界问题。
string& insert(char ch, size_t pos)
{
assert(pos <= _size);
if (_capacity == _size)
{
size_t newcapacity = _capacity == 0 ? 4 : 2 * _capacity;
reserve(newcapacity);
}
size_t end = _size+1;
while (end >= pos)
{
_str[end] = _str[end-1];
--end;
}
_str[pos] = ch;
_size++;
_str[_size] = '\0';
return *this;
}
string& insert(const char* str, size_t pos)
{
size_t len = strlen(str);
if (len + _size > _capacity)
{
reserve(len + _size);
}
size_t end = _size+len;
while (end >= pos + len)
{
_str[end] = _str[end-len];
--end;
}
strncpy(_str + pos, str, len);
_size += len;
return *this;
}
erase删除同样需要挪动数据,给删除个数一个缺省值npos,定义为常量-1,对于size_t来说是2^32,当没有给定删除个数或要删除的个数超出剩余的字符数就意味着删除pos位置之后所有数据;否则删除pos位置后面len个字符,可以用strcpy直接覆盖。
string& erase(size_t pos,size_t len = npos)
{
if (len == npos || pos + len >= _size)
{
_str[pos] = '\0';
}
else
{
strcpy(_str + pos,_str+pos+len);
_size -= len;
}
return *this;
}
const static int npos = -1;
11、find接口
find接口不仅要支持查找,还要能支持从指定位置开始查找。同样的,有查找单个字符的功能,还有查找字符串的功能。找到返回下标,找不到返回npos。
查找单个字符比较简单,直接遍历即可。
查找字符串可以用C函数库的strstr函数暴力查找,注意该函数返回的是指针,所以最后返回下标值时要用返回的指针 - 头指针。
const static int npos = -1;
size_t find(char ch, size_t pos = 0)
{
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)
{
assert(pos < _size);
const char* ptr = strstr(_str + pos, str);
if (ptr == nullptr)
return npos;
else
return ptr - _str;
}
12、resize接口
resize是改变容量的函数,可以变大也可以变小。
void resize(size_t n,char ch = '\0')
{
if (n > _size)
{
reserve(n);
_size = n;
for (size_t i = _size; i < n; ++i)
{
_str[i] = ch;
}
_str[n] = '\0';
}
else
{
_size = n;
_str[n] = '\0';
}
}
13、流插入cout
流插入操作符<<重载,需要注意[ ]的重载,上面只重载了[ ]的非const版本,对于只读对象调用需要const版本的。我们再重载一个只读的[ ]方法。
const char& operator[](size_t pos)const
{
assert(pos < _size);
return _str[pos];
}
ostream& operator<<(ostream& out,const string& s)
{
for (size_t i = 0; i < s.size(); ++i)
{
out << s[i];
}
return out;
}
有了c_str还不够,还需要 <<流插入。这两者打印的方式是不一样的。
c_str是按char*方式打印,字符串中间出现 '\0' 不会打印后面的内容;<<碰到 '\0' 依然会继续打印,'\0' 打印显示的是不可见字符。
14、流提取 >>
与流插入对应。流提取碰到空格或换行就停下,后面部分进入缓冲区。
想要拿到空格之后的内容需要用到get函数。
istream& operator>>(istream& in,string& s)
{
char ch = in.get();
while (ch != ' ' && ch != '\n')
{
s += ch;
ch = in.get();
}
return in;
}
这种方式有一个缺陷,字符串很长时会扩容很多次。想解决这个问题也不好先reserve给定capacity大小,因为capacity给大了浪费,小了没有效果。
因此可以借鉴C++标准库的实现方式,先设定一个buff数组,分段将字符串放入。
istream& operator>>(istream& in, string& s)
{
char buff[128] = { '\0' };
size_t i = 0;
char ch = in.get();
while (ch != ' ' && ch != '\n')
{
if (i == 127)//满了
{
s += buff;
i = 0;
}
buff[i++] = ch;
ch = in.get();
}
if (i > 0)
{
buff[i] = '\0';
s += buff;
}
return in;
}
15.拷贝构造
这里我们必须手动写一个拷贝构造函数,否则系统默认的是浅拷贝,对于自定义类型来说会出现指针指向同一空间以及内存泄漏的问题。
拷贝构造有两种写法:传统写法和现代写法。
1、传统写法
//传统写法
string(const string& s)
{
_str = new char[s._capacity + 1];
_capacity = s._capacity;
_size = s._size;
strcpy(_str, s._str);
}
传统写法就是创建一个和拷贝对象一样大小的新空间,再将数据拷贝到新空间中。
2、现代写法
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_capacity, s._capacity);
std::swap(_size, s._size);
}
//现代写法
string(const string& s)
:_str(nullptr)
,_capacity(0)
,_size(0)
{
string tmp(s._str); //构造函数
swap(tmp);
}
现代写法就是先用构造函数构造一个tmp,此时tmp拥有和拷贝对象s0一样的空间和数据,交换tmp和s1,使两者的空间和数据完全交换。因为tmp最后要调用析构函数清理空间,和s1交换后tmp指向的是随机值,所以提前让s1指向空,避免释放野指针的问题。
注意:现代写法并不能提高效率,只是简化了代码(结合赋值函数来看)
16、赋值重载
赋值也有传统和现代写法,与拷贝构造类似。
1、传统写法
//传统写法
string& operator=(const string& s)
{
if (this != &s)
{
char* tmp = new char[s._capacity + 1];
strcpy(tmp,s._str);
delete[]_str;
_str = tmp;
_capacity = s._capacity;
_size = s._size;
}
return *this;
}
2、现代写法
//现代写法1(不推荐)
string& operator=(const string& s)
{
if (this != &s)
{
string tmp(s);
swap(tmp);
}
return *this;
}
//现代写法2(推荐√)
string& operator=(string s)
{
swap(s);
return *this;
}
现代写法1还是先拷贝构造tmp,再将tmp与s1交换,这种写法tmp有点多余了。
其实可以直接传值传参,在传参的过程中拷贝构造s(写了拷贝构造这里是深拷贝),直接交换s和s1即可。