深入理解 C++ 智能指针:原理、使用与避坑指南

在 C++ 编程中,动态内存管理是绕不开的核心问题。手动使用new/delete管理内存时,一旦出现异常、逻辑疏漏或忘记释放,就极易引发内存泄漏。为了解决这一痛点,C++ 引入了智能指针,它基于 RAII 思想实现了资源的自动管理,让动态内存操作更安全、更简洁。本文将从使用场景、设计原理、标准库智能指针分类、循环引用问题等方面,全面解析智能指针的核心知识。

1. 智能指针的使用场景

我们为什么需要智能指针?手动管理动态内存时,异常场景下的资源释放是一大难点。先看一个典型的内存泄漏案例:

cpp 复制代码
double Divide(int a, int b) 
{
    if (b == 0)
        throw "Divide by zero condition!";
    return (double)a / (double)b;
}

void Func() 
{
    int* array1 = new int[10];
    int* array2 = new int[10]; // 若此处new抛异常,array1无法释放
    
    try 
    {
        int len, time;
        cin >> len >> time;
        cout << Divide(len, time) << endl;
    }
    catch (...) //..可以在这里捕捉,但毕竟不方便
    {
        delete[] array1;
        delete[] array2;
        throw;
    }
    delete[] array1;
    delete[] array2;
}

int main()
{
    try
    {
        Func();
    }
    catch (const char* errmsg)
    {
        cout << errmsg << endl;
    }
    catch (const exception& e)
    {
        cout << e.what() << endl;
    }
    catch (...)
    {
        cout << "未知异常" << endl;
    }

    return 0;
}

这段代码中,new操作、Divide函数都可能抛出异常,一旦异常发生,后续的delete语句可能无法执行,导致内存泄漏。如果嵌套多层new,异常处理的逻辑会变得极其复杂。

智能指针的出现,让我们无需手动处理异常场景下的资源释放 ------ 它会利用对象的生命周期自动管理内存,从根本上简化问题。

2. 智能指针的设计核心:RAII 思想

智能指针的底层实现依赖于RAII(Resource Acquisition Is Initialization),即资源获取即初始化。这是一种利用对象生命周期管理资源的设计思想,核心逻辑为:

1. 获取资源: 在对象构造时,获取动态资源(如内存、文件句柄)并将其委托给对象管理;
2. 持有资源 :在对象的生命周期内,资源始终有效,可通过对象访问资源;
**3. 释放资源:**在对象析构时,自动释放持有的资源,无需手动调用delete。

除了遵循 RAII,智能指针还需要模拟普通指针的行为,因此会重载operator*、operator->、operator[]等运算符。以下是一个简易智能指针的实现示例:

cpp 复制代码
template<class T>
class SmartPtr 
{
public:
    // RAII:构造时获取资源
    SmartPtr(T* ptr) 
        : _ptr(ptr)
    {}

    // 析构时释放资源
    ~SmartPtr() 
    {
        cout << "delete[] " << _ptr << endl;
        delete[] _ptr;
    }

    // 重载运算符,模拟指针访问
    T& operator*() { return *_ptr; }
    T* operator->() { return _ptr; }
    T& operator[](size_t i) { return _ptr[i]; }

private:
    T* _ptr;
};

使用这个简易智能指针重构上述Func函数,代码会变得简洁且安全:

cpp 复制代码
void Func() 
{
    SmartPtr<int> sp1 = new int[10];
    SmartPtr<int> sp2 = new int[10];

    int len, time;
    cin >> len >> time;
    cout << Divide(len, time) << endl;
    // 函数结束时,sp1、sp2自动析构,释放内存
}

这里当new操作或Divide函数出现异常时,会进行栈展开,沿函数调用链向上查找匹配的catch块,沿调用链创建的局部对象sp1、sp2会在栈展开时被析构,避免内存泄漏,这是我们智能指针基于RAII的核心思想,如想详细了解请移步上一篇文章:深入理解 C++ 异常:从概念到实战的全面解析-CSDN博客

