[C++高级]对象被优化以后才是高效的C++编程

文章详细探讨了C++中对象优化的规则,包括临时对象的构造优化、赋值操作的优化,并通过CMyString类举例说明。强调了在函数参数传递和返回时使用引用以减少拷贝构造和析构的次数,以及引入右值引用以优化拷贝构造和赋值操作,减少了不必要的内存开销。此外,文章还介绍了移动语义在CMyString类与vector中的应用,以及如何通过move和forward实现更高效的数据转移。
摘要由CSDN通过智能技术生成

  • C++编译器对于对象构造的优化:

    • 临时对象拷贝构造新对象的时候,临时对象就不产生了,直接构造新对象(关键词是:临时对象 拷贝构造新对象)
  • 在赋值的时候,使用临时对象给另一个对象赋值,那么临时对象必须产生,无法被优化,且临时对象的生命周期仅在所在的语句中

  • 隐式生成临时对象然后给另一个对象进行赋值,能够隐式生成临时对象,需要类提供具有相应类型参数列表的构造函数

t4 = 30; // 隐式生成临时对象,编译器会去找Test有没有提供参数为int类型的构造函数
  • 用指针指向临时对象是不安全的,因为出语句后临时对象就会析构,那么指针就变为了野指针,但是如果使用常引用(必须是常饮引用)去引用临时对象(临时对象没名字,引用后相当于有名字了)是可以的,出函数作用域后才析构
// p指向是一个已经析构的临时对象,是不安全的
Test* p = &Test(40);

// 使用常引用可以引用一个临时对象(延长了临时对象的生命周期)
const Test &ref = Test(50);

关于上述提到的一些内容,可以通过下面的例子来体现出来:

class Test 
{
public:
	// 带默认参数的构造函数支持Test(),Test(int), Test(int, int) 
	Test(int a=0, int b=0) :ma(a),mb(b) { cout << "Test(int)" << endl; }
	Test(const Test& t) 
	{
		ma = t.ma;
		mb = t.mb;
		cout << "Test(const Test& t)" << endl;
	}
	Test& operator=(const Test& t) 
	{ 
		ma = t.ma;
		mb = t.mb;
		cout << "Test& operator=(const Test& t)" << endl;
		return *this;
	}
	~Test() { cout << "~Test()" << endl; }
private:
	int ma;
	int mb;

};
int main() 
{
	Test t1;

	// 拷贝构造
	Test t2(t1);
	Test t3 = t1;

	// Test(20)显示构造临时对象,生命周期只在该语句
	// C++编译器对于对象构造的优化:用临时对象构造新对象的时候,临时对象就不产生了,直接构造新对象
	Test t4 = Test(20); // 和Test t4(20)没有区别

	cout << "-----------------------" << endl;

 	// 赋值操作,临时对象一定是会产生的,编译器无法去优化
    t4 = t2;
	// 注意这里临时对象是一定要产生的,因为并不是用临时对象构造新对象,而是调用了赋值运算符重载函数
	t4 = Test(30);//t4.operatpr=(Test(30))
	
	t4 = (Test)30; //也是显示生成临时对象,会去找类中是否有参数类型为int的构造函数
	t4 = 30; // 隐式生成临时对象,编译器会去找Test有没有提供参数为int类型的构造函数
	cout << "-----------------------" << endl;

	 
	/*
	// p指向是一个已经析构的临时对象,是不安全的
	// Test* p = &Test(40);
	
	ref是临时对象的引用,相当于一个别名,所以指向语句后临时对象不会析构
	注意新标准(c++11)下,要定义成常引用
	*/
	const Test &ref = Test(50);
	
    cout << "-----------------------" << endl;
	return 0;
}
  • 再来看下面的对象调用过程,你能够说出调用过程是怎样的吗?
