C++11新特性

列表初始化

背景
  • 问题:
在 C++98 中,标准允许使用花括号 {} 对数组元素进行统一的列表初始值设定,如:
int array1[] = {1,2,3,4,5};
int array2[5] = {0};

对于一些自定义的类型,却无法使用这样的初始化,如下面的这些操作在C++98中是不被允许的:
vector<int> v{1,2,3,4,5};
map<int, int> m{{1,2},{5,6},{7,9}};
这就导致如果要对这些容器类型进行初始化,就需要循环插入操作,非常不方便
  • 解决:C++11 扩大了用花括号括起的列表(初始化列表)的使用范围,使其可用于所有的内置类型和用户自定义的类型,使用初始化列表时,可添加等号 (=),也可不添加,二者并无区别;
内置类型
int main(){
	// 内置类型变量
	int x1 = {10};
	int x2{10};
	int x3 = 1+2;
	int x4 = {1+2};
	int x5{1+2};
	
	// 数组
	int arr1[5] {1,2,3,4,5};
	int arr2[]{1,2,3,4,5};
	
	// 动态数组,之前学到这的时候只讲了开辟单个空间时可以初始化,也就是
	int* ptr = new int(5);//使用圆括号进行单参初始化
	//而现在则可以在创建多个空间时也进行初始化,这在C++98中并不支持
	int* arr3 = new int[5]{1,2,3,4,5};
	
	// 标准容器,可以直接在后面用花括号进行初始化
	vector<int> v{1,2,3,4,5};
	map<int, int> m{{1,1}, {2,2,}, {3,3}, {4,4}};
	//如果容器中存放的是自定义类型数据,则需要显示调用构造函数,设A为自定义类型,构造函数的参数列表中有两个参数A(int a, int b){},使用如下:
	vector<A> v1{A(1,2),A(5,6),A(7,9)};
	map<A,int> m1{{A(1,2),5}, {A(5,6),16}, {A(7,9),21}};
	return 0;
}
自定义类型
  1. 标准库支持单个对象的列表初始化,也就是说,你在自定义类中不需要任何特殊处理,就可以在创建单个对象时使用列表初始化:
class Point{
public:
	Point(int x = 0, int y = 0)
		: _x(x)
		, _y(y)
	{}
private:
	int _x;
	int _y;
};
int main(){
	//构造一个对象时可以使用列表初始化
	Pointer p{ 1, 2 };
	return 0;
}
  1. 多个对象想要支持列表初始化,需给该类 (必须是一个模板类,因为需要用到泛型参数) 添加一个带有initializer_list类型参数的构造函数即可,注意:initializer_list是系统自定义的类模板,该类模板中主要有三个方法:begin()end()迭代器,以及获取区间中元素个数的方法size(),另外在使用时需要包含<initializer_list>头文件具体使用如下:
#include <initializer_list>
template<class T>
class Vector {
public:
	//带initializer_list参数的构造函数
	Vector(initializer_list<T> init)
		: _capacity(init.size())
		, _size(0)
	{
		//使用循环将接收到的列表内容放入容器中
		_array = new T[_capacity];
		for(auto e : init)
			_array[_size++] = e;
	}
	//这个是为了可以在列表初始化前面加上等于号
	Vector<T>& operator=(initializer_list<T> init) {
		delete[] _array;
		size_t i = 0;
		for (auto e : init)
			_array[i++] = e;
		return *this;
	}
	// ...
private:
	T* _array;
	size_t _capacity;
	size_t _size;
};

变量类型推导

auto
  • 使用auto关键字可以简化我们的代码书写,非常的方便,并且有时候还可以确保数据的安全性:
#include <map>
#include <string>
int main(){
	short a = 32670;
	short b = 32670;
	//变量c如果给成short,会造成数据丢失,如果能够让编译器根据a+b的结果推导c的实际类型,就不会存在问题
	//short c = a + b; 存在问题
	auto c = a + b;  //安全
	
	std::map<std::string, std::string> m {{"apple", "苹果"}, {"banana","香蕉"}};
	// 使用迭代器遍历容器, 迭代器类型太繁琐
	//std::map<std::string, std::string>::iterator it = m.begin();十分繁琐
	auto it = m.begin(); //这样就会很方便
	while(it != m.end()){
		cout << it->first << " " << it->second << endl;
		++it;
	}
return 0;
}
  • 关于auto的使用细则请看另一篇博客:点此跳转