3. C++标准库中的智能指针

C++ 标准库在<memory>头文件中提供了四种智能指针:auto_ptr、unique_ptr、shared_ptr、weak_ptr(其中weak_ptr不遵循 RAII,仅作为辅助工具)。它们的核心区别在于资源拷贝与管理的逻辑。

3.1 被淘汰的auto_ptr

auto_ptr是 C++98 推出的首个智能指针,其设计存在严重缺陷:拷贝时会转移资源的管理权,导致原对象悬空。

示例:

cpp 复制代码
struct Date { int _year; };

int main() 
{
    auto_ptr<Date> ap1(new Date);
    auto_ptr<Date> ap2(ap1); // 拷贝后,ap1的资源管理权转移给ap2
    // ap1->_year++; // 错误:ap1已悬空,访问空指针

    return 0;
}

由于这种设计极易引发空指针访问错误,我们强烈建议不要使用auto_ptr。

3.2 独占式智能指针unique_ptr

unique_ptr是 C++11 推出的独占式智能指针,核心特点是禁止拷贝,仅支持移动,确保同一资源只能被一个unique_ptr管理。

1. 禁止拷贝: 通过将拷贝构造函数和赋值运算符重载声明为delete,杜绝拷贝行为;
2. 支持移动: 通过移动构造和移动赋值,将资源管理权转移给新对象,原对象变为空;
**3. 数组特化:**针对new[]分配的数组,unique_ptr提供了特化版本,析构时自动调用delete[]。

示例:

cpp 复制代码
struct Date { int _year; };

int main() 
{
    unique_ptr<Date> up1(new Date);
    // unique_ptr<Date> up2(up1); // 错误:禁止拷贝

    unique_ptr<Date> up3(move(up1)); // 移动构造,up1悬空
    unique_ptr<Date[]> up4(new Date[5]); // 数组特化版本,析构调用delete[]

    return 0;
}

3.3 共享式智能指针shared_ptr

shared_ptr是 C++11 推出的共享式智能指针,支持拷贝和移动,底层通过引用计数实现资源的共享管理:

1. 引用计数: 每一份资源对应一个引用计数,记录当前管理该资源的shared_ptr数量;
2. 拷贝行为: 拷贝shared_ptr时,引用计数加 1;
3. 析构行为: shared_ptr析构时,引用计数减 1;若计数为 0,说明是最后一个管理资源的对象,释放资源;
**4. 数组特化与删除器:**同样提供数组特化版本,也支持自定义删除器(用于释放非new分配的资源,如文件指针,我们会在下面进行讲解)。

**注意:**这里如果不太清楚的话,请先跳到下面的底层原理部分,看完那里后再回来会有更好的体会。

cpp 复制代码
struct Date
{
	int _year, _month, _day;
	Date(int year = 1, int month = 1, int day = 1)
		:_year(year)
		, _month(month)
		, _day(day)
	{}
	~Date()
	{
		cout << "~Date()" << endl;
	}
};

// 自定义删除器:释放数组
template<class T>
void DeleteArrayFunc(T* ptr) 
{
    delete[] ptr;
}

int main() 
{
    shared_ptr<Date> sp1(new Date);
    shared_ptr<Date> sp2(sp1); // 拷贝,引用计数变为2,sp1、sp2管理同一份资源
    cout << sp1.use_count() << endl; // 输出:2

    sp1->_year++;
    cout << sp2->_year << endl; // 输出:2,共享资源修改同步

    shared_ptr<Date[]> sp3(new Date[2]); // 数组特化版本
    shared_ptr<Date> sp4(new Date[2], DeleteArrayFunc<Date>); // 自定义删除器

    cout << endl;

    return 0;
}

输出结果:

make_shared:更安全的构造方式

shared_ptr还提供了make_shared函数,直接通过参数构造资源对象,相比直接用new构造更安全(避免new抛异常导致的内存泄漏),且效率更高:

cpp 复制代码
// 推荐方式:make_shared直接构造对象
shared_ptr<Date> sp1 = make_shared<Date>(2024, 9, 11);
// 不推荐:new可能抛异常,导致计数内存泄漏
shared_ptr<Date> sp2(new Date(2024, 9, 11));

3.4 辅助型智能指针weak_ptr

weak_ptr是 C++11 推出的辅助工具,不遵循 RAII,无法直接管理资源,其核心作用是解决shared_ptr的循环引用问题。

(1)shared_ptr的循环引用问题

当两个shared_ptr互相引用时,会形成循环引用,导致引用计数无法归 0,资源无法释放,引发内存泄漏。例如双向链表节点的场景:

cpp 复制代码
struct ListNode 
{
    int _data;
    shared_ptr<ListNode> _next; // 共享下一个节点
    shared_ptr<ListNode> _prev; // 共享上一个节点
    ~ListNode() { cout << "~ListNode()" << endl; }
};

int main() 
{
    shared_ptr<ListNode> n1(new ListNode);
    shared_ptr<ListNode> n2(new ListNode);
    n1->_next = n2; // n2的引用计数变为2
    n2->_prev = n1; // n1的引用计数变为2

    // 函数结束时,n1、n2析构,引用计数各减1(变为1)
    // 循环引用导致计数无法归0,~ListNode()不会被调用,内存泄漏

    return 0;
}

输出结果:

(2)weak_ptr解决循环引用

weak_ptr的特点是:

1. 不增加引用计数: 绑定到shared_ptr时,不会改变其引用计数;
2. 不参与资源管理: 析构时不会释放资源,仅作为 "观察者";
**3. 安全访问资源:**通过lock()函数获取shared_ptr,若资源已释放则返回空对象;通过expired()函数检查资源是否过期。

修改上述链表节点代码,用weak_ptr替代shared_ptr存储节点引用:

cpp 复制代码
struct ListNode 
{
    int _data;
    weak_ptr<ListNode> _next; // 改为weak_ptr
    weak_ptr<ListNode> _prev; // 改为weak_ptr
    ~ListNode() { cout << "~ListNode()" << endl; }
};

int main() 
{
    shared_ptr<ListNode> n1(new ListNode);
    shared_ptr<ListNode> n2(new ListNode);
    n1->_next = n2; // 不增加n2的引用计数
    n2->_prev = n1; // 不增加n1的引用计数

    // 函数结束时,n1、n2析构,引用计数归0,资源正常释放
    return 0;
}

输出结果:

3.5 补充知识点

• shared_ptr和unique_ptr都支持operator bool的类型转换。如果智能指针对象是一个空对象,即没有管理资源,则返回false;否则返回true。这意味着我们可以直接将智能指针对象用于if语句中,以判断其是否为空。

• shared_ptr 和 unique_ptr 的构造函数都使用explicit修饰,防止普通指针隐式类型转换成智能指针对象。

cpp 复制代码
int main()
{
	shared_ptr<Date> sp1(new Date(2024, 9, 11));
	shared_ptr<Date> sp2 = make_shared<Date>(2024, 9, 11);
	auto sp3 = make_shared<Date>(2024, 9, 11);
	shared_ptr<Date> sp4;
	// 等同if (sp1.operator bool())
	if (sp1)
		cout << "sp is not nullptr" << endl;
	if (!sp4)
		cout << "sp is nullptr" << endl;

	// 报错 智能指针的构造函数被 explicit 修饰,禁止隐式类型转换,只能直接初始化构造
	shared_ptr<Date> sp5 = new Date(2024, 9, 11); // 错误
	unique_ptr<Date> sp6 = new Date(2024, 9, 11); // 错误
	//shared_ptr<Date> sp5(new Date(2024, 9, 11)); 正确

	return 0;
}

4. 删除器

4.1 删除器概念

智能指针析构时默认是进行delete释放资源,这也就意味着,如果不是new出来的资源交给智能指针管理,析构时就会崩溃。智能指针支持在构造时给一个删除器,所谓删除器,本质就是一个可调用对象(如函数指针、仿函数、Lambda 表达式),在这个可调用对象中实现你想要的释放资源的方式。当构造智能指针时,若给了定制的删除器,在智能指针析构时就会调用删除器去释放资源。

