STL库中string类模拟实现(c++)


前言

完整代码:string模拟实现
万字爆肝如何一步步模拟实现string,所有细节都在这,能明白string的底层究竟如何实现,会对string会有更深入的了解。

1 创建文件

在这里插入图片描述

//string.h
#pragma once
#include <iostream>
using namespace std;
//将自己定义的string放入命名空间my中,以便于和std库里的string区分
namespace my
{
	class string
	{

	};
}

//test.cpp
#include "string.h"
int main()
{
	return 0;
}

2 写出string的框架

从基本的资源管理的问题入手,只考虑资源管理的深浅拷贝问题,暂且不考虑增删查改。

2.1 粗略实现string

首先我们的目的是让自己写的string能够跑起来,也就是能够存储字符串,并且能实现简单的查改

//string.h
#define _CRT_SECURE_NO_WARNINGS 1
#pragma once
#include <iostream>
using namespace std;
#include <string.h>

namespace my
{
	class string
	{
	public:
		string(const char* str)
			:_str(new char[strlen(str) + 1])
		{
			strcpy(_str, str);
		}

		~string()
		{
			if (_str)
			{
				delete[] _str;
			}
		}
	private:
		char* _str;
	};
}

//test.cpp
#include "string.h"
void string_test1()
{
	my::string s1("hello world");
}
int main()
{
	string_test1();
	return 0;
}

由于我们暂时还没有重载流提取和流插入运算符,因此我们要打印字符串可以现在类中写一个c_str()成员函数来返回存储字符串的指针。

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

测试并输出查看结果:

void string_test1()
{
	my::string s1("hello world");
	cout << s1.c_str() << endl;
}

在这里插入图片描述
写一个通过下标访问string的运算符重载的成员函数用于查/改:

char& operator[](size_t pos)
{
	assert(pos < strlen(_str));
	return _str[pos];
}

写一个放回string大小的size()成员函数用于遍历等:

size_t size()
{
	return strlen(_str);
}

测试并输出查看结果:

void string_test1()
{
	my::string s1("hello world");
	cout << s1.c_str() << endl;

	s1[0] = 'x';
	cout << s1.c_str() << endl;

	for (size_t i = 0; i < s1.size(); i++)
	{
		cout << s1[i] << " ";
	}
	cout << endl;
}

在这里插入图片描述

2.2 string的深拷贝实现

在我们自己定义了拷贝构造函数之前,编译器会默认生成一个拷贝构造,但是默认生成的拷贝构造是浅拷贝,也就是把一个对象按字节拷贝给新的对象,但是在string类中,用编译器默认生成的拷贝构造函数是会出问题的。

测试:

void string_test2()
{
	my::string s1("hello world");
	my::string s2(s1);
	cout << s1.c_str() << endl;
	cout << s2.c_str() << endl;
}

在这里插入图片描述
不难发现,拷贝确实是成功了,但是程序却崩了。
通过调试,我们可以发现s2的成员变量_str是通过值拷贝将s1的成员变量_str按字节拷贝过去的
在这里插入图片描述
也就是说两个对象的_str指向同一块空间

在这里插入图片描述
因此在s1s2对象的生命周期结束后,就会调用它们的析构函数,而析构函数的定义如下:

~string()
		{
			if (_str)
			{
				delete[] _str;
			}
		}

它的作用是将分配给每个对象的_str指针所指向的堆中的空间给释放掉,然而上面的两个对象会分别调用一次析构函数,也就是重复释放同一块空间,因此程序就崩了。

除了有析构两次的问题,还有一个问题就是其中一个对象的修改会改变两一个对象:

void string_test2()
{
	//浅拷贝两大问题:1.析构两次 2.一个对象修改影响另一个对象
	my::string s1("hello world");
	my::string s2(s1);
	cout << s1.c_str() << endl;
	cout << s2.c_str() << endl;

	s1[0] = 'x';
	cout << s1.c_str() << endl;
	cout << s2.c_str() << endl;
}

在这里插入图片描述
那么深拷贝改怎么实现呢,其实就是为s2_str申请一块一样大的空间,再将s1_str所指向的空间的内容拷贝给s2_str所指向的空间

在这里插入图片描述
深拷贝实现:

string(const string& s)
//1.开一样大的空间
	:_str(new char[strlen(s._str) + 1])
{
	strcpy(_str, s._str);
}

通过调试,我们发现深拷贝后,两个对象的_str都指向各自的空间
在这里插入图片描述
并且上面的两个浅拷贝问题都解决了
在这里插入图片描述


聊完了深拷贝的实现,我们可以再趁热实现string的赋值构造函数

