ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C++学习/复习31智能指针

C++学习/复习31智能指针

0、大纲

0.1场景引入

下面这段归并排序算法故意留了内存泄漏问题,请分析:

void MergeSort(int* arr, int left, int right) { // 1. 分配临时数组(仅在需要合并时分配) int* tmp = new int[right - left + 1]; // 2. 递归终止条件:区间长度为 1 或无效时直接返回,防止内存泄漏 if (left >= right) { return; } // 3. 计算中间点,防止溢出 int mid = left + ((right - left) >> 1); // 4. 递归排序左右子区间 MergeSort(arr, left, mid); MergeSort(arr, mid + 1, right); // 5. 合并两个有序区间 int begin1 = left, end1 = mid; int begin2 = mid + 1, end2 = right; int index = 0; // tmp 数组的索引 // 比较并拷贝到 tmp while (begin1 <= end1 && begin2 <= end2) { if (arr[begin1] <= arr[begin2]) { tmp[index++] = arr[begin1++]; } else { tmp[index++] = arr[begin2++]; } } // 拷贝剩余元素 while (begin1 <= end1) { tmp[index++] = arr[begin1++]; } while (begin2 <= end2) { tmp[index++] = arr[begin2++]; } // 6. 将 tmp 中的数据拷回原数组 for (int i = 0; i < (right - left + 1); ++i) { arr[left + i] = tmp[i]; } // 7. 释放内存 delete[] tmp; }

这段代码分为7部分,核心内存泄漏问题集中在‌提前返回路径未释放已分配的堆内存:

  • 递归终止条件的提前返回路径‌:代码在第1部分就new int[right - left + 1]分配堆内存,第2部分才判断if (left >= right) { return; }。此时若满足if判断,delete[] tmp语句就完全没有机会执行,新分配的临时数组内存直接丢失引用,形成永久内存泄漏
  • 递归场景下的泄漏放大‌:归并排序是递归调用,当递归到区间长度为1的子问题时,每一层满足left >= right的调用都会执行一次new分配内存,然后直接返回不释放,递归深度越大,累积的泄漏内存越多。
  • 异常安全缺失‌:如果归并逻辑中任意一行代码抛出异常,程序执行流会直接跳过后续的delete[] tmp,已分配的tmp数组内存无法被回收,进一步加剧泄漏风险。
  • 重复分配冗余内存‌:当前写法在每一层递归都分配一次临时数组,不仅放大了泄漏概率,还会造成大量不必要的堆内存申请释放开销,降低排序效率。

修复方案参考:

  1. 把临时数组的分配操作移到递归终止条件判断之后,确保只有当区间需要合并时才分配内存,从根源避免提前返回导致的泄漏。
  2. 采用RAII机制,使用智能指针实现归并排序中的临时数组内存,彻底避免手动new/delete带来的内存泄漏问题;或用std::vector<int>替代裸指针new数组,vector会在出作用域时自动释放内存,完全避免手动管理内存的泄漏风险。(std::vector< >‌封装更完善,自带边界检查、迭代器等容器特性,使用更灵活,是数组类资源RAII管理的通用首选,但本文重点讲解智能指针,所以此处只简单提一下)。
  3. 优化为一次性在外部分配全局临时数组,递归过程中复用该数组,既消除泄漏点,又大幅提升排序性能。

智能指针实现归并排序示例

#include <memory> void MergeSort(int* arr, int left, int right) { // 递归终止条件先判断,避免无效分配 if (left >= right) {return;} int mid = left + ((right - left) >> 1); // 使用unique_ptr管理动态数组,自动RAII释放 std::unique_ptr<int[]> tmp = std::make_unique<int[]>(right - left + 1); MergeSort(arr, left, mid); MergeSort(arr, mid + 1, right); // 后续归并逻辑完全不变,直接通过智能指针下标访问数组 int begin1 = left, end1 = mid; int begin2 = mid + 1, end2 = right; int index = 0; while (begin1 <= end1 && begin2 <= end2) { if (arr[begin1] <= arr[begin2]) { tmp[index++] = arr[begin1++]; } else { tmp[index++] = arr[begin2++]; } } while (begin1 <= end1) tmp[index++] = arr[begin1++]; while (begin2 <= end2) tmp[index++] = arr[begin2++]; for (int i = 0; i < (right - left + 1); ++i) { arr[left + i] = tmp[i]; } // 无需手动写delete,tmp出作用域时自动释放内存 }

1、为什么需要智能指针

根源就是裸指针极易引发各类内存问题:

内存泄漏:分配内存后忘记调用delete/delete[],堆内存无法释放,长期运行会耗尽系统资源;空悬指针/野指针:内存已释放,但指针未置空,后续访问会触发非法内存访问,导致程序崩溃;重复释放:多个裸指针指向同一内存,多次释放会触发未定义行为;生命周期混乱:复杂场景下(如多线程、循环引用),难以精准把控对象的销毁时机。

智能指针本质是封装了裸指针的类模板重载了->\*操作符,让你用得跟指针一样,但析构的时候利用C++的RAII(资源获取即初始化)机制,在智能指针对象生命周期结束时,自动调用析构函数释放管理的内存,从根源上解决裸指针的内存管理难题。

1.1内存泄漏

C/C++ 两类典型内存泄漏:

其他伪泄漏 / 同类内存问题”:这些问题裸指针全都很难管控,这就是智能指针诞生的核心原因

  1. 野指针:指针指向已释放的堆内存、非法地址,解引用会崩溃;
  2. 双重释放:同一块内存连续两次delete,触发未定义行为;
  3. 内存越界读写:数组下标越界、改写堆边界,破坏堆结构;

1.2内存泄露预防检测

(事前预防为主)

(事后排查)

2.boost库与智能指针

2.1历史发展

2.2现代智能指针体系

C++11:

C++14:

C++17:

C++20:

C++23:

2.3未来发展

3.unique_ptr

3.1核心语义与特点

独占所有权、禁止拷贝它所管理的堆资源,同一时刻,只能被这一个unique_ptr持有。没有第二个指针可以共享这份资源。谁拿到这个unique_ptr,谁就是资源的唯一主人。指针销毁,资源立刻释放。

不允许拷贝,但允许移动std::move):把资源所有权转移出去,因此unique_ptr 能传递。移动是转移所有权,原指针放弃控制权,新指针接管资源。整个过程,始终只有一个指针管理资源,完全符合独占语义。

