C++ 智能指针深度解析(上):从 RAII 思想到手撕 shared_ptr 核心源码

目录

  • 前言
  • 一、智能指针的使用及其原理
    • [1.1 智能指针的使用场景分析](#1.1 智能指针的使用场景分析)
    • [1.2 RAII 和智能指针的设计思路](#1.2 RAII 和智能指针的设计思路)
      • [1.2.1 什么是 RAII?](#1.2.1 什么是 RAII?)
      • [1.2.2 智能指针的雏形实现](#1.2.2 智能指针的雏形实现)
  • 二、C++标准库智能指针的使用
    • [2.1 auto_ptr](#2.1 auto_ptr)
    • [2.2 unique_ptr](#2.2 unique_ptr)
    • [2.3 shared_ptr](#2.3 shared_ptr)
    • [2.4 手撕 shared_ptr](#2.4 手撕 shared_ptr)
      • [2.4.1 核心成员变量设计](#2.4.1 核心成员变量设计)
      • [2.4.2 细节要点](#2.4.2 细节要点)
      • [2.4.3 定制删除器 (Custom Deleter)](#2.4.3 定制删除器 (Custom Deleter))
        • [2.4.3.1 为什么要定制删除器?](#2.4.3.1 为什么要定制删除器?)
        • [2.4.3.2 什么是"可调用对象"?](#2.4.3.2 什么是“可调用对象”?)
        • [2.4.3.3 改造手撕版 shared_ptr](#2.4.3.3 改造手撕版 shared_ptr)
        • [2.4.3.4 为什么需要两个构造函数?](#2.4.3.4 为什么需要两个构造函数?)
        • [2.4.3.5 三种删除器的写法](#2.4.3.5 三种删除器的写法)
      • [2.4.4 核心差异深度解析:shared_ptr vs unique_ptr 的删除器设计](#2.4.4 核心差异深度解析:shared_ptr vs unique_ptr 的删除器设计)
        • [2.4.4.1 shared_ptr:删除器是"运行时"的参数(类型擦除)](#2.4.4.1 shared_ptr:删除器是“运行时”的参数(类型擦除))
        • [2.4.4.2 unique_ptr:删除器是"编译时"的类型(零开销抽象)](#2.4.4.2 unique_ptr:删除器是“编译时”的类型(零开销抽象))
        • [2.4.4.3 关于数组管理的"语法糖":<T\[\]>](#2.4.4.3 关于数组管理的“语法糖”:<T[]>)
        • [2.4.4.4 避坑指南](#2.4.4.4 避坑指南)
      • [2.4.5 make_shared 与工厂函数](#2.4.5 make_shared 与工厂函数)
        • [2.4.5.1 为什么要用 make_shared?](#2.4.5.1 为什么要用 make_shared?)
        • [2.4.5.2 用法演示](#2.4.5.2 用法演示)
  • 结语

🎬 云泽Q个人主页
🔥 专栏传送入口 : 《C语言》《数据结构》《C++》《Linux》《蓝桥杯系列》《笔试算法》《AI赋能》《STM32

⛺️遇见安然遇见你,不负代码不负卿~


前言

大家好啊,我是云泽Q,欢迎阅读我的文章,一名热爱计算机技术的在校大学生,喜欢在课余时间做一些计算机技术的总结性文章,希望我的文章能为你解答困惑~


一、智能指针的使用及其原理

1.1 智能指针的使用场景分析

智能指针无论是在实际开发中,还是在面试环节,智能指针都是一个绝对的重点。为什么它这么重要?这得从 C++ 语言的一个"特性"(或者说是"痛点")说起。

我们知道,像 Java 这样的语言,它们拥有垃圾回收机制(Garbage Collection, GC)。在 Java 里,你 new 了一个对象,用完了不需要管它,GC 会在后台通过一套复杂的算法,在合适的时机自动帮你把不用的内存释放掉。

但是C++ 没有垃圾回收机制

我们在 C++ 中动态申请的资源(比如通过 new 或 malloc),必须手动去释放(delete 或 free)。

你可能会问:"既然 GC 这么好,C++ 为什么不做一个呢?" 这里主要有两个原因:

  1. 历史包袱:C++ 需要兼容 C 语言,而 C 语言是没有这一套机制的。
  2. 效率至上:C++ 是一门非常关注运行效率的语言。GC 机制虽然方便,但它需要在后台运行复杂的算法,而且释放内存往往不是即时的(它要等到特定时机才清理),这会带来性能开销。对于追求极致性能的 C++ 来说,这是不可接受的。

既然没有 GC,我们就必须自己管理内存。这就引出了我们文中要讲的第一个痛点:异常安全与内存泄漏

cpp 复制代码
void Func() {
    int* array1 = new int[10];
    int* array2 = new int[10]; // 假设这里抛异常了怎么办?

    try {
        int len, time;
        cin >> len >> time;
        // 如果 Divide 函数内部抛出异常(比如除以0)
        cout << Divide(len, time) << endl; 
    }
    catch (...) {
        // 为了处理异常,我们必须在这里手动释放内存
        cout << "delete []" << array1 << endl;
        delete[] array1;
        cout << "delete []" << array2 << endl;
        delete[] array2;
        
        throw; // 处理完资源后,还得把异常重新抛出去
    }

    // 正常流程也要释放
    cout << "delete []" << array1 << endl;
    delete[] array1;
    cout << "delete []" << array2 << endl;
    delete[] array2;
}

如果在 array2 = new int[10] 的时候抛出了异常,或者在 Divide 函数执行时抛出了异常,程序的控制流就会直接跳转到 catch 块或者上层调用者。如果没有妥善的 try-catch 保护,后面的 delete 语句就永远不会被执行,从而导致内存泄漏

为了解决这个问题,我们不得不写大量的 try-catch 块来捕获异常、释放内存、再重新抛出异常。这不仅让代码变得极其丑陋、冗长,而且非常容易出错(比如漏写了某个 delete)。

这时候,如果我们能有一种机制,让对象在离开作用域时自动释放资源,就很爽了

智能指针就是为了解决这个场景而诞生的

1.2 RAII 和智能指针的设计思路

1.2.1 什么是 RAII?

为了解决上面提到的手动释放资源的麻烦,C++ 引入了一个非常重要的设计思想:RAII

RAII 的全称是 Resource Acquisition Is Initialization,翻译过来就是"资源获取即初始化"。

它的核心思想非常简单且优雅:

利用对象的生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥锁等)的生命周期。

具体来说,RAII 做了两件事:

  1. 在构造函数中获取资源:当我们创建一个对象时,顺便把资源申请好(比如 new 一块内存),并把这个资源委托给这个对象管理。
  2. 在析构函数中释放资源:当这个对象生命周期结束(比如函数执行完毕、或者因为异常导致栈展开)时,编译器会自动调用它的析构函数。我们在析构函数里执行 delete,从而保证资源一定被释放。

这样做的好处是巨大的:无论函数是正常结束,还是中途抛出了异常,局部对象的析构函数都会被调用。 这样就彻底杜绝了因异常导致的内存泄漏问题。

1.2.2 智能指针的雏形实现

基于 RAII 思想,我们可以实现一个最简单的智能指针类 SmartPtr。

版本一:基础版(仅实现自动释放)

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

    // 析构函数:释放资源(RAII的第二步)
    ~SmartPtr() {
        cout << "delete:" << _ptr << endl;
        delete _ptr; 
    }

private:
    T* _ptr; // 托管的原始指针
};

使用它,我们的 Func 函数的内存泄漏就大有改观

cpp 复制代码
void Func() {
    // 资源获取即初始化,交给 sp1 和 sp2 管理
    SmartPtr<int> sp1(new int[10]); 
    SmartPtr<int> sp2(new int[10]); 

    int len, time;
    cin >> len >> time;
    // 即使这里 Divide 抛出异常,sp1 和 sp2 也会因为栈展开被自动析构,内存自动释放!
    cout << Divide(len, time) << endl; 
}

不需要 try-catch,不需要手动 delete,代码简洁明了。

刚才那个版本虽然能自动释放内存,但它还不好用。因为它是个对象,我们不能直接用 * 去解引用,也不能用 -> 去访问成员,更不能像数组一样用 [] 下标访问。

为了让智能指针用起来和普通指针一模一样,我们需要重载运算符。这也是智能指针设计的第二个关键点。

版本二:功能完整版(重载运算符)

我们需要重载三个关键运算符:

  1. operator*:支持解引用,获取指向的对象。
  2. operator->:支持箭头操作符,访问对象的成员。
  3. operator[]:支持下标访问(针对数组情况)。
cpp 复制代码
template<class T>
class SmartPtr {
public:
    SmartPtr(T* ptr) :_ptr(ptr) {}

    ~SmartPtr() {
        cout << "delete []:" << _ptr << endl;
        delete[] _ptr; // 注意:这里演示的是数组释放,实际标准库会区分
    }

    // 1. 重载 * 运算符,返回对象的引用
    T& operator*() {
        return *_ptr;
    }

    // 2. 重载 -> 运算符,返回原始指针
    T* operator->() {
        return _ptr;
    }

    // 3. 重载 [] 运算符,支持下标访问
    T& operator[](size_t i) {
        return _ptr[i];
    }

private:
    T* _ptr;
};

测试验证

cpp 复制代码
int main() {
    // 测试 operator* 和 operator[]
    SmartPtr<int> sp1 = new int[5]{1, 2, 3, 4, 5};
    *sp1 += 1;      // 访问第一个元素,变为2
    sp1[2] += 1;    // 访问第三个元素,变为4
    
    // 测试 operator->
    // 假设我们有一个 pair 数组
    SmartPtr<pair<int, int>> sp2 = new pair<int, int>[2];
    sp2->first = 1;     // 访问第一个 pair 的成员
    sp2->second = 1;
    
    sp2[1].first = 1;   // 结合 [] 和 . 操作符
    sp2[1].second = 1;

    return 0; // 离开作用域,自动调用析构函数释放内存
}

通过这种方式,我们既享受了 RAII 带来的自动内存管理的安全性,又保留了指针操作的便捷性。这就是智能指针最基本的设计思路。

二、C++标准库智能指针的使用

在C++开发中,除非遇到极特殊的资源管理需求,否则我们强烈建议直接使用标准库 < memory > 头文件下的智能指针 。标准库不仅帮我们处理了复杂的异常安全,还针对不同的业务场景提供了多种选择。

下面结合具体的代码演示,我们来逐一拆解这几个指针

2.1 auto_ptr

这是C++98引入的老古董,但是现在基本不会用了,因为它非常坑。

cpp 复制代码
class A
{
public:
	A()
	{
		cout << "A()" << endl;
	}
	
	~A()
	{
		cout << "~A()" << endl;
	}
	int _a1 = 1;
	int _a2 = 1;
};

int main()
{
	// 创建一个 auto_ptr 管理对象 A
	auto_ptr<A> ap1(new A); 
	ap1->_a1++; // 此时 ap1 指向对象,正常访问
	
	// 【关键点】发生拷贝!
	auto_ptr<A> ap2(ap1); 
	
	// 此时如果你再去操作 ap1,程序就会崩溃
	// ap1->_a1++; // 错误!ap1 已经悬空了
	ap2->_a1++;   // 正常,资源归 ap2 管了
	return 0;
}
  • 核心机制:管理权转移。它的拷贝构造不是"复制",而是"掠夺"。一旦拷贝,原指针直接变空(置为 nullptr)。

  • 致命缺陷:这种设计太反直觉了。你以为拷贝了一份副本,结果原来的指针废了。在函数传参或容器存储时极易引发崩溃。

所以结论就是别用

2.2 unique_ptr

既然拷贝容易出问题,C++11 的 unique_ptr 选择了最简单的方案:禁止拷贝。

cpp 复制代码
unique_ptr<A> up1(new A);
up1->_a1++; // 正常使用

// unique_ptr<A> up2(up1); // 编译报错!不允许拷贝

// 如果确实需要转移所有权,必须显式使用 move
unique_ptr<A> up2 = std::move(up1);
// 此时 up1 变为空,up2 接管资源
  • 特性:它删除了拷贝构造函数和赋值运算符重载,只支持移动语义(Move Semantics)。当你确定资源不需要被共享,只需要在一个作用域或对象间转移时,它是效率最高的选择(没有引用计数的开销)。

  • 常用接口:除了基本的 *->,它还提供了 release()、reset()(重置)等实用方法。

2.3 shared_ptr

这是面试和实际开发中最常用的智能指针。它的核心特点是支持拷贝,允许多个 shared_ptr 共同管理同一块资源。

cpp 复制代码
shared_ptr<A> sp1(new A);
shared_ptr<A> sp2(sp1); // 支持拷贝!

sp1->_a1++; // sp1 和 sp2 指向同一块内存,修改是同步的
  • 底层原理:引用计数(Reference Counting)。每多一个 shared_ptr 指向资源,计数+1;每析构一个,计数-1。当计数归零时,才真正释放资源。
  • 注意:虽然它很好用,但维护引用计数是有性能开销的,而且在特定场景下(如循环引用)会导致内存泄漏(这个问题我们后面讲 weak_ptr 时会详细解决)。

2.4 手撕 shared_ptr

这部分是面试的重点,经常会让候选人现场手写一个 shared_ptr 。我们在实现时,最容易在引用计数的存储位置上踩坑

2.4.1 核心成员变量设计

要实现一个支持拷贝的 shared_ptr,我们需要两个核心成员:

  1. _ptr:指向管理资源的原生指针。
  2. _pcount:指向引用计数的指针。
cpp 复制代码
template<class T>
class shared_ptr {
private:
    T* _ptr;
    int* _pcount; // 重点:这里必须是指针,不能是普通int,也不能是static int
};

这是手撕的重点,很多兄弟第一反应是用 static int 或者类内的 int,这都是错的。

  • 错误方案1:使用普通成员变量 int _count
    如果是普通成员,每个对象都有一份独立的计数。sp1 拷贝给 sp2 时,sp2 拿到的是副本,它们各自改各自的计数,根本无法统计到底有多少个对象在共享资源。
  • 错误方案2:使用静态成员变量 static int _count
    静态成员属于整个类,所有对象共享同一个计数。
cpp 复制代码
shared_ptr<A> sp1(new A); // 计数变1
shared_ptr<A> sp2(sp1);   // 计数变2

shared_ptr<A> sp3(new A); // 糟糕!sp3是新资源,但因为共用静态变量,计数变成了3!

不同资源的对象混用了同一个计数器,导致资源该释放的时候没释放,或者不该释放的时候被释放了

  • 正确方案:使用堆内存 int* _pcount
    我们在构造第一个对象时,在堆上 new 一个 int 作为计数器。
    • 拷贝时:让新对象的 _pcount 指针指向同一块堆内存。这样,无论有多少个对象,只要它们管理的是同一份资源,它们就通过指针共享同一个计数值。
    • 不同资源:sp3(new A) 会 new 一个新的计数器,互不干扰。

结合我们的思路,来看一份标准的模拟实现代码:

cpp 复制代码
#include<iostream>
using namespace std;

class A
{
public:
	A()
	{
		cout << "A()" << endl;
	}
	
	~A()
	{
		cout << "~A()" << endl;
	}
	int _a1 = 1;
	int _a2 = 1;
};

namespace yunze
{
	template<class T>
	class shared_ptr
	{
	public:
		// RAII
		explicit shared_ptr(T* ptr)
			:_ptr(ptr)
			// 重点:在堆上开辟计数空间
			,_pcount(new int(1))
		{
		}

		shared_ptr(const shared_ptr<T>& sp)
			:_ptr(sp._ptr)
			, _pcount(sp._pcount)// 指向同一个计数器
		{
			// 加加计数
			++(*_pcount);
		}

		// sp1 = sp3
		shared_ptr& operator=(const shared_ptr& sp)
		{
			//if (this != &sp)
			// 判断管理的资源是否相同,而不仅仅是对象地址
			if(_ptr != sp._ptr)
			{
				// 先释放旧资源(如果计数归零则delete)
				release();

				_ptr = sp._ptr;
				_pcount = sp._pcount;
				++(*_pcount);// 新资源计数加 1
			}
			return *this;
		}


		void release()
		{
			// 引用计数减到0,说明最后一个管理智能指针对象要释放资源
			if (--(*_pcount) == 0)
			{
				//cout << "delete []:" << _ptr << endl;
				delete _ptr;// 没人用了,释放资源
				delete _pcount;// 释放计数器本身
			}
		}

		~shared_ptr()
		{
			release();
		}

		T* get()
		{
			return _ptr;
		}

		int use_count()
		{
			return *_pcount;
		}

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

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

		T& operator[](size_t i)
		{
			return _ptr[i];
		}
	private:
		T* _ptr;
		//int _count; // 引用计数
		//static int _count; // 引用计数
		int* _pcount; // 引用计数
	};
}

int main()
{
	yunze::shared_ptr<A> sp1(new A);
	yunze::shared_ptr<A> sp2(sp1);

	sp1 = sp1;
	sp1 = sp2;

	yunze::shared_ptr<A> sp3(new A);

	sp1 = sp3;

	return 0;
}

2.4.2 细节要点

  1. 赋值时的自检查:在 operator= 中,一定要判断 if (_ptr != sp._ptr)。很多兄弟写 if (this != &sp),这只能防止 sp1 = sp1 这种自身赋值。但如果 sp1 和 sp2 管理的是同一个资源(只是两个不同的对象),即 sp1 = sp2 且 _ptr 相同,如果不处理,就会先把计数减到 0 释放了资源,导致 sp2 变成悬空指针

  2. 构造函数的 explicit:防止隐式类型转换,比如防止 shared_ptr< A > sp = new A(); 这种写法,强制要求显式构造。

    yunze::shared_ptr< A > sp1(new A);等价于yunze::shared_ptr< A > sp1 = yunze::shared_ptr< A >(new A);

流程:1. new A 在堆上创建 A 对象,返回 A* 裸指针;

  1. 调用 shared_ptr< T > 的构造函数:explicit shared_ptr(T* ptr)

  2. 用这个裸指针直接构造 sp1 对象,不是隐式转换,是显式直接构造

  • explicit禁止「拷贝初始化」的隐式转换 ,不禁止「直接初始化」。
    ❌ 禁止(拷贝初始化,隐式转换):
cpp 复制代码
// 等价于 yunze::shared_ptr<A> sp1 = shared_ptr<A>(new A);
// 右边先用A*隐式构造临时shared_ptr,再拷贝给sp1
yunze::shared_ptr<A> sp1 = new A;
  • 这行会编译报错!因为 new AA*,编译器尝试隐式调用单参构造函数生成临时对象explicit 拦住了这个隐式转换。

  • ✅ 合法(直接初始化,没有隐式转换):

cpp 复制代码
// 直接调用构造函数,参数一一对应,不存在隐式转换
yunze::shared_ptr<A> sp1(new A);
// 列表初始化也可以(C++11后)
yunze::shared_ptr<A> sp1{new A};
// 显式构造临时再拷贝(允许,手动写了构造)
yunze::shared_ptr<A> sp1 = yunze::shared_ptr<A>(new A);
  1. release 的双重 delete:要注意,当计数为0时,不仅要 delete _ptr(用户资源),还要 delete _pcount(计数器本身也是 new 出来的,不删会内存泄漏)。

2.4.3 定制删除器 (Custom Deleter)

2.4.3.1 为什么要定制删除器?

我们在前面的文章中知道,智能指针的核心思想是 RAII(资源获取即初始化),利用对象的生命周期来管理资源。但是,我们之前手撕的 shared_ptr 有一个巨大的隐患:它的析构行为是写死的

大家看我们之前的代码,在 release() 函数里,当引用计数减为 0 时,我们执行的是:

cpp 复制代码
delete _ptr; 

这就带来了一个严重的问题:资源分配与释放的不匹配

  • 场景一:数组资源

    如果你通过 new A[10] 申请了一块内存,按照 C++ 的语法规则,释放时必须使用 delete[]。如果智能指针默认只调用 delete,对于类类型(如我们的 class A),它只会调用第一个对象的析构函数,剩下的 9 个对象永远不会被析构,导致内存泄漏 ;对于内置类型,虽然可能不报错,但这属于未定义行为,程序随时可能崩溃。

  • 场景二:文件句柄

    如果你用 fopen 打开了一个文件,你需要用 fclose 来关闭它。智能指针默认的 delete 根本无法识别这是一个文件指针,它会试图去释放这块内存,导致程序直接崩溃。

智能指针默认只能管理 new 出来的单个对象。为了通用性,我们需要一种机制,告诉智能指针:"当你需要释放资源时,请调用我指定的这个函数,而不是默认的 delete。"这就是定制删除器

2.4.3.2 什么是"可调用对象"?

要实现定制删除器,首先得理解 C++ 中的可调用对象 。简单来说,就是任何能像函数一样被调用的东西(即支持 func(args) 这种语法)。

在 C++ 中,主要有以下几种形式可以作为删除器:

  1. 函数指针:最传统的 C 语言风格。
  2. 仿函数 (Functor) :重载了 operator() 的类或结构体对象。
  3. Lambda 表达式:C++11 引入的匿名函数,写起来最简洁。
2.4.3.3 改造手撕版 shared_ptr

为了支持上述三种形式,我们需要修改 yunze::shared_ptr 的设计。

核心难点

如果我们把删除器设计成模板参数(像 unique_ptr 那样),会导致类型膨胀。比如 shared_ptr<A, Func1>shared_ptr<A, Func2> 会变成两个完全不同的类型,无法互相赋值,这违背了 shared_ptr 多态共享的初衷。

解决方案

利用 std::function。它是一个通用的函数包装器,可以容纳任何签名为 void(T*) 的可调用对象。这样,无论用户传进来的是 Lambda 还是函数指针,对我们来说都是同一个类型 std::function<void(T*)>

对包装器和 lambda 不太了解的可以看我之前整理的文章C++ 可调用对象通关指南:深度解析 Lambda 表达式、function 包装器与 bind 绑定器

代码实现细节

cpp 复制代码
#include <functional> // 必须包含这个头文件

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

		// 【构造函数】:接收原始指针和自定义删除器
        // D 是模板参数,可以是 lambda、仿函数或函数指针类型
		template<class D>
		explicit shared_ptr(T* ptr, D del)
			:_ptr(ptr)
			,_pcount(new int(1))
			,_del(del)// 将传入的删除器保存下来
		{
		}

		shared_ptr(const shared_ptr<T>& sp)
			:_ptr(sp._ptr)
			, _pcount(sp._pcount)
		{
			// 加加计数
			++(*_pcount);
		}

		// sp1 = sp3
		shared_ptr& operator=(const shared_ptr& sp)
		{
			//if (this != &sp)
			if(_ptr != sp._ptr)
			{
				release();

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


		void release()
		{
			// 引用计数减到0,说明最后一个管理智能指针对象要释放资源
			if (--(*_pcount) == 0)
			{
				//cout << "delete []:" << _ptr << endl;
				//delete _ptr;
				// 【关键修改】:不再直接 delete _ptr
                // 而是调用我们保存的 _del 对象
                // 如果用户没传删除器,_del 会使用默认值(见私有成员)
				_del(_ptr);

				delete _pcount;
			}
		}

		~shared_ptr()
		{
			release();
		}

		T* get()
		{
			return _ptr;
		}

		int use_count()
		{
			return *_pcount;
		}

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

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

		T& operator[](size_t i)
		{
			return _ptr[i];
		}

    private:
        T* _ptr;
        int* _pcount;
        
        // 【核心设计】:使用 std::function 统一存储删除器
        // 默认值是一个 Lambda,执行标准的 delete 操作
        // 这样即使用户不传删除器,也能正常工作
        std::function<void(T*)> _del = [](T* p) { delete p; };
    };
}

template<class T>
void DeleteArrayFunc(T* ptr)
{
	delete[] ptr;
}

template<class T>
class DeleteArray
{
public:
	void operator()(T* ptr)
	{
		delete[] ptr;
	}
};

int main()
{
	//程序均会崩溃
	//yunze::shared_ptr<A> sp1(new A[10]);
	//std::shared_ptr<A> sp1(new A[10]);

	//定制删除器
	yunze::shared_ptr<A> sp1(new A[10], DeleteArray<A>());  // 仿函数
	yunze::shared_ptr<A> sp2(new A[10], DeleteArrayFunc<A>);// 函数指针
	
	//最推荐
	yunze::shared_ptr<A> sp3(new A[10], [](A* ptr) {delete[] ptr; });
	yunze::shared_ptr<FILE> sp4(fopen("Test.cpp", "r"), [](FILE* ptr) {fclose(ptr); });
	yunze::shared_ptr<A> sp5(new A);

	//unique_ptr和shared_ptr删除器的位置不一致
	std::unique_ptr<A, DeleteArray<A>> up1(new A[10]);// 仿函数
	//std::unique_ptr<A, void(*)(A*)> up2(new A[10]);   // 函数指针,这里会报错
	std::unique_ptr<A, void(*)(A*)> up2(new A[10], DeleteArrayFunc<A>);   // 函数指针
	
	auto del = [](A* ptr) {delete[] ptr; };
	std::unique_ptr<A, decltype(del)> up3(new A[10], del); //lambda
	
	//报错
	//std::shared_ptr<A> sp10(new A[10]);
	//std::unique_ptr<A> up10(new A[10]);

	////更简洁的方式
	std::shared_ptr<A[]> sp10(new A[10]);
	std::unique_ptr<A[]> up10(new A[10]);

	//auto sp11 = make_shared<A>(1, 1);
	return 0;
}
2.4.3.4 为什么需要两个构造函数?

这是很多初学者在手撕智能指针时容易困惑的地方。大家看我的代码,我在 shared_ptr 类中提供了两个构造函数

cpp 复制代码
// 构造函数 1:普通构造
explicit shared_ptr(T* ptr = nullptr)
    :_ptr(ptr)
    , _pcount(new atomic<int>(1))
{}

// 构造函数 2:带删除器的构造
template<class D>
explicit shared_ptr(T* ptr, D del)
    :_ptr(ptr)
    , _pcount(new int(1)) // 注意:这里为了演示简化了原子操作
    , _del(del)
{}

这其实是出于接口易用性C++ 语法限制 的双重考虑:

满足"懒人模式"(默认行为):

  1. 在 90% 的场景下,我们就是用 new 创建对象。如果只有一个带删除器的构造函数,那用户每次都得写成 shared_ptr< A > sp(new A, [](A*p){delete p;});,这非常繁琐
    所以,构造函数 1 是为了用得爽。配合成员变量的默认值 _del = [](T* ptr) { delete ptr; };,实现了"只要你不传删除器,我就默认帮你 delete"。
  2. 应对"特殊需求"(泛型支持):
    当我们需要管理数组或文件时,必须传入自定义的删除器。构造函数 2 使用了模板参数 < class D >,这是为了支持泛型。因为 Lambda 和仿函数的类型千奇百怪,只有模板才能接收所有类型的可调用对象。
2.4.3.5 三种删除器的写法

有了上面的改造,我们就可以处理各种奇葩资源了。

A. 使用仿函数(Functor)

适合逻辑复杂、需要复用的删除逻辑。

cpp 复制代码
// 定义一个专门用来释放数组的仿函数
template<class T>
class DeleteArray {
public:
    void operator()(T* ptr) {
        cout << "DeleteArray called" << endl; // 调试用
        delete[] ptr;
    }
};

// 使用:
yunze::shared_ptr<A> sp1(new A[10], DeleteArray<A>());

B. 使用函数指针

适合 C 风格的接口。

cpp 复制代码
template<class T>
void DeleteArrayFunc(T* ptr) {
    delete[] ptr;
}

// 使用:
yunze::shared_ptr<A> sp2(new A[10], DeleteArrayFunc<A>);

C. 使用 Lambda(最推荐)

简单粗暴,就地编写,无需额外定义类或函数。

cpp 复制代码
// 释放数组
yunze::shared_ptr<A> sp3(new A[10], [](A* ptr) { delete[] ptr; });

// 释放文件句柄
yunze::shared_ptr<FILE> sp4(fopen("Test.cpp", "r"), [](FILE* ptr) { fclose(ptr); });

2.4.4 核心差异深度解析:shared_ptr vs unique_ptr 的删除器设计

大家在写代码或者看文档时,一定会发现一个有趣的现象:

  • std::shared_ptr :不管你是用 lambda、仿函数还是函数指针做删除器,它的类型声明永远都是 std::shared_ptr<T>,非常干净。
  • std::unique_ptr :一旦你想换个删除器,它的类型声明就变得巨长无比,比如 std::unique_ptr<T, MyDeleter>

为什么会这样?这涉及到两者底层实现的核心区别:类型擦除 (Type Erasure) vs 类型参数化

2.4.4.1 shared_ptr:删除器是"运行时"的参数(类型擦除)

观察代码:

cpp 复制代码
// shared_ptr 的用法
yunze::shared_ptr<A> sp1(new A[10], DeleteArray<A>()); // 仿函数
yunze::shared_ptr<FILE> sp4(fopen("Test.cpp", "r"), [](FILE* ptr) {fclose(ptr); });

关键点:

请注意看 sp1sp4 的类型定义,它们依然是 shared_ptr<A>shared_ptr<FILE>删除器并没有出现在尖括号 < > 里,而是作为构造函数的第二个参数传进去的。

底层原理:

这就是所谓的类型擦除shared_ptr 内部通常使用 std::function<void(T*)> 或者虚函数基类指针来存储这个删除器。

  • 好处 :无论你的删除器是复杂的仿函数 DeleteArray,还是一个简单的 Lambda,对 shared_ptr 来说,它都被统一成了一个通用的接口。
  • 代价 :因为要存储这个通用的接口(通常是堆内存分配或虚函数表),shared_ptr 会有一点点额外的性能开销,且删除操作是在运行时动态调用的。
2.4.4.2 unique_ptr:删除器是"编译时"的类型(零开销抽象)

观察代码:

cpp 复制代码
// unique_ptr 的用法
std::unique_ptr<A, DeleteArray<A>> up1(new A[10]);       // 仿函数
std::unique_ptr<A, void(*)(A*)> up2(new A[10], func);    // 函数指针
auto del = [](A* ptr) { delete[] ptr; };
std::unique_ptr<A, decltype(del)> up3(new A[10], del);   // Lambda

关键点:

这里你会发现,删除器的类型直接变成了模板参数的一部分

  • 如果你用仿函数,类型就是 <A, DeleteArray<A>>
  • 如果你用函数指针,类型就是 <A, void(*)(A*)>
  • 如果你用 Lambda,你还得用 decltype 去推导它的类型。

底层原理:

unique_ptr 追求的是极致的性能。它把删除器作为类的一个成员变量直接存储(如果是空类优化,甚至不占空间)。

  • 好处 :没有虚函数调用,没有堆内存分配,编译器在编译阶段就知道该调用哪个删除函数,效率极高。
  • 代价 :类型变得非常复杂。如果两个 unique_ptr 管理的资源一样,但删除器类型不同(一个是 Lambda,一个是函数指针),它们在 C++ 看来就是完全不同的类型,无法互相赋值。
2.4.4.3 关于数组管理的"语法糖":<T\[\]>

在代码最后,提两种更简洁的写法:

cpp 复制代码
std::shared_ptr<A[]> sp10(new A[10]);
std::unique_ptr<A[]> up10(new A[10]);

这是 C++11 引入的数组特化版本

  • 为什么要用它?

    正如我们之前所说,普通的 shared_ptr<A> 默认只会调用 delete。如果你写 new A[10] 却不用定制删除器,程序会崩溃。

    但是,如果你显式地写成 shared_ptr<A[]>(注意那个方括号),标准库就会自动识别:"哦,这是一个数组智能指针",它会自动将默认的删除行为从 delete 切换为 delete[]

    • 方法一(定制删除器):灵活,可以管理文件句柄、Socket 等任何资源,但写起来麻烦。
    • 方法二(数组特化 <T[]> :简单,专门针对 new[] 分配的内存,一行代码解决数组释放问题,但不能用于管理文件句柄等非内存资源。
2.4.4.4 避坑指南
cpp 复制代码
// std::unique_ptr<A, void(*)(A*)> up2(new A[10]); // 报错!

为什么报错?

对于 unique_ptr,如果你指定了删除器类型为函数指针 void(*)(A*),你就必须 在构造函数中把这个函数指针传进去。因为它不是像 shared_ptr 那样有个默认的"万能容器"帮你兜底,unique_ptr 里的函数指针如果不初始化,它就是空的或者是野指针,编译器为了安全禁止了这种隐式默认构造。

正确写法:

cpp 复制代码
std::unique_ptr<A, void(*)(A*)> up2(new A[10], DeleteArrayFunc<A>);   // 函数指针

shared_ptr 因为有默认构造函数和默认删除器逻辑,所以即使你不传,它也能正常工作(默认执行 delete)。

2.4.5 make_shared 与工厂函数

最后,再给大家介绍一下make_shared

2.4.5.1 为什么要用 make_shared?

回顾一下我们创建智能指针的常规姿势:

cpp 复制代码
std::shared_ptr<A> sp(new A(1, 1));

这行代码其实做了两次内存申请:

  1. new A(1, 1):在堆上申请 A 对象的内存。
  2. shared_ptr 内部:申请引用计数 _pcount 的内存。

频繁的内存碎片化会影响性能。而 make_shared 可以在一次内存申请中,同时搞定对象和引用计数控制块,效率更高,也更安全(防止内存泄漏)。

2.4.5.2 用法演示

make_shared 是一个模板函数,它会自动帮你 new 对象并包装成 shared_ptr

cpp 复制代码
// 以前的写法
std::shared_ptr<A> sp1(new A(10, 20));

// 推荐的写法 (C++11)
auto sp2 = std::make_shared<A>(10, 20); 
// 这里的 10, 20 会完美转发给 A 的构造函数

注意

  • make_shared 返回的就是 shared_ptr,所以可以用 auto 接收。
  • 如果是数组,C++20 之前没有直接的 make_shared<T[]>,通常还是得手动 new 配合定制删除器,或者使用 std::shared_ptr<A[]> sp(new A[10]) 这种特化版本(C++17支持)。
  • 同理,C++14 引入了 make_unique,用法类似:auto up = std::make_unique<A>(10);

结语

相关推荐
現実君26 分钟前
【硬件进阶】AD22上位替换JLEDA教程
开发语言·php
mubei-12332 分钟前
SpringDAO的用法
java·开发语言·数据库
2601_956121972 小时前
二分算法(知识点+题目)
c++·算法
格林威3 小时前
多相机微秒级对齐:硬件触发 vs PTP(IEEE 1588)方案实战对比
开发语言·人工智能·数码相机·机器学习·计算机视觉·视觉检测·机器视觉
初级代码游戏4 小时前
iOS开发 Swift 速记2:三种集合类型 Array Set Dictionary
开发语言·ios·swift
蒙奇·D·路飞-4 小时前
OpenClaw:开源的《合金弹头》精神续作与终极本地部署指南
c++·开源·mfc
峥嵘life6 小时前
Android WiFi 热点 Channel 信道 和 Frequency 频率 转换总结
android·开发语言
小星星闪亮登场6 小时前
2026萌新联赛第三场-- (郑州轻工业大学)
数据结构·c++·经验分享·算法·贪心算法·排序算法·深度优先
wgego6 小时前
基础的反序列化一些总结(php和java)
java·开发语言·笔记
梓䈑6 小时前
【用 Vibe Coding 实现的 C++17 在线判题系统】前端开发 + Web 自动化测试
前端·c++·ai编程