void string_test3()
{
	my::string s1("hello world");
	my::string s2("xxxxxxxxxxxxxxxxxxxxxxxxxxxx");
	//这里调用赋值构造
	s1 = s2;
}

只有在两个对象都存在的时候使用“=”赋值才会调用赋值构造函数,也就是两个对象的_str指针都指向它们各自的空间,有人可能会认为可以直接调用strcpy()函数进行值拷贝但是如果s1指向的空间内存大小小于s2的话,会出现越界问题,而s1指向的空间如果太大,远超于s2的话又会出现空间浪费的问题。
因此我们选择这样实现它:

//s1 = s2
string& operator=(const string& s)
{
	delete[] _str;
	_str = new char[strlen(s._str) + 1];
	strcpy(_str, s._str);

	return *this;
}

测试样例:
在这里插入图片描述
但是有的时候抵挡不住有人用自身给自身赋值的情况:
在这里插入图片描述
出现这种结果的原因是调用delete[] _str;语句后s1_str指向的空间被编译器释放并置随机值,_str就变成了一个野指针,就会出现上面的情况。
因此我们这样处理:

//s1 = s2
string& operator=(const string& s)
{
	if (this != &s)
	{
		delete[] _str;
		_str = new char[strlen(s._str) + 1];
		strcpy(_str, s._str);
	}
	
	return *this;
}

测试样例:
在这里插入图片描述

但是上面的代码还会有些瑕疵:
也就是万一_str = new char[strlen(s._str) + 1];开空间失败了,就会抛异常然后不会跑后面的代码。失败也就罢了,然而s1_str已经被delete掉了,数据就会丢失,因此稍稍改进成下面这样,便不会出现上面的问题。

//s1 = s2
string& operator=(const string& s)
{
	if (this != &s)
	{
		//创建一个临时指针来存储s2的内容
		char* tmp = new char[strlen(s._str) + 1];
		strcpy(tmp, s._str);
		//确保开空间成功后再释放空间
		delete[] _str;
		_str = tmp;

	}
	
	return *this;
}

还有一个好处就是:修改成上面这种写法后,是先进行深拷贝,再delete[] _str;释放空间,就不会存在野指针问题,因此就算自己给自己赋值,也不会出问题,所以在这里就可以把if语句给去掉

//s1 = s2
string& operator=(const string& s)
{
		//创建一个临时指针来存储s2的内容
		char* tmp = new char[strlen(s._str) + 1];
		strcpy(tmp, s._str);
		//确保开空间成功后再释放空间
		delete[] _str;
		_str = tmp;
	
	return *this;
}

3 完善string的增删查改

由于string的增删查改需要扩容等操作,因此单靠一个成员变量_str是不能满足要求的,需要增加两个成员变量_size(有效字符个数)和_capacity(能存储有效字符的空间)

注:这里的有效字符也就是不包含’\0’的字符个数

//class string
private:
		char* _str;
		size_t _size;//有效字符个数
		size_t _capacity;//能存储有效字符的空间

3.1 修改先前定义的函数

构造函数作出对应的修改:

//原先的构造函数
string(const char* str)
	:_str(new char[strlen(str) + 1])
{
	strcpy(_str, str);
}

//修改后的构造函数
string(const char* str)
	:_size(strlen(str))
	,_capacity(strlen(str))
{
	_str = new char[_capacity + 1];// +1是为'\0'多开的空间
	strcpy(_str, str);
}

之所以不把_str = new char[_capacity + 1]放在初始化列表中的原因是,初始化列表是按照string类中成员变量的声明顺序对其进行初始化的:
在这里插入图片描述
如果放在讲语句放在初始化列表中的话,在为_str开空间的时候,_capacity还未被初始化的,也就是一个随机值,所以不能放在初始化列表中。

除了这种带参的构造函数我们还需要一个无参的或者全缺省的构造函数,来提供一个默认构造函数,以解决下面的问题:
在这里插入图片描述

实现无参的构造函数:

string()
	:_str(nullptr)
	,_size(0)
	,_capacity(0)
{}

上面这种写法还有些问题,测试样例如下:

在这里插入图片描述
但是库里的string并不会因为这种写法而程序崩溃,原因是库里的无参构造函数是给一个空字符串(实际上有1byte的空间来存储'\0'),而不是一个空指针。

正确的实现:

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

最优解:合并上面两个构造函数–>直接提供一个全缺省构造函数:

//全缺省
string(const char* str = "")
	:_size(strlen(str))
	,_capacity(strlen(str))
{
	_str = new char[_capacity + 1];// +1是为'\0'多开的空间
	strcpy(_str, str);
}

