C++String类的手撕实现

目录

构造函数

提前准备工作:

有参构造

析构函数

c_str

无参构造:

无参和有参的结合

 operater[]的实现

简易版的迭代器

begin

end

 原因:

reserve

思想步骤

获取_capacity 和 _size

测试 

push_back 

思想步骤

append

insert字符

思想步骤:

测验

循环条件的问题

insert字符串

npos

erase

判断删除数据的长度

全删

部分删

最终代码为

 swap

find字符

测试

find字符串

代码

测验

​编辑

substr

代码

测试 

问题提出

问题分析

 问题解答

构建拷贝和赋值

测试 

流插入和提取

流插入

流提取

cin对于空格和换行

get( )

频繁扩容问题

解决办法


构造函数

提前准备工作:

string的成员变量

private:
    char* _str;
    size_t _size;
    size_t _capacity;

需要的头文件

#include<iostream>
#include"string.h"    //它是有个头文件的

有参构造

string(const char* str){
    _size = strlen(str);
    _capacity = _size;
    _str = new char[_capacity + 1];
}

有人可能会走参数列表,单走参数列表的话,在此处是没有特别大的意义,且很容易出错。

这是正确使用参数列表的方法
string(const char* str= "")
	:_str(new char[_capacity + 1]),
	_size(strlen(str)),
	_capacity(_size)
	{
		strcpy(_str, str);
	}

 很多会变成出错的原因,就是参数列表没有对应好成员变量的顺序,所以为了避免出现这样的问题就是不用参数列表,直接在函数内定义

析构函数

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

c_str

先用来写出打印出自定义类型的,因为IO流不能输出自定义类型

所以要自己写

const char* c_str(){
    return _str;
}

无参构造:

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

很多朋友可能会写成这个样子,并觉得没有任何问题,那我们就来调用一下

出现了异常,原因就是c_str在返回的时候对空指针进行了解引用的行为

所以报错了

所以要对无参构造怎么搞也要有一个初始值

string()
    :_str(new char[1]),
	_size(0),
	_capacity(0)
{
	_str[0] = '\0';
}

无参和有参的结合

那么这么麻烦,其实可以将有参构造和无参构造合二为一的,在缺省参数上赋予一个空字符串

string(const char* str = ""){
	_size = strlen(str);
	_capacity = _size;
    _str = new char[_capacity + 1];
    strcpy(_str, str);


此处的顺序结构也不能乱,当时我就是把_str放到最上面,导致其实_capacity是随机值的时候
就把空间内容赋给_str,最终报错

    //_str = new char[_capacity + 1];
	//_size = strlen(str);
	//_capacity = _size;
}

拷贝构造我们往下点再说 !


 operater[]的实现

char& operator[](size_t i) {
	assert(i <= _size);
	return _str[i];
}

assert的条件里无需再写 i>=0了,因为参数类型是size_t,所以能很好地避免出现负数的情况


简易版的迭代器

begin

typedef char* iterator;

iterator begin(){
    return _str;
}

end

上面已经typedef,这里就不再写了
iterator end(){
    return _str + _size;
}
		const_iterator begin() const
		{
			return _str;
		}

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

 

 原因:

因为string是物理线性结构,这个字符的地址的下个地址,就是下一个字符的地址,这是它的底层结构的特性。如果换成物理上不是连续的地址的时候,我们就不能够这样去实现这种迭代器了

比如,链表,树结构


reserve

思想步骤

  1. 如果要新空间的大小大于原空间的大小的话,就要扩容
  2. 创建一个临时对象,来接受新增加的空间
  3. 将原来空间里的数据,拷贝到新空间里面去
  4. 将旧空间释放掉
  5. 将新空间的指针变量交给原来的指针变量
  6. 更新_capacity

void reserve(size_t i) {
	if (i > _capacity) {
	    char* tmp = new char[i];
		strcpy(tmp, _str);
		delete[] _str;
		_str = tmp;
		_capacity = i;
	}
}

获取_capacity 和 _size

size_t getCapacity() {
	return _capacity;
}

size_t getSize() {
	return _size;
}

测试 

void test1() {
	string s1("hello reverse");
	cout << "我原来的大小是:" << s1.getSize() << endl;
	cout << "我原来的空间是:" << s1.getCapacity() << endl << endl;

	s1.reserve(100);
	cout << "我现在的大小是:" << s1.getSize() << endl;
	cout << "我现在的空间是:" << s1.getCapacity() << endl;
}


push_back 

插入数据最核心的点就是扩容, 在push_back之前先完成reserve