decltype
  • 为什么需要?
    auto使用的前提是:必须要对auto声明的类型进行初始化,否则编译器无法推导出auto的实际类型,但有时候可能需要根据表达式运行完成之后结果的类型进行推导,但是因为编译期间,代码不会运行,此时auto也就无能为力,例如函数模板中的泛型参数需要在运行时才会确定类型,此时如果对于函数的返回值使用auto就不可以了;
  • RTTI(Run-Time Type Identification):运行时类型识别,这就是在程序运行时对参数的类型进行识别,在 C++98 中虽然有 RTTI 技术,但是却没有太大关系:
    • typeid:可以查看指定参数的类型,通过这样的语句——typeid(参数).name()就可以查看对应的类型了;
    • dynamic_cast只能应用于含有虚函数的继承体系中(不具体介绍,其实我也不太懂);
  • decltype关键字
    1. 推演表达式类型作为变量的定义类型;
    2. 推演函数返回值的类型;
int* GetMemory(int a){
	int* ptr = new int(10);
	return ptr;
}
int main(){
	int a = 10;
	int b = 20;
	// 用decltype推演a+b的实际类型,作为定义c的类型
	decltype(a+b) c;
	cout<<typeid(c).name()<<endl;

	// 如果在decltype中放的是函数的名字,则推导函数的类型(也就是函数指针)
	cout << typeid(decltype(GetMemory)).name() << endl;
	// 如果在decltype中放的是函数名加参数的完整形式,推导的是函数返回值的类型,注意:此处只是推演,不会执行函数
	cout << typeid(decltype(GetMemory(0))).name() <<endl;
	return 0;
}

在这里插入图片描述

默认成员函数控制

背景
  • 在 C++ 中对于空类编译器会生成一些默认的成员函数,比如:构造函数、拷贝构造函数、赋值运算符重载、析构函数、&const&的重载、移动构造、移动拷贝构造等函数,如果在类中显式定义了这些函数,编译器将不会重新生成默认版本。有时候这样的规则可能被忘记,最常见的是声明了带参数的构造函数,但是在写代码时使用了无参的构造函数,而且编译器对于这些默认成员函数的生成时机会把人搞晕,有时编译器会生成,有时又不生成,于是 C++11 让程序员可以控制是否需要编译器生成;
显示缺省函数
  • 在 C++11 中,可以在默认成员函数的定义或者声明时后面加上= default,从而显式的指示编译器生成该函数的默认版本,用= default修饰的函数称为显式缺省函数;
class A{
public:
	A(int a)
		: _a(a)
	{}
	// 显式缺省构造函数,让编译器主动生成,这算是在声明时显示缺省
	A() = default;
	// 在类中声明,在类外定义时让编译器生成默认赋值运算符重载,这算是在定义时显示缺省
	A& operator=(const A& a);
private:
	int _a;
};
//定义时显示缺省
A& A::operator=(const A& a) = default;
int main(){
	A a1(10);
	A a2;
	a2 = a1;
	return 0;
}
删除默认构造
  • 如果能想要限制某些默认函数的生成,在 C++98 中,是将该函数声明到到private作用域下,并且不给定义,这样只要其他人想要调用就会报错。在 C++11 中更简单,只需在该函数的声明后面加上= delete即可,该语法指示编译器不生成对应函数的默认版本,用= delete修饰的函数称为删除函数;
class A{
public:
	A(int a)
		: _a(a)
	{}
	// 禁止编译器生成默认的拷贝构造函数以及赋值运算符重载
	A(const A&) = delete;
	A& operator(const A&) = delete;
private:
	int _a;
};
int main(){
	A a1(10);
	// 编译失败,因为该类没有拷贝构造函数
	//A a2(a1);
	A a3(20);
	// 编译失败,因为该类没有赋值运算符重载
	//a3 = a2;
	return 0;
}

右值引用

左值与右值

