第五讲(下)| string类的模拟实现

string类的模拟实现

结合底层的角度理解string类,并不是说自己实现出一个更好的或者是一样的string类。

不按照模板实现,因为按照模板实现会涉及编码,有点复杂。自己写一个简洁版的学习即可。

string类就是一个管理字符数组的类,实际空间中永远都有标识字符'\0',开空间时就要多开一个存储标识字符'\0',支持扩容底层就有size、capacity。但是无论是size还是capacity都不包含'\0'(size指向最后一个有效数据的下一个位置),所以我们自己实现的也要和库里的保持一致。这段话一定要牢牢记得,后面底层实现会反复用到的。

cpp 复制代码
// 自己实现
// string.h
#include <iostream>
using namespace std;
namespace bit
{
	class string {
	public:
	private:
		char* _str;
		size_t _size;
		size_t _capacity;
	};
}

string类的设计本身就有一些是冗余的,实现最基本的使用即可。实现时我们可以参考文档看看库里的函数的声明怎么给的,根据声明试着实现定义底层。

文档链接: https://legacy.cplusplus.com/

为了防止发生命名冲突,我们将对实现string类的测试、声明定义都被一个同名命名空间域包起来,不同的文件的同名namespace会被认为是同一个namespace。

cpp 复制代码
// string.h
#include <iostream>
#include <string.h>
#include <assert.h>
using namespace std;
namespace bit
{
	class string {
	public:
		typedef char* iterator;
		typedef const char* const_iterator;
		iterator begin();
		const_iterator begin() const;
		iterator end();
		const_iterator end() const;

		size_t size() const;
		size_t capacity() const;
		char& operator[] (size_t pos);
		const char& operator[] (size_t pos) const;

		 无参默认构造
		//string()
		//	// 设置成空串就会有问题,一个字符都没有就等于没有'\0',但是string类永远都有标识字符'\0',所以要new一下。
		//	//:_str(nullptr)
		//	:_str(new char[1] {'\0'})
		//	// 但是_size和_capacity永远都不包括标识字符'\0'
		//	,_size(0)
		//	,_capacity(0)
		//{}
		 带参构造
		//string(const char* str)
		//	:_size(strlen(str))
		//{
		//	_str = new char[_size + 1];
		//	_capacity = _size;
		//	//strcpy(_str, str);
		//  memcpy(_str, str, _size + 1);
		//}
		// 析构函数
		~string()
		{
			if (_str)
			{
				delete[] _str;// 配套使用
				_str = nullptr;
				_size = _capacity = 0;
			}
		}
		const char* c_str() const
		{
			return _str;
		}
		// 无参、带参构造可以合并成全缺省默认构造
		// string(const char* str = nullptr)// 不能这样写,strlen(nullptr),会对空指针解引用计算字符串的长度,遇到'\0'才会停止
		// string(const char* str = " ")// 什么都不写也行,因为C中会默认加标识字符'\0'(空串中也会默认加'\0')
		string(const char* str = "\0")
			// 不传实参,用缺省值,strlen()计算串的长度是0,即_size、_capacity也是0,但是开空间会多开一个
			:_size(strlen(str))
		{
			_capacity = _size;
			_str = new char[_size + 1];
			//strcpy(_str, str);
			memcpy(_str, str, _size + 1);
		}
		// range constructor
		template <class InputIterator>
		string(InputIterator first, InputIterator last)
		{
			while (first != last)
			{
				push_back(*first);
				++first;
			}
		}

		void reserve(size_t n = 0);
		void push_back(char c);
		string& append(const char* s);
		string& operator+=(char c);
		string& operator+=(const char* s);

		void insert(size_t pos, char c);
		void insert(size_t pos, const char* s);
		void erase(size_t pos, size_t len = npos);

		// 传统写法
		//string(const string& s);
		//string& operator=(const string& s);
		// 现代写法
		string(const string& s);
		string& operator=(const string& s);
		string& operator=(string s);

		string substr(size_t pos = 0, size_t len = npos) const;

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

		void clear();

		bool operator>(const string& s) const;
		bool operator>=(const string& s) const;
		bool operator<(const string& s) const;
		bool operator<=(const string& s) const;
		bool operator==(const string& s) const;
		bool operator!=(const string& s) const;

		void swap(string& s);
	private:
		// 相当于在这里有优化
		// char _buff[16];
		char* _str = nullptr;
		size_t _size = 0;
		size_t _capacity = 0;
	public:
		 static const size_t npos;
		// 支持特殊处理
		//static const size_t npos = -1;

		// 不支持下列写法
		//static const double d = 12.34;
		//static size_t x = 1;
	};
	std::ostream& operator<< (std::ostream& os, const string& s);
	std:: istream& operator>> (std::istream& is, string& s);
	void swap(string& x, string& y);
}

一、Member constants(成员常数)

npos

npos(全称"no position"或"not a position")是编程中常见的特殊常量,主要用于表示无效位置或未找到的标识。

定义:std::string::npos是std::string类的静态常量成员,类型为size_t,表示字符串中不存在的索引位置。其值通常被定义为size_t类型的最大值(即-1的补码表示,大概42亿9000万)。

npos是一个公有的静态常量成员,在类外面能被访问。自己怎么实现呢?

法一:类里声明,类外面定义。声明中加静态的属性就够了。

法二:缺省值是给初始化列表的,静态成员不走初始化列表,不能加缺省值。但是这里可以加,这是C++的特殊处理,只有整型(比如浮点型就不可以)的const静态可以这样,就不用在类外面定义了。但是普通静态成员变量不能这样。

cpp 复制代码
// string.h
namespace bit
{
	class string
	{
	public:
		 static const size_t npos;
		// 支持特殊处理
		//static const size_t npos = -1;
	
		// 不支持下列写法
		//static const double d = 12.34;
		//static size_t x = 1;
	};
}
cpp 复制代码
// string.cpp
namespace bit
{
	const size_t stirng::npos = -1;
}

二、Member functions(成员函数)

constructor(构造)、destructor(析构)、c_str

构造和析构都是被最高频调用的短小函数,直接定义在类里会默认是内联函数。

分别实现无参构造和带参构造,无参和带参的合并就是全缺省构造。

流插入、流提取写起来比较复杂,我们暂且用c_str也可以输出string类对象。流插入、流提取运算符重载后面会讲解及其实现。