**•**因为new[]经常使用,所以为了简洁一点,unique_ptr和shared_ptr都特化了一份[]的版本。使用时,unique_ptr<Date[]> up1(new Date[5]);shared_ptr<Date[]> sp1(new Date[5]);就可以管理new[]的资源。

unique_ptr和shared_ptr支持删除器的方式不同:

• unique_ptr: 通过类模板参数指定删除器类型;
**• shared_ptr:**通过构造函数参数传递删除器对象。

4.2 删除器实例

(1)函数指针作为删除器

cpp 复制代码
// 函数指针:释放数组
template<class T>
void DeleteArrayFunc(T* ptr) 
{
    delete[] ptr;
}

int main() 
{
    // unique_ptr需显式指定函数指针类型
    unique_ptr<Date, void(*)(Date*)> up1(new Date[5], DeleteArrayFunc<Date>);

    // shared_ptr自动推导函数指针类型
    shared_ptr<Date> sp1(new Date[5], DeleteArrayFunc<Date>);

	return 0;
}

(2)仿函数作为删除器

cpp 复制代码
// 仿函数:释放数组
template<class T>
class DeleteArray 
{
public:
    void operator()(T* ptr) 
    {
        delete[] ptr;
    }
};

// 仿函数:关闭文件
class Fclose 
{
public:
    void operator()(FILE* ptr) 
    {
        cout << "fclose:" << ptr << endl;
        fclose(ptr);
    }
};

int main() 
{
    // unique_ptr通过类模板参数指定仿函数类型
    unique_ptr<Date, DeleteArray<Date>> up2(new Date[5]);
    // shared_ptr在构造函数传递仿函数对象
    shared_ptr<Date> sp2(new Date[5], DeleteArray<Date>());

    // 管理文件指针
    shared_ptr<FILE> sp3(fopen("test.txt", "w"), Fclose());

    return 0;
}

(3)Lambda作为删除器

cpp 复制代码
int main() 
{
	// unique_ptr需用decltype推导Lambda类型,不仅要在类模板参数传,还要在构造函数传
	auto delLambda = [](Date* ptr) 
		{
			delete[] ptr;
			cout << "Deleted array with Lambda" << endl;
		};
	unique_ptr<Date, decltype(delLambda)> up3(new Date[5], delLambda);

	// shared_ptr直接在构造函数传递Lambda
	shared_ptr<Date> sp4(new Date[5], [](Date* ptr) 
		{
			delete[] ptr;
			cout << "Deleted array with Lambda" << endl;
		});

	return 0;
}

4.3 推荐使用场景与区别

推荐使用场景:

unique_ptr相对建议使用仿函数作为删除器(Lambda类型考虑的比较多)。

shared_ptr相对建议使用Lambda作为删除器。

核心区别:

语法形式: Lambda 最简洁,仿函数次之,函数指针最繁琐。
可复用性: 仿函数 > 函数指针 > Lambda。
存储开销: 无状态仿函数(unique_ptr)< Lambda = 函数指针(shared_ptr 有额外开销)。
**使用灵活性:**Lambda 支持捕获上下文(尽管删除器一般用不到),仿函数和函数指针不支持。

5. 智能指针的原理

接下来我们模拟实现几个智能指针的核心功能,其中auto_ptr和unique_ptr这两个智能指针的实现比较简单,我们了解一下原理即可。auto_ptr的思路是拷贝时转移资源管理权给被拷贝对象,这种思路是不被认可的,也不建议使用。unique_ptr的思路是不支持拷贝。而shared_ptr是需要我们重点了解的。

5.1 auto_ptr

auto_ptr是早期智能指针,核心通过管理权转移实现资源管理,但存在严重设计缺陷:

cpp 复制代码
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)
	{
		// 1. 检测是否为自赋值,避免自身资源被释放
		if (this != &ap)
		{
			// 2. 释放当前对象持有的资源
			if (_ptr)
				delete _ptr;

			// 3. 转移源对象的资源所有权
			_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;  // 唯一成员变量:管理的资源指针
};

5.2 unique_ptr

unique_ptr通过禁止拷贝、支持移动实现独占式资源管理:

cpp 复制代码
template<class T>
class unique_ptr
{
public:
	// 构造函数:explicit防止隐式类型转换,避免意外构造
	explicit unique_ptr(T* ptr)
		:_ptr(ptr)
	{}

	// 析构函数:释放资源
	~unique_ptr()
	{
		if (_ptr)
		{
			cout << "delete:" << _ptr << endl;
			delete _ptr;
		}
	}

	// 重载解引用运算符
	T& operator*()
	{
		return *_ptr;
	}

	// 重载->运算符
	T* operator->()
	{
		return _ptr;
	}

	// 关键:禁用拷贝构造函数(=delete),从根源杜绝拷贝
	unique_ptr(const unique_ptr<T>& sp) = delete;

	// 关键:禁用赋值运算符重载,禁止拷贝赋值
	unique_ptr<T>& operator=(const unique_ptr<T>& sp) = delete;

	// 移动构造:转移资源所有权(右值引用)
	unique_ptr(unique_ptr<T>&& sp)
		:_ptr(sp._ptr)  // 接管源对象资源
	{
		sp._ptr = nullptr;  // 源对象置空,避免析构时重复释放
	}

	// 移动赋值:先释放当前资源,再转移所有权
	unique_ptr<T>& operator=(unique_ptr<T>&& sp)
	{
		delete _ptr;  // 释放当前对象的旧资源
		_ptr = sp._ptr;  // 接管源对象资源
		sp._ptr = nullptr;  // 源对象置空
		return *this;
	}

private:
	T* _ptr;  // 管理的资源指针
};

5.3 shared_ptr(重点)

我们要重点看看shared_ptr是如何设计的,尤其是引用计数的设计。主要是这里一份资源就需要一个引用计数,所以采用静态成员的方式是无法实现引用计数的,要使用堆上动态开辟的方式。构造智能指针对象时,针对一份资源,就要new一个引用计数出来。多个shared_ptr指向资源时,就对引用计数++;shared_ptr对象析构时,就对引用计数--。当引用计数减到0时,代表当前析构的shared_ptr是最后一个管理资源的对象,此时则析构资源。

cpp 复制代码
template<class T>
class shared_ptr
{
public:
	// 默认构造函数:初始化资源指针和引用计数
	explicit shared_ptr(T* ptr = nullptr)
		: _ptr(ptr)  // 管理的资源指针
		, _pcount(new int(1))  // 引用计数初始化为1(自身持有)
	{}

	// 带自定义删除器的构造函数:支持自定义资源释放逻辑
	template < class D>
	shared_ptr(T* ptr, D del)
		: _ptr(ptr)
		, _pcount(new int(1))  // 引用计数初始化为1
		, _del(del)  // 存储自定义删除器
	{}

	// 拷贝构造:共享资源,引用计数+1
	shared_ptr(const shared_ptr<T>& sp)
		:_ptr(sp._ptr) 
		, _pcount(sp._pcount) 
		, _del(sp._del) 
	{
		++(*_pcount);  // 引用计数自增,表示新增一个管理者
	}

	// 核心:释放资源的逻辑封装
	void release()
	{
		// 引用计数-1后检查是否为0
		if (--(*_pcount) == 0)
		{
			// 最后一个管理者:释放资源和引用计数空间
			_del(_ptr);  // 使用删除器释放资源
			delete _pcount;  // 释放引用计数的内存
			_ptr = nullptr;  // 置空防止野指针
			_pcount = nullptr;
		}
	}

	// 赋值运算符重载:先释放当前资源,再共享新资源
	shared_ptr<T>& operator=(const shared_ptr<T>& sp)
	{
		// 避免自赋值
		// if (this != &sp) 这里这样也不算错,但还是下面的更优
		if (_ptr != sp._ptr)
		{
			release();  // 释放当前对象持有的旧资源
			_ptr = sp._ptr;  
			_pcount = sp._pcount; 
			++(*_pcount); 
			_del = sp._del; 
		}
		return *this;
	}

	// 析构函数:调用release释放资源
	~shared_ptr()
	{
		release();
	}

	// 获取原始指针
	T* get() const
	{
		return _ptr;
	}

	// 获取当前引用计数
	int use_count() const
	{
		return *_pcount;
	}

	// 重载解引用运算符
	T& operator*()
	{
		return *_ptr;
	}

	// 重载->运算符
	T* operator->()
	{
		return _ptr;
	}

private:
	T* _ptr;  // 管理的资源指针
	int* _pcount;  // 引用计数指针(多个shared_ptr共享)
	// atomic<int>* _pcount;  // 线程安全版本需用原子类型

	// 默认删除器:lambda表达式,默认用delete释放资源,这里一定要加默认的,不然编不过
	//lambda没有类型是个难题,但我们可以用function接收,这样就完美解决了
	function<void(T*)> _del = [](T* ptr) {delete ptr; };
};

核心机制:

• _pcount是指向引用计数的指针,所有共享同一资源的shared_ptr共用该计数;

• 拷贝 / 赋值时引用计数 + 1,析构时引用计数 - 1;

• 引用计数为 0 时,才真正释放资源(避免提前释放或内存泄漏);

• 支持自定义删除器,灵活处理不同类型资源(如数组、文件句柄)。

在不需要拷贝的场景,我们还是推荐使用unique_ptr,因为在上面的实现中我们可以看到,引用计数还是占了一定资源的,此时使用unique_ptr更优。

5.4 weak_ptr

weak_ptr是辅助智能指针,不管理资源,仅观察shared_ptr的资源状态,用于解决循环引用问题。

cpp 复制代码
template<class T>
class weak_ptr
{
public:
	// 默认构造函数:初始化为空
	weak_ptr()
	{}

	// 构造函数:从shared_ptr构造,不增加引用计数
	weak_ptr(const shared_ptr<T>& sp)
		:_ptr(sp.get())  // 仅获取资源指针,不影响引用计数
	{}

	// 赋值运算符重载:从shared_ptr赋值
	weak_ptr<T>& operator=(const shared_ptr<T>& sp)
	{
		_ptr = sp.get();  // 仅拷贝资源指针
		return *this;
	}

private:
	T* _ptr = nullptr;  // 仅存储资源指针,不管理生命周期
};

需要注意的是,我们这里实现的shared_ptr和weak_ptr都是以最简洁的方式实现的,只能满足基本的功能。这里的weak_ptr的lock等功能是无法实现的,想要实现,就要把shared_ptr和weak_ptr一起改了,把引用计数拿出来放到一个单独类型,shared_ptr和weak_ptr都要存储指向这个类的对象才能实现。

结语

好好学习,天天向上!有任何问题请指正,谢谢观看!

相关推荐
二川bro1 小时前
Python大语言模型调优:LLM微调完整实践指南
开发语言·python·语言模型
4***V2021 小时前
PHP在微服务通信中的消息队列
开发语言·微服务·php
亿坊电商1 小时前
PHP框架 vs 原生开发:移动应用后端开发实战对比!
开发语言·php
S***q1922 小时前
Kotlin内联函数优化
android·开发语言·kotlin
在路上看风景2 小时前
2.3 C#装箱和拆箱
开发语言·c#
C语言小火车2 小时前
C/C++ 指针全面解析:从基础到进阶的终极指南
c语言·开发语言·c++·指针
g***B7382 小时前
Python数据分析案例
开发语言·python·数据分析
小灰灰搞电子2 小时前
Qt 使用打印机详解
开发语言·qt
lqj_本人2 小时前
鸿蒙Qt混合开发:NAPI数据转换的深坑与避雷指南
开发语言·qt