C++—智能指针

C++——智能指针

1.为什么需要智能指针

其实和异常有关系,看下面这段代码就明白了:

// 首先为什么需要智能指针呢?
// 前面我们学过异常,在异常中有一个异常安全的问题,就是异常会打乱执行流导致内存泄漏和死锁之类的问题
// 虽然c++本身有一个解决方案——先捕获处理在重新抛出异常
// 但是这种方案不太好,为什么?看下面这段代码:

int div()
{
	int a, b;
	cin >> a >> b;
	if (b == 0)
		throw invalid_argument("除0错误");
	return a / b;
}
void Func()
{
	// 1、如果p1这里new 抛异常会如何?
	// 2、如果p2这里new 抛异常会如何?
	// 3、如果div调用这里又会抛异常会如何?——这里采用捕获再重新抛异常
	int* p1 = new int;
	int* p2 = new int;
	try
	{
		cout << div() << endl;
	}
	catch (exception& e)
	{
		delete p1;
		delete p2;
		throw e;
	}
	// 这种情况我们会发现,p1和p2如果new的时候抛异常会很不好处理。因此为了解决这个问题,智能指针就出来了

	delete p1;
	delete p2;
}


int main()
{
	try
	{
		Func();
	}
	catch (exception& e)
	{
		cout << e.what() << endl;
	}

	return 0;
}

上面这段代码会导致内存泄漏,这是由于抛异常打乱执行流导致该释放的资源没有正常被释放所导致的,除了内存泄漏,抛异常还可以导致死锁的问题。

这里在复习一下内存泄漏:

  • **什么是内存泄漏:**内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。

  • **内存泄漏的危害:**长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死。

  • 内存泄漏的两种方面

    C/C++程序中一般我们关心两种方面的内存泄漏:

    • 堆内存泄漏(Heap leak)

      堆内存指的是程序执行中依据须要分配通过malloc / calloc / realloc / new等从堆中分配的一块内存,用完后必须通过调用相应的 free或者delete 删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak。

    • 系统资源泄漏

      指程序使用系统分配的资源,比方套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定。

  • 如何避免内存泄漏:

    1. 工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。ps:这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下一条智能指针来管理才有保证。
    2. 采用RAII思想或者智能指针来管理资源
    3. 有些公司内部规范使用内部实现的私有内存管理库。这套库自带内存泄漏检测的功能选项。
    4. 出问题了使用内存泄漏工具检测。ps:不过很多工具都不够靠谱,或者收费昂贵。

2.智能指针的使用和原理

那智能指针是如何解决这里的问题的呢?下面给一个例子:

SmartPtr.h:

#pragma once
#include<iostream>
using namespace std;

template<class T>
class SmartPtr
{
public:
	SmartPtr(T* ptr)
		:_ptr(ptr)
	{}

	~SmartPtr()
	{
		if (_ptr)
		{
			cout << "delete: " << _ptr << endl;
			delete _ptr;
		}
	}

private:
	T* _ptr;
};

test.cpp:

// 下面我们来看看智能指针是如何解决这个问题的
#include"SmartPtr.h"
int div()
{
	int a, b;
	cin >> a >> b;
	if (b == 0)
		throw invalid_argument("除0错误");

	return a / b;
}

void Func()
{
	// 用智能指针来解决内存泄漏的问题
	int* p1 = new int();
	cout << p1 << endl;
	SmartPtr<int> sp1(p1); //有了智能指针哪怕在div中抛异常了,也能释放掉p1的资源
	cout << div() << endl;
}

int main()
{
	try
	{
		Func();
	}
	catch (exception& e)
	{
		cout << e.what() << endl;
	}

	return 0;
}

执行结果如下:

image-20250427134833383

可以发现,出现了抛异常后,我们没有做捕获在抛出的处理,但是资源仍然被释放掉了,这就说明智能指针解决了这个问题,那具体是如何实现的呢?

其实很简单,它将释放资源这个任务交给了智能指针的生命周期了。当生命周期结束后,就会导致生命周期结束,调用析构函数,从而实现释放资源。因此不管是抛异常还是正常结束,都会导致其智能指针的对象的生命周期结束。