无参默认构造:设置成空串就会有问题,一个字符都没有就等于没有'\0',但是string类永远都有'\0',所以要new出一个空间。但是_size和_capacity永远都不包括'\0'。new底层相当于调用了malloc(),会有资源,所以析构函数也要自己实现,对象出了作用域会调用对应的析构函数的。

cpp 复制代码
// 无参默认构造
string()
	// 设置成空串就会有问题,一个字符都没有就等于没有'\0',但是string类永远都有标识字符'\0',所以要new一下。
	//:_str(nullptr)
	:_str(new char[1] {'\0'})
	// 但是_size和_capacity永远都不包括标识字符'\0'
	,_size(0)
	,_capacity(0)
{}
cpp 复制代码
// 析构函数
~string()
{
	// 析构函数析构空是没有问题的,但是若怕delete空可以加个条件
	if (_str)
	{
		delete[] _str;// 配套使用
		_str = nullptr;
		_size = _capacity = 0;
	}
}
cpp 复制代码
// c_str
const char* c_str() const
{
	return _str;
}

带参构造

  1. 按照成员变量的声明顺序进行初始化,_size还没有初始化,是随机值,所以_str是随机值,即输出的类对象s2也是随机值:
  1. 但是成员变量的声明顺序改成与初始化列表的顺序一样,还是会打印出随机值。因为只把空间开出来了,但是并没有把数据拷贝过去。strcpy()遇到'\0'会终止,遇到中间有'\0'的字符串会拷贝不完全,可以用memcpy()解决这一问题。拷贝数据在函数体内实现:

极端情况下字符串中间会出现'\0':

这样的情况下,实现reserve扩容(异地扩容),用strcpy就会出现问题,strcpy遇到'\0'就会停止拷贝,会导致数据丢失,所以用memcpy。拷贝逻辑最好都改成memcpy,避免字符串中间有'\0'的问题。

  1. 但是顺序一旦不一样了,按照声明顺序初始化,编译器下_size可能是随机值,也可能是0,是0的情况下就只会new一个字节的空间,但是现在要拷贝过去12个字节(hello world)的数据就会有越界的问题,越界不会报错,但是析构的时候(delete有检查是否越界的功能)会报错,也就是出了作用域才会检查有没有越界。所以要将size和capacity的声明顺序放在前面,即强制绑定声明顺序与初始化列表顺序一致,但是这样也不可靠,其他人用可能会改变顺序,就会很麻烦。解决办法:干脆直接将_capacity和_str在函数体内初始化,就不会受声明顺序的影响
cpp 复制代码
// 带参构造
string(const char* str)
	:_size(strlen(str))
{
    _capacity = _size;
	_str = new char[_capacity + 1];
	//strcpy(_str, str);
	memcpy(_str, str, _size + 1);
}
  1. 其他两种初始化成员变量的劣势:若都用strlen()作为初始值初始化成员变量,三次strlen()会有效率问题;强制绑定声明顺序与初始化列表顺序一致,其他人用可能会改变顺序,就会很麻烦。(用sizeof()计算_str的大小也是不行的,_str是指针了。)

全缺省默认构造

无参、带参构造可以合并成全缺省默认构造。

cpp 复制代码
// string(const char* str = nullptr)// 不能这样写,strlen(nullptr),会对空指针解引用计算字符串的长度,遇到'\0'才会停止
// string(const char* str = " ")// 什么都不写也行,因为C中会默认加标识字符'\0'(空串中也会默认加'\0')
string(const char* str = "\0")
	// 不传实参,用缺省值,strlen()计算串的长度是0,即_size、_capacity也是0,但是开空间会多开一个
	:_size(strlen(str))
{
	_capacity = _size;
	_str = new char[_capacity + 1];// 空间数总是多一个,注意是空间!
	//strcpy(_str, str);
	memcpy(_str, str, _size + 1);// 拷贝数据不要忘了最后的标识字符'\0'
}

cpp 复制代码
void test_string1()
{
	//初步定义一个空串(_str(nullptr))也没有问题,但是提供流插入、流提取就有问题了(暂且不提供,流插入、流提取写起来比较复杂,用c_str也可以输出str)。
	//string s1;
	//c_str返回const char*,程序崩溃的原因:c_str里面的_str是空指针,cout一个空指针,char*类型打印不会去按照指针去打印,cout输出时,char*类型会按照字符串打印(会对返回的指针进行解引用,遇到'\0'就终止。所以遇到字符串中间有'\0'时没有打印完全也不要惊讶,可以通过调试看看变化或者范围for遍历输出)

	//cout << s1.c_str() << endl;
	// 
	//而库里面的空对象是不会有问题的,啥都不打印,打印一个空
	//std::string s1;
	//cout << s1.c_str() << endl;
	
	// 都可以调用全缺省默认构造
	string s1;
	cout << s1.c_str() << endl;// 无参构造,什么都不会打印出来,通过调试可以看到标识字符'\0'
	string s2("hello world");
	cout << s2.c_str() << endl;// 带参构造,随机值
	string s3("hello\0\0\0\0world");
	cout << s3.c_str() << endl;
}

range constructor

迭代器区间的构造函数

cpp 复制代码
// string.h
template <class InputIterator>
string(InputIterator first, InputIterator last)
{
	while (first != last)
	{
		push_back(*first);
		++first;
	}
}

但是,若是传递过来的字符串中间有非标识字符'\0',那么strlen()就计算到'\0'就终止了,这样会导致数据缺失,所以,可以实现一个范围构造函数。

我们发现,实现string类不像之前实现日期类一样那么简单,相反,string类的实现要考虑很多层面,稍有不慎,就会出错。

若出现:

之前的封装就是数据和方法放在一起,其实还有上下层的分离,上下层是不一样的,上层是指所有的容器都用着同一种方式去访问的,但是下层(底层实现)就千变万化了。

遍历1 :Iterators

迭代器(不一定是指针)。

给迭代器提供类型。在类里面实现类型有两种方法:内部类或typedef。

反向迭代器的实现比较复杂,涉及适配器,学到后面会讲解

范围for会替换成迭代器,就是迭代器假如写成Begin范围for就执行不了,因为没有找到begin()

