C++string的模拟实现

· CSDN的uu们,大家好。这里是C++入门的第十六讲。
· 座右铭:前路坎坷,披荆斩棘,扶摇直上。
· 博客主页: @姬如祎
· 收录专栏:C++专题

 

目录

1. string类的成员变量

2. 构造函数

3. 析构函数

4. const char* c_str() const 

5. size_t size() const 

6. char& operator[](size_t pos) 

7. void reserve(size_ t n)

8. void push_back(char ch)

9. void append(const char* s)

​编辑

10. operator+=

10.1 string& operator+=(char ch)

10.2 string& operator+=(const char* s)

11. insert()

​编辑

11.1 void insert(size_t pos, size_t n, char ch)

11.2 void insert(size_t pos, const char* s)

12. void erase(size_t pos, size_t len = npos)

13. find()

13.1 size_t find(char ch, size_t pos = 0)

13.2 size_t find(const char* s, size_t pos = 0)

14. string substr(size_t pos = 0, size_t len = npos)

15. void resize(size_ t n, char ch = '\0')

 16. void clear()

17. 比较运算符的重载

17.1 bool operator<(cons string& s) const

17.2 bool operator==(cons string& s) const

17.3 接下来全部都是复用啦

 18. ostream& operator<<(ostream& out, const string& s)

19. istream& operator>>(istream& in, string& s)

20. 拷贝构造函数 

20.1 传统写法

20.2 现代写法 

21. void swap(string& s)

22. 赋值运算符重载

23. string迭代器的实现 


在上一讲,我们学习了如何使用string类。这节课我们将用一种叫较为简单的方式模拟实现一个string类。目的是加深对string类的理解。

1. string类的成员变量

在string类的使用那一节我们就已经知道了string类其实维护了一个char数组,一个表示char数组实际大小的size和一个表示数组真实容量的capacity,因此,我们模拟实现的string类的成员变量就是这三个啦!

因为string维护的char数组不是静态的,因此string类维护的是一个char*的指针,size表征有效字符的个数。维护capacity方便扩容。

namespace Tchey
{
	class string
	{
	private:
		char* _str;
		size_t _size;
		size_t _capacity;
	};
}

我们就定义出了一个基础的string类,在string类的定义外套一层明明空间主要是为了和库里面的string冲突哈! 

2. 构造函数

我们也是看到string的构造函数是非常多的啊!我们就实现一个:string(const char* s)的版本就行啦!

C++string的模拟实现_第1张图片

该构造函数可以使用一个字符串常量构造一个string对象!我们在用传入的字符串常量构造string对象时,不能将形参s直接赋值给成员变量_str。而是需要在堆上开辟一块空间,然后将字符串常量的数据拷贝过去。同时初始化其他成员变量!

原因:

1:形参s指向的空间是常量区,不允许修改,后续对string对象的操作可能报错!

2:如果构造出来的string对象将空间释放,那么就会释放常量区的空间,非法操作!

string(const char* s)
{
	int len = strlen(s); //计算字符串常量的大小
	_str = new char[len + 1]; //开辟空间
	strcpy(_str, s); //拷贝数据
	_size = len; //修改其他成员变量
	_capacity = _size;
}

这里开辟 len + 1的空间是为了 strcpy 拷贝 ‘\0’ 预留的空间哈!为什么要有 '\0' 呢?不是有一个 _size 维护了字符串的大小嘛?那是因为string 有一个 c_str 接口,没有 '\0' 的话,没法兼容C语言啦!

在定义string对象的时候,我们可能会这么定义!上面的构造函数显然不能行。那怎么办呢?

string s;

在string的使用那一节我们讲到,直接这样定义string对象,在内存中其实是在下标为 0 的位置存储了一个 '\0' 的!因此,可以在上面我们写的构造函数给一个缺省参数!像这样:

string(const char* s = "")
{
	int len = strlen(s); //计算字符串常量的大小
	_str = new char[len + 1]; //开辟空间
	strcpy(_str, s); //拷贝数据
	_size = len; //修改其他成员变量
	_capacity = _size;
}

这样做是不是既简单右好理解呢?

3. 析构函数