左值与右值是 C 语言中的概念,但 C 标准并没有给出严格的区分方式,一般认为:可以放在 = 左边的,或者能够取地址的称为左值,只能放在 = 右边的,或者不能取地址的称为右值,但是也不一定完全正确。

  1. 普通类型的变量,因为有名字,可以取地址,所以认为是左值;
  2. const修饰的常量,因为不可修改,是只读类型的,理论应该按照右值对待,但因为其可以取地址(如果只是const类型常量的定义,编译器不给其开辟空间,如果对该常量取地址时,编译器才为其开辟空间),所以 C++11 认为这是左值;
  3. 如果表达式的运行结果或者表达式的某个变量是一个持久对象、可以取地址,则认为这是一个左值;
  4. 如果表达式的运行结果或者表达式的某个变量是一个临时对象、不可以取地址、没有名字、即将被销毁,则认为这是一个右值;
  • 总结:
    1. 不能简单地通过能否放在=左侧右侧或者能否取地址来判断左值或者右值,要根据表达式结果或变量的性质判断;
    2. 左值可以直接引用,右值不能直接引用,但是可以通过加上const来直接引用右值;
    3. 常见的右值有常量(纯右值)、临时变量 / 匿名变量、将亡值;其他的都为左值
      • 纯右值:比如:a + b,、100;
      • 将亡值:比如:表达式的中间结果、函数按照值的方式进行返回,虽然存在,但是即将要被销毁了;
引用
  • 左值引用:C++98 中提出了引用的概念,引用即别名,引用变量与其引用实体公用同一块内存空间,而引用的底层是通过指针来实现的,因此使用引用相当于封装了指针,可以提高程序的可读性,我们之前接触的引用被称为左值引用;
//参数传引用也可以实现交换
void Swap(int& left, int& right){
	int temp = left;
	left = right;
	right = temp;
}
int main(){
	int a = 10;
	int b = 20;
	Swap(a, b);
}
  • 右值引用:为了提高程序运行效率,C++11 中引入了右值引用,右值引用也是别名,但其只能对右值进行引用,右值引用的方式和左值引用就类似,不过需要将&改为&&,也就是在进行右值引用时多加一个取地址符;
int Add(int a, int b){
	return a + b;
}
int main(){
	const int&& ra = 10;
	// 引用函数的返回值,返回值是一个临时变量(右值),此时接收的并不是重新创建的临时变量,而是函数返回的那个将亡值
	int&& rRet = Add(10, 20);
	return 0;
}
  • 注意
    • 普通引用(左值引用)只能引用左值,不能引用右值,const左值引用既可引用左值,也可引用右值;
    • C++11 中右值引用只能引用右值,一般情况不能直接引用左值;
int test(){
	// 普通类型引用只能引用左值,不能引用右值
	int a = 10;
	int& ra1 = a; // ra1为a的别名
	//int& ra2 = 10; // 编译失败,因为10是右值
	const int& ra3 = 10;//可以通过,因为有const修饰
	const int& ra4 = a;
	return 0;
}
int test(){
	// 10纯右值,只是一个符号,没有具体的空间,
	// 在右值引用变量r1的过程中,编译器产生了一个临时变量(存放10),r1实际引用的是这个临时变量
	int&& r1 = 10;
	r1 = 100;
	int a = 10;
	//int&& r2 = a; // 编译失败:右值引用不能引用左值
	return 0;
}
返回值的缺陷
  • 如果一个类中涉及到资源管理,用户必须显式提供拷贝构造、赋值运算符重载以及析构函数,来完善对资源的管理,否则编译器将会自动生成默认成员函数,但是这些默认的成员函数并不会对资源进行管理,而此时再进行拷贝对象或者对象之间相互赋值时,就会出错,因此我们在写代码时需要考虑到这些事情,正确的写法如下:
class String{
public:
	//构造函数
	String(char* str = ""){
		if (nullptr == str)
			str = "";
		_str = new char[strlen(str) + 1];
		strcpy(_str, str);
	}
	//拷贝构造
	String(const String& s)
		: _str(new char[strlen(s._str) + 1])
	{
		strcpy(_str, s._str);
	}
	//赋值运算符
	String& operator=(const String& s){
		if (this != &s){
			char* pTemp = new char[strlen(s._str) +1];
			strcpy(pTemp, s._str);
			delete[] _str;
			_str = pTemp;
		}
		return *this;
	}
	String operator+(const String& s){
		char* pTemp = new char[strlen(_str) + strlen(s._str) + 1];
		strcpy(pTemp, _str);
		strcpy(pTemp + strlen(_str), s._str);
		String strRet(pTemp);
		return strRet;
	}
	~String(){
		if (_str) delete[] _str;
	}
private:
	char* _str;
};
int main(){
	String s1("hello");
	String s2("world");
	String s3(s1+s2);
return 0;
}
  • 上面的代码逻辑十分完备,在使用时没有什么错误,但是其实存在一个优化问题,在上面这么一个简单的过程中,却存在着多次空间的创建、释放,而且这些空间中的内容都是一样的;
    在这里插入图片描述
