文章目录
一、前言
二、模拟实现的意义何在?
三、string类的模拟实现
成员函数:
0.迭代器相关函数
begin 函数
end 函数
1.构造函数
2.析构函数
(关于深浅拷贝问题)
3.拷贝构造函数
4.赋值重载函数
5. swap 函数
6. c_str 函数
7. operator[] 函数
8. size 函数
9. reserve 函数
10. resize 函数
11. insert 函数
12. erase 函数
13. push_back 函数
14. append 函数
15. operator+= 函数
16. find 函数
17. clear 函数
全局函数:
1.关系运算符重载
2.流提取运算符重载
3.流插入运算符重载
一、前言
在C++的STL中,string 是一个专门管理字符串的类,可以将 string类 简单地理解成 元素都是字符的动态顺序表。
因此,自己在进行模拟实现时,跟写动态顺序表差不多。
推荐的 C/C++ 参考文档:http://www.cplusplus.com
二、模拟实现的意义何在?
为了更好地理解 string类 的底层实现原理,加深对 string类 的认知。
三、string类的模拟实现
首先,先定义 string类。当然,为了防止命名冲突,将它放在一个命名空间里面,这个命名空间就叫 MyLib 吧。
namespace MyLib
{
class string
{
public:
//迭代器
typedef char* iterator;
typedef const char* const_iterator;
//成员函数
private:
char* _str;
size_t _size; // 目前已存储的有效字符个数
size_t _capacity; // 能存储有效字符的空间数,不包含'\0'
};
//全局函数
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
图解 string:
下面模拟实现的都是一些比较常用的重载函数。
成员函数:
0.迭代器相关函数
调用库里的话,一般这么写:
string s1("hello Unix");
string::iterator it = s1.begin();
while (it != s1.end())
{
*it += 1;
++it;
}
it = s1.begin();
while (it != s1.end())
{
cout << *it << " ";
++it;
}
cout << endl;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
注:迭代器在 string类 里就是用指针去实现的。
begin 函数
作用是返回对象内部字符串的首地址。
//普通版本
iterator begin()
{
return _str;
}
1
2
3
4
5
//const版本
const_iterator begin() const
{
return _str;
}
1
2
3
4
5
end 函数
作用是返回对象内部字符串最后一个有效字符的下一个地址,即 ‘\0’ 的地址。
//普通版本
iterator end()
{
return _str + _size;
}
1
2
3
4
5
//const版本
const_iterator end() const
{
return _str + _size;
}
1
2
3
4
5
1.构造函数
我们在使用库里的 string 时,在定义对象时一般这么写:
string s1(“hello world”);
或
string s1;
因此,在构造对象时,把常量字符串拷贝到申请来的字符数组空间里面,并且设置对象的 _size 和 _capacity 。
因为以后涉及对象的增删查改操作,所以对象必须拥有属于自己的空间。
考虑到第二种构造情况,应设置缺省值。
string(const char* str = "") // 缺省值不能设置为空指针 nullptr
:_size(strlen(str)) // 设置大小和容量
,_capacity(_size)
{
_str = new char[_capacity + 1]; // 多开一个空间,用来存放'\0'
strcpy(_str, str); // 拷贝
}
1
2
3
4
5
6
7
为什么缺省值不能设置为空指针 nullptr 呢?
如果这样做,传进 strlen函数的参数就是空指针(strlen函数内部不检查空指针),然后 strlen函数为了找 ‘\0’ 就开始解引用,所以就会出现由于空指针解引用导致程序崩溃的问题。
故缺省值不能设置为空指针 nullptr 。
2.析构函数
析构时主要是把申请的空间释放掉即可。
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
1
2
3
4
5
6
(关于深浅拷贝问题)
有人可能会想:既然不显式定义 拷贝构造函数 或 赋值重载函数 的话,系统就会默认生成一份,那么就没必要自己去写,直接用系统默认生成的就行了。
可是,这样做真的不会有问题吗?
其实,如果这样做的话,程序一运行就会崩掉:
那么,原因是什么?
其实是因为系统默认生成的 拷贝构造函数 和 赋值重载函数 是浅拷贝(就是直接把值复制过来),对于像日期类这些不涉及资源管理的类的话,是可以的。但是对于涉及资源管理的类(比如 string类)的话,是个大问题了。
比如,有这么一段代码:
string s1(“hello world”);
string s2(s1);
用 s1 去拷贝构造 s2 。
或者
string s1(“hello world”);
string s2(“Good”);
s2 = s1;
把 s2 赋值给 s1
如果我们不显式定义 拷贝构造函数 和 赋值重载函数 ,直接用系统默认生成的话,将会是这个样子:
大伙看出问题来了吗?
由于是浅拷贝,它会直接把值复制过来,连改都不改。对于指针而言,就是指向了同一块内存空间,也就是 s1 和 s2 内部共用同一块内存空间。当一个对象被销毁时,会调用析构函数,释放该空间资源,但是另一个对象是不知道的,此时其内部的指针就变成了野指针,再对该空间资源进行访问的话,是违法的。或者这个对象被销毁时,调用析构函数,再一次对该空间资源进行释放。由于同一块空间被释放多次,就会导致程序崩溃。
如果是默认生成的赋值重载函数的话,还把原来指向的空间给弄丢了,造成内存泄漏。
因此,浅拷贝是不能满足我们的需求的。我们希望的是每个对象都拥有只属于自己的内存空间:
所以,这就需要我们去显式定义能够实现深拷贝的 拷贝构造函数 和 赋值重载函数 了,
简单地来说,深拷贝的实现就是在调用 拷贝构造函数 或 赋值重载函数 后,让每个对象都拥有只属于自己的内存空间。
一般情况下,如果一个类涉及到空间资源的管理,必须显式地实现深拷贝。
3.拷贝构造函数
调用库里的话,一般这么写:
string s1(“hello csdn”);
string s2(s1);
其思想跟构造函数差不多,只是传参的对象由一个字符串变成了一个对象罢了。
//传统写法
string(const string& s)
:_size(s._size)
,_capacity(s._capacity)
{
_str = new char[_capacity + 1]; // 深拷贝
strcpy(_str, s._str);
}
1
2
3
4
5
6
7
8
还有现代写法,比传统写法简洁些。
//现代写法
string(const string& s)
:_str(nullptr) // 很重要的细节,必须写
,_size(0)
,_capacity(0)
{
string tmp(s._str); // 复用构造函数
swap(tmp); // 调用模拟实现的成员函数swap
}
1
2
3
4
5
6
7
8
9
为什么说先把 _str 初始化为空指针是个必须的细节呢?
如果你不写那一行代码的话,没有初始化的_str 就是个野指针,调用 swap 函数后,交换了两个指针变量的值,即此时的 tmp._str 就是野指针,拷贝构造函数调用完成后,tmp 这个对象就会被销毁,就会调用析构函数,释放空间资源,但由于是野指针,在调试的情况下,就会看到:
因此必须先将 _str 初始化为空指针!
4.赋值重载函数
调用库里的话,一般这么写:
string s1(“hello csdn”);
string s2(“hello blog”);
s2 = s1;
把 s1 赋值给 s2 。
主要是注意空间资源的管理。
//传统写法
string& operator=(const string& s)
{
if (this != &s) // 防止对象自己给自己赋值:s1 = s1;
{
char* tmp = new char[s._capacity + 1]; // 当申请空间失败时,抛异常
strcpy(tmp, s._str);
delete[] _str;
_str = tmp;
_size = s._size;
_capacity = s._capacity;
//错误的写法
/*delete[] _str;
_str = new char[s._capacity + 1];
strcpy(tmp, s._str);
_size = s._size;
_capacity = s._capacity;*/
}
return *this;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
用传统写法的话,一定要先用 tmp 去接收,因为不知道空间申请是否成功,万一失败了呢?
假设直接使用 _str 去接收,刚好空间申请失败了(因为你是先 delete 掉 _str 原有的空间再去接收的),连原有的空间都释放掉,找不回来了。
我们期望的是就算空间申请失败,也不要破坏掉原来的对象。
因此,一定要先用 tmp 去接收!
//现代写法
string& operator=(const string& s)
{
if (this != &s) // 防止对象自己给自己赋值
{
string tmp(s); // 复用拷贝构造函数
swap(tmp); // 调用模拟实现的成员函数swap
}
return *this;
}
1
2
3
4
5
6
7
8
9
10
11
现代写法比较简洁,拷贝构造出 tmp 对象后,交换 tmp 和 当前对象。当 tmp 对象销毁时,调用析构函数,顺便把空间资源也一起回收。
//更简洁的现代写法
string& operator=(string s) // 传值传参,调用拷贝构造函数
{
swap(s); // 调用模拟实现的成员函数swap
return *this;
}
1
2
3
4
5
6
7
跟上面的现代写法的思想是一样的,在这种写法中,传值传参时会调用拷贝构造函数。当前函数结束后,s 对象销毁时调用的析构函数回收空间资源。
5. swap 函数
调用库里的话,一般这么写:
string s1(“hello csdn”);
string s2(“hello blog”);
s1.swap(s2);
作用是将两个对象的私有成员的值进行交换。
void swap(string& s)
{
std::swap(_str, s._str); // 交换空间
std::swap(_size, s._size); // 交换大小
std::swap(_capacity, s._capacity); // 交换容量
}
1
2
3
4
5
6
直接调用三次 std库里的 swap函数即可。
6. c_str 函数
调用库里的话,一般这么写:
string s1(“Good boy”);
cout << s1.c_str() << endl;
作用是返回 C语言形式的字符串(以 ‘\0’ 作标识)。
const char* c_str() const
{
return _str;
}
1
2
3
4
7. operator[] 函数
调用库里的话,一般这么写:
string s1(“super”);
s1[ 3 ] = ‘d’;
cout << s1[ 3 ] << endl;
或
const string s2(“super”);
cout << s2[ 3 ] << endl;
当然也可以用于遍历操作。
作用是返回 s._str[ i ]的引用。
//普通版本(返回引用)
char& operator[](size_t pos)
{
assert(pos < _size); // 防止访问越界
return _str[pos];
}
1
2
3
4
5
6
//const版本(返回常引用,无法修改)
const char& operator[](size_t pos) const
{
assert(pos < _size); // 防止访问越界
return _str[pos];
}
1
2
3
4
5
6
既然有两个重载函数,那么在实际调用时会调用哪一个呢?
根据实参类型去自动匹配最符合要求的函数。
比如:实参是 string类型,就会调用普通版本;实参是 const string类型,就会调用 const版本。
8. size 函数
调用库里的话,一般这么写:
string s1(“hello”);
for(size_t i = 0; i < s1.size(); ++i)
{…}
作用是返回对象的 _size 。
size_t size() const
{
return _size;
}
1
2
3
4
9. reserve 函数
调用库里的话,一般这么写:
string s1(“hello Linux”);
s1.reserve(25);
作用是扩容(将储存有效字符的空间容量_capacity 扩大为 n,其中 n > _capacity)。
如果 n <= _capacity,则什么都不干。
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1]; // 当申请空间失败时,抛异常
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n; // 更新容量的大小
}
}
1
2
3
4
5
6
7
8
9
10
11
12
这里 tmp 的作用跟赋值重载函数中的 tmp 是一样的。
10. resize 函数
调用库里的话,一般这么写:
string s1(“hello”);
s1.resize(2); 或 s1.resize(8, ‘k’);
作用是改变对象的 _size 。
1)若 n 小于或等于原来的_size,将有效字符个数保留为 n 个(不影响 _capacity)。
2)若 n 大于原来的_size(若 n 大于 _capacity,就会调用 reserve 函数进行扩容),将有效字符个数重新设置成 n 个,并用 ch 填充多出来的有效字符空间。(给 ch 设置缺省值)
void resize(size_t n, char ch = '\0')
{
if (n <= _size)
{
_size = n;
_str[_size] = '\0';
}
else
{
if (n > _capacity) // 检查是否需要扩容
{
reserve(n);
}
memset(_str + _size, ch, n - _size);
_size = n;
_str[_size] = '\0';
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
11. insert 函数
调用库里的话,一般这么写:
string s1(“helo”);
s1.insert(2, ‘l’);
或
string s2(“ho”);
s2.insert(1, “ell”);
作用是在给定的下标处插入单个字符或字符串。
//插入单个字符
string& insert(size_t pos, char ch)
{
assert(pos <= _size); // 防止访问越界
if (_size == _capacity) // 判断当前的有效空间是否已经存满
{
reserve(_capacity == 0 ? 4 : _capacity * 2); // 重要的细节
//错误的写法
/*reserve(_capacity * 2);*/
}
//下标从 pos 开始到 '\0',都往后挪一个字符的长度
size_t end = _size + 1;
while (end > pos) // 如果是头插,当 0 > 0 时,不再进入循环体
{
_str[end] = _str[end - 1];
--end;
}
//错误的循环写法
/*size_t end = _size;
while (end >= pos)
{
_str[end + 1] = _str[end];
--end;
}*/
_str[pos] = ch; // 在指定的下标位置插入字符
++_size;
return *this;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
为什么上面的那行代码不能写成 reserve(_capacity * 2) ?
请仔细思考一下,万一是 string s1; 这种情况呢?
如果是 string s1; 这种情况的话,由于没有用字符串去初始化对象,在构造函数中是用了缺省值的,它的 _size 和 _capacity 都等于 0,_capacity * 2 还是等于 0,reserve(0); 是没有达到扩容的效果的。
因此,不能写成 reserve(_capacity * 2); 。
为什么说上面的循环写法是错误的呢?
不知道你是否注意到这种情况:如果是头插的话呢?
(其实,这种写法只有头插不行,其他地方的插入都可以)
如果是头插,此时 pos 等于 0,当 end 等于 0 时,满足循环条件(0 >= 0),进入循环体,然后 end 自减,那么问题从这里开始就出现了。由于 end 的类型是 size_t ,它自减后是 size_t 所能表示的最大数,
再次满足循环条件(4294967295 >= 0),又进入了循环体,结果在执行语句 _str[end + 1] = _str[end]; 时造成非法访问。
而且还不止这样,当 end 每次自减到 0 时再次自减,又变成了最大数,就这样一直下去,永远都跳不出循环体,构成死循环。
所以说上面的写法是错误的。
那么这个循环写法就不会出现问题吗?
是的,它不会。
原因:循环条件不取 = 并且写成 _str[end] = _str[end - 1],避免了 size_t 的 -1 问题。
如果是头插,当 0 > 0 时,就不再进入循环体了。
所以这个循环写法就不会出现问题。
还有指针版本的写法,这样的话就不用考虑这个问题了。
其实这些都是细节上的问题,大都是边界的判断与处理。
//插入字符串
string& insert(size_t pos, const char* s)
{
assert(pos <= _size); // 防止访问越界
size_t len = strlen(s);
if (_size + len > _capacity) // 判断插入字符串后是否超过了容量大小
{
reserve(_size + len);
}
//下标从 pos 开始到 '\0',都往后挪 len 个字符的长度
//循环的细节跟上面所说的一样,这里不再重复
size_t end = _size + len;
while (end > pos + len - 1)
{
_str[end] = _str[end - len];
--end;
}
strncpy(_str + pos, s, len); // 在指定的下标位置插入字符串(不拷该字符串的'\0')
_size += len;
return *this;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
12. erase 函数
调用库里的话,一般这么写:
string s1(“hello world”);
s1.erase(2); 或 s1.erase(2, 5);
作用是从指定的下标位置 pos 开始,往后删 len 个字符。
1)如果不指定下标位置 pos 的话,会从头开始删 len 个字符。
2)如果不指定 len 的话,会从 pos 下标位置开始尽可能多地往后删。
都不指定的话,就会把全部字符清空。
注:在模拟实现时,将 npos 定义为公有的静态成员常量 static const size_t npos = -1; 。
string& erase(size_t pos = 0, size_t len = npos)
{
assert(pos <= _size); // 防止访问越界
if (len == npos || pos + len >= _size) // 没有指定 len 或者传入的参数 len 过大
{
_str[pos] = '\0';
_size = pos;
}
else
{
//将后面的字符挪过来,包括'\0'(覆盖掉需要删除的字符)
strcpy(_str + pos, _str + pos + len);
_size -= len;
}
return *this;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
13. push_back 函数
调用库里的话,一般这么写:
string s1(“hell”);
s1.push_back(‘o’);
作用是尾插一个字符。
void push_back(char ch)
{
//这种写法也可以
/*if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
_str[_size] = ch;
++_size;
_str[_size] = '\0';*/
//复用模拟实现的成员函数insert
insert(_size, ch); // 尾插一个字符
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
14. append 函数
调用库里的话,一般这么写:
string s1(“hello ”);
s1.append(‘world’);
作用是尾插一个字符串。
string& append(const char* str)
{
//这种写法也可以
/*size_t len = strlen(str);
if (_size + len > _capacity)
{
reserve(_size + len);
}
strcpy(_str + _size, str);
_size += len;*/
//复用模拟实现的成员函数insert
insert(_size, str); // 尾插一个字符串
return *this;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
15. operator+= 函数
调用库里的话,一般这么写:
string s1(“hell”);
s1 += ‘o’; 或 s1 += ‘o world’;
作用是尾插一个字符或者一个字符串。
//尾插一个字符
string& operator+=(char ch)
{
//复用模拟实现的成员函数push_back
push_back(ch);
return *this;
}
1
2
3
4
5
6
7
8
//尾插一个字符串
string& operator+=(const char* str)
{
//复用模拟实现的成员函数append
append(str);
return *this;
}
1
2
3
4
5
6
7
8
16. find 函数
调用库里的话,一般这么写:
string s1(“hello Linux”);
size_t pos = s1.find(‘u’); 或 size_t pos = s1.find(“Linux”);
作用是从指定的下标位置开始查找单个字符或一个子串的位置,返回值是第一次匹配的第一个字符的下标。
若没有指定下标位置,则使用缺省值。
//从指定的下标位置开始查找单个字符的位置
size_t find(char ch, size_t pos = 0) const
{
assert(pos < _size); // 防止访问越界
for (size_t i = pos; i < _size; ++i)
{
if (ch == _str[i])
{
return i;
}
}
return npos;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//从指定的下标位置开始查找子串的位置
size_t find(const char* s, size_t pos = 0) const
{
assert(pos < _size); // 防止访问越界
const char* ptr = strstr(_str + pos, s);
//找不到子串
if (ptr == nullptr)
{
return npos;
}
return ptr - _str;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
17. clear 函数
调用库里的话,一般这么写:
string s1(“My computer”);
s1.clear();
作用是清空对象内部的有效字符(不改变有效空间的大小)。
void clear()
{
_size = 0;
_str[_size] = '\0';
}
1
2
3
4
5
全局函数:
1.关系运算符重载
调用库里的话,一般这么写:
cout << (s1 > s2) << endl;
或
cout << (s1 != s2) << endl;
等等
作用是比较对象内部的字符串。
bool operator<(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str()) < 0; // 复用模拟实现的成员函数c_str
}
bool operator==(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str()) == 0; // 复用模拟实现的成员函数c_str
}
//以下函数均复用之前已经写过的函数
bool operator<=(const string& s1, const string& s2)
{
return s1 < s2 || s1 == s2;
}
bool operator>(const string& s1, const string& s2)
{
return !(s1 <= s2);
}
bool operator>=(const string& s1, const string& s2)
{
return !(s1 < s2);
}
bool operator!=(const string& s1, const string& s2)
{
return !(s1 == s2);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
2.流提取运算符重载
调用库里的话,一般这么写:
string s1(“hello Linux”);
cout << s1 << endl;
作用是输出对象内部的所有有效字符(以 _size 作标识)。
ostream& operator<<(ostream& out, const string& s)
{
for (size_t i = 0; i < s.size(); ++i)
{
out << s[i];
}
return out;
}
1
2
3
4
5
6
7
8
9
注意区分 c_str函数 和 流提取运算符重载函数:前者是以 ‘\0’ 作标识,后者是以 _size 作标识。
虽然说在绝大多数的情况下它们输出的结果都一样,但是也存在这种情况:’\0’作为普通字符尾插上去。
3.流插入运算符重载
调用库里的话,一般这么写:
string s1;
cin >> s1;
或
string s2(“haha”);
cin >> s2;
作用是先清空对象内部的有效字符,再输入字符串。
istream& operator>>(istream& in, string& s)
{
s.clear(); // 复用模拟实现的成员函数clear
char ch = in.get();
while (ch != ' ' && ch != '\n') // 读取到空格符或换行符就结束读取
{
s += ch; // 复用模拟实现的成员函数operator+=
ch = in.get();
}
return in;
}
————————————————
版权声明:本文为CSDN博主「Butayarou」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/m0_59938453/article/details/123461201