cpp 复制代码
// string.h
typedef char* iterator;
typedef const char* const_iterator;
iterator begin();
const_iterator begin() const;
iterator end();
const_iterator end() const;
cpp 复制代码
// string.cpp
string::iterator string::begin()
{
	return _str;
}
string::const_iterator string::begin() const
{
	return _str;
}
string::iterator string::end()
{
	return _str + _size;
}
string::const_iterator string::end() const
{
	return _str + _size;
}
cpp 复制代码
// Test.cpp
void func(const string& s)
{
	string::const_iterator it = s.begin();
	while (it != s.end())
	{
		//(*it)++;
		cout << *it << " ";
		++it;
	}
	cout << endl;
}
void test_string2()
{
	string s1("hello world");
	string::iterator it = s1.begin();
	while (it != s1.end())
	{
		(*it)++;
		cout << *it << " ";
		++it;
	}
	cout << endl;
	func(s1);

	// 范围for会替换为迭代器
	for (auto ch : s1)
	{
		--ch;
		cout << ch << " ";
	}
	cout << endl;
	for (auto& ch : s1)
	{
		--ch;
	}
	cout << s1.c_str() << endl;
}

遍历2:下标+operator[](捎带着实现 size、capacity)

cpp 复制代码
// string.h
size_t size() const;
size_t capacity() const;
char& operator[] (size_t pos);
const char& operator[] (size_t pos) const;
cpp 复制代码
// string.cpp
size_t string::size() const
{
	return _size;
}
size_t string::capacity() const
{
	return _capacity;
}
char& string::operator[] (size_t pos)
{
	// pos已经是非负整数了,肯定大于等于0,只用判断它小于_size即可
	assert(pos < _size);
	return _str[pos];
}
const char& string::operator[] (size_t pos) const
{
	assert(pos < _size);
	return _str[pos];
}
cpp 复制代码
// Test.cpp
void test_string3()
{
	string s1("hello world");
	// 可读可修改
	s1[0]++;
	for (size_t i = 0; i < s1.size(); ++i)
	{
		cout << s1[i] << " ";
	}
	cout << endl;

	// 只读
	const string s2("hello world");
	//s2[0]++;
	for (size_t i = 0; i < s2.size(); ++i)
	{
		cout << s2[i] << " ";
	}
	cout << endl;
}

实现append(追加)数据:push_back、append、operator+=,一旦追加数据就避免不了扩容的问题,所以在实现它们之前先实现reserve。

reserve

cpp 复制代码
// string.h
void reserve(size_t n = 0);
cpp 复制代码
// string.cpp
void string::reserve(size_t n)// 声明和定义分离时缺省值只能给声明
{
	if (n > _capacity)
	{
		// 关于new扩容,需要自己手动实现
		char* tmp = new char[n + 1];
		if (_str)
		{
			memcpy(tmp, _str, _size + 1);// 拷贝空会出现问题的
			delete[] _str;
		}
		_str = tmp;
		_capacity = n;
	}
}

push_back、append、operator+=

cpp 复制代码
// string.h
void push_back(char c);
string& append(const char* s);
string& operator+=(char c);
string& operator+=(const char* s);

这个逻辑写得不对,因为有一种极端的场景,若用默认参数初始化(不传实参),也就是用空串初始化,此时_size、_capacity都是0,开了一个空间。这时去尾插扩2倍的容量,就会出现_capacity还是0的问题,就要写个三目操作符。

cpp 复制代码
// string.cpp
void string::push_back(char c)
{
	if (_size == _capacity)
	{
		reserve(_capacity == 0 ? 4 : 2 * _capacity);
	}
	// 插入数据后,新的数据会把标识字符'\0'覆盖,需要自己手动加上'\0'
	_str[_size++] = c;
	_str[_size] = '\0';
}
// 扩2倍逻辑不好用了,假设_size、_capacity都为10,新加入的字符串长度len为30,那么2倍扩后的容量肯定不够。
// 需要多少开多少也不行,每次append就会导致频繁的扩容。有一个扩容方法可以缓解上面的问题:
string& string::append(const char* s)
{
	size_t len = strlen(s);
	if (_size + len > _capacity)
	{
		size_t newCapacity = _size + len > 2 * _capacity ? _size + len : 2 * _capacity;
		reserve(newCapacity);
	}
	memcpy(_str + _size, s, len + 1);
	_size += len;
	return *this;
}
string& string::operator+=(char c)
{
	push_back(c);
	return *this;
}
string& string::operator+=(const char* s)
{
	append(s);
	return *this;
}
cpp 复制代码
// Test.cpp
void test_string4()
{
	string s1;
	s1.push_back('1');
	s1.push_back('2');
	s1.push_back('3');
	s1.push_back('4');
	s1.push_back('x');
	s1.push_back('y');
	s1.push_back('z');
	s1.push_back('\0');
	for (auto ch : s1)
	{
		cout << ch << " ";
	}
	cout << endl;

	s1.append("11111111111111111111");
	// 遇到'\0'就终止了,可以用范围for遍历输出
	cout << s1.c_str() << endl;
	for (auto ch : s1)
	{
		cout << ch << " ";
	}
	cout << endl;

	string s2("hello");
	s2 += 'a';
	s2 += ' ';
	s2 += "world";
	cout << s2.c_str() << endl;
}

insert、erase

cpp 复制代码
// string.h
void insert(size_t pos, char c);
void insert(size_t pos, const char* s);
void erase(size_t pos, size_t len = npos);

insert头插一个字符,-1 >= 0,还是会进入循环,原因是运算符的两个操作数类型不同时会引发类型提升,范围小的向范围大的提升,end是有符号,pos是无符号的,比较的时候int会提升成无符号整型。那把pos强制类型转换成int类型也不可取,因为库里面pos接口设计的都是无符号整型的。法一:end >= (int)pos,避免类型提升。法二:避免无符号end小于0,end一开始在'\0'后一个位置(_size + 1),让前一个数据往后挪动。