这个思路叫做RAII

3.1RAII

RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。

在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:

  1. 不需要显式地释放资源
  2. 采用这种方式,对象所需的资源在其生命期内始终保持有效。

RAII是一种托管资源的方式,智能指针利用了这个方案实现。类似这样的方案还要c++线程库里之前所使用过的,下图所示:

image-20250427212446032

3.2智能指针的原理

上述的SmartPtr还不能算是智能指针,因为它还不具有指针的行为。指针可以解引用,也可 以通过->去访问所指空间中的内容,如:SmartPtr<int> sp2(new int);的情况下,就无法访问不了该指针所指向的内容了,这个时候就要重载*和->,这样就可以访问了

template<class T>
class SmartPtr
{
public:
	SmartPtr(T* ptr)
		:_ptr(ptr)
	{}

	~SmartPtr()
	{
		if (_ptr)
		{
			cout << "delete: " << _ptr << endl;
			delete _ptr;
		}
	}

	T& operator*()
	{
		return *_ptr;
	}

	T* operator->()
	{
		return _ptr;
	}

private:
	T* _ptr;
};

测试代码:

void Func()
{
	SmartPtr<int> sp2(new int);
	*sp2 = 10;
	SmartPtr<pair<int, int>> sp3(new pair<int, int>);
	sp3->first = 1; //这里其实是两个->,但是编译器优化成一个->了,这之前讲过了
	sp3->second = 2;

}

总结一下智能指针的原理:

  1. RAII特性
  2. 重载operator*和opertaor->,具有像指针一样的行为。

3.3智能指针的坑

但是这样还是不能称上完整的智能指针,只能说是最简单的智能指针。会有很多坑,比如拷贝构造。

如果在代码中直接执行拷贝构造就会报错

	SmartPtr<int> sp4(new int); //拷贝构造
	SmartPtr<int> sp5 = sp4;

为什么?之前写了这么多代码,和拷贝构造相关又能爆这个错误,其实就是这里是浅拷贝,又是指针,说明sp4和sp5指向了同一个空间,那么就会对同一个空间释放两次,就会导致程序崩b溃。

那怎么解决呢?——之前所采取的解决方案是用深拷贝,但是这里可以用深拷贝吗?其实是不行的,因为智能指针,我只是想利用你RAII的风格来解决我内存泄露的问题,我只是将资源托管给你,在你生命周期结束之后能够释放资源,而不是擅自开一个空间来使用。

也就是说,智能指针的拷贝构造就应该像原生指针的行为p2 = p1一样,指向的是同一个空间,可是这样会导致崩溃。因此重点在解决析构同一个空间两次这个问题

其实这个问题之前在学习Linux文件系统的时候已经学过了,那就是引用计数【忘记了就要复习】

这里有三种解决方式:

  1. 管理权转移【这个方法不好】
  2. 防拷贝
  3. 引用计数

而c++里也是用这三个方式解决的,c++98用管理权转移(auto_ptr)解决, c++11提出了用防拷贝(unique_ptr)和引用计数解决

3.4 auto_ptr

C++98版本的库中就提供了auto_ptr的智能指针。下面演示的auto_ptr的使用及问题。 auto_ptr的实现原理:管理权转移的思想,下面简化模拟实现了一份auto_ptr来了解它的原理

namespace wzf
{
	template<class T>
	class auto_ptr
	{
	public:
		auto_ptr(T* ptr)
			:_ptr(ptr)
		{
		}

		auto_ptr(auto_ptr<T>& sp)
			:_ptr(sp._ptr)
		{
			// 管理权转移
			sp._ptr = nullptr;
		}
		auto_ptr<T>& operator=(auto_ptr<T>& ap)
		{
			// 检测是否为自己给自己赋值
			if (this != &ap)
			{
				// 释放当前对象中资源
				if (_ptr)
					delete _ptr;

				// 转移ap中资源到当前对象中
				_ptr = ap._ptr;
				ap._ptr = NULL;
			}
			return *this;
		}