移动语义
  • 概念:C++11 提出了移动语义概念,即:将一个对象 s1 中的资源移动到另一个对象 s2 中,这样可以有效缓解上面的问题,当然,这里面的 s1 是一个右值,不然也不会出现上面的那些问题,另外如果需要实现移动语义,必须使用右值引用;
  • 移动构造:上面的问题就是在调用拷贝构造函数时,存在多余的对象的创建和释放,因此我们可以将那些要释放的对象的资源移动给将要创建的对象,这样就避免了多余的空间操作,而想要实现这个效果就需要再写一个具有移动语义的拷贝构造函数——移动构造函数:
String(String&& s)
	: _str(s._str)
{
	//这里一定要记着将将亡值的原本指针置空,否则在将s释放时,会把我们移动用的资源释放了
	s._str = nullptr;
}

在这里插入图片描述

  • 移动赋值:在将一个右值对象赋值给另一个对象时,如果是普通的赋值运算符,那么将会有一些不必要的繁琐操作,但是如果是移动赋值运算符的话,那么就会将右值对象的空间移动给需要赋值的对象,这样就可以少许多重复的操作;
String& operator=(String&& s){
	if (this != &s){
		char* temp = _str;
		_str = s._str;
		s._str = temp;
	}
	return *this;
}
  • 注意:
    1. 移动构造函数的参数千万不能设置成const类型的右值引用,因为会造成资源无法转移而导致移动语义失效;
    2. 在 C++11 中,编译器会为类默认生成一个移动构造,该移动构造为浅拷贝,因此当类中涉及到资源管理时,用户必须显式定义自己的移动构造;
右值引用引用左值
  • 概念:按照语法,右值引用只能引用右值,但右值引用一定不能引用左值吗?因为有些场景下,可能真的需要用右值去引用左值实现移动语义,当需要用右值引用去引用一个左值时,可以通过move函数将左值转化为右值,C++11 中,std::move()函数位于std作用域下,该函数名字具有迷惑性,它并不搬移任何东西,唯一的功能就是将一个左值强制转化为右值引用,然后实现移动语义,下面是函数原型;
template<class _Ty>
inline typename remove_reference<_Ty>::type&& move(_Ty&& _Arg) _NOEXCEPT{
	// forward _Arg as movable
	return ((typename remove_reference<_Ty>::type&&)_Arg);
}
  • 注意
    1. 被转化的左值,其生命周期并没有随着左值的转化而改变,即经过std::move转化的左值变量不会被销毁;
    2. STL 中也有另一个move函数,就是将一个范围中的元素搬移到另一个位置,这需要注意区分;
  • 错误使用:不要将一个正在使用的左值对象修改为右值属性,这样很可能会导致该对象中的资源丢失,无法正常使用;
String s1("hello world");
//因为s1现在是一个右值了,所以此处会使用移动构造,移动构造完成后s1中就没有资源了,s1也就废了
String s2(move(s1));
//此时s1已经是一个空对象了
String s3(s1);
  • 实例:实现一个 Person 类,在其中使用了上面写的 String 类,当把一个 Person 类的右值对象 p1 返回给需要创建的对象 p2 时,此时会调用移动构造函数,但是因为 Person 类内部又包含了几个 String 对象,这些内容是左值属性,所以底层的 String 对象的拷贝还是进行的复杂的拷贝构造,但是当我们将这些 String 对象修改为右值属性时,他们就会调用他们内部的移动拷贝,这样就实现了真正的移动拷贝,如果没有后面这一步,那么将会是虚假的移动拷贝;