构造函数写了,我们就可直接写出来析构函数啦!析构函数很好写:释放维护堆区空间的指针,将_size 和 _capacity 置为 0 即可! 

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

4. const char* c_str() const 

这个函数返回C风格式的字符串嘛!很简单直接返回_str即可。后面的那个const修饰的是*this哈,代表成员变量不可修改!const对象与非const对象都可以调用!

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

5. size_t size() const 

 返回字符串有效字符的数量!返回string类维护的_size即可!

size_t size() const
{
	return _size;
}

6. char& operator[](size_t pos) 

为了使得我们的程序看起来比较健壮!因此我们可以检查一下pos的合法性,当我们的pos >= _size显然是不合法的!我们assert断言一下就可以啦!返回值就很好处理啦:返回pos位置的字符就行啦!注意到const的string对象也是可以调用operator[]的因此,我们还要实现一个const的版本:const char& operator[] const。方便const对象调用!const对象调用operator[]得到的字符不允许修改!

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

7. void reserve(size_ t n)

这个函数是用来修改_str维护字符数组的真实容量的,我们需要做的就是:当n > _capacity的时候,开一块容量为n的空间,然后将原来空间的数据拷贝过去,然后在释放_str,在修改_str的指向,使其维护新开出来的空间!

void reserve(size_t n)
{
	if (n > _capacity)
	{
		char* tmp = new char[n + 1];
		memcpy(tmp, _str, _size + 1);
		delete[] _str;
		_str = tmp;
		_capacity = n;
	}
}

这里为什么我们开辟的是 n + 1个字符的空间呢?主要是因为 '\0' 的缘故啊!因为我们的C++必须兼容C语言,所以string对象有效字符的结尾都必须有一个 '\0', 同理memcpy,copy的字节数也要比_size大一,也是因为'\0'的缘故!这里必须要用memcpy不允许使用strcpy,那是因为我们的字符串可能中间出现 '\0',用strcpy就会导致数据拷贝不完整!

8. void push_back(char ch)

该函数可以在string的末尾追加一个字符,模拟实现的时候注意扩容逻辑就行了!什么时候扩容:就是当_size >= _capacity 的时候,我们就需要扩容啦!扩容很好办,直接调用我们的reserve接口就行啦!

还有一个问题就是我们reserve的空间是多大呢?这就取决于你的实现啦!我比较习惯与扩容到原来的两倍!但是有一个魔鬼细节:如果扩容的时候原string的容量就是0 ,扩容成两倍不就是相当于没有扩容嘛,因此我们还需要做一个if条件判断!

void push_back(char ch)
{
	if (_size >= _capacity)
	{
		reserve(_capacity == 0 ? 4 : _capacity * 2);
	}
	_str[_size++] = ch;
	_str[_size] = '\0';
}

9. void append(const char* s)

我们知道append的重载版本很多,为了简单,我们选择实现append一个常量字符串的版本。

C++string的模拟实现_第2张图片

首先,我们还是需要判断是否需要进行扩容!我们先求出 常量字符串的长度 len,如果 _size + len > _capacity 就说明需要扩容!最后,我们直接调用 strcpy 将常量字符串中的字符拷贝到str里面就可以啦!strcpy默认是会拷贝 '\0' 的所以没有任何问题哈!注意修改_size的大小哦!

void append(const char* s)
{
	int len = strlen(s);
	if (_size + len > _capacity)
	{
		reserve(_size + len);
	}
	strcpy(_str + _size, s);
	_size += len;
}

10. operator+=

库里面重载了三个版本,我们实现两个版本就行啦,加等一个字符和加等一个常量字符串。

10.1 string& operator+=(char ch)

嘿嘿,很简单,直接调用 push_back() 就行啦!push_back() 会做好一切!

string& operator+=(char ch)
{
	push_back(ch);
	return *this;
}

10.2 string& operator+=(const char* s)

同样地,我们直接调用append接口就行啦!

string& operator+= (const char* s)
{
	append(s);
	return *this;
}

11. insert()

我们看到库里面重载的版本真的很多呢!我们实现两个版本:在pos位置插入n个字符ch和在pos位置插入一个常量字符串。

C++string的模拟实现_第3张图片