开空间时要多开一个,留给\0,因为capacity指的是有效空间,真实的空间是会比他多一个的

思想步骤

  1. 判断空间是否已满

  2. 若已满,则进行扩容,用reserve即可

  3. 将要插入的ch放进对象里的最后一位

  4. ++size

  5. 将原本被ch覆盖掉的'\0'补回去 

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

append

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

strcpy是能够将'\0'也拷贝进去的


insert字符

思想步骤:

  1. 判断空间是否已满
  2. 给予一个变量,用来记录开始移动的数据
  3. 给予判断条件,开始移动
  4. 放入新数据
  5. ++_size

测验

void test3() {
	string s1("hello insert");
	cout << s1.c_str() << endl << endl;
	s1.insert(0, 'x');
	cout << s1.c_str() << endl << endl;
}

我们现在来看看第三步,给予判断条件

size_t end = _size;
while (end >= pos){
    _str[end+1] = _str[end];
    --end;
}
_str[pos] = ch;

如果是这样的话,能不能成功 ?

肯定不行啦,都这样问了

循环条件的问题

当在0位置插入的时候就挂了

因为end是size_t定义的,当pos为0时,不断--end,当end被减到-1时,就变成了无穷大,所以又变成了>0,陷入了一个死循环

改int也不行

因为当两边的操作符类型不一样的时候,它们会发生类型提升,有符号会向无符号转,所以在比较的时候,会转成无符号进行比较,所以结果还是一样的

所以正确的做法是:避免pos变为-1;

while (end > pos) {
	_str[end] = _str[end-1];
	--end;
}

变成这样之后,就要考虑一个重点问题了,'\0'怎么办? 

这里有两种方法

  1. end 从_size 变成 _size + 1,让end - 1 所代表的 '\0' 有去处
  2.  后面给它加一个,_str [_size] = '\0' ;

最终代码为:

void insert(size_t pos, char ch) {
	assert(pos <= _size);
	if (_size == _capacity) {
		size_t newcapacity = _capacity == 0 ? 4 : _capacity * 2;
		reserve(newcapacity);
	}
	size_t end = _size + 1;
	while (end > pos) {
		_str[end] = _str[end-1];
		--end;
	}
	_str[pos] = ch;
	++_size;
}

insert字符串

思想步骤跟insert字符差不多

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

strncpy:避免\0拷贝进去


npos

const static size_t npos = -1 ; 它在私有成员变量里也能够这么定义的,这算是一个特殊处理了吧,既算声明,也算定义

以前我们在学习类的静态成员变量的时候

类的静态成员变量定义和初始化要分离,它不走初始化列表,需要在类外给定义

原因很简单:静态成员变量只能初始化一次,如果你在类里面给缺省值,就相当于我每创建一个对象,就要对这个静态成员变量初始化一次,显然有违背于静态成员变量规则

现在可将它看作是一种特例

没有任何报错

它并不是说加了const就可以!!!

只有整数类型才能接收


erase

清除数据,缩短长度

判断删除数据的长度

再从某个位置清空数据的时候,要么全删,要么删部分长度

全删

当要删的长度刚好到从开始位置到结束位置

当要删的长度是无穷大,即-1

所以判断条件可为

if(len == npos || pos + len >= _size)

部分删

从某个位置开始删除一般长度的字符串,用strcpy即可

最终代码为

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

 swap

直接调用库里面的swap换一下就可以了

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

find字符

既然是找,给个循环就可以了

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

测试


find字符串

用strstr()函数

它会返回第一次出现在str1里面的str2的指针,没找到就返回空

代码

size_t find(const char* str, size_t pos = npos) {
	const char* ptr = strstr(_str + pos, str);
	if (!ptr) {
		return npos;
	}
	else {
		return ptr - _str;
	}
}

测验


substr

截取一段字符串,肯定是要返回的

代码

string substr(size_t pos = 0, size_t len = npos) {
	assert(pos < _size);
	size_t end = pos + len;
	if (len == npos || pos + len > _size) {
		end = _size;
	}
	string str;
	str.reserve(end - pos);
	for (size_t i = pos; i < end; i++) {
		str  += _str[i];
	}
	return str;
}

测试 

问题提出

可能很多人会认为到这里已经没有任何问题了

那我换种样子呢?

	void test6() {
		string s("hello substr");
		string s2;
		s2 = s.substr(6, 6);
		cout << s2.c_str() << endl;
	}

 换动是将string s2 = s.substr(6, 6);   