cpp 复制代码
// string.cpp
void string::insert(size_t pos, char c)
{
	// 也有可能在_size的位置插入数据
	assert(pos <= _size);
	if (_size == _capacity)
	{
		reserve(_capacity == 0 ? 4 : 2 * _capacity);
	}
	// end可能会出界
	//int end = _size;
	//while (end >= (int)pos)// 为什么类型不同却能正常运行?end是int类型,pos是size_t类型,类型提升。但是若end为-1,也就是越界了,会有符号位丢失及比较运算符陷阱的问题。若将pos强制类型转换为int也不是一劳永逸的事情。
	//{ }
	/*size_t end = _size;
	while (end >= pos)
	{
		_str[end + 1] = _str[end];
		--end;
	}*/
	size_t end = _size + 1;
	while (end > pos)
	{
		_str[end] = _str[end - 1];
		--end;
	}
	_str[pos] = c;
	_size++;
}
void string::insert(size_t pos, const char* s)
{
	assert(pos <= _size);
	size_t len = strlen(s);
	if (_size + len > _capacity)
	{
		size_t newCapacity = _size + len > 2 * _capacity ? _size + len : 2 * _capacity;
		reserve(newCapacity);
	}
	size_t end = _size + len;
	while (end >= pos + len)// 或while (end > pos + len - 1)
	{
		_str[end] = _str[end - len];
		--end;
	}
	// 若是while循环写成下面这样,会多挪动几个数据,这样实现结果也没有问题。但是第一个问题是多挪动几个数据会导致白做功了,第二个问题就是pos为0时会越界,越界不会报错。
	/*while (end > pos)
	{
		_str[end - len] = _str[end];
		--end;
	}*/
	for (size_t i = 0; i < len; ++i)
	{
		_str[i + pos] = s[i];
	}
	_size += len;
}
void string::erase(size_t pos, size_t len)
{
	// 没办法在_size位置删除数据,_size位置不是有效数据
	assert(pos < _size);
	if (len == npos || len >= _size - pos)// len == npos表示len肯定大于后面删除的长度,不可能有npos(42亿9000万)这么长
	{
		// 全部删完
		_str[pos] = '\0';
		_size = pos;
	}
	else {
		// 删除部分,挪动数据覆盖
		//memmove可以解决内存重叠的问题,别忘了标识字符'\0'
		memmove(_str + pos, _str + pos + len, _size + 1 - (pos + len));
		_size -= len;
	}
}
cpp 复制代码
// Test.cpp
void test_string5()
{
	string s1("hello world");
	s1.insert(6, 'x');
	cout << s1.c_str() << endl;
	s1.insert(0, '1');
	cout << s1.c_str() << endl;

	string s2("hello world");
	s2.insert(6, "xxx");
	cout << s2.c_str() << endl;
	s2.insert(0, "aaa");
	cout << s2.c_str() << endl;

	s2.erase(6);
	cout << s2.c_str() << endl;
	s2.erase(3, 100);
	cout << s2.c_str() << endl;
	s1.erase(5, 3);
	cout << s1.c_str() << endl;
}

substr、copy constructor、operator=

cpp 复制代码
// string.h
string(const string& s);
string substr(size_t pos = 0, size_t len = npos) const;
string& operator=(const string& s);

拷贝构造:一个已经存在的对象去拷贝初始化另一个要创建的对象。

赋值运算符重载:两个已经存在的对象之间的赋值。

若在模拟实现substr时没有自己实现拷贝构造:

ret拷贝构造出临时对象,因为我们没有自己实现拷贝构造,所以这里是浅拷贝,会导致ret和临时对象都指向同一块空间,ret出了作用域会调用析构函数(我们之前自己实现了),ret及其指向的空间会释放,所以临时对象中指向这块空间的指针就是一个野指针。(实际上,编译器会优化成一个拷贝构造)。本质都是传值返回会生成拷贝。这个问题跟下面图中的问题是一样的。

后定义的(s5)先析构,会调用两次析构:

那么就要自己写拷贝构造来实现深拷贝!

所有开空间的地方,像拷贝构造、reserve,永远都会多开一个给'\0',所以不用考虑'\0'的空间,它永远有空间的。需要多少个字符就开多少空间,调用对应的接口。


string类对象赋值,s1 = s2,有三种情况:

第一种:s1 s2两个capacity一样或者s1的capacity比s2的大,s2的都可以赋值给s1。

第二种:s1的capacity比s2的小,原s1空间释放,开一块与s2一样大的空间,赋值。

第三种:s1的capacity比s2的大很多,拷贝赋值后,后面的空间用不上会浪费。

综上,干脆直接将s1的空间释放,开一块与s2一样大的新空间,s2里的值赋值给s1。

我们不写,编译器默认生成的赋值运算符重载实现浅拷贝,会导致s1空间丢失:

所以,自己实现substr和赋值运算符重载时都需要自己写拷贝构造实现深拷贝!


cpp 复制代码
// string.cpp
string::string(const string& s)
{
	_str = new char[s._capacity + 1];
	memcpy(_str, s._str, s._size + 1);
	_size = s._size;
	_capacity = s._capacity;
}
string string::substr(size_t pos, size_t len) const
{
	assert(pos < _size);
	//两个条件综合在一起可以去掉第一个条件
	//if (len == npos || len > _size - pos)
	// 若len大于后面剩余的字符个数,就将len改为实际长度
	if (len > _size - pos)
	{
		len = _size - pos;
	}
	string ret;
	ret.reserve(len);
	for (size_t i = 0; i < len; ++i)
	{
		ret += _str[i + pos];
	}
	return ret;
}
string& 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;
}
cpp 复制代码
// Test.cpp
void test_string6()
{
	string s1("hello world");
	//string s2 = s1.substr(6);// 拷贝构造
	string s2 = s1.substr(6, 100);// 拷贝构造
	cout << s2.c_str() << endl;

	string s3("hello world");
	string s4 = s3.substr(6, 3);// 拷贝构造
	cout << s4.c_str() << endl;

	s4 = s3;
	cout << s4.c_str() << endl;

	string s5(s4);
	cout << s5.c_str() << endl;

	string s6("hello world");
	string s7("xxxxxxxxxxxhello world");
	s6 = s7;
	cout << s6.c_str() << endl;
	cout << s7.c_str() << endl;
	s6 = s6;
	cout << s6.c_str() << endl;
	cout << s6.c_str() << endl;
}

find