		~auto_ptr()
		{
			if (_ptr)
			{
				cout << "delete:" << _ptr << endl;
				delete _ptr;
			}
		}

		// 像指针一样使用
		T& operator*()
		{
			return *_ptr;
		}

		T* operator->()
		{
			return _ptr;
		}

	private:
		T* _ptr;

	};
	// 结论:auto_ptr是一个失败设计,很多公司明确要求不能使用auto_ptr
}

测试代码:

#include <memory>
 int main()
 {
	//首先是c++98的auto_ptr(转移管理权)
	std::auto_ptr<int> sp1(new int);
	std::auto_ptr<int> sp2(sp1); // 管理权转移

	// sp1悬空
	*sp2 = 10;
	cout << *sp2 << endl;
	//cout << *sp1 << endl; //已经被置空了,无法在操作sp1

	return 0;
 }

这个方案并不是很好,因为它并没有完全模拟原生指针的行为,它是将sp1管理的资源(指针),直接赋值给sp2,然后将sp1置空。**即让sp2管理资源,自己不管了,这样就不会析构同一个空间两次了。**可以说是早期的设计缺点

image-20250427212509929

3.5 unique_ptr

C++11中开始提供更靠谱的unique_ptr

unique_ptr文档

unique_ptr的实现原理:简单粗暴的防拷贝,下面简化模拟实现了一份unique_ptr来了解它的原理

namespace wzf
{
	template<class T>
	class unique_ptr
	{
	public:
		unique_ptr(T* ptr = nullptr)
			:_ptr(ptr)
		{}

		unique_ptr(const unique_ptr<T>& up) = delete;
		unique_ptr<T>& operator=(const unique_ptr<T>& up) = delete;

		~unique_ptr()
		{
			if (_ptr)
			{
				delete _ptr;
			}
		}

		T& operator*()
		{
			return *_ptr;
		}

		T* operator->()
		{
			return _ptr;
		}

	private:
		T* _ptr;
	};
}

防拷贝就是直接很简单粗暴的不让拷贝构造了

image-20250427212715225

image-20250427212744400

3.6 shared_ptr

由于总有一些情况要用到拷贝构造,因此c++11又弄了一个支持拷贝构造的智能指针,采取的解决方案是引用计数

namespace wzf
{
	template<class T>
	class shared_ptr
	{
	public:
		shared_ptr(T* ptr = nullptr)
			:_ptr(ptr)
			, _pcount(new int(1))
		{}

		//拷贝构造
		shared_ptr(const shared_ptr<T>& sp)
			:_ptr(sp._ptr)
			, _pcount(sp._pcount)
		{
			++(*_pcount); //指向同一个空间的指针变多了。++
		}

		shared_ptr<T>& operator=(const shared_ptr<T> sp)
		{
			if (this != &sp)
			{
				if (_ptr)
				{
					// 由于要赋值,以为这this这个智能指针不能管理自己的资源了,要管理sp所指向的资源了
					//先释放掉原有的资源,然后再被赋值。
					// 但是这个释放不能简单的直接释放,要考虑引用计数
					(*_pcount)--;
					if ((*_pcount) == 0) //当减为0之后才能释放
					{
						delete _ptr;
						delete _pcount;
						_ptr = nullptr;
						_pcount = nullptr;
					}
				}

				_ptr = sp._ptr;
				_pcount = sp._pcount;
				++(*_pcount);
			}

			return *this;
		}

		~shared_ptr()
		{
			if (--(*_pcount) == 0 && _ptr)
			{
				cout << "delete:" << _ptr << endl;
				delete _ptr;
				_ptr = nullptr;

				delete _pcount; //指针也要释放
				_pcount = nullptr;
			}
		}

		T& operator*()
		{
			return *_ptr;
		}

		T* operator->()
		{
			return _ptr;
		}

	private:
		T* _ptr;
		int* _pcount; //引用计数
	};
}

要注意,这个引用计数,不能是静态成员,因为静态成员属于这类,一旦有一个对象不指向同一个空间,就会重置,这样是不对的

这里要用指针,动态的,对每一个不同的空间都有一个指针来做引用计数