11.1 void insert(size_t pos, size_t n, char ch)

1:检查pos位置的合法性,pos应该是要 <= _size 的。

2:判断是否需要扩容?当 _size + n > _capacity 就需要扩容啦!至于扩容到多大,取决于你,我们选择效仿上面append的扩容逻辑,就扩容到刚好够!

3:挪动数据,既然是在pos位置前面插入字符,我们肯定要腾出来 n 个字符的位置撒!不然怎么插入呢!

我们来看看移动前后的数组对比,再来看如何移动:假设我们要在 pos 位置插入 3 个 'b'

C++string的模拟实现_第4张图片

 根据示意图:我们知道要将pos位置及其之后的所有字符向后移动3个下标!我们可以初始化一个变量:end(或者其他名字),指向_size的位置,因为我们的 '\0' 也要移动嘛。然后将下标为 end + n 的字符赋值为下标为 end 指向的字符,我们就完成了字符向后移动 3 个下标,然后让 end--。直到end < pos,因为pos位置的字符也需要移动嘛!

C++string的模拟实现_第5张图片

移动完成之后从pos位置开始向后填充 n 个字符就行啦!

 但是,还是有一个问题!假如 pos == 0,在完成一个字符移动,end--之后与 pos 比较就会出问题。因为 int 类型 与 size_t 类型一起运算的时候会发生整形提升!int 会被提升为 size_t 从而原本 end == -1 整形提升之后 end 就会变成 无符号整形的最大值!从而导致循环无法结束!

解决办法:

1:在判断条件处将 pos 强转为 int。

C++string的模拟实现_第6张图片

2. 我们可以将 end == -1 的情况单独拿出来判断。我们在学习string的使用时不是引入了一个 npos 嘛,他就是有符号的 -1 无符号的最大值,现在我们也可以在自己的string类中定义一个。

静态成员变量的声明和初始化!不可以直接给缺省值:static size_t npos = -1;因为静态成员变量是属于类的!缺省值给的是初始化列表,静态成员不会通过初始化列表初始化!

有个奇怪的C++语法:const static size_t npos = -1;是能够编译通过的!只不过这个语法仅限于整形家族,是不是特别戳!

C++string的模拟实现_第7张图片

3:我们可以换一种移动字符的方式嘛,将end 初始化为 _size + n 然后将 end - n 的字符赋值给 end 也可以。这样就不会有特殊情况了!只不过代码有点奇怪!

C++string的模拟实现_第8张图片

我们选择第二种解决办法来实现我们的代码!

void insert(size_t pos, size_t n, char ch)
{
	assert(pos <= _size);
	if (_size + n > _capacity)
		reserve(_size + n);

	int end = _size;
	while (end >= pos && end != npos)
	{
		_str[end + n] = _str[end];
		end--;
	}
	for (int i = 0; i < n; i++)
		_str[pos++] = ch;
	_size += n;
}

11.2 void insert(size_t pos, const char* s)

有了前面插入n个字符的铺垫,这个函数就很好实现啦!

1:检查pos的合法性。

2:计算字符串常量的长度 len,判断len + _size 是否大于 _capacity,如果大于则需要扩容!

3:移动字符,移动的方法我们还是选择上面的第二种哈!

4:插入新的字符串!

void insert(size_t pos, const char* s)
{
	assert(pos <= _size);
	size_t len = strlen(s);
	if (len + _size > _capacity)
	{
		reserve(len + _size);
	}
	int end = _size;
	while (end >= pos && end != npos)
	{
		_str[end + len] = _str[end];
		end--;
	}

	for (int i = 0; i < len; i++)
	{
		_str[pos++] = s[i];
	}

	_size += len;
}

12. void erase(size_t pos, size_t len = npos)

这个函数就是删除pos位置后面的 len 个字符!

1:检查 pos 的合法性。

2:如果 len 不传 或者 len 传得比较大!就是删除 pos 之后所有的字符!即,当 pos + len >= _size 的时候与不传 len 的时候是等效的!这个时候我们只需要将 pos 位置的字符修改为 '\0' 即可。然后更新 _size;

3:如果 len 比较小,那么我们就需要移动字符啦!