Test t1(10, 10);// 1.
int main() 
{
	Test t2(20, 20);//3.Test(int, int)
	Test t3 = t2;//4.拷贝构造,Test(const Test* val) 
	
	//编译器优化,不构造临时对象,直接构造对象,等价于static Test t4(30,30);
	static Test t4 = Test(30, 30);//5.程序运行时,第一次执行该语句时初始化
	t2 = Test(40, 40);// 6.Test(int,int), operator=(const T& val), ~Test()

	// 逗号表达式最终结果就是最后一个值(50,50)-->50, (Test)类型强转
    // (Test)(50, 50) 等价于 (Test)50
	t2 = (Test)(50, 50);//7.Test(int), operator=(const T& val), ~Test()
	t2 = 60;// 8.Test(int), operator=(const T& val), ~Test()
	
	// (new)堆上构造的对象,不是临时对象,只有调用delete才会析构
	// new后必须delete释放内存
	Test* p1 = new Test(70, 70); // 9.Test(int,int)
	Test* p2 = new Test[2]; // 10.new一个对象数组,调用两次构造构造,Test(int, int) 

	// 不要用指针指向临时对象,临时对象出了该语句会析构
	Test* p3 = &Test(80, 80);//11.vs2019已经不允许了,&只能对左值取地址
	const Test& p4 = Test(90, 90);//12. 构造临时对象,初始化常引用来引用临时对象,临时对象的生命周期延长到和引用变量一样,引用变量何时出作用域,临时对象何时析构
	delete p1;// ~Test()
	delete[]p2;// 对象数组中的每个对象都调用析构~Test() ~Test()

	return 0;
}
Test t5(100, 100);// 2.
  • 全局变量先进行构造,程序要结束时才会进行析构;

  • static局部变量,在程序运行之前已经分配好了内存地址,但是在程序运行时第一次执行该语句的时候才会进行初始化(这里就是构造对象),并且程序结束时才会调用析构

  • new构造的不是临时对象,而是堆上的对象,只有调用delete才会去执行析构

  • 指针变量不要指向临时对象,因为临时对象的生命周期仅在该语句,出了语句后就调用析构

  • 常引用变量(注意是const)可以引用临时对象(相当于给临时对象取名),并且可以延长临时对象的生命周期,只有引用变量出作用域时,临时对象才会调用析构函数(而普通引用只能引用左值


函数调用过程中对象背后调用的方法太多

来看下面的例子:

class Test
{
public:
	Test(int a = 0) :ma(a) { cout << "Test(int)" << endl; }
	Test(const Test& t) :ma(t.ma) { cout << "Test(const Test& t)" << endl; }
	Test& operator=(const Test& t)
	{
		ma = t.ma;
		cout << "Test& operator=(const Test& t)" << endl;
		return *this;
	}
	int getData() const { return ma; }
	~Test() { cout << "~Test()" << endl; }
private:
	int ma;
};
// 函数调用过程中对象背后调用的方法太多了
Test GetObject(Test t)  //3.main中的t拷贝构造形参t Test(const Test& t)
{
	int val = t.getData();
	Test tmp(val); // 4.Test(int)
	//static Test tmp(val); //静态局部对象,存放在数据段,整个程序运行结束,才后释放,可以返回其地址或引用

	//return &tmp; //不能局部对象的地址或引用
	return tmp;//5.利用tmp在main函数栈帧上拷贝构造临时对象
	// 6.析构tmp ~Test()
	// 7.析构形参t ~Test()
}
int main()
{
	Test t1; // 1.Test(int)
	Test t2; // 2.Test(int)

	// 函数调用,实参到形参是初始化,所以会调用构造函数;对于编译器内置类型来说,没有区别,汇编代码是一样的
	t2 = GetObject(t1); //8.临时对象给t2赋值;operator=(const Test& t)
	//9.临时对象析构
	//10.t2析构
	//11.t1析构
	return 0;
}

结合代码段中的注释,我们可以看到看似简单的函数调用过程中,其实包含了大量对象成员方法的调用(拷贝构造,构造,析构,赋值运算符重载)

在这里插入图片描述


总结三条对象优化的规则

  1. 函数参数传递过程中,对象优先按照引用传递,不要按值传递(形参为引用变量,可以减少原来形参t的拷贝构造和析构)

  2. 函数返回对象时,尽量把构造对象所需参数计算出来,然后直接返回临时对象,不要返回一个定义过的对象

    return Test(val)时,构造的临时对象Test(val)无法带到main函数栈帧上,需要利用函数中的临时对象在main函数中拷贝构造一个临时对象(新对象),而这将会被编译器优化成直接在main上构造临时对象

  3. 接收返回值是对象的函数时,优先用初始化方式接收,不要用赋值方式接收

    t2 = GetObject(t1); // (1)

    Test t2 = GetObject(t1); //(2)

    (2)和(1)比起来又是利用临时对象来拷贝构造新的对象,那么临时对象的构造也被省略,从而直接构造对象t2

 Test GetObject(Test &t)  
 {
 	int val = t.getData();
 	/*Test tmp(val);  
 	return tmp; */
 	return Test(val); // 返回临时对象,用临时对象在main上拷贝构造一个临时对象(新对象),编译器优化成不产生临时对象,直接在main函数栈帧上构造一个临时对象
 }
int main()
{
	Test t1; // 1.Test(int)
	// t2 = GetObject(t1); 调用赋值运算符重载函数
    Test t2 = GetObject(t1); // 2.Test(int) 
	//3.t2析构
	//4.t1析构
	return 0;
}

在这里插入图片描述

可以看到,最后对象成员方法的调用被优化成了只有4次(也就是t1,t2的构造和析构)


CMyString的代码问题

class CMyString 
{
public:
	CMyString(const char* ptr=nullptr) 
	{
		cout << "CMyString(const char* ptr)" << endl;
		if (ptr != nullptr) 
		{
			mptr = new char[strlen(ptr) + 1];
			strcpy(mptr, ptr);
		}
		else 
		{
			mptr = new char[1];
			*mptr = '\0';
		}
	}
	CMyString(const CMyString& src) 
	{
		cout << "CMyString(const CMyString& src)" << endl;
		mptr = new char[strlen(src.mptr) + 1];
		strcpy(mptr, src.mptr);
	}
	CMyString& operator=(const CMyString& src) 
	{
		cout << "CMyString& operator=(const CMyString& src)" << endl;
		if (&src == this) 
		{
			return *this;
		}
		delete[] mptr;
		mptr = new char[strlen(src.mptr) + 1];
		strcpy(mptr, src.mptr);
		return *this;
	}
	~CMyString() 
	{
		cout << "~CMyString()" << endl;
		delete[] mptr;
		mptr = nullptr;
	}
	const char* c_str() const { return mptr; }
private:
	char* mptr;
};
CMyString GetString(CMyString& str) 
{
	const char* pstr = str.c_str();
	CMyString tmpStr(pstr);//3.普通构造
	return tmpStr;//4. 拷贝构造,在main函数栈帧上构造新对象(临时对象)
    // 花费大量开销拷贝构造完后,tmpstr就马上析构了....
}
int main() 
{
	CMyString str1("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");// 1.普通构造
	CMyString str2;// 2.普通构造
	str2 = GetString(str1);
	cout << str2.c_str() << endl;
	return 0;	
}

可以看到在GetString中,return时为了将对象成员变量的值(指向外部资源的指针)带出去,需要进行拷贝构造,在main函数栈帧上构造新对象(临时对象),而这个拷贝构造涉及了大量的内存开辟操作,开销大,而且拷贝构造完后,tmpstr就马上进行了析构…,那不如直接把资源让出去,以避免无用的内存开辟操作;

str2 = GetString(str1);这句也是一样的,利用临时对象给str2进行赋值,而CMyString赋值运算符重载函数中涉及了堆内存资源的释放和开辟麻烦的是,语句执行完后,临时对象又进行了析构,释放了它的堆内存资源,那这样还不如直接把临时对象占有的堆内存资源让给t2,避免多余的堆内存开辟和释放


添加带右值引用参数的拷贝构造和赋值函数

右值:没名字或者没内存的临时量

一个右值引用变量本事是一个左值,需要用左值引用来引用右值引用变量

常量、数字、临时对象都是右值,使用右值引用变量来引用

  • 定义带右值引用参数的拷贝构造和赋值函数来解决上面提到的代码问题
class CMyString
{
public:
	CMyString(const char* ptr = nullptr)
	{
		cout << "CMyString(const char* ptr)" << endl;
		if (ptr != nullptr)
		{
			mptr = new char[strlen(ptr) + 1];
			strcpy(mptr, ptr);
		}
		else
		{
			mptr = new char[1];
			*mptr = '\0';
		}
	}
	CMyString(const CMyString& src)
	{
		cout << "CMyString(const CMyString& src)" << endl;
		mptr = new char[strlen(src.mptr) + 1];
		strcpy(mptr, src.mptr);
	}
	// 定义带右值引用参数的拷贝构造函数
	CMyString(CMyString&& src) //src引用的就是一个临时对象
	{
		cout << "CMyString(CMyString&& src)" << endl;
		// 直接接管临时对象的资源,并且将临时对象指向外部资源的指针置为nullptr
        mptr = src.mptr;
		src.mptr = nullptr;
	}

	CMyString& operator=(const CMyString& src)  
	{
		cout << "CMyString& operator=(const CMyString& src)" << endl;
		if (&src == this)
		{
			return *this;
		}
		delete[] mptr;
		mptr = new char[strlen(src.mptr) + 1];
		strcpy(mptr, src.mptr);
		return *this;
	}
	// 带右值引用参数的赋值重载运算符函数
	CMyString& operator=(CMyString&& src)
	{
		cout << "CMyString& operator=(CMyString&& src)" << endl;
		if (&src == this)
		{
			return *this;
		}
        /*
        释放掉原来的资源,然后直接拿到临时对象的资源,然后将临时对象的指向置为nullptr
        */
		delete[] mptr;
		mptr = src.mptr;
		src.mptr = nullptr;
		return *this;
	}
	~CMyString()
	{
		cout << "~CMyString()" << endl;
		delete[] mptr;
		mptr = nullptr;
	}
	const char* c_str() const { return mptr; }
	 
private:
	char* mptr;
	friend CMyString operator+(const CMyString& lhs, const CMyString& rhs);
	friend ostream& operator<<(ostream& out, const CMyString& src);
};

//+号运算符重载函数
CMyString operator+(const CMyString &lhs, const CMyString& rhs) 
{
	CMyString tmpStr;
	// 直接让tmpstr底层的指针指向新开辟的堆内存,出作用域后会自动调用析构函数对堆上内存进行释放
    tmpStr.mptr = new char[strlen(lhs.mptr) + strlen(rhs.mptr) + 1];
	strcpy(tmpStr.mptr, lhs.mptr);
	strcat(tmpStr.mptr, rhs.mptr);
	return tmpStr;// 调用带右值引用参数的拷贝构造函数 
}
//cout<<myStr
ostream& operator<<(ostream& out, const CMyString& src) 
{
	out << src.mptr;
	return out;
}
CMyString GetString(CMyString& str) 
{
	const char* pstr = str.c_str();
	CMyString tmpStr(pstr);
	return tmpStr;// 这里使用tmpstr拷贝构造临时对象时,使用了带右值引用参数的构造函数,省去了内存的开辟操作(其实,不太理解为什么编译器会将tmpstr传入带右值引用参数的构造函数,我认为应该是: return语句后对象就要析构了,那不如把它占有的资源让出去)
}
int main() 
{
	CMyString str1("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
	CMyString str2;
	str2 = GetString(str1);// 这里调用的是带右值引用参数的赋值运算符重载函数
	cout << str2.c_str() << endl;
	return 0;	
}

在这里插入图片描述

使用带右值引用参数的拷贝构造和赋值函数,直接“接管“ 临时对象占有的外部资源,省去了大量的内存开辟以释放的开销

int main() 
{
	CMyString str1 = "hello ";//构造临时对象,然后拷贝构造新对象 ----》被优化为直接构造新对象
	CMyString str2 = "world";//构造临时对象,然后拷贝构造新对象 ----》被优化为直接构造新对象
	cout << "------------------------" << endl;
	CMyString str3 = str1 + str2;   
	cout << "------------------------" << endl;
	cout << str3 << endl;
	return 0;
}

在这里插入图片描述

需要注意:+号运算符重载函数本来执行的是利用tmpstr,通过调用带右值引用参数的拷贝构造在main上构造临时对象,但是在main中使用初始化的方法来接收返回值,也就是说(构造临时对象,然后用临时对象去构造新对象这样的操作被优化成:直接构造新对象),所以最终优化成:利用tmpstr,调用带右值引用参数的拷贝构造在main上直接构造了新对象str3,没有临时对象的生成


CMyString在vector上的应用

#include <iostream>
#include <vector>
using namespace std;

int main()
{
	vector<CMyString> vec;  
	vec.reserve(10);			// 只开辟内存,只有push_back时才会构造对象
	cout << "------------------------" << endl;
	CMyString str1 = "aaa";
	vec.push_back(str1);  // 调用了带左值引用参数的拷贝构造
	vec.push_back(CMyString("bbb"));  // 调用了带右值引用参数的拷贝构造(移动语义, 将资源直接给别人,减少内存的开辟)
	return 0;
}

在这里插入图片描述

vector容器底层也已经实现了带右值引用的拷贝构造,push_back以一个临时对象作为实参传入时,在vector底层的数组上,会调用CMyString带右值引用的拷贝构造去构造对象,把临时对象自己开辟的内存资源给新对象,自己底层指向资源的指针置为nullptr后,语句结束时临时对象析构。

在这里插入图片描述


move移动语义和forward完美类型转发

move:移动语义,得到右值类型(底层实际上是一个类型强转static_cast)

forward:类型完美转发,能够识别左值和右值的类型

/ 定义容器的空间配置器,和c++标准库的allocator实现一样
template<typename T>
struct Allocator
{
	// typedef unsigned int     size_t;
	T* allocate(size_t size) // 只负责内存开辟 
	{// malloc只开辟内存,不构造对象
		return (T*)malloc(sizeof(T) * size);
	}

	void deallocate(void* p) // 负责内存释放
	{
		free(p);
	}

	//void construct(T* p, const T& val) // 负责对象构造
	//{
	//	new(p)T(val); // 定位new
	//}
	//
	//void construct(T* p, T&& val) // 负责对象构造
	//{
	//	//new(p)T(val); // 右值引用本身是左值
	//	new(p)T(std::move(val));
	//}

	template<typename Ty>
	void construct(T* p, Ty&& val) // 负责对象构造
	{
		new(p)T(std::forward<Ty>(val));
	}

	void destroy(T* p)
	{
		p->~T(); // ~T()代表了T类型的析构函数
	}
};

// 实现vector向量容器
// 容器底层内存开辟,内存释放,对象构造和析构都通过allocator空间配置器来实现
template<typename T, typename Alloc = Allocator<T>>  // 带默认类型的类型参数, 类名 = 模板名+类型列表
class Vector
{
public:
	Vector(int size = 10)
	{
		// new会开辟内存空间, 还会构造对象
		// 需要把内存开辟和对象构造分开处理
		//_first = new T[size];
		_first = _allocator.allocate(size);
		_last = _first;
		_end = _first + size;
	}
	~Vector()
	{
		// 应该析构容器有效元素然后释放_first指针指向的堆内存
		// delete[]_first;
		for (T* p = _first; p != _last; p++)
		{
			_allocator.destroy(p); // 把_first指针指向的数组的有效元素进行析构操作
		}
		_allocator.deallocate(_first); // 释放堆上的数组内存
		_first = _last = _end = nullptr;
	}
	Vector(const Vector<T>& rhs)
	{
		int size = rhs._end - rhs._first; // 数组空间大小
		int len = rhs._last - rhs._first; // 有效元素长度
		//_first = new T[size];
		_first = _allocator.allocate(size);
		for (int i = 0; i < len; i++)
		{
			//_first[i] = rhs._first[i];
			_allocator.construct(_first + i, rhs._first[i]);
		}
		_last = _first + len;
		_end = _first + size;
	}
	Vector<T>& operator=(const Vector<T>& rhs)
	{
		if (this == &rhs)
			return *this;

		// delete[]_first;
		for (T* p = _first; p != _last; p++)
		{
			_allocator.destroy(p); // 把_first指针指向的数组的有效元素进行析构操作
		}
		_allocator.deallocate(_first); // 释放堆上的数组内存

		int size = rhs._end - rhs._first; // 数组空间大小
		int len = rhs._last - rhs._first; // 有效元素长度
		// _first = new T[size];
		_first = _allocator.allocate(size);
		for (int i = 0; i < len; i++)
		{
			_allocator.construct(_first + i, rhs._first[i]);
		}

		_last = _first + len;
		_end = _first + size;
		return *this;
	}
	
	//void push_back(const T& val) // 接收左值引用变量
	//{
	//	if (full())
	//		expand();
	//	//*_last++ = val;
	//	// 在_last指针所指向的位置构造对象
	//	_allocator.construct(_last, val);
	//	_last++;
	//}

	//void push_back(T&& val) // 接收右值引用变量
	//{
	//	if (full())
	//		expand();
	//	// 在_last指针所指向的位置构造对象
	//	//_allocator.construct(_last, val); 注意右值引用本身也是左值,所以construct接收的是左值引用
	//	_allocator.construct(_last, std::move(val));  // 把左值引用强转成右值引用
	//	_last++;
	//}
	
    /*
    下面的函数模板可以接收左值引用和右值引用
    */
	template<typename Ty>
	void push_back(Ty&& val) // 函数模板的类型推演 + 引用折叠
	{// (CMystring &&&) -> CMystring & + && = CMystring &
	// (CMystring &&&&) -> CMystring && + && = CMystring &&
	// 通过函数模板的类型推演,Ty是CMystring & 或者 CMystring &&

		if (full())
			expand();
		//forward: 类型完美转发,能够识别变量是左值还是右值类型(告诉construct方法 val到底是左值还是右值)
		_allocator.construct(_last,std::forward<Ty>(val));
		_last++;
	}


	void pop_back() // 删除容器末尾的元素的值 
	{
		if (empty())
			return;
		 
		_last--;
		_allocator.destroy(_last);
	}
	T back() const
	{
		return *(_last - 1);
	}
	bool full() const
	{
		return _last == _end;
	}
	bool empty() const
	{
		return _first == _last;
	}
	int size() const
	{
		return _last - _first;
	}
	T& operator[](int index)
	{
		if (index < 0 || index >= size())
		{
			throw "OutOfRangeException";
		}
		return _first[index];
	}

	// 迭代器一般实现成容器的嵌套类型,不同容器的底层数据结构是不一样的

	class iterator
	{
	public:
		iterator(T* ptr = nullptr)
			:_ptr(ptr)
		{}
		bool operator!=(const iterator& it)const
		{
			return _ptr != it._ptr;
		}
		void operator++()// 前置++
		{
			_ptr++;
		}
		const T& operator*() const
		{// *_ptr不是该函数的局部变量,所以可以设置成返回引用的形式
			return *_ptr;
		}
		T& operator*()
		{// *_ptr不是该函数的局部变量,所以可以设置成返回引用的形式
			return *_ptr;
		}

	private:
		T* _ptr;
	};
	// 需要给容器提供begin和end方法
	iterator begin() { return iterator(_first); }
	iterator end() { return iterator(_last); }
private:
	T* _first; // 指向数组的起始位置
	T* _last; // 指向数组中有效元素的后继位置
	T* _end; // 指向数组空间的后继位置
	Alloc _allocator; // 定义容器的空间配置器对象
	void expand() // 容器的2倍扩容接口
	{//需要扩容,说明_end == _last
		int size = _end - _first;
		// T *ptmp = new T[size * 2];
		T* ptmp = _allocator.allocate(size * 2); // 开辟2*size大小的空间
		for (int i = 0; i < size; i++)
		{
			//ptmp[i] = _first[i];
			_allocator.construct(ptmp + i, _first[i]);
		}
		// delete[]_first;
		for (T* p = _first; p != _last; p++)
		{
			_allocator.destroy(p);
		}
		_allocator.deallocate(_first);

		_first = ptmp;
		_last = _first + size;
		_end = _first + 2 * size; // 2倍扩容

	}
};

在vector容器中,无论是带右值引用参数的push_back还是带左值引用参数的push_back,实际上实现的代码逻辑都是一样的,所以vector去定义两个push_back,包括空间配置器(allocator)去定义两个construct都比较麻烦,代码也比较重复。

通过将push_back实现成函数模板,在main中通过vec.push调用时,通过传入的实参类型,进行模板的实参推演,然后实例化出相应的模板函数进行编译,而根据传入实参的类型(左值CMyString& 还是 右值CMyString&&),类型参数Ty=CMyString& 或者 CMyString&&,然后通过引用折叠,CMyString& +&& =》CMyString& ;CMyString&&+&&=》CMyString&&,接着通过调用forward类型完美转发去识别形参val是(CMyString& 或 CMyString&&),从而以同样的方式调用空间配置器的construct方法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

下酒番陪绅士

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

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

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

打赏作者

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

抵扣说明:

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

余额充值