测试代码:

	//c++11的引用计数
	wzf::shared_ptr<int> sp5(new int);
	wzf::shared_ptr<int> sp6(sp5);
	wzf::shared_ptr<int> sp7;
	sp7 = sp6;
	wzf::shared_ptr<int> sp8(new int);

3. shared_ptr的线程安全问题

其实上面自己模拟实现的shared_ptr会存在线程安全问题,因为存在引用计数,对同一个空间++,–。因此一旦有多线程的操作,就会存在线程安全的问题。

比如:有两个线程同时对一个shared_ptr进行拷贝构造,那么就会对引用计数都++。一旦拷贝构造的次数多了,由于++不是原子操作,因此,一旦在++的时候出现线程切换,就可能出现两个线程++一次之后,引用计数只++了一次。这里不详细讲,忘记了就复习Linux——多线程—02

下面是一个例子:

//关于shared_ptr的线程安全问题【库里的shared_ptr肯定线程安全,这里说的是自己模拟实现的】
#include"shared_ptr.h"
#include<thread>
using namespace std;
int main()
{
	wzf::shared_ptr<int> sp(new int);
	cout << sp.use_count() << endl;
	int n = 2000;

	//若shared_ptr线程不安全【对引用计数++,--操作不是互斥的】,则下面是会出现线程安全问题的代码:
	thread t1([&](){
		for (int i = 1; i <= n; i++)
		{
			wzf::shared_ptr<int> sp1(sp); //对sp进行一次拷贝构造
		}
		});

	thread t2([&](){
		for (int i = 1; i <= n; i++)
		{
			wzf::shared_ptr<int> sp2(sp); //进行一次拷贝构造
		}
		});

	t1.join();
	t2.join();

	cout << sp.use_count() << endl;

	return 0;
}

每次的执行结果都不一定一样

出现线程安全问题的话,只会有两种可能,一个是++的时候+少了,–的时候不够-,导致析构同一个空间两次,程序崩溃,还要一种可能是–的时候-少了,导致–不到0,无法析构。

正常应该是会delete掉的

所以还得继续改造一下shared_ptr,让它变得线程安全才行

以下是改造之后线程安全的shared_ptr

namespace wzf
{
	//实现线程安全的shared_ptr
	template<class T>
	class shared_ptr
	{
	public:
		shared_ptr(T* ptr = nullptr)
			:_ptr(ptr)
			, _pcount(new int(1))
			,_pmutex(new mutex)
		{}

		//拷贝构造
		shared_ptr(const shared_ptr<T>& sp)
			:_ptr(sp._ptr)
			,_pcount(sp._pcount)
			,_pmutex(sp._pmutex)
		{
			add_ref_count(); //指向同一个空间的指针变多了。++
		}

		shared_ptr<T>& operator=(const shared_ptr<T> sp)
		{
			if (this != &sp)
			{
				if (_ptr)
				{
					// 由于要赋值,以为这this这个智能指针不能管理自己的资源了,要管理sp所指向的资源了
					//先释放掉原有的资源,然后再被赋值。
					// 但是这个释放不能简单的直接释放,要考虑引用计数
					release();
				}

				_ptr = sp._ptr;
				_pcount = sp._pcount;
                _pmutex = sp._pmutex;
				add_ref_count();
			}

			return *this;
		}

		~shared_ptr()
		{
			release();
		}

		T& operator*()
		{
			return *_ptr;
		}

		T* operator->()
		{
			return _ptr;
		}

		T* get()
		{
			return _ptr;
		}

		int use_count()
		{
			return (*_pcount);
		}

	private:
		
		void add_ref_count()
		{
			//为了保证线程安全,在对临界资源操作的时候,要给临界资源_pcount加锁
			_pmutex->lock();
			(*_pcount)++;
			_pmutex->unlock();
		}