假设字符串是:"abcdefg" 我们要删除 pos = 1 后面的 3 个字符。我们可以使用 for 循环,初始化循环变量为 i,然后将 i + len 的字符移动到 i 的位置。很明显循环结束的条件就是当 i > _size - len.

移动完成之后更新_size 就可以啦!

C++string的模拟实现_第9张图片

void erase(size_t pos, size_t len = npos)
{
	assert(pos < _size);
	if (len == npos || pos + len >= _size)
	{
		_str[pos] = '\0';
		_size = pos;
	}
	else
	{
		for (int i = pos; i <= _size - len; i++)
			_str[i] = _str[i + len];

		_size -= len;
	}
}

13. find()

find函数我们实现两个版本:1. 从 pos 位置开始查找字符。2. 从 pos 位置开始查找字符串。

13.1 size_t find(char ch, size_t pos = 0)

这个函数就很好实现啦。我们先检查一下pos,然后从pos位置开始查找string中有没有这个字符就行啦!找不到返回npos! 

size_t find(char ch, size_t pos = 0)
{
	assert(pos < _size);
	for (int i = pos; i < _size; i++)
		if (_str[i] == ch)
			return i;
	return npos;
}

13.2 size_t find(const char* s, size_t pos = 0)

我们直接套用 C语言的库函数 strstr(),就可以了!注意参数的传入以及返回值的书写。strstr的原型:

C++string的模拟实现_第10张图片

我们要从 pos 位置开始找,因此参数1的实参应该怎么写:_str + pos。

strstr的返回值是一个char* 我们可以通过 指针减 指针来获得 查找成功的下标!

size_t find(const char* s, size_t pos = 0)
{
	assert(pos < _size);
	const char* ptr = strstr(_str + pos, s);
	if (ptr)
	{
		return ptr - _str;
	}
	else
		return npos;
}

14. string substr(size_t pos = 0, size_t len = npos)

从pos位置截取 len 个字符来构造返回一个新的string对象。

1:检查pos合法性,pos >= _size 都是不合法的!

2:如果 不传 len 或者说 pos + len > _size,就是截取 pos 之后的所有字符,此时我们可以修正截取的实际字符数量,令 n = _size - pos。

3:根据实际的字符数量,遍历字符构造字符串返回即可!我们可以在遍历的时候使用 +=。

string substr(size_t pos = 0, size_t len = npos)
{
	assert(pos < _size);

	size_t n = len; //实际截取的字符数量
	if (len == npos || pos + len > _size)
	{
		n = _size - pos;
	}
	string s;
	for (int i = 0; i < n; i++)
		s += _str[pos++];
	return s;
}

15. void resize(size_ t n, char ch = '\0')

这个函数的使用在 string 使用哪一节是讲过的了!

1:如果 n < _size 很简单,将_size位置的字符修改为 '\0' 即可!

2:如果 n >= _size,我们就需要考虑是否要扩容啦!如果 n > _capacity 需要扩容,否则不需要。因此,我们直接调用 reserve(n),在 reserve 的实现中我们是判断了 n 是否大于 _capacity 的,因此不用担心!

3:用 ch 初始化新的空间即可!

void resize(size_t n, char ch = '\0')
{
	if (n < _size)
	{
		_str[_size] = '\0';
		_size = n;
	}
	else
	{
		reserve(n);
		for (int i = _size; i < n; i++)
			_str[i] = ch;

		_size = n;
		_str[_size] = '\0';
	}
}

 16. void clear()

 比较简单,我们只需要将下标为 0 的字符修改为:'\0',将_size 更新为 0 即可!

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

17. 比较运算符的重载

17.1 bool operator<(cons string& s) const

字符串的比较一直都是按照 字典序 来比较的哈!不是按长度哦!

当 memcmp 的结果不等于0 的时候,如果结果 < 0,则返回 true;如果结果 > 0 返回false。

我们来看memcmp == 0的情况:

1:hello && helloXX,这种情况我们用 memcmp 比较,比较的字节数为两个string 对象中 size()较小的string中有效字符个数所占的字节数。memcmp 的结果为 0,但是因为 helloXX 的hello 后面还有字符,因此 hello < helloXX,返回true。