class Person
{
public:
	Person(char* name, char* sex, int age)
		: _name(name)
		, _sex(sex)
		, _age(age)
	{}
	Person(const Person& p)
		: _name(p._name)
		, _sex(p._sex)
		, _age(p._age)
	{}
	//这里虽然使用了移动构造,但是内部没有实现移动构造,所以还是一个复杂的拷贝构造
	Person(Person&& p)
		//String对象不是右值属性,所以不能移动构造
		: _name(p._name)
		, _sex(p._sex)
		, _age(p._age)
	{}
	//这里面将String对象修改为右值属性
	Person(Person&& p)
		//所以此时进行的拷贝构造就是移动构造
		: _name(move(p._name))
		, _sex(move(p._sex))
		, _age(p._age)
	{}
private:
	String _name;
	String _sex;
	int _age;
};
Person GetTempPerson(){
	Person p("prety", "male", 18);
	return p;
}
int main(){
	Person p(GetTempPerson());
	return 0;
}
完美转发
  • 概念:完美转发是指在函数模板中,完全依照模板的参数的类型,将参数传递给函数模板中调用的另外一个函数,下面代码中,PerfectForward为转发的模板函数,Func为实际目标函数,但是下面的转发还不算完美,完美转发是目标函数总希望将参数按照传递给转发函数的实际类型转给目标函数,而不产生额外的开销,就好像转发者不存在一样。
void Func(int x){
	// ......
}
template<typename T>
void PerfectForward(T t){
	Fun(t);
}
  • 所谓完美:函数模板在向其他函数传递自身形参时,如果相应实参是左值,它就应该被转发为左值;如果相应实参是右值,它就应该被转发为右值,这样做是为了保留转发过程中的其他函数针对转发而来的参数的左右值属性进行不同的处理(比如参数为左值时实施拷贝语义,参数为右值时实施移动语义);
  • C++11 通过forward函数来实现完美转发,在完美转发前需要接收数据,而想要实现完美转发就必须做到什么属性的数据传进来,就要保持它原有的属性,所以接收数据的参数的类型为——泛型参数后面加两个取地址符号T&&,这个是一个未定义类型,可以是一个左值,也可以是一个右值,根据传进来的值的属性来确定类型;如果这里使用了一个具体的类型来接收数据,那么接收到的数据一定会是一个左值,所以实现完美转发的例子如下:
void Fun(int &x){cout << "lvalue ref" << endl;}
void Fun(int &&x){cout << "rvalue ref" << endl;}
void Fun(const int &x){cout << "const lvalue ref" << endl;}
void Fun(const int &&x){cout << "const rvalue ref" << endl;}
template<typename T>
//此处T&&为未定义类型,所以可以实现接收不同类型的数据而保持不变
void PerfectForward(T&& t){Fun(std::forward<T>(t));}
int main(){
	PerfectForward(10); // rvalue ref
	int a;
	PerfectForward(a); // lvalue ref
	PerfectForward(std::move(a)); // rvalue ref
	const int b = 8;
	PerfectForward(b); // const lvalue ref
	PerfectForward(std::move(b)); // const rvalue ref
	return 0;
}

lambda表达式

概念
  • lambda表达式实际上可以理解为无名函数,该函数无法直接调用,如果想要直接调用,可借助auto将其赋值给一个变量,然后调用(下面讲);
  • lambda表达式书写格式:
[capture-list] (parameters) mutable -> return_type { statement };
含义
  • [capture-list]:捕捉列表,该列表总是出现在lambda函数的开始位置,编译器根据[]来判断接下来的代码是否为lambda函数,捕捉列表能够捕捉同一作用域下上下文中的变量供lambda函数使用;
    • [var]:表示值传递方式捕捉变量var
    • [=]:表示值传递方式捕获所有父作用域中的变量(如果在成员函数中,则包括 this);
    • [&]:表示引用传递捕捉所有父作用域中的变量(如果在成员函数中,则包括 this);
    • [&var]:表示引用传递捕捉单个变量 var;
    • [=var]:表示值传递捕捉单个变量 var;
    • [=, &var1, &var2]:表示以引用传递的方式捕捉单个变量 var1 和 var2,以值传递方式捕获其他所有父作用域中的变量(如果在成员函数中,则包括 this);
    • [&, =var1, =var2]:表示以值传递的方式捕捉单个变量 var1 和 var2,以引用传递方式捕获其他所有父作用域中的变量(如果在成员函数中,则包括 this),这后面的两个等号可写可不写;
    • [this]:表示值传递方式捕捉当前的this指针;
  • (parameters):参数列表,与普通函数的参数列表一致,如果不需要参数传递,则可以连同()一起省略;
  • mutable:默认情况下,lambda函数总是一个const函数,mutable可以取消其常量性,使用该修饰符时,参数列表不可省略 (即使参数为空);
  • ->return_type:返回值类型,用追踪返回类型形式声明函数的返回值类型,没有返回值时此部分可省略,返回值类型明确情况下,也可省略,由编译器对返回类型进行推导;
  • {statement}:函数体,在该函数体内,除了可以使用传入的参数外,还可以使用所有捕获到的变量;
  • 注意
    1. lambda函数定义中,参数列表和返回值类型都是可选部分,可以省略,而捕捉列表和函数体可以为空,但是不能省略,因此 C++11 中最简单的lambda函数为:[]{};lambda函数不能做任何事情;
    2. lambda表达式是一条语句,并不算是函数,所以lambda表达式末尾需要加上分号;
    3. auto fun = [捕捉列表](参数)->返回值类型{函数体};,这样一条lambda语句只是定义,并不会运行,只有当调用的时候才会运行,调用格式为fun(传入参数);
int main(){
	// 最简单的lambda表达式, 该lambda表达式没有任何意义
	[]{};
	// 省略参数列表和返回值类型,返回值类型由编译器推导为int
	int a = 3, b = 4;
	[=]{return a + 3; };
	// 省略了返回值类型,无返回值类型
	auto fun1 = [&](int c){b = a + c; };
	fun1(10)
	cout<<a<<" "<<b<<endl;
	// 各部分都很完善的lambda函数
	auto fun2 = [=, &b](int c)->int{return b += a+ c; };
	cout<<fun2(10)<<endl;
	// 复制捕捉x
	int x = 10;
	auto add_x = [x](int a) mutable { x *= 2; return a + x; };
	cout << add_x(10) << endl;
	return 0;
}
注意事项
  1. 父作用域指包含lambda函数的语句块;
  2. 语法上捕捉列表可由多个捕捉项组成,并以逗号分割,但是捕捉列表不允许变量重复传递,否则就会导致编译错误, 比如:[=, a]=已经以值传递方式捕捉了所有变量,捕捉 a 又是以值传递的方式,这样就会造成重复;
  3. 没有写在任何块作用中的lambda函数捕捉列表必须为空,那怕有全局变量也不可以捕捉;
  4. 在块作用域中的lambda函数仅能捕捉父作用域中局部变量,捕捉任何非此作用域或者非局部变量都会导致编译报错;
  5. lambda表达式之间不能相互赋值,即使看起来类型相同,但是lambda表达式允许使用一个lambda表达式拷贝构造一个新的副本,也可以将lambda表达式赋值给相同类型的函数指针;
void (*PF)();
int main(){
	auto f1 = []{cout << "hello world" << endl; };
	auto f2 = []{cout << "hello world" << endl; };
	// 这个是不可以的,不能相互赋值
	//f1 = f2;  编译失败--->提示找不到operator=()
	// 允许使用一个lambda表达式拷贝构造一个新的副本
	auto f3(f2);
	f3();
	// 可以将lambda表达式赋值给相同类型的函数指针
	PF = f2;
	PF();
	return 0;
}
底层原理
  • 先看代码,从使用上来看仿函数与lambda表达式完全一样;
class Rate{
public:
	Rate(double rate)
		: _rate(rate)
	{}
	double operator()(double money, int year){ 
		return money * _rate * year;
	}
private:
	double _rate;
};
int main(){
	// 函数对象
	double rate = 0.49;
	Rate r1(rate);
	r1(10000, 2);
	// lamber
	auto r2 = [=](double monty, int year)->double{return monty*rate*year; };
	r2(10000, 2);
	return 0;
}
  • 这是底层的汇编代码,实际在底层编译器对于lambda表达式的处理方式,完全就是按照仿函数的方式处理的,即:如果定义了一个lambda表达式,编译器会自动生成一个类,在该类中重载了operator()
    在这里插入图片描述
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值