【C++开发】从裸指针到智能指针

前言

老C/C++开发,用惯了裸指针的方式,之前发布过一篇文章,专门分享结构体通过函数指针成员的方式是实现类成员函数的效果,本篇风格也一样,从C/C++的裸指针,再延伸到C++智能指针。

C/C++ 聊聊结构体、指针、类_qt 结构体指针-CSDN博客文章浏览阅读759次,点赞8次,收藏21次。本文面向QT开发者,系统梳理C/C++中结构体、指针及类的核心概念与演进关系。重点涵盖:C结构体作为数据聚合器、C++结构体作为轻量级类;指针函数与函数指针的区别及在动态库调用中的应用;类从结构体的延伸,封装、继承、构造/析构顺序、虚函数表与this指针机制;运行时多态(虚函数)与编译时多态(重载、模板)的实现原理。内容紧扣QT开发中对接第三方库和OOP实践需求。https://blog.csdn.net/liangyuna8787/article/details/155556760?spm=1001.2014.3001.5502

概述

裸指针:只保存地址,不表达所有权,也不保证释放。在动态内存场景下,如果不主动释放内存,或者改变保存的地址,都会导致内存泄漏。

智能指针:通过 ‌RAII(资源获取即初始化) 机制,①在智能指针对象创建 (构造函数调用)时,接管动态分配的内存或其他资源 ;②当智能指针对象离开作用域 (例如函数结束、代码块退出或发生异常栈展开)时,编译器会自动调用其析构函数 ,从而自动释放所管理的资源。

纯理论会感觉比较生硬,以下从语法编译&&系统&&资源方向拆解出易懂的概念。

语法编译&&系统&&资源

我的一位大学计算机教授老师(杨耀)在给我们上代码怎么变成可执行程序的课程时,详细的讲解了gcc/g++编译器的执行过程,其中最浅显的理解如下:

LIFO 嵌套结构规则

代码中的'{'和'}'之间是一个作用域,他在语法和资源分配上都符合LIFO 嵌套结构(典型数据结构栈--"后进先出")的规则。(需要注意的是这个LIFO嵌套结构不是系统中的物理栈)

以下是一个常见的代码案例,展示入栈/出栈节点。

复制代码
//-------------------------------案例1--------------------------------------
int g;                    // 栈底(第一个入栈):全局作用域
void f() {                // 压入:函数体作用域
    int a;
    {                     // 压入:内层块作用域
        int b;
        // 符号查找:沿栈向上搜索 b → a → g(最近声明优先,即"遮蔽")
    }                     // 弹出:b 离开可见性,符号表恢复
}                         // 弹出:a 离开可见性

//-------------------------------案例2--------------------------------------
void c() { /* ... */ }
void b() { c(); }
void a() { b(); }
int main() { a(); }
// 调用序列压栈:main → a → b → c
// 返回序列弹栈:c → b → a → main   (严格后进先出)

注意,'{'和'}'不是维持绝对的作用域资源释放规则,C++语法中也有特殊规则,比如在局部作用域中用static 定义的变量,这个静态变量存储在静态存储区 (.data/.bss),全局变量也存储在静态存储区中,所以在main()函数结束时,才会释放这个特殊的资源。

复制代码
void f() {
    static int counter = 0;   // 存储在静态数据区 (.data/.bss)--与全局变量一个区域
    counter++;
}   // counter 活到程序结束,下次调用值还在

生成/释放资源

LIFO 模型展示了主要生命周期,生命周期中最重要的就是资源的管理(生成/释放),应用程序运行时,内存中主要提供了栈、堆、静态存储区的资源分配。

内存的知识点可以查看我的另外一篇博文,本篇不展开

【QT入门到晋级】内存_qt new 结构体-CSDN博客文章浏览阅读803次,点赞15次,收藏16次。本文面向QT后台开发者,系统讲解C/C++在Linux环境下的内存布局与应用实践,涵盖栈、静态存储区、堆及虚拟地址空间(mmap)四大内存区域的特性、适用场景与性能对比。重点分析各类内存的生命周期、大小限制、效率差异及典型使用案例,如大数组处理、批量数据库操作、高清视频共享内存设计等,为QT开发者提供内存选型与优化依据。https://blog.csdn.net/liangyuna8787/article/details/150637450?spm=1001.2014.3001.5501 在语法层次上,LIFO 模型管理的是"变量",如果其中一个变量是裸指针,当裸指针所在作用域结束时,裸指针也会被直接释放掉,如果在裸指针被释放之前,开发人员通过new申请了堆资源(动态内存),但没有用delete释放堆资源(动态内存),那么这个堆资源就会一直存在,这就是内存泄漏常见的场景。