析构函数进行修改:

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

在这里插入图片描述


拷贝构造函数进行修改:

string(const string& s)
//1.开一样大的空间
	:_size(strlen(s._str))
	,_capacity(_size)
{
	_str = new char[_capacity + 1];
	strcpy(_str, s._str);
}

赋值构造函数进行修改:

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

测试样例:
在这里插入图片描述


operator[]进行修改:

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

对返回_size值的函数进行修改,并添加一个返回_capacity值的函数:

size_t size() const
{
	return _size;
}

size_t capacity() const
{
	return _capacity;
}

3.2 实现非成员函数(大小比较)

bool operator<(const string& s1, const string& s2)
{
	return strcmp(s1.c_str(), s2.c_str()) < 0;
}
bool operator==(const string& s1, const string& s2)
{
	return strcmp(s1.c_str(), s2.c_str()) == 0;
}
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);
}

3.3 实现“增”操作

实现reserver()函数:

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

		_capacity = n;
	}
}

实现resize()函数:

注:和“增”操作没啥关系,但是与reserve相对应,因此在这里提到

实现要分为下面三种情况:

  • 大于_capacity:先扩容,再改变_size
  • 小于_capacity,但大于_size:直接改变_size
  • 小于_size:直接改变_size

我们可以用库中的resize()函数的样例观察:
在这里插入图片描述
函数实现:

void resize(size_t n, char ch = '\0')
{
	//先处理小于size的情况
	if (n < _size)
	{
		_size = n;
		_str[_size] = '\0';
	}

	else
	{
		//再处理大于capacity的情况
		if (n > _capacity)
		{
			reserve(n);
		}

		for (size_t i = _size; i < n; ++i)
		{
			_str[i] = ch;
		}
		_size = n;
		_str[_size] = '\0';
	}
}

实现push_back()函数:

void push_back(char ch)
{
	//检查是否空间是否满了,满了则扩容
	if (_size == _capacity)
	{
		reserve(_capacity * 2);
	}

	//插入数据
	_str[_size] = ch;
	++_size;
	_str[_size] = '\0';
}

需要注意的是,如果是一个string是一个空字符串,那就会出问题了:
在这里插入图片描述
上面问题的原因是:_capacity是有效字符个数,如果string是空字符串的话,那么_capacity就是0,而新开辟的空间是二倍的_capacity+1,也就是空间没变,只能够存储一个'\0',因此插入后会出现越界问题。

修改后的代码:

void push_back(char ch)
{
	//检查是否空间是否满了,满了则扩容
	if (_size == _capacity)
	{
		reserve(_capacity == 0 ? 4 : _capacity * 2);
	}

	//插入数据
	_str[_size] = ch;
	++_size;
	_str[_size] = '\0';
}

运算符重载operator+=

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

实现append()函数:
push_back()不同的是,如果需要插入的字符串很长的话,空间扩二倍不一定能够满足需求,因此要先计算插入子串后的字符串大小:

void append(const char* str)
{
	//计算插入子串后的字符串大小
	size_t len = _size + strlen(str);
	//如果大于能存储的有效字符空间就扩容
	if (len > _capacity)
	{
		reserve(len);
	}
	strcpy(_str + _size, str);
	_size += len;
}

重载operator+=

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

测试样例:
在这里插入图片描述


实现insert()插入单个字符的函数:
insert()函数三步走:1.检查是否需要扩容;2.把pos位置到'\0'位置的数据全部向后移动一位;3.插入数据并++_size

string& insert(size_t pos, const char ch)
{
	assert(pos <= _size);
	//检查空间是否满了,满了就扩容
	if (_size == _capacity)
	{
		reserve(_capacity == 0 ? 4 : _capacity * 2);
	}

	//挪数据,必须从后往前挪,否则数据会被覆盖
	size_t end = _size;//end索引'\0'的位置
	while(end >= pos)
	{
		_str[end + 1] = _str[end];
		--end;
	}

	//插入数据
	_str[pos] = ch;
	++_size;

	return *this;
}

但是,还存在一个边界问题,当我们头插的时候,也就是传pos=0的时候,无符号整形size_t end再减一后,end就会变成无符号整形的最大值:
在这里插入图片描述

为了解决这个问题你可以把endpos都强转成int,但我们可以不这么做,直接让end索引'\0'后面一个位置:
在这里插入图片描述
这样一来,只要end移动到pos位置的时候,循环就可以结束了
修改后的代码:

string& insert(size_t pos, const char ch)
{
	assert(pos <= _size);
	//检查空间是否满了,满了就扩容
	if (_size == _capacity)
	{
		reserve(_capacity == 0 ? 4 : _capacity * 2);
	}

	//挪数据,必须从后往前挪,否则数据会被覆盖
	size_t end = _size + 1;
	while(end > pos)
	{
		_str[end] = _str[end-1];
		--end;
	}

	//插入数据
	_str[pos] = ch;
	++_size;

	return *this;
}

实现insert()插入一个字符串的函数:
跟刚才同样的三步走:1.检查是否需要扩容;2.把pos位置到'\0'位置的数据全部向后移动需要插入的字符串的长度位;3.插入数据并让_size+插入字符串的长度

string& 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;
	//如果写成end >= pos + len可能会出现越界问题
	while (end > pos + len - 1)
	{
		_str[end] = _str[end - len];
		--end;
	}

	//插入
	strncpy(_str + pos, str, len);
	_size += len;

	return *this;
}

完成insert后,push_back()append()函数就可以复用它了:

void push_back(char ch)
{
	insert(_size, ch);
}

void append(const char* str)
{
	insert(_size, str);
}

至此,“增”操作就完成了。

3.4 实现string的遍历(迭代器)

  1. 通过 []+下标遍历(已实现)

在这里插入图片描述
但是像下面这样写会因为权限放大而报错:
在这里插入图片描述
因此需要重载一个const版本:

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

  1. 通过迭代器遍历

string中的迭代器其实就是指针,begin()指向第一个有效字符,end()指向最后一个有效字符的下一个位置
在这里插入图片描述
实现:

typedef char* iterator;

iterator begin()
{
	return _str;
}

iterator end()
{
	return _str + _size;
}

使用:

在这里插入图片描述


  1. 范围for遍历

由于范围for的底层其实就是用迭代器遍历,因此用范围for遍历的前提是实现了iterator迭代器
在这里插入图片描述
如果把自己实现的iterator迭代器注释掉,会出现下面的错误:
在这里插入图片描述
也就是说范围for底层是通过begin()end()来遍历的。

解决了遍历问题,但是同样,由于权限放大问题不支持下面这样使用:
在这里插入图片描述
因此要提供一个const版本

//错误版本
iterator begin() const 
{
	return _str;
}

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

但是还有一个问题,虽然编译器不会报语法错误了,但是你会发现,这里的const my::string& s居然是可以被修改的:
在这里插入图片描述
要想解决,就必须再添加一个const迭代器,并且将begin()end()的返回值改为const迭代器:

typedef const char* const_iterator;

const_iterator begin() const
{
	return _str;
}

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

这样就不支持被修改了:
在这里插入图片描述

3.5 实现“删”操作

实现npos

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

	//声明
	const static size_t npos;
};

	//定义
const size_t string::npos = -1;

实现erase()函数
分两种情况:
1.将pos位置即之后的全删,只要在pos位置加个’\0’
2.删除部分,步骤如下:
在这里插入图片描述

string& erase(size_t pos, size_t len = npos)
{
	assert(pos < _size);
	
	//pos之后的数据全删除的情况:只需要在pos位置加上一个'\0'
	if (len == npos || pos + len >= _size)
	{
		_str[pos] = '\0';
		_size = pos;
	}
	//否则要挪动数据
	else
	{
		size_t begin = pos + len;
		while (begin <= _size)
		{
			_str[begin - len] = _str[begin];
			begin++;
		}
	}
	_size -= len;
	return *this;
}

有一个细节,也就是越界问题:
在这里插入图片描述
本意是删除索引为5及其之后的字符,但是由于size_t的范围为0~4,294,967,295,因此pos+len就会变成3,就会出现上面的错误。
修改后:
在这里插入图片描述

3.6 实现“查”操作

//找单个字符
size_t find(const char ch, size_t pos = 0) const
{
	assert(pos < _size);
	for (; pos < _size; pos++)
	{
		if (ch == _str[pos])
		{
			return pos;
		}
	}
	return npos;
}

//找字符串
size_t find(const char* str, size_t pos = 0) const
{
	assert(pos < _size);
	const char* p = strstr(_str + pos, str);
	if (p == nullptr)
	{
		return npos;
	}
	return p - _str;
}

3.6 实现“流提取、流插入”操作

由于流提取(cout << s)流插入(cin >> s)中,对象s是做右操作数的,所以必须写在类外。

实现流提取

ostream& operator<<(ostream& out, const string& s)
{
	out << s.c_str();
	return out;
}

上面这种写法会因为插入’\0’而无法打印出所有字符:

