当前位置: 首页 > news >正文

【C++篇】:告别手动内存管理!——C++智能指针的快速上手指南

✨感谢您阅读本篇文章,文章内容是个人学习笔记的整理,如果哪里有误的话还请您指正噢✨
✨ 个人主页:余辉zmh–CSDN博客
✨ 文章所属专栏:c++篇–CSDN博客

在这里插入图片描述

文章目录

  • 智能指针
  • 一.为什么需要智能指针?
  • 二.智能指针的原理和使用
    • 1.RAII原理
    • 2.智能指针的使用
    • 3.智能指针的拷贝问题
  • 三.智能指针的模拟实现
    • 1.`auto_ptr`
    • 2.`unique_ptr`
    • 3.`shared_ptr`
  • 四.循环引用和`weak_ptr`
    • 1.循环引用现象
    • 2.`weak_ptr`
    • 3.模拟实现一个`weak_ptr`
  • 补充内容
    • 定制删除器

智能指针

一.为什么需要智能指针?

先来分析一下下面这段代码有没有什么内存方面的问题?

int div(){int a, b;cin >> a >> b;if (b == 0){throw invalid_argument("除0错误");}return a / b;
}void f1(){pair<string, string> *p1 = new pair<string, string>;pair<string, string> *p2 = new pair<string, string>;try{div();}catch(...){delete p1;cout << "delete: " << p1 << endl;throw;}delete p1;cout << "delete: " << p1 << endl;
}int main(){try{f1();}catch(const exception& e){cout << e.what() << endl;}return 0;
}

在这里插入图片描述

通过分析上面的代码,可以发现,这是一个典型的内存泄漏问题!

上面代码中内存泄露的具体情况:

  • 情况一:异常发生时的泄露
    • div()抛出异常;
    • 但是catch块中只释放了p1p2并没有被释放,造成了内存泄漏
  • 情况二:正常执行时的泄露
    • 即使div()不抛出异常;
    • 正常流程中也只释放了p1p2仍然没有被释放,造成了内存泄漏

正确的做法就需要我们手动管理内存了:

void f1(){pair<string, string> *p1 = new pair<string, string>;pair<string, string> *p2 = new pair<string, string>;try{div();}catch(...){delete p1;  // 释放p1delete p2;  // 必须释放p2cout << "delete: " << p1 << endl;cout << "delete: " << p2 << endl;throw;}delete p1;  // 正常情况释放p1delete p2;  // 必须释放p2cout << "delete: " << p1 << endl;cout << "delete: " << p2 << endl;
}

很显然,手动管理内存容易带来各种各样的问题,不仅仅是内存泄漏,还有代码复杂,需要在多个地方管理内存等

既然如此,我们就需要一种便捷的方式来替代手动管理内存,这就是为什么需要智能指针的原因:

智能指针可以帮助我们自动管理内存生命周期,代码简洁,防止内存管理错误等

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

1.RAII原理

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

基本思想

  • 在对象构造时获取资源:在构造函数中获取资源,控制对资源的访问使之在对象的生命周期内始终保持有效;
  • 在对象析构时释放资源:当作用域结束时,自动调用析构函数,在析构函数中释放资源;
  • 实际上是把管理一份资源的责任托管给了一个对象,利用对象的生命周期来自动管理资源!!!

这种做法有两种好处

  • 不需要显示的释放资源;
  • 采用这种方式,对象所需的资源在其生命周期内始终保持有效;

智能指针的原理就是RAII思想,是RAII的典型应用

使用RAII思想设计一个SmartPtr

template <class T>
class SmartPtr{
public:// 构造函数获取资源SmartPtr(T *ptr = nullptr): _ptr(ptr){}// 析构函数释放资源~SmartPtr(){if (_ptr){std::cout << "delete: " << _ptr << std::endl;delete _ptr;}}private:T *_ptr;
};

还是用上面的那份测试代码,这不过这次用我们自己写好的SmartPtr类来管理资源:

int div(){int a, b;cin >> a >> b;if (b == 0){throw invalid_argument("除0错误");}return a / b;
}void f2(){SmartPtr<pair<string, string>> p1(new pair<string, string>("aaa", "111"));SmartPtr<pair<string, string>> p2(new pair<string, string>("bbb", "222"));div();
}int main(){try{f2();}catch(const exception& e){cout << e.what() << endl;}return 0;
}

在这里插入图片描述

这次使用SmartPtr类来管理p1p2,即使我们没有显示的释放资源,最后在进程结束时,也会自动释放掉,这样就不会造成内存泄露的问题了,还能简化代码。

2.智能指针的使用

上面的SmartPtr类还不能称其为智能指针,因为他还不具备指针的行为因为指针可以解引用,也可以通过->去访问所指空间中的内容,因此还需要在SmartPtr类中增加*->运算符重载,才可以像指针一样使用。

operator*()——解引用运算符

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

作用

  • 返回指针指向的对象的引用
  • 访问的是指针指向的对象本身

operator->()——箭头运算符

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

作用

  • 返回指针本身
  • 访问的是对象的成员函数或成员变量

测试代码

int main(){SmartPtr<pair<string, string>> p1(new pair<string, string>("aaa", "111"));SmartPtr<pair<string, string>> p2(new pair<string, string>("bbb", "222"));cout << ((*p1).first) << " " << ((*p1).second) << endl;// 实际是p1.operator*().firstcout << (p2->first) << " " << (p2->second) << endl;// 实际是p2.operator->()->firstreturn 0;
}

在这里插入图片描述

注意

使用*解引用后,返回的是一个pair对象,所以需要用.继续访问成员变量;

而使用->后,返回的是一个pair对象的指针,所以需要用->继续访问成员变量,但是为了可读性一般都是省略第二个->,编译器会再自动调用,不需要真的写两个->

3.智能指针的拷贝问题

先来看一个测试(继续用上面写好的SmartPtr类):

int main(){SmartPtr<pair<string, string>> p1(new pair<string, string>("aaa", "111"));SmartPtr<pair<string, string>> p2(new pair<string, string>("bbb", "222"));SmartPtr<pair<string, string>> p3(p1);return 0;
}

在这里插入图片描述

很明显当我们尝试用一个对象去拷贝构造一个新对象时,发生了错误,这是一个典型的浅拷贝导致的双重释放问题;

问题所在

  • 首先p1指向内存空间0x5598ec3dc2b0
  • 因为SmartPtr类中没有定义拷贝构造函数,所以编译器会自动生成一个默认的拷贝构造函数,并进行浅拷贝
  • 所以用p1拷贝构造p3时,发生浅拷贝,使p1p3指向了同一个内存地址;
  • 当程序结束时,p3析构,释放内存0x5598ec3dc2b0p1析构,再次释放同一个内存地址,导致双重释放;

这个问题其实有好几种解决方案,并且也可以算得上是智能指针的发展历程了。

这里直接展示库里的几个智能指针:

1.C++98中:auto_ptr(已废弃)

auto_ptr<string> a1(new string("hello"));
auto_ptr<string> a2(a1);        // 移交管理权,a1已经置为nullptr
// cout << a1->size() << endl;    // 错误使用

解决方式:通过移交管理权导致原指针失效(原指针置为空),弊端也很明显,容易误用原指针,造成悬垂指针(所以现在很少用,基本废弃了);

2.C++11中:unique_ptr(独占所有权)

unique_ptr<string> u1(new string("hello"));
//unique_ptr<string> u2(u1);unique_ptr<string> u3(move(u1));   // 移动构造 移动后u1置为nullptr
cout << (*u3) << endl;  // 输出hello

解决方式:明确禁止拷贝,防止误用;强制使用移动语义(可以移动右值),语义更清晰;并且是在编译时检查,更加安全;

在这里插入图片描述

3.C++11中:shared_ptr(共享所有权)

shared_ptr<string> s1(new string("hello"));
shared_ptr<string> s2(s1);
cout << (*s1) << " " << s1 << endl;
cout << (*s2) << " " << s2 << endl;

解决方式:允许多个对象共享同一分资源,通过引用计算管理,最后一个对象销毁时,自动释放资源;

在这里插入图片描述

上面三种智能指针从auto_ptr的尝试解决拷贝问题,但语义不清晰;到unique_ptr的明确禁止,强制移动语义;再到shared_ptr允许共享等,智能指针的设计也从简单到复杂,所以也算得上是智能指针的发展历程了。

三.智能指针的模拟实现

1.auto_ptr

重点是实现拷贝构造和拷贝赋值,像构造,析构,*->重载其实和上面的SmartPtr类差不多

namespace MySmartPtr{template<typename T>class auto_ptr{public:auto_ptr(T* ptr = nullptr):_ptr(ptr){}~auto_ptr(){if(_ptr){std::cout << "delete: " << _ptr << std::endl;delete _ptr;}}// 拷贝构造auto_ptr(auto_ptr<T>& a):_ptr(a._ptr)            // 移交管理权{a._ptr = nullptr;    // 原对象置为空}// 拷贝赋值auto_ptr<T>& operator=(auto_ptr<T>& a){if(_ptr != a._ptr){_ptr = a._ptr;        // 移交管理权a._ptr = nullptr;     // 原对象置为空}return *this;}T& operator*(){return *_ptr;}T* operator->(){return _ptr;}T* GetPtr() const {return _ptr;}private:T *_ptr;};
}

测试代码

int main(){MySmartPtr::auto_ptr<string> a1(new string("hello"));cout << (*a1) << " " << (a1.GetPtr()) << endl;  //打印a1的资源以及资源地址MySmartPtr::auto_ptr<string> a2(a1);cout << (*a2) << " " << (a2.GetPtr()) << endl;  // 打印移交后的a2的资源以及资源地址return 0;
}

在这里插入图片描述

2.unique_ptr

unique_ptr因为明确禁止拷贝构造和拷贝赋值,所以对于这两个函数可以直接删除;主要是实现移动语义(移动构造和移动赋值),实现思路也很简单,交换资源,然后再将被移动对象置为空即可,但是有一点,在移动赋值这里需要先检查一下自赋值;

namespace MySmartPtr{template<typename T>class unique_ptr{public:unique_ptr(T* ptr = nullptr):_ptr(ptr){}~unique_ptr(){if(_ptr){std::cout << "delete: " << _ptr << std::endl;delete _ptr;}}unique_ptr(unique_ptr<T> &u) = delete;                     // 删除拷贝构造unique_ptr<T> &operator=(unique_ptr<T> &u) = delete;       // 删除拷贝赋值// 移动构造unique_ptr(unique_ptr<T> &&u):_ptr(u._ptr)                 {u._ptr = nullptr;}// 移动赋值unique_ptr<T> &operator=(unique_ptr<T> &&u){// 自赋值检查if (_ptr != u._ptr){_ptr = u._ptr;u._ptr = nullptr;}return *this;}T& operator*(){return *_ptr;}T* operator->(){return _ptr;}T* GetPtr() const {return _ptr;}private:T *_ptr;};
}

测试代码

int main(){MySmartPtr::unique_ptr<string> u1(new string("hello"));cout << (*u1) << " " << (u1.GetPtr()) << endl;MySmartPtr::unique_ptr<string> u2(move(u1));    // 移动拷贝cout << (*u2) << " " << (u2.GetPtr()) << endl;MySmartPtr::unique_ptr<string> u3(new string);u3 = move(u2);                                  // 移动赋值cout << (*u3) << " " << (u3.GetPtr()) << endl;u3 = move(u3);                                  // 自赋值cout << (*u3) << " " << (u3.GetPtr()) << endl;return 0;
}

在这里插入图片描述

3.shared_ptr

shared_ptr的原理:通过引用计数的方式来实现多个shared_ptr对象之间的共享资源;

  • shared_ptr在其内部,给每个资源都维护了一份计数器,用来记录该资源被几个对象共享
  • 对象被销毁时(也就是调用析构函数时),就说明自己已经不再使用该资源了,对象的引用计数减一
  • 如果减完后引用计数变为0,就说明自己是最后一个使用该资源的对象,必须释放该资源
  • 如果减完后引用计数不为0,就说明除了自己以外还有其他的对象在使用该份资源,不能释放资源,否则其他对象就变成野指针了;
namespace MySmartPtr{template<typename T>class shared_ptr{public:shared_ptr(T* ptr = nullptr):_ptr(ptr),_count(new int(1)){}~shared_ptr(){std::cout << "~shared_ptr()" << std::endl;// 析构时,先将计数器减一,然后再判断是否需要释放if (--(*_count) == 0){std::cout << "delete: " << _ptr << std::endl;delete _ptr;delete _count;}}// 拷贝构造shared_ptr(const shared_ptr<T> &s):_ptr(s._ptr),_count(s._count){++(*_count); //同一个动态计数器,当计数器发生改变,所有共享这份资源的对象都能看到}// 拷贝赋值shared_ptr<T> &operator=(const shared_ptr<T> &s){if (_ptr != s._ptr){         if (--(*_count) == 0){   delete _ptr;delete _count;}_ptr = s._ptr;_count = s._count;++(*_count);}return *this;}T& operator*(){return *_ptr;}T* operator->(){return _ptr;}T* GetPtr() const {return _ptr;}int& use_count() const {return *_count;}public:T *_ptr;int *_count;    // 指针形式的计数器};
}

几个细节点:

1.为什么要用指针形式的计数器

关键点

  • int* _count 指向堆上的一个int值

  • 所有shared_ptr对象都指向同一个计数器

  • 当计数器变化时,所有对象都能看到

2.拷贝赋值时的关键点

自赋值检查

  • 检查当前对象和赋值对象是否指向同一个资源

  • 避免自赋值时的资源释放问题

  • 这是关键的安全检查

引用计数管理

  • 先将当前对象的引用计数减1

  • 如果减到0,说明没有其他对象共享这个资源,需要释放

  • 释放包括:原始指针_ptr和计数器_count

资源转移

  • 将新资源的指针和计数器赋值给当前对象

  • 增加新资源的引用计数

测试代码

int main(){MySmartPtr::shared_ptr<string> s1(new string("hello"));cout << (*(s1.GetPtr())) << " " << (s1.GetPtr()) << " " << (s1.use_count()) << endl;MySmartPtr::shared_ptr<string> s2(s1);                                          // 拷贝构造cout << (*(s2.GetPtr())) << " " << (s2.GetPtr()) << " " << (s2.use_count()) << endl;MySmartPtr::shared_ptr<string> s3(new string);s3 = s2;                                                                        // 拷贝赋值cout << (*(s3.GetPtr())) << " " << (s3.GetPtr()) << " " << (s3.use_count()) << endl;s3 = s3;                                                                        // 自赋值cout << (*(s3.GetPtr())) << " " << (s3.GetPtr()) << " " << (s3.use_count()) << endl;return 0;
}

在这里插入图片描述

四.循环引用和weak_ptr

1.循环引用现象

通过一份测试代码来看一个现象:

struct ListNode{ListNode(){}~ListNode(){cout << "~ListNode()" << endl;}int _data;shared_ptr<ListNode> _next;shared_ptr<ListNode> _prev;
};int main(){shared_ptr<ListNode> node1(new ListNode);shared_ptr<ListNode> node2(new ListNode);cout << node1.use_count() << endl;cout << node2.use_count() << endl;// node1->_next = node2;// node2->_prev = node1;cout << node1.use_count() << endl;cout << node2.use_count() << endl;return 0;
}

先把中间那两句注释掉看一下结果:

在这里插入图片描述

最后两个节点都会正常释放掉,但是如果中间两句取消注释后,结果就是:

在这里插入图片描述

这一次两个节点的引用计数都变为2,并且最后没有自动释放掉;

这就是shared_ptr中的一个特殊场景,循环引用问题。

循环引用分析

  • node1node2两个智能指针对象指向两个节点,引用计数变成1;
  • node1_next指向node2node2_prev指向node1,引用计数变成2;
  • node1node2析构时,引用计数减一变成1,此时node1_next还指向node2node2_prev还指向node1
  • 也就是说_next管着node2_prev管着node1
  • 那什么时候_prev析构?——node2析构时,_prev就会析构;
  • 那什么时候node2析构呢?——_next析构,node2就会析构;
  • 那什么时候_next析构呢?——node1析构,_next就会析构;
  • 那什么时候node1析构呢?——_prev析构,node1就会析构;
  • 由于在进程结束时引用计数不为 0,所以两个节点都不会被销毁,造成内存泄漏
  • 上面这种情况就是循环引用,谁也不会释放;

解决方式就是使用weak_ptr来设置节点的_next_prev

2.weak_ptr

weak_ptr的本质

weak_ptrshared_ptr弱引用,它有以下几个关键特性:

  • 不增加引用计数weak_ptr指向一个对象时,不会增加该对象的引用计数;
  • 不用有对象weak_ptr不拥有对象的所有权,只是观察者
  • 可以检测对象是否还存在:通过expired()方法检查对象是否已经被销毁;

weak_ptr解决循环引用

_prev_next改成weak_ptr

struct ListNode{ListNode(){}~ListNode(){cout << "~ListNode()" << endl;}int _data;weak_ptr<ListNode> _next;weak_ptr<ListNode> _prev;
};

现在的情况是:

  • node1_next指向node2时,node2的引用计数不变
  • node2_prev指向node1时,node1的引用计数不变
  • node1node2的引用计数始终保持1不变;
  • 当函数结束时,node1node2调用析构,引用计数减一变为0,此时两个节点被释放;

在这里插入图片描述

weak_ptr 解决循环引用的核心原理

  • 打破引用计数的循环:weak_ptr 不增加引用计数,所以不会形成循环

  • 提供安全的观察机制:可以检测对象是否还存在,避免访问已销毁的对象

  • 保持对象生命周期管理:只有 shared_ptr 才真正拥有对象,weak_ptr 只是观察者

3.模拟实现一个weak_ptr

namespace MySmartPtr{template<typename T>class weak_ptr{public:weak_ptr():_ptr(nullptr){}weak_ptr(const shared_ptr<T> &s):_ptr(s.GetPtr()){}// shared_ptr -> weak_ptr 的拷贝构造weak_ptr<T> &operator=(const shared_ptr<T> &s){_ptr = s.GetPtr();return *this;}// weak_ptr -> weak_ptr 的拷贝构造weak_ptr<T> &operator=(const weak_ptr<T> &w){_ptr = w._ptr;return *this;}~weak_ptr(){// weak_ptr 不负责删除对象,只是观察者}T& operator*(){return *_ptr;}T* operator->(){return _ptr;}private:T *_ptr;};
}

weak_ptr 的设计原则

  • 不增加引用计数weak_ptr 不会影响对象的生命周期

  • 不删除对象:只有 shared_ptr 负责删除对象

  • 只是观察者weak_ptr 只是观察对象是否存在,不拥有对象

测试代码

struct ListNode{ListNode(){}~ListNode(){cout << "~ListNode()" << endl;}int _data;MySmartPtr::weak_ptr<ListNode> _next;MySmartPtr::weak_ptr<ListNode> _prev;
};int main(){MySmartPtr::shared_ptr<ListNode> node1(new ListNode);MySmartPtr::shared_ptr<ListNode> node2(new ListNode);cout << node1.use_count() << endl;cout << node2.use_count() << endl;node1->_next = node2;node2->_prev = node1;cout << node1.use_count() << endl;cout << node2.use_count() << endl;return 0;
}

在这里插入图片描述

weak_ptr 除了可以解决循环引用外,还可以用来安全观察对象是否存在,避免悬挂指针等各种引用场景,是现代C++中非常重要的一个智能指针!

补充内容

定制删除器

1.什么是定制删除器?

定制删除器就是自定义智能指针销毁对象时调用的函数,而不是默认的delete

2.为什么需要定制删除器?

因为不是所有资源都可以像堆内存一样用delete销毁;

比如下面这些资源

// 文件句柄 需要使用fclose()
FILE* file = fopen("test.txt", "r");// 数组 需要使用delete[]
int* array = new int[100];// C风格内存  需要使用free()
void* men = malloc(1024);// 除此之外,还有网络连接,数据库连接等各种资源

正是因为定制删除器的存在,可以让智能指针管理任何类型的资源,而不单单是堆内存

3.常用的实现方式

在这里插入图片描述

在标准库中,其中一个构造函数可以使用定制删除器,通过第二个模板参数D del(可调用对象)来传递删除器;

  • 函数指针
void file_deleter(FILE* fp) {if (fp) fclose(fp);
}shared_ptr<FILE> file_ptr(fopen("test.txt", "r"), file_deleter);
  • Lambda表达式
shared_ptr<FILE> file_ptr(fopen("test.txt", "r"), [](FILE* fp) { if (fp) fclose(fp); });
  • 仿函数(函数对象)
struct ArrayDeleter {void operator()(int* p) {delete[] p;}
};shared_ptr<int> array_ptr(new int[100], ArrayDeleter());
  • 标准库提供的删除器
// unique_ptr 有专门的数组版本
unique_ptr<int[]> array_ptr(new int[100]);  // 自动用 delete[]// shared_ptr 需要手动指定
shared_ptr<int> array_ptr(new int[100], default_delete<int[]>());

4.给自己写的shared_ptr实现定制删除器功能

设计思路

shared_ptr类中添加一个成员变量:由包装器包装的可调用对象_del

std::function<void(T *)> _del = [](T *ptr){if(ptr){delete ptr;} 
};
  • 将所有的删除器都包装成std::function
  • 支持函数指针,lambda表达式,函数对象等各种可调用对象;
  • 同时利用lambda表达式提供默认的delete行为;

增加一个新的构造函数

template<typename D>
shared_ptr(T *ptr, D del) 
: _ptr(ptr)
, _count(new int(1))
, _del(del) 
{}
  • 通过模板参数D来自动推导删除器类型;
  • 没有自定义删除器时,使用默认的delete
  • 编译时类型检查,避免运行时错误;

同时更改析构函数中的delete行为

~shared_ptr(){std::cout << "~shared_ptr()" << std::endl;if (--(*_count) == 0){std::cout << "delete: " << _ptr << std::endl;// delete _ptr;_del(_ptr);delete _count;}
}

以及拷贝构造和拷贝赋值中增加删除器的拷贝

// 拷贝构造
shared_ptr(const shared_ptr<T> &s)
:_ptr(s._ptr)
,_count(s._count)
,_del(s._del)        // 拷贝删除器
{++(*_count); 
}// 拷贝赋值
shared_ptr<T> &operator=(const shared_ptr<T> &s){if (_ptr != s._ptr){         if (--(*_count) == 0){// delete _ptr;_del(_ptr);delete _count;}_ptr = s._ptr;_count = s._count;_del = s._del;  // 拷贝删除器++(*_count);}return *this;
}

最后用一段代码来进行测试:

struct ArrayDelete{void operator()(int *p){if(p){cout << "array_delete" << endl;delete[] p;}}
};
int main()
{MySmartPtr::shared_ptr<int> array_ptr(new int[100], ArrayDelete());MySmartPtr::shared_ptr<string> s1(new string("hello"));MySmartPtr::shared_ptr<string> s2(s1);return 0;
}

在这里插入图片描述

以上就是关于C++智能指针的讲解,如果哪里有错的话,可以在评论区指正,也欢迎大家一起讨论学习,如果对你的学习有帮助的话,点点赞关注支持一下吧!!!

http://www.xdnf.cn/news/1121671.html

相关文章:

  • 宝塔面板常见问题
  • 驱动开发系列60- Vulkan 驱动实现-SPIRV到HW指令的实现过程(1)
  • 开疆智能EtherCAT转CANopen网关连接磁导航传感器配置案例
  • 空间智能-李飞飞团队工作总结(至2025.07)
  • spark广播表大小超过Spark默认的8GB限制
  • redis面试高频问题汇总(一)
  • wpf 实现窗口点击关闭按钮时 ​​隐藏​​ 而不是真正关闭,并且只有当 ​​父窗口关闭时才真正退出​​ 、父子窗口顺序控制与资源安全释放​
  • NAT原理与实验指南:网络地址转换技术解析与实践
  • ubuntu之坑(十五)——设备树
  • 【论文阅读】Thinkless: LLM Learns When to Think
  • .net天擎分钟降水数据统计
  • 【飞牛云fnOS】告别数据孤岛:飞牛云fnOS私人资料管家
  • React 第六十九节 Router中renderMatches的使用详解及注意事项
  • JMeter 连接与配置 ClickHouse 数据库
  • Mysql用户管理及在windows下安装Mysql5.7(压缩包方式)远程连接云服务器(linux)上的Mysql数据库
  • 【一维 前缀和+差分】
  • ether.js—6—contractFactory以部署ERC20代币标准为例子
  • CSS手写题
  • 详解彩信 SMIL规范
  • Leaflet面试题及答案(81-100)
  • 代码随想录day34dp2
  • ARMv8.1原子操作指令(ll_sc/lse)
  • 苍穹外卖学习指南(java的一个项目)(老师能运行,但你不行,看这里!!)
  • python的微竞网咖管理系统
  • UI前端与数字孪生结合实践探索:智慧物流的仓储自动化管理系统
  • Java文件操作
  • Reactor 模式详解
  • 【Echarts】 电影票房汇总实时数据横向柱状图比图
  • ubuntu 22.04 anaconda comfyui安装
  • libimagequant windows 编译