RAII 优化 LIFO 模型

LIFO 模型抽象本身只保证"自动变量的存储随作用域失效",它并不天然管理堆内存、文件句柄这些非栈资源,这也是在使用裸指针申请堆内存时,必须显示释放堆内存,才能规避内存泄漏的风险(出现异常退出时,不能完全规避这种风险)。

但如果引入RAII机制,以类替代裸指针 入栈,释放堆内存交由类的析构函数来完成。这样的好处就是①不需要显示释放堆内存;②异常退出情况下,也不会出现内存泄漏的风险(出栈时,必定会调用这个类的析构函数释放资源)。

以上描述**"以类替代裸指针入栈"** 的特征就是智能指针 的特性。智能指针本身就是一个类 ,按LIFO 模型资源出栈(作用域结束 / 异常展开 / 函数返回)时,必定会通过智能指针的析构函数释放堆内容,以下是流程图:

复制代码
栈上对象(unique_ptr<T>,8字节)
        │ 出栈(作用域结束 / 异常展开 / 函数返回)
        ▼
   析构函数 ~unique_ptr()
        │
        ▼
   delete ptr_   ──────► 堆上的 T 对象被销毁

智能指针的本质

一个单成员(裸指针)的类模板,把"堆内存的释放"这一行代码搬进了析构函数,从而把一个不受作用域管辖的资源,绑到一个严格受 LIFO 管辖的对象上。unique_ptr 是它的最简形式(零开销,delete 直挂),shared_ptr 是它的多路复用形式(释放动作变成"原子计数归零判定",且自己管理着第二层资源------控制块)。

以下是GCC libstdc++(bits/unique_ptr.h)的真实代码

复制代码
template<typename _Tp, typename _Dp = default_delete<_Tp> >
class unique_ptr {
    // 数据成员不是裸的 pair,而是一个 tuple ------ 暗藏 EBO 优化,见下文
    tuple<pointer, deleter_type> _M_t;

public:
    /// Destructor, the only important part
    ~unique_ptr() noexcept
    {
        auto& __ptr = _M_t._M_ptr();
        if (__ptr != nullptr)
            get_deleter()(std::move(__ptr));   // 通过删除器释放,而非直接 delete
        __ptr = pointer();                     // 置空
    }

    // reset 的逻辑与析构共享同一实现(do_reset / 直接内联)
    pointer reset(pointer __p = pointer()) noexcept {
        pointer __old = get();
        _M_t._M_ptr() = __p;
        if (__old != nullptr)
            get_deleter()(std::move(__old));
        return __old;
    }
    // ...
};

从上面的源码中,可以看到有一个独享智能指针unique_ptr的模板类,这个模板类包含两个参数:①要创建的对象;②自定义的删除函数。

智能指针删除器

默认删除器

不提供第二个参数(自定义的删除函数)时,就调用默认的析构函数,下个给出裸指针和同等效果的智能指针的对比代码

复制代码
#include <iostream>
#include <vector>
#include <windows.h>
#include <memory>
using namespace std;

// 自定义测试类,打印所有构造/析构的调用信息
class MyClass {
private:
    int val;
public:
    // 普通构造函数
    MyClass(int v = 0) : val(v) {
        cout << "[调用构造函数] val = " << val << endl;
    }

    void printMessage(const char* msg){
        std::cout << "Message: " << msg << std::endl;
    }
    // 析构函数
    ~MyClass() {
        cout << "[调用析构函数] val = " << val << endl;
    }
};


void old_process(int v = 0) {
    MyClass* obj = new MyClass(v);   // 手动分配
    obj->printMessage("hello nomal");
    delete obj;                     // 必须手动释放
}

void uq_process(int v = 1) {
    // 用 make_unique 创建,异常安全,避免 new 裸调用
    std::unique_ptr obj = std::make_unique<MyClass>(v);
    obj->printMessage("hello unique");
    // 离开作用域时,unique_ptr 自动析构 MyClass
}

int main() {
    // 新增这两行,强制控制台使用UTF-8编码,同时刷新输出缓冲
    SetConsoleOutputCP(CP_UTF8);
    ios::sync_with_stdio(true);
    cout << "=== main函数正式开始执行 ===" << endl;
    old_process();
    uq_process();
    return 0;
}