cpp 复制代码
// string.h
size_t find(char c, size_t pos = 0) const;
size_t find(const char* s, size_t pos = 0) const;
cpp 复制代码
// string.cpp
size_t string::find(char c, size_t pos) const
{
	assert(pos < _size);
	for (size_t i = pos; i < _size; ++i)
	{
		if (_str[i] == c)
			return i;
	}
	return npos;
}
size_t string::find(const char* s, size_t pos) const
{
	assert(pos < _size);
	const char* p = strstr(_str, s);
	if (nullptr == p)
	{
		return npos;
	}
	else {
		return p - _str;
	}
}
cpp 复制代码
// Test.cpp
void test_string7()
{
	string url("https://legacy.cplusplus.com/reference/string/string/find/");
	size_t pos1 = url.find("://");
	if (pos1 != string::npos)
	{
		string sub1 = url.substr(0, pos1);
		cout << sub1.c_str() << endl;
	}
	size_t pos2 = url.find('/', pos1 + 3);
	if (pos2 != string::npos)
	{
		string sub2 = url.substr(pos1 + 3, pos2 - (pos1 + 3));
		cout << sub2.c_str() << endl;

		string sub3 = url.substr(pos2 + 1);
		cout << sub3.c_str() << endl;
	}
}

clear

cpp 复制代码
// string.h
void clear();
cpp 复制代码
// string.cpp
void string::clear()
{
	_str[0] = '\0';
	_size = 0;
}

三、Non-member function overloads(非成员函数重载)

operator>>、operator<<

cpp 复制代码
// string.h
std::ostream& operator<< (std::ostream& os, const string& s);
std:: istream& operator>> (std::istream& is, string& s);

判断:流插入、流提取运算符重载都要写成友元。错误的。

流插入、流提取运算符重载,只能写成全局的。

不用写成友元,原因是输出一个又一个的字符,访问字符(内置类型库里提供了<< >>运算符重载)可以范围for、下标+[]。

一般情况下流提取和c_str没什么区别,但是特殊情况下有区别:字符串中间有'\0'时,c_str会按照字符串的形式输出,遇到'\0'就终止。所以用流提取输出肯定是更靠谱的。

流提取后面不加const:流里面提取内容放到string类对象里。

流插入跳过空格直接读4,读不到空格:

流提取读取字符的时候,还是会跳过空格、换行符的,所以一般想将所有的都识别为字符读取,要用get函数。是因为cin(和scanf)会把空格、换行当作是多项之间的分割,都会忽略掉的,就会有输入个不停的现象。那么想读取空格怎么办呢?字符一个一个读可以调用C++里的get()

cpp 复制代码
std::istream& operator>> (std::istream& is, string& s)
{
	s.clear();
	char ch;
	//is >> ch;
	ch = is.get();//is.get(ch);
	while (ch != ' ' && ch != '\n')
	{
		s += ch;
		//is >> ch;
		ch = is.get();//is.get(ch);
	}
	return is;
}

但是若输入了一个长度非常长的串,可能会存在大量扩容的问题,若是提前开好假如是1024个空间,这时又会导致另一个问题,输入短串会有空间浪费的问题。在没有get()到字符之前是不知道有多少个字符的,就没有办法一次性开好空间。折中办法:开256(自己定空间大小,在栈上开空间效率很高,栈在编译时就运算好了要开多少空间)个空间,不+=这个字符,而是把这个字符放进数组buff里,就可以根据s+=buff一次性开好空间,这样会很大程度上减少扩容的次数,函数结束局部数组buff也跟着销毁了,效率高。而用reserve提前开好大空间,追加短串,string对象不销毁,空间就一直在,会造成空间浪费。------综上,提前开空间,开少了不够用会频繁扩容,开多了会浪费。就可以放在数组buff里一段一段加,数组空间大小自己定。

cpp 复制代码
// string.cpp
std::ostream& operator<< (std::ostream& os, const string& s)
{
	for (size_t i = 0; i < s.size(); ++i)
	{
		os << s[i];
	}
	return os;
}
std::istream& operator>> (std::istream& is, string& s)
{
	s.clear();
	char buff[256];
	char ch = is.get();
	size_t i = 0;
	while (ch != ' ' && ch != '\n')
	{
		buff[i++] = ch;
		is.get(ch);

		if (255 == i)
		{
			buff[i] = '\0';
			s += buff;

			i = 0;
		}
	}
	if (i > 0)
	{
		buff[i] = '\0';
		s += buff;
	}
	return is;
}
cpp 复制代码
// Test.cpp
void test_string8()
{
	string s1("hello world");
	string s2("xxxxxxxxxxxhello world");
	cout << s1 << endl;
	cout << s1.c_str() << endl;

	s1 += '\0';
	s1 += "xxxxxxxx";
	cout << s1 << endl;
	cout << s1.c_str() << endl;// 返回的const char*类型按照字符串的形式输出,遇到'\0'就停止 

	cin >> s1 >> s2;
	cout << s1 << endl;
	cout << s2 << endl;
	system("pause");

	cout << s1.size() << endl;
	cout << s1.capacity() << endl;
	cout << s2.size() << endl;
	cout << s2.capacity() << endl;
}

relational operators(关系运算符)

库里面是重载成非成员函数了,我们从学习的角度就把它们重载成成员函数即可。

cpp 复制代码
// string.h
bool operator>(const string& s) const;
bool operator>=(const string& s) const;
bool operator<(const string& s) const;
bool operator<=(const string& s) const;
bool operator==(const string& s) const;
bool operator!=(const string& s) const;

直接调用库里的strcmp(),strcmp()也有遇到'\0'就停止的问题,用memcm,但是memcmp的比较字节数没办法确定(比如"hello"和"hello world",指定短的字节数,就会认为他们俩是相等的),所以需要我们自己实现。

string与C++库里的string不能比较,不同的命名空间里就是两种类型了:

cpp 复制代码
// string.cpp
bool string::operator>(const string& s) const
{
	return !(*this <= s);
}
bool string::operator>=(const string& s) const
{
	return !(*this < s);
}
bool string::operator<(const string& s) const
{
	size_t len1 = _size, len2 = s._size;
	size_t i1 = 0, i2 = 0;
	while (i1 < len1 && i2 < len2)
	{
		if (_str[i1] < s._str[i2])
		{
			return true;
		}
		else if (_str[i1] > s._str[i2])
		{
			return false;
		}
		else {
			++i1;
			++i2;
		}
	}
	/*if (i1 == len1 && i2 < len2)
		return true;
	else
		return false;*/
	return i1 == len1 && i2 < len2;// 也包括 i1 < len1 && i2 == len2,返回false
}
bool string::operator<=(const string& s) const
{
	return *this == s || *this < s;
}
bool string::operator==(const string& s) const
{
	size_t len1 = _size, len2 = s._size;
	size_t i1 = 0, i2 = 0;
	while (i1 < len1 && i2 < len2)
	{
		if (_str[i1] != s._str[i2])
			return false;
		else {
			++i1;
			++i2;
		}
	}
	return i1 == len1 && i2 == len2;
}
bool string::operator!=(const string& s) const
{
	return !(*this == s);
}
cpp 复制代码
// Test.cpp
void test_string9()
{
	string s1("hello");
	string s2("hello");
	s1 += '\0';
	s1 += "world";
	string s3("hello");

	cout << (s1 == s2) << endl;
	cout << (s1 < s2) << endl;
	cout << (s1 <= s2) << endl;
	cout << (s2 == s3) << endl;
	cout << (s2 < s1) << endl;
}

四、swap

swap有点特殊,有重载为类的成员函数和重载为非成员函数两个版本。

cpp 复制代码
// string.h
namespace bit
{
	class string
	{
	public:
		void swap(string& s);
	private:
	};
	void swap (string& x, string& y);
}
cpp 复制代码
// string.cpp
void string::swap(string& s)
{
	std::swap(_str, s._str);
	std::swap(_capacity, s._capacity);
	std::swap(_size, s._size);
}
void swap(string& x, string& y)
{
	x.swap(y);
}

假设我们还没有实现任何针对string类的swap()函数,其实直接调用算法库里的swap也可以实现,但是会有问题,两个类对象指向的空间会被换成新的空间,原因是内部会出现3个深拷贝。我们自己模拟实现库里的swap()函数就按照下图中红色线框住的内容实现。a拷贝构造c,调用自己实现的能完成深拷贝的拷贝构造,c就开了一块跟a一样大的空间一样的值(第一次深拷贝);b赋值a(调用自己实现的能完成深拷贝的赋值运算符重载),a开一块与b一样的空间,与b有一样的值,释放a旧空间(第二次深拷贝);c赋值给b,b开一块与c一样的空间,与c有一样的值,释放b旧空间(第三次深拷贝)。c是局部变量,出了作用域会销毁,c空间释放。a与b指向的空间及其内容完成了交换,但是3次深拷贝的代价太大了。像日期类拷贝代价没那么大,直接用算法库里的swap是没问题的,但是要深拷贝的类直接调用代价就太大了。解决办法:直接改变指向两块空间的两个指针的指向即可,但是不要只交换指针,还要交换空间及其数据,这是等会实现类中的成员函数swap和非成员函数swap时需要注意的。这也就是为什么像string类这样内部有资源的类,类中自己就提供了swap成员函数或针对自身类型的非成员函数swap。



模拟实现类的成员函数swap,非成员函数swap直接对类的成员函数swap进行复用即可。


学C++的人分为两类人,一类是学了使用还学习底层的人,另一类就是只学习使用的人。只学习使用的人可能就会觉得直接用算法库里的swap何乐而不为呢?所以就从使用的角度出发,string类中还提供了全局的交换函数。在讲函数模板的时候提到过普通函数和函数模板能同时存在,下图中的它们同时存在,但是会调用现成的函数而不是函数模板(有现成吃现成)。这样,下面两种调用方式(调用针对string类的全局函数swap和string类的成员函数swap)效率都很高,因为始终调用不到函数模板的模板函数。

cpp 复制代码
// Test.cpp
// 模拟实现算法库里的swap()
template <class T> void swap(T& a, T& b)
{
	T c(a); a = b; b = c;
}
void test_string10()
{
	string s1("hello");
	string s2("helloxxxxxxxxxxxxxxx");
	cout << s1 << endl;
	cout << s2 << endl;

	swap(s1, s2);
	s1.swap(s2);

	cout << s1 << endl;
	cout << s2 << endl;
}

五、拷贝构造和赋值运算符重载的传统写法和现代写法

拷贝构造和赋值运算符重载还有不同于传统写法的现代写法,都是开空间拷贝数据但是方式更另类一些。

对于传统写法和现代写法可以这么理解:比如我想吃红烧牛腩。

传统吃法(传统写法):自己养牛,给它喂草,让它长大,把它吃了。(什么都自己实现)

现代吃法(现代写法):点外卖,钱与食物的交换。(交给其他函数去做,然后交换,本质是一种复用)

现代写法和传统写法对于空间的消耗都是一样的 ,没有效率的提升。

cpp 复制代码
// string.h
string(const string& s);
string& operator=(const string& s);
string& operator=(string s);

拷贝构造现代写法

s1的_str初始化tmp,swap后tmp指向s2之前指向的空间,因为s2还没有初始化,所以s2之前指向的空间是随机值(vs下是空),若是随机值,tmp出了作用域后析构会有问题,析构函数析构空是没有问题的,但是若怕delete空可以加个条件。若怕其他编译器让s2是随机值,那么就可以给成员变量缺省值。极端情况下,现代写法会受困于中间有'\0'的字符串,会拷贝不完全,原因是里面的构造的strlen的问题,这样会不满足我们的需求。我们其实可以实现迭代器区间的构造(前面已经实现过啦)来解决这样的问题,成员函数也可以是一个模板。

cpp 复制代码
// string.cpp
// s2(s1)
string::string(const string& s)
{
	//string tmp(s._str);
	string tmp(s.begin(), s.end());
	swap(tmp);
}

怕其他编译器给s2随机值,那么可以给成员变量缺省值:

cpp 复制代码
class string
{
	private:
		char* _str = nullptr;
		size_t _size = 0;
		size_t _capacity = 0;
};

赋值运算符重载现代写法:复用现代写法的拷贝构造

s2拷贝构造tmp,构造出跟s2一样大小的空间和值,s1与tmp交换,s1还有空间需要释放,也是借助tmp,tmp是局部变量,出了作用域直接调用析构销毁tmp(tmp是原来s1的空间和值)。

cpp 复制代码
// string.cpp
// s1 = s2
/*string& string::operator=(const string& s)
{
	if (this != &s)
	{
		string tmp(s);
		swap(tmp);
	}
	return *this;
}*/
// 更简洁版本
// s1 = s2
string& string::operator=(string tmp)// 传值传参调用拷贝构造,s2拷贝构造tmp
{
	swap(tmp);
	return *this;
}