		void release()
		{
			//给临界区加锁
			_pmutex->lock();
			bool flag = false;
			if (--(*_pcount) == 0 && _ptr)
			{
				cout << "delete:" << _ptr << endl;
				delete _ptr;
				_ptr = nullptr;

				delete _pcount; //指针也要释放
				_pcount = nullptr;

				flag = true;
			}

			_pmutex->unlock();
			//如果引用计数为0了,那么锁也要释放了
			if (flag)
			{
				delete _pmutex;
				_pmutex = nullptr;
			}
		}

		T* _ptr;
		int* _pcount; //引用计数
		mutex* _pmutex;
	};
}

此时线程安全之后,无论怎么执行上面那个多线程的代码,都是正常的

image-20250428121711429

4. shared_ptr的循环引用问题

// shared_ptr的循环引用问题
#include"shared_ptr.h"
class ListNode
{
public:
	int _val;
	wzf::shared_ptr<ListNode> _next;
	wzf::shared_ptr<ListNode> _prev;

	~ListNode()
	{
		cout << "~ListNode" << endl;

	}
};

int main()
{ 
	wzf::shared_ptr<ListNode> sp1(new ListNode);
	wzf::shared_ptr<ListNode> sp2(new ListNode);

	// shared_ptr的循环引用
	// 因为有一些时候,我们会相同智能指针来管理自定义类型,而在使用的过程中可能会出现给指针赋值的情况
	// 但是这个时候类内的指针域有可能是内置类型,无法完成赋值操作。这个时候就需要将指针域也用智能指针来管理
	// 但是这个时候就会触发循环引用的问题 
	sp1->_next = sp2;
	sp2->_prev = sp1;
	// 前面我们说了对于shared_ptr来说赋值就是将当前空间交给对方一起管理。
	// 所以sp1管理着一个ListNode,这个ListNode的_next又管理着sp2,此时sp2的引用计数为2
	// sp2管理着一个ListNode,引用计数为1,然后该ListNnode的prev又管理着sp1,sp1的引用计数为2。
	// 因此在析构的时候,就无法析构,因为只会--一次,不为0就不析构。
	// 这个循环牵制对方的现象就叫做循环引用!

	return 0;
}

image-20250428182815705

循环引用分析:

  1. node1和node2两个智能指针对象指向两个节点,引用计数变成1,我们不需要手动delete。

  2. node1的_next指向node2,node2的_prev指向node1,引用计数变成2。

  3. node1和node2析构,引用计数减到1,但是_next还指向下一个节点。但是_prev还指向上一个节点。

  4. 也就是说_next析构了,node2就释放了。

  5. 也就是说_prev析构了,node1就释放了。

  6. 但是_next属于node的成员,node1释放了,_next才会析构,而node1由_prev管理,_prev属于node2成员,所以这就叫循环引用,谁也不会释放

image-20250428184133082

这是shared_ptr的一个缺点,无法自己解决,只能通过其他方式来解决

那如何解决呢?

c++给了一个弱指针——weak_ptr

weak_ptr是专门用来解决shared_ptr的循环引用问题的,它的原理就是不引用计数,直接将shared_ptr的对象作为参数传给weak_ptr去构造,而weak_ptr不会对shared_ptr所指向的空间做引用计数,这样就不会存在循环引用的问题了

#include"shared_ptr.h"
class ListNode
{
public:
	int _val;
	//wzf::shared_ptr<ListNode> _next;
	//wzf::shared_ptr<ListNode> _prev;
	
	// 为了防止引用计数的问题,这里要用weak_ptr
	std::weak_ptr<ListNode> _next;
	std::weak_ptr<ListNode> _prev;

	~ListNode()
	{
		cout << "~ListNode" << endl;

	}
};

int main()
{ 
	//那这个问题要如何解决呢?
	//就是弱指针——weak_ptr
	std::shared_ptr<ListNode> sp1(new ListNode);
	std::shared_ptr<ListNode> sp2(new ListNode);

	sp1->_next = sp2;
	sp2->_next = sp1;

	return 0;
}

5.C++11和boost中智能指针的关系

  1. C++ 98 中产生了第一个智能指针auto_ptr.

  2. C++ boost给出了更实用的scoped_ptr和shared_ptr和weak_ptr.

  3. C++ TR1,引入了shared_ptr等。不过注意的是TR1并不是标准版。

  4. C++ 11,引入了unique_ptr和shared_ptr和weak_ptr。需要注意的是unique_ptr对应boost的scoped_ptr。并且这些智能指针的实现原理是参考boost中的实现的。