/*******打印信息********
=== main函数正式开始执行 ===
[调用构造函数] val = 0
Message: hello nomal
[调用析构函数] val = 0
[调用构造函数] val = 1
Message: hello unique
[调用析构函数] val = 1
***************/

自定义删除器

如果释放资源不仅仅是delete释放堆,还需要进行业务逻辑操作时(比如某个业务类退出时,必须同步通知关联的类时),可以自定义删除器,代码如下

复制代码
#include <iostream>
#include <vector>
#include <windows.h>
#include <memory>

class MyClass {
public:
    MyClass();
    ~MyClass();
    void printMessage(const char* msg) {          // ① 原代码多了一个 {,括号不配对
        std::cout << "Message: " << msg << std::endl;
    }
};

#ifdef __cplusplus
extern "C" {
#endif

MyClass* createMyClass() {
    return new MyClass();
}

void destroyMyClass(MyClass* obj) {
    //-----这里进行其他业务操作
    delete obj;               
}

#ifdef __cplusplus
}
#endif

#include <memory>
#include "MyClass.h"
// 删除器类型直接写函数指针类型:
//第二个参数void(*)(MyClass*)是void destroyMyClass(MyClass* obj) 的函数指针
using MyClassPtr = std::unique_ptr<MyClass, void(*)(MyClass*)>;

void demo1() {
    // 构造时必须同时传【对象指针】和【删除器函数】
    MyClassPtr p(createMyClass(), &destroyMyClass);
    
    
    //std::unique_ptr<MyClass, void(*)(MyClass*)> p(
    //    createMyClass(),
    //    &destroyMyClass   // 自定义删除器,函数指针方式
    //);

    p->printMessage("hello");
}   // 析构 → get_deleter()(ptr) → 通过函数指针调用 destroyMyClass(ptr) → delete obj

智能指针类型

类型 适用场景 注意事项
unique_ptr 独占资源、成员变量、工厂返回 不能拷贝,只能 move
shared_ptr 多所有者共享 有控制块和原子计数开销
weak_ptr 观察、缓存、打破循环引用 使用前必须 lock()
atomic<shared_ptr> 多线程原子切换指针 保护指针本身,不保护对象内容
out_ptr/inout_ptr 对接 C API C++23 起可用

unique_ptr:独占所有权

同一时刻只能有一个 unique_ptr 拥有对象,禁止拷贝,只允许移动 。是之前的裸指针升级替代版,大部分情况下,都是用 unique_ptr,下面是定义方法:

类别 写法 资源来源
一、裸指针接管 unique_ptr<T> p{new T}; new 表达式
一、裸指针接管 unique_ptr<T> p(new T); new 表达式
一、裸指针接管 unique_ptr<T[]> p(new T[n]); new[]
一、裸指针接管 unique_ptr<T, D> p(cApiCreate(), deleter); C 工厂函数
二、make 工厂 auto p = make_unique<T>(args); 函数内 new
二、make 工厂 auto p = make_unique<T[]>(n); 函数内 new[]
二、make 工厂 auto p = make_unique_for_overwrite<T[]>(n); 跳过清零
三、移动/转换 unique_ptr<T> p2(std::move(p1)); 另一个 unique_ptr
三、移动/转换 unique_ptr<T> p2 = std::move(p1); 同上
三、移动/转换 unique_ptr<Base> p = make_unique<Derived>(); 派生类指针
三、移动/转换 auto p = factory(); 工厂函数返回值
四、后期绑定 p.reset(new T(...)); 运行时决定
一、裸指针接管

前三项案例如下,第四项的C工厂函数参考前面的自定义删除器的案例。

复制代码
class Widget {
public:
    explicit Widget(std::string name) : name_(std::move(name)) {
        std::cout << "  [" << name_ << "] 构造\n";
    }
    ~Widget() {
        std::cout << "  [" << name_ << "] 析构\n";
    }
    void hello() const { std::cout << "  [" << name_ << "] hello\n"; }
private:
    std::string name_;
};

void demo_from_new() {
    // 直接初始化
    std::unique_ptr<Widget> p(new Widget("甲"));
    // 花括号括号初始化
    std::unique_ptr<Widget> q{new Widget("乙")};
    p->hello();
    q->hello();
}   // 输出析构:乙、甲(后构造先析构)