六、扩展

我们计算类对象的大小时只计算成员变量的大小,下面图中两个对象sizeof()一样大吗?一样大。理论上的计算结果是12字节,为什么输出结果是28字节呢?输出结果取决于平台,不同平台实现不同。

_Mysize是size,_Myreserve是capacity。_Buf点开加了16字节,也就是可以存储15个有效字符,字符个数小于16的话,字符就会存储在buff里,若大小是16及其以上,就会存储在_ptr(_str)里,这样就避免了在堆上频繁开小块的空间引发内存碎片、降低效率的问题。字符串比较短的就不要在堆上开了,就存储在string对象里面的数组buff里。这是vs自己给的优化方案。小于16字符在buff里找,大于等于16在_str里找。

cpp 复制代码
class string
{
	private:
		// 相当于在这里有优化
		// char _buff[16];
		char* _str = nullptr;
		size_t _size = 0;
		size_t _capacity = 0;
};

七、string的模拟实现代码

cpp 复制代码
// string.cpp
#include "string.h"
namespace bit
{
	const size_t string::npos = -1;
	string::iterator string::begin()
	{
		return _str;
	}
	string::const_iterator string::begin() const
	{
		return _str;
	}
	string::iterator string::end()
	{
		return _str + _size;
	}
	string::const_iterator string::end() const
	{
		return _str + _size;
	}
	size_t string::size() const
	{
		return _size;
	}
	size_t string::capacity() const
	{
		return _capacity;
	}
	char& string::operator[] (size_t pos)
	{
		// pos已经是非负整数了,肯定大于等于0,只用判断它小于_size即可
		assert(pos < _size);
		return _str[pos];
	}
	const char& string::operator[] (size_t pos) const
	{
		assert(pos < _size);
		return _str[pos];
	}
	void string::reserve(size_t n)// 声明和定义分离时缺省值只能给声明
	{
		if (n > _capacity)
		{
			// 关于new扩容,需要自己手动实现
			char* tmp = new char[n + 1];
			if (_str)
			{
				memcpy(tmp, _str, _size + 1);
				delete[] _str;
			}
			_str = tmp;
			_capacity = n;
		}
	}
	void string::push_back(char c)
	{
		if (_size == _capacity)
		{
			reserve(_capacity == 0 ? 4 : 2 * _capacity);
		}
		// 插入数据后,新的数据会把标识字符'\0'覆盖,需要自己手动加上'\0'
		_str[_size++] = c;
		_str[_size] = '\0';
	}
	string& string::append(const char* s)
	{
		size_t len = strlen(s);
		if (_size + len > _capacity)
		{
			size_t newCapacity = _size + len > 2 * _capacity ? _size + len : 2 * _capacity;
			reserve(newCapacity);
		}
		memcpy(_str + _size, s, len + 1);
		_size += len;
		return *this;
	}
	string& string::operator+=(char c)
	{
		push_back(c);
		return *this;
	}
	string& string::operator+=(const char* s)
	{
		append(s);
		return *this;
	}
	void string::insert(size_t pos, char c)
	{
		// 也有可能在_size的位置插入数据
		assert(pos <= _size);
		if (_size == _capacity)
		{
			reserve(_capacity == 0 ? 4 : 2 * _capacity);
		}
		// end可能会出界
		//int end = _size;
		//while (end >= (int)pos)// 为什么类型不同却能正常运行?end是int类型,pos是size_t类型,类型提升。但是若end为-1,也就是越界了,会有符号位丢失及比较运算符陷阱的问题。若将pos强制类型转换为int也不是一劳永逸的事情。
		//{ }
		/*size_t end = _size;
		while (end >= pos)
		{
			_str[end + 1] = _str[end];
			--end;
		}*/
		size_t end = _size + 1;
		while (end > pos)
		{
			_str[end] = _str[end - 1];
			--end;
		}
		_str[pos] = c;
		_size++;
	}
	void string::insert(size_t pos, const char* s)
	{
		assert(pos <= _size);
		size_t len = strlen(s);
		if (_size + len > _capacity)
		{
			size_t newCapacity = _size + len > 2 * _capacity ? _size + len : 2 * _capacity;
			reserve(newCapacity);
		}
		size_t end = _size + len;
		while (end >= pos + len)// 或while (end > pos + len - 1)
		{
			_str[end] = _str[end - len];
			--end;
		}
		// 若是while循环写成下面这样,对于头插就不行了
		/*while (end > pos)
		{
			_str[end - len] = _str[end];
			--end;
		}*/
		for (size_t i = 0; i < len; ++i)
		{
			_str[i + pos] = s[i];
		}
		_size += len;
	}
	void string::erase(size_t pos, size_t len)
	{
		// 没办法在_size位置删除数据,_size位置不是有效数据
		assert(pos < _size);
		if (len == npos || len >= _size - pos)
		{
			// 全部删完
			_str[pos] = '\0';
			_size = pos;
		}
		else {
			// 删除部分,挪动数据覆盖
			memmove(_str + pos, _str + pos + len, _size + 1 - (pos + len));
			_size -= len;
		}
	}

	// 传统写法的拷贝构造和赋值运算符重载
	//string::string(const string& s)
	//{
	//	_str = new char[s._capacity + 1];
	//	memcpy(_str, s._str, s._size + 1);
	//	_size = s._size;
	//	_capacity = s._capacity;
	//}
	//string& 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;
	//}
	
	// 现代写法的拷贝构造和赋值运算符重载
	// s2(s1)
	string::string(const string& s)
	{
		string tmp(s._str);
		//string tmp(s.begin(), s.end());
		swap(tmp);
	}
	// s1 = s2
	/*string& string::operator=(const string& s)
	{
		if (this != &s)
		{
			string tmp(s);
			swap(tmp);
		}
		return *this;
	}*/
	// 更简洁版本
	// s1 = s2
	string& string::operator=(string tmp)// 传值传参调用拷贝构造,s2拷贝构造tmp
	{
		swap(tmp);
		return *this;
	}