注:'\0’也是个字符,不过不可见,因此我们加一个用于标识的字符

在这里插入图片描述

因此我们改成下面这样:

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

在这里插入图片描述


实现流插入

实现流插入前要先清除数据再插入,因此要先写一个clear()成员函数。
实现clear()成员函数:

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

流插入的实现需要用到istream cin对象里的get()函数来从缓冲区里获取字符。

代码实现:

istream& operator>>(istream& in, string& s)
{
	s.clear();
	char ch;
	ch = in.get();//从缓冲区获取字符
	while (ch != ' ' && ch != '\n')
	{
		s += ch;
		ch = in.get();
	}

	return in;
}

测试样例:

在这里插入图片描述
上面的写法如果在插入的字符串很长的时候,会频繁扩容,导致影响运行效率,我们可以用下面这种写法来优化:

istream& operator>>(istream& in, string& s)
{
	s.clear();
	char ch;
	ch = in.get();//从缓冲区获取字符

	//用于存放即将插入的字符,最多能存放128个有效字符,并且初始化为全'\0'
	char buff[128] = { '\0' };
	size_t i = 0;

	while (ch != ' ' && ch != '\n')
	{
		buff[i++] = ch;
		//索引值到127后就不能再插入字符了,因为127的索引值必须用来存放'\0'
		if (i == 127)
		{
			s += buff;
			memset(buff, '\0', 128);
			i = 0;
		}
		ch = in.get();
	}

	//将剩下的字符串再插入s中
	s += buff;

	return in;
}

调试观察:
第一次存满后,先插入到s中,再将容器置全’\0’:
在这里插入图片描述

在第二次存到一半的时候遇到’\n’,结束循环,发现容器中还有65个有效字符,继续插入到s中,至此插入结束,总共扩容两次:在这里插入图片描述


实现getline()函数

>>重载其实是一样的,只要改改循环条件就好啦:

istream& getline(istream& in, string& s)
{
	s.clear();
	char ch;
	ch = in.get();//从缓冲区获取字符

	//用于存放即将插入的字符,最多能存放128个有效字符,并且初始化为全'\0'
	char buff[128] = { '\0' };
	size_t i = 0;

	while (ch != '\n')
	{
		buff[i++] = ch;
		//索引值到127后就不能再插入字符了,因为127的索引值必须用来存放'\0'
		if (i == 127)
		{
			s += buff;
			memset(buff, '\0', 128);
			i = 0;
		}
		ch = in.get();
	}

	//将剩下的字符串再插入s中
	s += buff;

	return in;
}

在这里插入图片描述


3.7 现代写法

现代写法写起来更加简洁,鼓励大家去写现代写法

传统写法:老老实实地开空间进行深拷贝,也就是目前的写法

string(const string& s)
//1.开一样大的空间
	:_size(strlen(s._str))
	,_capacity(_size)
{
	_str = new char[_capacity + 1];
	strcpy(_str, s._str);
}

//s1 = s2
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;
}

现代写法:本质是剥削,要完成深拷贝,自己却不干活,让别人干活


下面我们来把上面的写法改为现代写法就明白为什么要这么说了

先实现一个string类里的swap成员函数:

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

实现string(const string& s)函数:

string(const string& s)
{
	string tmp(s._str);
	swap(tmp);
}

剥削本质:thistmp借助默认构造函数去完成深拷贝,然后再通过swap()函数来窃取tmp的劳动成果

测试样例:在这里插入图片描述
但是由于tmp对象是一个局部对象,出了作用域后要调用析构函数:

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

swap过后,tmp_str变成一个随机值,如果调用delete[] _str这个语句释放随机地址,程序就会崩溃。
虽然在我的vs2019下,这种情况会自动置空指针,但是其他的编译器就可能会出错:
vs2019:
在这里插入图片描述
vs2013:
在这里插入图片描述
因此为了避免上面这种错误,我们要把this._str初始化一下:

string(const string& s)
	:_str(nullptr)
	, _size(0)
	, _capacity(0)
{
	string tmp(s._str);
	swap(tmp);
}

赋值重载更是诠释了什么是剥削

原先代码又要给_str开空间深拷贝,又要释放内存

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

使用现代写法后,tmp又要替this深拷贝,又要在出作用域的时候调用析构函数把this的内存释放掉:

string& operator=(const string& s)
{
	string tmp(s._str);
	swap(tmp);

	return *this;
}

其实还可以再简洁一点,把tmp用形参来代替:

string& operator=(string s)
{
		swap(s);
		return *this;
}
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

干脆面la

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值