void demo_from_new_array() {
    // 注意模板参数是 T[],删除器自动变成 delete[]
    std::unique_ptr<Widget[]> arr(new Widget[3]{
        Widget("one"), Widget("two"), Widget("three")
    });
    arr[0].hello();      // 数组版用 operator[] 访问
}   // 一次性 delete[] 三个

int main() {
    // 新增这两行,强制控制台使用UTF-8编码,同时刷新输出缓冲
    SetConsoleOutputCP(CP_UTF8);
    ios::sync_with_stdio(true);
    cout << "=== main函数正式开始执行 ===" << endl;
    demo_from_new();
    demo_from_new_array();
    return 0;
}

/********打印输出*********
=== main函数正式开始执行 ===
  [甲] 构造
  [乙] 构造
  [甲] hello
  [乙] hello
  [乙] 析构
  [甲] 析构
  [one] 构造
  [two] 构造
  [three] 构造
  [one] hello
  [three] 析构
  [two] 析构
  [one] 析构
*****************/
二、make 工厂

前两项是最常用的用法,std::make_unique的优点在于:①异常安全;②代码里不出现裸 new;③ 与make_shared 风格统一,案例代码如下:

复制代码
class Widget {
public:
    explicit Widget(std::string name) : name_(std::move(name)) {
        std::cout << "  [" << name_ << "] 构造\n";
    }
    ~Widget() {
        std::cout << "  [" << name_ << "] 析构\n";
    }
    void hello() const { std::cout << "  [" << name_ << "] hello\n"; }
private:
    std::string name_;
};

void demo_make() {
    // C++14 起可用。auto 推导出 unique_ptr<Widget>
    auto p = std::make_unique<Widget>("旺财");

    // 显式类型也行(不推荐,多打字还容易写错)
    std::unique_ptr<Widget> q = std::make_unique<Widget>("小黑");

    p->hello();
}

void demo_make_array() {
    auto arr = std::make_unique<Widget[]>(3);  // 3 个元素,值初始化
    arr[0].hello();
    // 注意:这里只能默认构造(调用 Widget()),
    // 要带参数构造数组没有 make 版本,得退回 new Widget[3]{...}
}


int main() {
    // 新增这两行,强制控制台使用UTF-8编码,同时刷新输出缓冲
    SetConsoleOutputCP(CP_UTF8);
    ios::sync_with_stdio(true);
    cout << "=== main函数正式开始执行 ===" << endl;
    demo_make();
    demo_make_array();
    return 0;
}

/********打印输出*********
=== main函数正式开始执行 ===
  [甲] 构造
  [乙] 构造
  [甲] hello
  [乙] hello
  [乙] 析构
  [甲] 析构
  [one] 构造
  [two] 构造
  [three] 构造
  [one] hello
  [three] 析构
  [two] 析构
  [one] 析构
*****************/

第三项std::make_unique_for_overwrite用途是跳过memset清零过程,后续直接用 read/recv/memcpy 把数据写进去,因为马上会被覆盖,所以没必要先清零。如果写成 make_unique<char[]>(4096),它会先清零,然后 read() 又覆盖掉,清零就浪费了。

使用要点

  • 只适合 马上会整体覆盖写入 的场景,比如 IO 缓冲区、网络接收缓冲、文件读取缓冲。
  • 不能在写入之前读取缓冲区内容,否则是未定义行为。
  • 对于有构造函数的类类型,make_unique_for_overwrite 仍然会调用默认构造函数;它主要省的是 POD/数组的初始化开销。
  • 需要 C++20 支持。

可以把它理解成:"我知道这块内存马上会被写满,所以别帮我清零。"

三、移动/转换

前两种是常用的移动构造的方法,一种是圆括号,一种是等号,参见函数demo_move:

复制代码
#include <iostream>
#include <vector>
#include <windows.h>
#include <memory>
using namespace std;

class Widget {
public:
    Widget() : name_("默认") { std::cout << "  [默认] 构造\n"; }
    explicit Widget(std::string name) : name_(std::move(name)) {
        std::cout << "  [" << name_ << "] 构造\n";
    }
    ~Widget() {
        std::cout << "  [" << name_ << "] 析构\n";
    }
    void hello() const { std::cout << "  [" << name_ << "] hello\n"; }
private:
    std::string name_;
};