//up1、up2 各自独占一块堆内存。 std::unique_ptr<A> up1(new A(1)); std::unique_ptr<A> up2(new A(2)); // 允许移动,up1变成空 std::unique_ptr<A> up3 = std::move(up1);

这种设计带来了两个极致的优点。第一,没有引用计数开销,性能和原生裸指针完全一致,是真正的零开销抽象。第二,所有权唯一,不会出现共享冲突、循环引用的问题,安全性拉满。

3.2简易版 unique_ptr 实现

#include <iostream> #include <utility> // std::move template<typename T> class MyUniquePtr { private: T* _ptr = nullptr; public: // 构造函数 接管资源 explicit MyUniquePtr(T* p = nullptr) : _ptr(p) { } // 析构:释放资源 ~MyUniquePtr() { delete _ptr; } // 禁止拷贝构造、拷贝赋值 MyUniquePtr(const MyUniquePtr&) = delete; MyUniquePtr& operator=(const MyUniquePtr&) = delete; // 移动构造:创建全新对象,从别的 unique_ptr 抢夺资源 MyUniquePtr(MyUniquePtr&& other) noexcept : _ptr(nullptr) { // 接管对方资源 _ptr = other._ptr; other._ptr = nullptr; } // 移动赋值:对象已经存在,先释放自己手里旧资源,再抢夺对方资源; MyUniquePtr& operator=(MyUniquePtr&& other) noexcept { if (this != &other) { //需要判断`this != &other`防止自移动 // 释放自己旧资源 delete _ptr; // 抢夺other的指针 _ptr = other._ptr; other._ptr = nullptr; } return *this; } // 解引用 T& operator*() const { return *_ptr; } // ->重载 T* operator->() const { return _ptr; } // 显式bool转换,判断是否持有资源 explicit operator bool() const { return _ptr != nullptr; } // get:获取裸指针,不移交所有权 T* get() const { return _ptr; } // release:释放所有权,返回裸指针,内部置空,不释放内存 T* release() { T* temp = _ptr; _ptr = nullptr; return temp; } // reset:释放旧资源,接管新指针 void reset(T* p = nullptr) { delete _ptr; _ptr = p; } }; // 测试用例 struct TestObj { int val; TestObj(int v) : val(v) { std::cout << "构造 TestObj " << val << "\n"; } ~TestObj() { std::cout << "析构 TestObj " << val << "\n"; } }; int main() { MyUniquePtr<TestObj> p1(new TestObj(100)); std::cout << p1->val << "\n"; // 移动语义 MyUniquePtr<TestObj> p2 = std::move(p1); if (!p1) { std::cout << "p1已经为空\n"; } std::cout << p2->val << "\n"; MyUniquePtr<TestObj> p3(new TestObj(200)); p3 = std::move(p2); std::cout << p3->val << "\n"; // release TestObj* raw = p3.release(); std::cout << "release拿到裸指针 val=" << raw->val << "\n"; delete raw; // release之后需要手动释放! return 0; }

只展示unique_ptr封装裸指针、禁止拷贝、支持移动、析构自动释放等核心功能,以下内容暂不展示:

  1. 只支持单个对象,不支持数组MyUniquePtr<int> arr(new int[10]);会 UB,析构调用delete而不是delete[]。标准库unique_ptr<T[]>会特化,使用delete[]
  2. 没有自定义删除器,标准std::unique_ptr支持传入自定义 deleter。
  3. 没有swap辅助函数。
  4. 此处用了new没用make_unique,下面会讲二者区别

3.3代码说明

(1)为什么 unique_ptr 禁止拷贝?

如果unique_ptr支持拷贝,那一个资源就会被两个unique_ptr同时管理。第一个指针出作用域,释放资源。第二个指针再出作用域,会再次释放同一块资源,直接触发双重释放崩溃为了守住独占所有权的核心语义,标准库直接删掉了unique_ptr的拷贝构造、拷贝赋值函数。从语法层面杜绝拷贝操作,从根源上避免bug。若试着写一行拷贝代码,编译器会直接报错,根本不让你编译通过,这就是编译期安全保障。

(2)移动语义与所有权转移

禁止拷贝,不代表不能传递资源。unique_ptr支持移动语义,这是C++11移动语法的经典应用,通过std::move()‌把左值强制转换为右值引用,告诉编译器“这个对象可以被移动”,接着unique_ptr的移动构造函数让调用者接管资源。拷贝是复制一份资源,两个指针共存。移动是转移所有权,原指针放弃控制权,新指针接管资源。整个过程,始终只有一个指针管理资源,完全符合独占语义。

这个特性在函数传参、函数返回值场景里用得特别多。比如函数返回一个unique_ptr,本质就是把资源所有权转移给调用方,安全又高效。

3.4 unique_ptr的使用

返回列表