image-20250429022845458

6.定制删除器

关于定制删除器:

首先智能指针默认的释放资源的处理方式都是delete _ptr;

在一些特殊场景下,单纯的一个delete _ptr,无法满足释放资源的需求了

下面的代码例子中会有讲

// 关于智能指针的一些补充————定制删除器

class A
{
public:

	~A()
	{
		cout << "~A()\n";
	}

private:
	int _a;
};

template<class T>
struct DeleteArr
{
	void operator()(T* p)
	{
		delete[] p;
	}
};

template<class T>
struct Free
{
	void operator()(T* p)
	{
		cout << "free()" << endl;
		free(p);
	}
};

struct Fclose
{
	void operator()(FILE* p)
	{
		cout << "fclose()" << endl;
		fclose(p);
	}
};

int main()
{
	 //为什么需要对智能指针定制一个删除器呢
	 // 这是因为在一些特殊场景下,单纯的一个delete _ptr,无法满足释放资源的需求了
	 // 下面是例子:
	shared_ptr<A> sp1(new A); //这个情况下,智能指针能够释放资源
	//DeleteArr<A> d;
	//shared_ptr<A> sp2(new A[5]); //这个情况下,就是报错,因为单纯的delete _ptr无法处理这个情况
	// 不仅是上面这个,下面这两种也会
	//shared_ptr<A> sp3((A*)malloc(sizeof(A)));
	//shared_ptr<FILE> sp4(fopen("test.txt", "r"));

	// 因此我们需要定制一个删除器,以便于处理当前这个情况
	// 具体要怎么做呢,就是传一个仿函数给shared_ptr,让它用仿函数来处理常规方式无法释放的类型的资源的释放
	shared_ptr<A> sp2(new A[5], DeleteArr<A>());
	// 对sp3就要传一个专门处理malloc的仿函数
	shared_ptr<A> sp3((A*)malloc(sizeof(A)), Free<A>());
	// 对sp4要传一个专门处理FILE*的仿函数
	shared_ptr<FILE> sp4(fopen("test.txt", "w"), Fclose());

	// 下面是程序执行的结果
	//	fclose()
	//	free()
	//	~A()
	//	~A()
	//	~A()
	//	~A()
	//	~A()
	//	~A()

	return 0;
}

image-20250502174450178

这里不是重点,了解即可
;
fclose§;
}
};

int main()
{
//为什么需要对智能指针定制一个删除器呢
// 这是因为在一些特殊场景下,单纯的一个delete _ptr,无法满足释放资源的需求了
// 下面是例子:
shared_ptr sp1(new A); //这个情况下,智能指针能够释放资源
//DeleteArr d;
//shared_ptr sp2(new A[5]); //这个情况下,就是报错,因为单纯的delete _ptr无法处理这个情况
// 不仅是上面这个,下面这两种也会
//shared_ptr sp3((A*)malloc(sizeof(A)));
//shared_ptr sp4(fopen(“test.txt”, “r”));

// 因此我们需要定制一个删除器,以便于处理当前这个情况
// 具体要怎么做呢,就是传一个仿函数给shared_ptr,让它用仿函数来处理常规方式无法释放的类型的资源的释放
shared_ptr<A> sp2(new A[5], DeleteArr<A>());
// 对sp3就要传一个专门处理malloc的仿函数
shared_ptr<A> sp3((A*)malloc(sizeof(A)), Free<A>());
// 对sp4要传一个专门处理FILE*的仿函数
shared_ptr<FILE> sp4(fopen("test.txt", "w"), Fclose());

// 下面是程序执行的结果
//	fclose()
//	free()
//	~A()
//	~A()
//	~A()
//	~A()
//	~A()
//	~A()

return 0;

}


[外链图片转存中...(img-MBhVcbzm-1746854443683)]

这里不是重点,了解即可
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值