	string string::substr(size_t pos, size_t len) const
	{
		assert(pos < _size);
		// 若len大于后面剩余的字符个数,就将len改为实际长度
		if (len > _size - pos)
		{
			len = _size - pos;
		}
		string ret;
		ret.reserve(len);
		for (size_t i = 0; i < len; ++i)
		{
			ret += _str[i + pos];
		}
		return ret;
	}
	size_t string::find(char c, size_t pos) const
	{
		assert(pos < _size);
		for (size_t i = pos; i < _size; ++i)
		{
			if (_str[i] == c)
				return i;
		}
		return npos;
	}
	size_t string::find(const char* s, size_t pos) const
	{
		assert(pos < _size);
		const char* p = strstr(_str, s);
		if (nullptr == p)
		{
			return npos;
		}
		else {
			return p - _str;
		}
	}
	void string::clear()
	{
		_str[0] = '\0';
		_size = 0;
	}
	std::ostream& operator<< (std::ostream& os, const string& s)
	{
		for (size_t i = 0; i < s.size(); ++i)
		{
			os << s[i];
		}
		return os;
	}
	//std::istream& operator>> (std::istream& is, string& s)
	//{
	//	s.clear();
	//	char ch;
	//	//is >> ch;
	//	ch = is.get();//is.get(ch);
	//	while (ch != ' ' && ch != '\n')
	//	{
	//		s += ch;
	//		//is >> ch;
	//		ch = is.get();//is.get(ch);
	//	}
	//	return is;
	//}
	std::istream& operator>> (std::istream& is, string& s)
	{
		s.clear();
		char buff[256];
		char ch = is.get();
		size_t i = 0;
		while (ch != ' ' && ch != '\n')
		{
			buff[i++] = ch;
			is.get(ch);

			if (255 == i)
			{
				buff[i] = '\0';
				s += buff;

				i = 0;
			}
		}
		if (i > 0)
		{
			buff[i] = '\0';
			s += buff;
		}
		return is;
	}
	bool string::operator>(const string& s) const
	{
		return !(*this <= s);
	}
	bool string::operator>=(const string& s) const
	{
		return !(*this < s);
	}
	bool string::operator<(const string& s) const
	{
		size_t len1 = _size, len2 = s._size;
		size_t i1 = 0, i2 = 0;
		while (i1 < len1 && i2 < len2)
		{
			if (_str[i1] < s._str[i2])
			{
				return true;
			}
			else if (_str[i1] > s._str[i2])
			{
				return false;
			}
			else {
				++i1;
				++i2;
			}
		}
		/*if (i1 == len1 && i2 < len2)
			return true;
		else
			return false;*/
		return i1 == len1 && i2 < len2;// 也包括 i1 < len1 && i2 == len2,返回false
	}
	bool string::operator<=(const string& s) const
	{
		return *this == s || *this < s;
	}
	bool string::operator==(const string& s) const
	{
		size_t len1 = _size, len2 = s._size;
		size_t i1 = 0, i2 = 0;
		while (i1 < len1 && i2 < len2)
		{
			if (_str[i1] != s._str[i2])
				return false;
			else {
				++i1;
				++i2;
			}
		}
		return i1 == len1 && i2 == len2;
	}
	bool string::operator!=(const string& s) const
	{
		return !(*this == s);
	}
	void string::swap(string& s)
	{
		std::swap(_str, s._str);
		std::swap(_capacity, s._capacity);
		std::swap(_size, s._size);
	}
	void swap(string& x, string& y)
	{
		x.swap(y);
	}
}

八、编码

为什么string是个模板?不是针对某种类型例如char实现的,而是针对多种类型实现的,为什么会有这么多类型呢?这时就涉及编码的问题了,编码本质是编码表,值和符号的映射,内存里面存储的都是ASCII值,不能存储符号,

ASCII编码表编码的是老美的文字,后来计算机向全世界推广,肯定要显示全世界的文字。

一个汉字就是一个符号,值和汉字怎么映射呢?

unicode编码方案,可以编码全世界的文字,里面有3大方案,UTF-8(以1个字节为单位)最常用,省空间、UTF-16(以2个字节为单位)、UTF-32(以4个字节为单位)。GBK能编码我们的文字。GBK类比UTF-8。

字符串就是用来存储各种类型的文字的,文字编码表主要采用unicode的UTF-8(以1个字节为单位)方案,所以用:

分别对应:

string(1个字节的char)

u16string(2个字节的char)、u32string(4个字节的char),它们里面与string的实现很相似的。

wstring对应宽字符,一般是2个字节,但是不太规范,由于历史发展的原因,具体是几个字节还不太清楚,有可能是2个字节,有可能是4个字节。根据不同的平台而不同。早期只有string和wstring,由于在不同的平台下实现不同,C++11又出了u16string和u32string。

cpp 复制代码
int main()
{
	/*std::string s1("11111111111111111111111111");
	std::string s2("22222");
	cout << sizeof(s1) << endl;
	cout << sizeof(s2) << endl;*/

	char buff1[] = "abcd";
	cout << buff1 << endl;

	char buff2[] = "大家好";
	cout << buff2 << endl;

	buff2[1]++;
	cout << buff2 << endl;
	buff2[1]++;
	cout << buff2 << endl;	
	buff2[1]++;
	cout << buff2 << endl;
	return 0;
}
相关推荐
XYY3695 分钟前
前缀和 一维差分和二维差分 差分&差分矩阵
数据结构·c++·算法·前缀和·差分
PacosonSWJTU7 分钟前
python基础-13-处理excel电子表格
开发语言·python·excel
froginwe117 分钟前
Perl 条件语句
开发语言
longlong int15 分钟前
【每日算法】Day 16-1:跳表(Skip List)——Redis有序集合的核心实现原理(C++手写实现)
数据库·c++·redis·算法·缓存
24白菜头15 分钟前
C和C++(list)的链表初步
c语言·数据结构·c++·笔记·算法·链表
小军要奋进26 分钟前
httpx模块的使用
笔记·爬虫·python·学习·httpx
啥都鼓捣的小yao28 分钟前
利用C++编写操作OpenCV常用操作
开发语言·c++·opencv
灼华十一30 分钟前
Golang系列 - 内存对齐
开发语言·后端·golang
程序媛学姐37 分钟前
SpringRabbitMQ消息模型:交换机类型与绑定关系
java·开发语言·spring
努力努力再努力wz44 分钟前
【c++深入系列】:类与对象详解(中)
java·c语言·开发语言·c++·redis