C++ STL中 string类的模拟实现

一、前言

在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'
	};

	//全局函数
}

图解 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;

注:迭代器在 string类 里就是用指针去实现的。

begin 函数

作用是返回对象内部字符串的首地址

//普通版本
iterator begin()
{
	return _str;
}
//const版本
const_iterator begin() const
{
	return _str;
}
end 函数

作用是返回对象内部字符串最后一个有效字符的下一个地址,即 ‘\0’ 的地址。

//普通版本
iterator end()
{
	return _str + _size;
}
//const版本
const_iterator end() const
{
	return _str + _size;
}

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);  // 拷贝
}

为什么缺省值不能设置为空指针 nullptr 呢?
如果这样做,传进 strlen函数的参数就是空指针(strlen函数内部不检查空指针),然后 strlen函数为了找 ‘\0’ 就开始解引用,所以就会出现由于空指针解引用导致程序崩溃的问题。
故缺省值不能设置为空指针 nullptr 。

2.析构函数

析构时主要是把申请的空间释放掉即可。

~string()
{
	delete[] _str;
	_str = nullptr;
	_size = _capacity = 0;
}

(关于深浅拷贝问题)

有人可能会想:既然不显式定义 拷贝构造函数 或 赋值重载函数 的话,系统就会默认生成一份,那么就没必要自己去写,直接用系统默认生成的就行了。

可是,这样做真的不会有问题吗?
其实,如果这样做的话,程序一运行就会崩掉:
在这里插入图片描述
那么,原因是什么?
其实是因为系统默认生成的 拷贝构造函数 和 赋值重载函数 是浅拷贝(就是直接把值复制过来),对于像日期类这些不涉及资源管理的类的话,是可以的。但是对于涉及资源管理的类(比如 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);
}

还有现代写法,比传统写法简洁些。

//现代写法
string(const string& s)
	:_str(nullptr)  // 很重要的细节,必须写
	,_size(0)
	,_capacity(0)
{
	string tmp(s._str);  // 复用构造函数
	swap(tmp);  // 调用模拟实现的成员函数swap
}

为什么说先把 _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;
}

用传统写法的话,一定要先用 tmp 去接收,因为不知道空间申请是否成功,万一失败了呢?
假设直接使用 _str 去接收,刚好空间申请失败了(因为你是先 delete 掉 _str 原有的空间再去接收的),连原有的空间都释放掉,找不回来了。
我们期望的是就算空间申请失败,也不要破坏掉原来的对象。
因此,一定要先用 tmp 去接收!

//现代写法
string& operator=(const string& s)
{
	if (this != &s)  // 防止对象自己给自己赋值
	{
		string tmp(s);  // 复用拷贝构造函数
		swap(tmp);  // 调用模拟实现的成员函数swap
	}

	return *this;	
}

现代写法比较简洁,拷贝构造出 tmp 对象后,交换 tmp 和 当前对象。当 tmp 对象销毁时,调用析构函数,顺便把空间资源也一起回收。

//更简洁的现代写法
string& operator=(string s)  // 传值传参,调用拷贝构造函数
{
	swap(s);  // 调用模拟实现的成员函数swap

	return *this;
}

跟上面的现代写法的思想是一样的,在这种写法中,传值传参时会调用拷贝构造函数。当前函数结束后,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);  // 交换容量
}

直接调用三次 std库里的 swap函数即可。

6. c_str 函数

调用库里的话,一般这么写:
string s1(“Good boy”);
cout << s1.c_str() << endl;

作用是返回 C语言形式的字符串(以 ‘\0’ 作标识)。

const char* c_str() const
{
	return _str;
}

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];
}
//const版本(返回常引用,无法修改)
const char& operator[](size_t pos) const
{
	assert(pos < _size);  // 防止访问越界
	return _str[pos];
}

既然有两个重载函数,那么在实际调用时会调用哪一个呢?
根据实参类型去自动匹配最符合要求的函数。
比如:实参是 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;
}

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;  // 更新容量的大小
	}
}

这里 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';
	}
}

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;
}

为什么上面的那行代码不能写成 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 所能表示的最大数,
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;
}

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;
}

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);  // 尾插一个字符
}

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;
}

15. operator+= 函数

调用库里的话,一般这么写:
string s1(“hell”);
s1 += ‘o’;s1 += ‘o world’;

作用是尾插一个字符或者一个字符串。

//尾插一个字符
string& operator+=(char ch)
{
	//复用模拟实现的成员函数push_back
	push_back(ch);
	
	return *this;
}
//尾插一个字符串
string& operator+=(const char* str)
{
	//复用模拟实现的成员函数append
	append(str);
	
	return *this;
}

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;
}
//从指定的下标位置开始查找子串的位置
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;
}

17. clear 函数

调用库里的话,一般这么写:
string s1(“My computer”);
s1.clear();

作用是清空对象内部的有效字符(不改变有效空间的大小)。

void clear()
{
	_size = 0;
	_str[_size] = '\0';
}

全局函数:

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);
}

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;
}

注意区分 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;
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值