2:aaaXX && aaa,同样选择用 memcmp 比较size较小的string的字节数,结果依然是 0,但是因为 aaaXX 的 aaa 后面还有字符,因此 aaaXX > aaa, 返回false。

3:hello && hello,这种情况 memcmp 的结果依然是 0 ,但是两个string 后面肚饿没有字符,因此 hello == hello, 返回false。

所以,在 memcmp == 0的情况下,如果 _size < s._size,那么返回 true 否则返回 false。 

这里你可能发问了,为什么不能用 strcmp 呢?原因就是可能出现这样的字符串:

aaa\0bbb && aaa\0ccc,strcpy的结果是0,两个string 对象的size也相等,用strcmp得到的结果就是 false,但起始结果是 true。

bool operator<(const string& s) const
{
	int ret = memcmp(_str, s._str, _size < s._size ? _size : s._size);
	return ret == 0 ? _size < s._size : ret < 0;
}

17.2 bool operator==(cons string& s) const

两个string相等的前提条件就是 他俩的size必须一样,因此如果 size 一样再 memcmp 就行啦!

bool operator==(const string& s) const
{
	return _size == s._size && memcpy(_str, s._str, _size);
}

17.3 接下来全部都是复用啦

bool operator<=(const string& s) const
{
	return (*this) < s || (*this) == s;
}

bool operator>(const string& s) const
{
	return !((*this) <= s);
}

bool operator>=(const string& s) const
{
	return !((*this) < s);
}

bool operator!=(const string& s) const
{
	return !((*this) == s);
}

 18. ostream& operator<<(ostream& out, const string& s)

库里面的 string 是支持 cout << s << endl的。就是因为实现了 流插入运算符的重载!在C++中,ostream的实现是禁用了拷贝构造函数的,因此形参只能写 ostream 的引用。

C++string的模拟实现_第11张图片

还有就是重载流插入和流提取的时候都不要写成成员函数,如果写成成员函数,使用operator<<的顺序就不符合我们的书写习惯啦!一定要写在你定义的namespace内部,不写在你定义的namespace内部函数形参这么写也行:

ostream& operator<<(ostream& out, const Tchey::string& s)

因为可能存在 "aaaa\0bbbb" 这样的神奇字符串,因此我们的函数体不能直接写成:

out << s.c_str(); 

ostream& operator<<(ostream& out, const string& s)
{
	for(int i = 0; i < s.size(); i++)
		out << s[i];
	return out;
}

19. istream& operator>>(istream& in, string& s)

这里的函数体千万不敢这样写:

in >> s.c_str();

第一:直接这样写并没有为 s 开辟空间,强行写入会引起内存错误,单只程序崩溃的!
第二:就算你提前开辟了空间,但是我们并不知道用户要输入多少字符哇,提前开多少空间呢?

因此,我们是不能直接这么搞的,需要一个字符一个字符得读!

我们定义一个char 变量 ch 用于接收输入的字符,每输入一个字符我们就让 s += ch,直到 ch == ' ', 或者 ch == ‘\0’ 的时候,结束输入(循环)。

但是这里有一个 魔鬼细节,如果用 cin 输入的话,是无法读入空格和换行的导致死循环,这个可以你自己在编译器上验证,编译器认为空格 和 换行 是用来区分不同的变量的输入的,因此 cin 读不进去 空格 和换行。我们可以用 cin.get() 这样就能读取到 空格 和换行啦。

于是我们写出了这样的代码:

C++string的模拟实现_第12张图片

没问题了吗?并不是!当我们尝试向一个string对象进行连续的 cin 时,它还是会保留上一次的输入,因此在输入之前还需要清楚 s 原有的字符!

还不够,我们对比 std::string 发现,输入一连串空格之后再输入其他字符,或者输入一连串换行之后再输入其它字符,是能够正确跳过空格和换行输入有效字符的,因此我们还需要清空缓冲区!

istream& operator>>(istream& in, string& s)
{
	//清除s的其他字符
	s.clear();

	char ch = in.get();

	//清空缓冲区
	while (ch == ' ' || ch == '\n')
		ch = in.get();

	while (ch != ' ' && ch != '\n')
	{
		s += ch;
		ch = in.get();
	}
	return in;
}