换成 string s2 ;         s2 = s.substr(6, 6); 

结果就是崩了 !!!

何罪至此?

问题分析

是否还记得这张图,对的,就是我们没有拷贝构造这种东西。

之前没有问题,全是编译器优化的功劳,当在同一段表达式的时候,编译器会直接优化很多,导致我们肉眼看不出来任何区别,

当我们没有写拷贝构造的时候,类里面的默认拷贝构造就只是一个简简单单的浅拷贝(值拷贝),对于我们new出来的空间,只是拷贝一个临时对象,但指向的内容还是一样的

深拷贝的解决方案就是会开一个一模一样的空间,你指你的,我指我的,不会互相打扰的,你被挂掉就挂掉吧,反正临时对象不会挂掉 

 

 问题解答

构建拷贝和赋值

string(const string& s) {
	_str = new char[s._capacity + 1];
	strcpy(_str, s._str);
	_size = s._size;
	_capacity = s._capacity;
}

string& operator=(const string& s) {
	if (this != &s) {
		char* tmp = new char[s._capacity + 1];
		strcpy(tmp, s._str);
		delete[] _str;
		_str = tmp;
		_size = s._size;
		_capacity = s._capacity;
	}
	return *this;
}

测试 

此刻便是平安无事


流插入和提取

因为在此处我无需再次使用类里面的私有成员变量,所以在这里无需使用友元函数声明

流插入

循环遍历即可

	ostream& operator<<(ostream& out, const string& s) {
		for (auto ch : s) {
			out << ch;
		}
		return out;
	}

流提取

istream& operator>>(istream& in, string& s) {
	char ch;
	in >> ch;
	while (ch != ' ' && ch != '\n') {
		s += ch;
		in >> ch;
	}
	return in;
}

我们先看看这样子行不行

 发现即使我们按下回车,也依旧可以继续输入

 这就需要我们知道关于cin的一个性质

cin对于空格和换行

 在我们使用流插入的时候,或者说平常使用C的scanf的时候,它会自动省略空格和换行

		int a, b;
		scanf("%d%d", &a, &b);
		printf("%d %d", a, b);
		int a, b;
		cin >> a >> b;
		cout << a<<" " << b;

 都是下面结果

  

所以我们就可以用c++里的另一个函数,专门取出字符的函数

get( )

 

get函数,可以专门提取出字符

	istream& operator>>(istream& in, string& s) {
		char ch;
		ch = in.get();
		while (ch != ' ' && ch != '\n') {
			s += ch;
			ch = in.get();
		}
		return in;
	}

换成这样,我们就能正常的读取到空格和换行

 

 

频繁扩容问题

再谈论另外一个问题,我们使用的是s+=ch; 也就是说,我们每次新增一次字符,我们就要扩容一次,因为我们要调用reserve函数,我们可以知道频繁扩容给我们带来的代价是比较大的

性能开销:容器在扩容时需要重新分配内存并复制现有元素到新的内存区域。这个过程是昂贵的,因为它涉及到内存分配和数据复制

内存分配:频繁的内存分配和释放可能导致内存碎片,影响程序的内存使用效率

时间复杂度:如果一个容器在添加元素时需要频繁扩容,那么其时间复杂度可能从理论上的O(1)增加到O(n),因为每次扩容都需要复制所有现有元素

异常安全性:在C++中,如果内存分配失败,会抛出异常。频繁的内存分配增加了抛出异常的可能性,这可能需要额外的异常处理逻辑。

资源竞争:在多线程环境中,频繁的内存分配和释放可能导致资源竞争,从而影响程序的并发性能。

解决办法

所以,为了解决这个问题,我们可以像缓冲区一样,构建一个缓冲

即,先划分一个相对比较合理的空间,让内容先填进去,等到满了,或者触发了某个特定条件,比如空格跟换行,再把缓冲内容倒进容器里。这样就可以避免频繁扩容

所以比之前较为优化的写法为

	istream& operator>>(istream& in, string& s) {
		s.clear();
		char buffer[128];
		char ch = in.get();
		int i = 0;
		while (ch != ' ' && ch != '\n') {
			buffer[i] = ch;
			i++;
			if (i == 127) {	//不能到128,因为0-127就是128位了
				buffer[i] = '\0';
				s += buffer;
				i = 0;
			}
			ch = in.get();
		}
		if (i > 0) {
			buffer[i] = '\0';
			s += buffer;
		}
		return in;
	}

以上就是本次博客的学习内容,如有错误,还望各位大佬指点,谢谢阅读

  • 18
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值