void demo_move() {
    auto p1 = std::make_unique<Widget>("原主");
    std::cout << "移动前 p1 是否为空: " << (p1 == nullptr) << "\n";   // false

    // 两种等价写法:
    std::unique_ptr<Widget> p2(std::move(p1));    // ① 圆括号
    // std::unique_ptr<Widget> p2 = std::move(p1);// ② 等号(拷贝初始化,走移动构造)

    std::cout << "移动后 p1 是否为空: " << (p1 == nullptr) << "\n";  // true!
    p2->hello();                                   // 只有 p2 能用
    // p1->hello();                                // ❌ 空指针解引用,崩溃
}

int main() {
    // 新增这两行,强制控制台使用UTF-8编码,同时刷新输出缓冲
    SetConsoleOutputCP(CP_UTF8);
    ios::sync_with_stdio(true);
    cout << "=== main函数正式开始执行 ===" << endl;
    demo_move();
    return 0;
}

关键理解 :std::move 之后,原指针立刻变空。它不是"复制了一份",而是"把钥匙交出去了"。

第三种make_unique用途是转换移动:派生类 → 基类(多态工厂的根基)

复制代码
struct Shape   { virtual ~Shape() = default; virtual void draw() const = 0; };
struct Circle  : Shape { void draw() const override { std::cout << "○\n"; } };
struct Square  : Shape { void draw() const override { std::cout << "□\n"; } };

void demo_convert() {
    // make_unique<Circle> 返回 unique_ptr<Circle>,
    // 直接初始化 unique_ptr<Shape> ------ 转换移动构造自动完成
    std::unique_ptr<Shape> s = std::make_unique<Circle>();
    s->draw();                                  // ○

    s = std::make_unique<Square>();             // 移动赋值:先 delete 旧的 ○,再接管 □
    s->draw();                                  // □
}   // 只析构 □(○ 早在赋值时就被释放了)

这是"工厂返回 unique_ptr<Base>"能成立的原因。前提 :Shape 必须有虚析构函数,否则 delete 走基类指针是 UB。

还有另外一种从工厂函数返回值接收的方式:

复制代码
std::unique_ptr<Widget> createWidget(std::string name) {
    return std::make_unique<Widget>(std::move(name));
    // 注意:这里【不要】写 return std::move(local)!
    // 返回局部变量直接 return,编译器做 RVO/移动,写 move 反而阻止优化
}

void demo_factory() {
    // std::unique_ptr<Widget> p = createWidget("工厂货"); // 接收返回值,不需要显式 move
    p->hello();
}
四、后期绑定(定义和使用分离)

先进行空定义,再进行reset重置更新,reset时,会调用之前类的析构函数。

复制代码
void demo_reset() {
    std::unique_ptr<Widget> p;               // 先空定义(第一类)
    std::cout << "--- 开始绑定 ---\n";

    p.reset(new Widget("绑定一号"));          // 接管一个裸指针
    p->hello();
    std::cout << "--- 换绑 ---\n";

    p.reset(new Widget("绑定二号"));          // 立即析构一号,接管二号
    p->hello();
    std::cout << "--- 清空 ---\n";

    p.reset();                                // 立即析构二号,p 变空
    // p = nullptr;                          // 完全等价的另一种写法
    std::cout << "--- 结束 ---\n";
}
/* 输出:
--- 开始绑定 ---
  [绑定一号] 构造
  [绑定一号] hello
--- 换绑 ---
  [绑定二号] 构造
  [绑定一号] 析构        ← reset(new...) 时,旧对象立即释放
  [绑定二号] hello
--- 清空 ---
  [绑定二号] 析构        ← reset() 时,立即释放
--- 结束 ---
*/
相关推荐
geats人山人海1 小时前
洛谷好题推荐 2026年10月24日
1024程序员节
xUxIAOrUIII1 小时前
CS329a-推理时计算扩展
1024程序员节
KryHan1 小时前
【JavaScript】第三章数组
1024程序员节
小南家的青蛙1 小时前
Redis的资料
redis·缓存·1024程序员节
初阳7851 小时前
【基于Qt5仿音乐播放器(一)】——项目准备
qt·1024程序员节
CAIE研习社1 小时前
跨专业考证如何帮助求职?从原有专业到AI应用能力的组合思路
1024程序员节
小小龙学IT1 小时前
Python Polars 高性能数据处理引擎深度解析
开发语言·python·rust·1024程序员节
Nuanyt1 小时前
Hot 100 刷题笔记 · 数组与哈希专题(3 题)
java·笔记·算法·leetcode·哈希算法·1024程序员节
Hardworking6661 小时前
2026年10月24日系统集成项目管理工程师上午考试内容
1024程序员节·系统集成项目管理工程师