还有一些人认为,如果输入的字符数量很多,可能会导致 s 扩容次数太多,影响效率。因此搞出了一个数组,用来暂时存放输入的字符,等到这个字符满了,或者输入结束,再让 s += 这个字符数组。

istream& operator>>(istream& in, string& s)
{
//清除s的其他字符
s.clear();

char ch = in.get();

//清空缓冲区
while (ch == ' ' || ch == '\n')
	ch = in.get();

char buff[128];
int index = 0;

while (ch != ' ' && ch != '\n')
{
	if (index == 127)
	{
		buff[127] = '\0';
		s += buff;
		index = 0;
	}
	buff[index] = ch;
	ch = in.get();
}

if (index != 0)
{
	buff[index] = '\0';
	s += buff;

}

return in;
}

20. 拷贝构造函数 

哈哈,你肯定想知道为啥拷贝构造函数现在才写!肯定不是因为忘了!哈哈哈!

string 类中的对象维护了堆区的数据,因此我们要实现深拷贝!深拷贝之前就讲了很多啦!不再赘述!

20.1 传统写法

string(const string& s)
{
	char* tmp = new char[s._capacity + 1];
	memcpy(tmp, s._str, s._size + 1); //+1 是为了拷贝\0
	delete _str;
	_str = tmp;
	_size = s._size;
	_capacity = s._capacity;
}

20.2 现代写法 

现代写法之所以现代就是因为它现代!

string(const string& s)
{
	if(this != &s)
	{
		string tmp(s.c_str());
		std::swap(_str, tmp._str);
		std::swap(_size, tmp._size);
		std::swap(_capacity, tmp._capacity);
	}
}

我们通过 s.c_str() 构造出来一个 tmp 对象,然后通过 swap 函数将 tmp 维护的空间和信息交给 *this 对象。又因为 tmp 是一个临时对象,出了作用域 tmp 就会销毁,调用析构函数,正好将 *this 维护的空间释放掉,简直就是依据两得!有点工具人的味道,哈哈哈!

C++string的模拟实现_第13张图片

21. void swap(string& s)

这个函数就是用来交换两个对象所维护的空间和信息的,实现这个函数我们能更加方便的使用现代写法! 

void swap(string& s)
{
	std::swap(_str, s._str);
	std::swap(_size, s._size);
	std::swap(_capacity, s._capacity);
}

22. 赋值运算符重载

有了现代写法,赋值运算符重载写起来那简直叫一个爽!

//传统写法
string& operator=(const string& s)
{
	if (this != &s)
	{
		char* tmp = new char[s._capacity + 1];
		memcpy(tmp, s._str, s._size+1);
		delete[] _str;
		_str = tmp;

		_size = s._size;
		_capacity = s._capacity;
	}

	return *this;
}


//现代写法
string& operator=(string tmp)
{
	swap(tmp);

	return *this;
}

23. string迭代器的实现 

迭代器在string中就是 char* 的指针,迭代器究竟是什么,等我们学到 list 再来理解!

首先我们要在 string 中定义一个迭代器类型!

然后我们就逐一来实现 begin() 和end() 起始很简单啊!

begin():返回 下标为 0 的元素的地址。

end():返回 _size 下标处的地址。

*,++,--,都不需要自己实现,因为指针是内置类型,并且 string 的物理空间连续。完全不需要自己动手!

注意实现两个版本:const 和 非 const 版本!

迭代器只要实现了,范围 for 就可以使用了,如果范围 for 不知道是什么的老铁可以复习一下:

21天学会C++:Day8----范围for与nullptr_姬如祎的博客-CSDN博客

范围for的底层就是 迭代器,范围for会被无脑替换成 迭代器遍历!

iterator begin()
{
	return _str;
}

iterator end()
{
	return _str + _size;
}

const_iterator begin() const
{
	return _str;
}

const_iterator end() const
{
	return _str + _size;
}

 到这里,我们就实现了一个属于自己的string,模拟 std::string 实现的方法有很多。模拟只是为了加深对 string 的理解与应用!好啦!不见不散!

 C++string的模拟实现_第14张图片

 

你可能感兴趣的:(C++专题,c++,开发语言)