C++智能指针万字详细讲解(包含智能指针的模拟实现)
在笔试,面试中智能指针经常出现,如果你对智能指针的作用,原理,用法不了解,那么可以看看这篇博客讲解,此外本博客还简单模拟实现了各种指针,在本篇的最后还应对面试题对智能指针的知识点进行了拓展。希望能加深你对智能指针的理解。那么开始学习吧!
一.智能指针作用
C++的智能指针主要作用是为了防止内存泄漏。在代码中我们new出来的对象都需要delete,但是当我们我们忘记或者代码出现异常导致没有delete对象,就会产生内存泄漏。长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死。
下面我们看看一个常见的因为异常导致内存泄漏的例子:
#include<iostream>
using namespace std;
static int sa = 1;
int div_func(int a, int b)
{
if (b == 0)
{
throw invalid_argument("除0错误");
}
return a / b;
}
int Func1()
{
int* a1 = new int{1};
int* a2 = new int{sa};
int n=div_func(*a1,*a2);
sa--;
delete a1;
delete a2;
return n;
}
int main()
{
try
{
while (1)
{
int n = Func1();
cout << n;
}
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
运行结果:
main函数调用func1函数,func中new出了a1,和a2,然后调用div_func,当b=0,此时就会抛出异常,异常被mian函数捕获直接跳转,此时new出来的a1和a2就不会被delete,导致内存泄漏。这种代码的内存泄漏,还是比较难防备,此时就需要使用智能指针。
二.智能指针原理
我们首先介绍一下什么是RAII。
RAII(Resource Acquisition Is Initialization)(资源获取即初始化)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。
这种做法有两大好处:
- 1.不需要显式地释放资源。
- 2.对象所需的资源在其生命期内始终保持有效。
智能指针也就是利用RAII的原理实现的,把管理一份资源的责任托管给了一个对象,通过构造函数获取资源,通过析构函数释放资源,看代码:
template<class T>
class SmartPtr {
public:
SmartPtr(T* ptr = nullptr)
:
_ptr(ptr)
{}
~SmartPtr()
{
if (_ptr)
delete _ptr;
}
private:
T* _ptr;
};
这样我们就能通过类的生命周期来对资源进行管理和释放。对于最开始的代码我们只需要把 int* a1 = new int{1};int* a2 = new int{sa}代码写成SmartPtr<int > sp1=new{1};SmartPtr<int >sp2=new{sa},这样即使因为抛异常跳转到mian()函数也会因为生命周期的结束自动释放资源。上面的代码就是智能指针的基本原理。
三.智能指针介绍和使用
C++常见的智能指针有三种,这里我们只做基本介绍和使用,详细特点我们后面实现再介绍。
1.std::unique_ptr
特点:独占资源所有权,不可复制(不能进行拷贝构造和赋值运算符重载)但支持移动语义,生命周期结束时自动释放资源,保证只有一个对象只有一个unique_ptr指针,避免重复析构。
class A
{
public:
int a;
A(int n)
{
a = n;
}
~A()
{
std::cout << "调用析构" << std::endl;
}
};
int main()
{
std::unique_ptr<A> ptr = std::make_unique<A>(1);//c++高版本写法。
std::unique_ptr<A> ptr( new A(1));//第二种写法
//std::unique_ptr<A> ptr1=ptr;//禁止了拷贝构造会报错
std::unique_ptr<A> ptr2 = std::move(ptr); // 所有权转移
}
2.std::shared_ptr
特点:共享资源所有权,通过引用计数管理生命周期,线程安全的引用计数更新。允许复制。
每复制一个shared_ptr,计数+1,析构一个计数-1,计数为零才调用析构。
class A
{
public:
int a;
A(int n)
{
a = n;
}
~A()
{
std::cout << "调用析构" << std::endl;
}
};
int main()
{
std::shared_ptr<A> ptr = std::make_shared<A>(1);
std::shared_ptr<A> ptr1 = ptr;
}
3.std::weak_ptr
- 特点:弱引用,不影响
shared_ptr
的引用计数,需通过lock()
提升为shared_ptr
访问资源(后面详细讲解)。
四.简单模拟实现std::auto_ptr
auto_ptr主要是在早期版本的C++使用,现在基本不会使用,特点是转移管理权,即当指针复制时,让新指针指向旧指针,再将旧指针指向空,我们主要做个了解。
namespace bit
{
template<class T>
class auto_ptr
{
public:
auto_ptr(T* ptr = nullptr)
:_ptr(ptr)
{
}
//移交管理权
auto_ptr(auto_ptr<T>& ap)
{
_ptr = ap.get();
ap._ptr = nullptr;
}
//释放原来的,接收管理权
auto_ptr<T>& operator=(auto_ptr<T>& ap)
{
if (this != &ap)
{
// 释放当前对象中资源
if (_ptr)
delete _ptr;
}
_ptr=ap.get();
ap.ptr=null;
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
~auto_ptr()
{
delete _ptr;
}
T* get()
{
return _ptr;
}
private:
T* _ptr;
};
}
class Date
{
public:
Date(int year,int month,int day)
{
_year = year;
_month = month;
_day = day;
cout << "调用构造函数" << endl;
}
~Date()
{
cout << "调用析构" << endl;
}
int _year;
int _month;
int _day;
};
int main()
{
bit::auto_ptr<int> ap1 = new int{ 1 };
bit::auto_ptr<Date> ap2 = new Date{ 1,1,1 };
cout << *ap1 << endl;
cout << ap2->_year << endl;
bit::auto_ptr<Date> ap3 = ap2;
}
运行结果:
最后一行时的监视窗口:
auto_ptr作为智能指针,当调用拷贝构造或赋值运算符重载,不允许多个智能指针指向同一个对象,而是将一个智能指针的资源管理权移交给另外一个智能指针,这种做法是不太好的,这意味着,赋值后的原auto_ptr对象将不再拥有指针的所有权,其内部指针会被置为NULL。这种行为可能导致一些潜在的错误,因为程序员可能期望原对象仍然拥有指针的所有权。很多公司明确要求不能使用auto_ptr。
五.简单模拟实现std::unique_ptr
unique_ptr的实现原理:简单粗暴的防拷贝,下面简化模拟实现了一份UniquePtr来了解它的原理。
template<class T>
class unique_ptr
{
public:
unique_ptr(T* ptr)
:_ptr(ptr)
{
}
unique_ptr (unique_ptr& up) = delete;
unique_ptr<T>& operator=(unique_ptr& up) = delete;
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
T* get()
{
return _ptr;
}
private:
T* _ptr;
};
通过删除拷贝构造函数和赋值运算符重载来确保指向该资源的只有该智能指针。也就是说是一个资源只能有一个智能指针。
六.简单模拟实现std::shared_ptr
上面的俩种指针之所以只能做到一个资源只能有一个智能指针,是因为没有解决多个智能指针指向一份资源从而导致重复析构的问题而shared_ptr可以解决这个问题。
shared_ptr的原理:是通过引用计数的方式来实现多个shared_ptr对象之间共享资源。所有智能指针都指向同一个内存和引用计数。
当有智能指针指向内存资源时,同时让共享的引用计数++,当智能指针析构时,只是让引用计数--,只有当引用计数为0时再调用指向资源的析构函数。此外为了多线程访问,对计数需要加锁保护。
具体看代码:
namespace bit
{
template<class T>
class shared_ptr
{
public:
shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
, _pRefCount(new int(1))
, _pmtx(new mutex)
{}
shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr)
, _pRefCount(sp._pRefCount)
, _pmtx(sp._pmtx)
{
AddRef();
}
void Release()
{
_pmtx->lock();
bool flag = false;
if (--(*_pRefCount) == 0 && _ptr)
{
cout << "delete:" << _ptr << endl;
delete _ptr;
delete _pRefCount;
flag = true;
}
_pmtx->unlock();
if (flag == true)
{
delete _pmtx;
}
}
void AddRef()
{
_pmtx->lock();
++(*_pRefCount);
_pmtx->unlock();
}
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
//if (this != &sp)
if (_ptr != sp._ptr)
{
Release();
_ptr = sp._ptr;
_pRefCount = sp._pRefCount;
_pmtx = sp._pmtx;
AddRef();
}
return *this;
}
int use_count()
{
return *_pRefCount;
}
~shared_ptr()
{
Release();
}
// 像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
T* get() const
{
return _ptr;
}
private:
T* _ptr;
int* _pRefCount;
mutex* _pmtx;
};
必须注意的是多线程的话,因为所有指针共享一个引用计数,对引用计数必须加锁访问,这里我们只做简单模拟。
七.weak_ptr的模拟实现
weak_ptr有2个作用:
- 打破循环引用:通过将类成员声明为
weak_ptr
,避免shared_ptr
的循环引用导致内存泄漏- 安全访问资源:通过
lock()
方法原子性地获取shared_ptr
,若对象已释放则返回空指针,避免悬垂指针
下面我们引入第一个作用: 打破循环引用
在shard_ptr中看似很安全,但是可能会出现循环引用的问题,下面让我们看看
class B;
class A {
public:
std::shared_ptr<B> b_ptr; // 强引用
int a;
~A() { std::cout << "A destroyed\n"; }
};
class B {
public:
std::shared_ptr<A> a_ptr; // 强引用
int b;
~B() { std::cout << "B destroyed\n"; }
};
int main(){
auto ptr_A = std::make_shared<A>();//c++新版本创建智能指针的新方法
auto ptr_B = std::make_shared<B>();
ptr_A->b_ptr = ptr_B; //ptr_A的引用计数++;
ptr_B->a_ptr = ptr_A; //ptr_B的引用计数++ 循环引用,引用计数均为2B
std::cout << "运行完毕" << std::endl;
} // mian结束,智能指针ptr_A,ptr_B引用计数只能减为1,对象未销毁
运行结果:
可以看到我们并未成功调用对象A和B的析构函数,造成了内存泄漏。我们来分析一下原因。
首先ptr_A指向A对象(假设为a),ptr_A的计数为1,ptr_B指向B对象(假设为b),ptr_B的计数也为1,然后 ptr_A->b_ptr = ptr_B; ptr_B->a_ptr = ptr_A; 此时ptr_A和ptr_B的计数增加为2.
我们来画图理解。
这里我们用控制块A和控制B代表指向A 和B的计数 ,当程序运行结束ptr_A,ptr_B调用析构时,计数都减少1,如下:
此时指向A和B对象的计数都为1,无法自动调用析构,造成内存泄漏(new 出来的对象也是不会自动调用析构的)。
我们要知道shared_ptr智能指针计数为0时才能调用指向对象的析构函数。
为了解决上面的问题,我们创键了weak_ptr.
weak_ptr一般和shard_ptr搭配使用,weak_ptr可以接受shard_prt类型的指针,但是不会影响计数。也就是说weak_ptr的构造和析构都不会增加和减少计数,同时weak_ptr也不会计数为0也不会调用指向对象的析构函数,只是充当指向作用。
我们使用weak_ptr对上面的代码进行修改。
class B;
class A {
public:
std::weak_ptr<B> b_ptr; // 强引用
int a;
~A() { std::cout << "A destroyed\n"; }
};
class B {
public:
std::shared_ptr<A> a_ptr; // 强引用
int b;
~B() { std::cout << "B destroyed\n"; }
};
int main()
{
auto ptr_A = std::make_shared<A>();
auto ptr_B = std::make_shared<B>();
ptr_A->b_ptr = ptr_B;
ptr_B->a_ptr = ptr_A;
std::cout << "运行完毕";
}
运行结果如下:
我们将对象A的智能指针替换为weak_ptr,其他不变。再来分析析构过程
首先ptr_A指向A对象,ptr_B指向B对象,计数都为1。ptr_A->b_ptr = ptr_B,此时A中的是weak_ptr<B>指针,不会增加ptr_B的计数,而 ptr_B->a_ptr = ptr_A,会增加ptr_A的计数(对哪个指针进行拷贝就是增加哪个指针的计数)。ptr_A计数为2,ptr_B计数为1。此时情况如图:
当main结束时ptr_A调用自己的析构函数计数减少为1, 同时ptr_B自动调用自己的析构函数,计数减少为0。
由于ptr_B计数为0需要调用b的析构函数,调用b的析构函数释放资源后要调用成员变量a_ptr的析构函数(析构函数的顺序是先调用自己的,再调用成员变量的),此时控制块A计数为1(a_ptr是拷贝ptr_A的,俩者计数相同),减一后为0,计数为零需要调用a的析构函数,a的成员变量b_ptr计数为0,A可以直接析构,到此对象全部成功析构。图示如下:
因此析构函数的调用顺序是先B后A,但是B对象是后于A对象被销毁的。
上面这么多就是为了论证weak_ptr的一个作用:打破循环引用,避免内存泄漏。
对于weak_ptr的第二个作用就好理解多了。
- 安全访问资源:通过
lock()
方法原子性地获取shared_ptr
,若对象已释放则返回空指针,避免悬垂指针。
std::weak_ptr<int> wp;
if (auto sp = wp.lock()) { // 检查对象是否存在
// 安全使用 sp
}
wp.lock()是看计数是否为0,为0返回空,不为零返回一个shared_ptr(计数也会++)。
以上这些讲讲的都是weak_ptr的作用和原理,下面我们给出weak_ptr的模拟实现。
template <typename T>
class WeakPtr {
public:
// 默认构造函数(空指针)
WeakPtr() : ptr_(nullptr), ref_count_(nullptr) {}
// 从 SharedPtr 构造
template <typename U>
WeakPtr(const SharedPtr<U>& shared)
: ptr_(shared.ptr), ref_count_(shared.ref_count) {
}
// 拷贝构造函数
WeakPtr(const WeakPtr& other)
: ptr_(other.ptr_), ref_count_(other.ref_count) {
}
shared_ptr<T> lock() const {
if (*ptr<=0) return shared_ptr<T>();
return shared_ptr<T>(ptr, ref_count_);
}
// 析构函数
~WeakPtr() {
}
T& operator*()
{
return *ptr;
}
T* operator->()
{
return ptr;
}
T* get() const
{
return ptr;
}
private:
T* ptr; // 指向对象的指针
int* ref_count_; // 指向控制块的指针
// 允许 SharedPtr 访问私有成员
template <typename U>
friend class SharedPtr;
};
要注意的这里只是简单实现,并不详细。
八.面试题拓展
上面的讲解基本就能解决绝大多数的面试题了,但是面试的知识点也越来越细了,因此我们再根据常见的面试题进行拓展。
weak_ptr真的不计数?是否有计数方式?在哪分配的空间?
对于1,2小问,这里我们需要介绍一下share_ptr和weak_ptr中控制块的概念。
控制块是智能指针实现引用计数机制的核心数据结构,包含以下信息
- 强引用计数(
use_count
):记录当前有多少个shared_ptr
持有对象。 - 弱引用计数(
weak_count
):记录当前有多少个weak_ptr
观察对象。 - 对象指针:指向实际管理的对象(可能为空,若对象已被销毁)。
- 自定义删除器(可选)。
也就是说shared_ptr和weak_ptr中有2个强弱俩个计数,其中强引用计数作用就是当计数为0时调用指向对象的析构函数,但控制块仍存在,弱引用作用是当弱引用归零时控制块本身被释放。
那么控制块的作用是什么:
1. 支持
weak_ptr
的安全操作
- 感知对象状态:即使对象已被销毁(强引用归零),
weak_ptr
仍需通过控制块判断对象是否有效(如lock()要通过控制块判断
);- 避免悬空控制块:若控制块随对象一起释放,
weak_ptr
将无法判断对象是否存在,导致未定义行为2. 避免控制块内存泄漏
- 生命周期分离:控制块的存活由弱引用计数决定。即使对象已销毁,只要存在
weak_ptr
观察,控制块就必须保留以记录弱引用信息- 最终释放机制:当所有
weak_ptr
销毁(弱引用归零),控制块才会被释放,避免内存残留
这里我们在总结一下智能指针的释放流程。
释放流程
- 对象销毁:当最后一个
shared_ptr
析构时,强引用计数归零,对象被释放。- 控制块保留:若仍有
weak_ptr
观察(弱引用计数>0),控制块继续存在。- 控制块释放:当所有
weak_ptr
析构(弱引用归零),控制块被销毁
对于最后1个小问,我们还需要了解控制块的分配方式
-
new
分配:直接使用new
时,对象和控制块分两次分配,控制块独立存在-
make_shared
优化:通过make_shared
创建shared_ptr
时,对象和控制块分配在同一块连续内存中,减少内存碎片和分配次数。因此第二种方法更好一点。
因此上面面试题的答案是:
weak_ptr真的不计数?是否有计数方式,在哪分配的空间。
计数,控制块中有强弱引用计数,如果是使用make_shared初始化的函数则它所在的控制块空间是在所引用的shared_ptr中同一块的空间,若是new则控制器所分配的内存与shared_ptr本身所在的空间不在同一块内存。
好了,智能指针就讲解到这了,感觉有帮助的话,请点点赞吧,这真的很重要。