前言

在C++中,智能指针是用于自动管理动态内存的工具,能够有效避免内存泄漏和悬空指针问题

C++标准库:提供了以下几种智能指针,主要包括:std::unique_ptr、std::shared_ptr和std::weak_ptr,以及废弃的std::auto_ptr。

Qt框架:而在Qt框架下,Qt也类似的封装了: QScopedPointer、 QSharedPointer、QWeakPointer,和标准库前3个指针是一样的,另外还额外的提供QPointer。

Windows库:而在windows下,则提供COM引用指针。

下面将分别介绍各个智能指针的背后原理,以及它的用途。

 

std::unique_ptr和QScopedPointer

底层实现原理

  1. 独占所有权
    • std::unique_ptr 通过禁止拷贝构造函数和拷贝赋值运算符(将它们声明为 delete)来实现独占所有权。
    • 它支持移动语义(通过移动构造函数和移动赋值运算符),可以将所有权从一个 std::unique_ptr 转移到另一个。
  2. 自动释放资源
    • std::unique_ptr 内部封装了一个原始指针,并在析构时调用 delete 或 delete[](如果是数组)来释放资源。
    • 它使用了 RAII(资源获取即初始化)技术,确保资源在离开作用域时自动释放。
  3. 自定义删除器
    • std::unique_ptr 允许指定自定义删除器,用于释放资源(例如文件句柄、套接字等)。

下面是简化的实现版本

template <typename T>
class SimpleUniquePtr {
private:
    T* ptr; // 原始指针

public:
    // 构造函数
    explicit SimpleUniquePtr(T* p = nullptr) : ptr(p) {}

    // 禁止拷贝
    SimpleUniquePtr(const SimpleUniquePtr&) = delete;
    SimpleUniquePtr& operator=(const SimpleUniquePtr&) = delete;

    // 移动构造函数
    SimpleUniquePtr(SimpleUniquePtr&& other) noexcept : ptr(other.ptr) {
        other.ptr = nullptr; // 转移所有权
    }

    // 移动赋值运算符
    SimpleUniquePtr& operator=(SimpleUniquePtr&& other) noexcept {
        if (this != &other) {
            delete ptr;       // 释放当前资源
            ptr = other.ptr;   // 转移所有权
            other.ptr = nullptr;
        }
        return *this;
    }

    // 析构函数
    ~SimpleUniquePtr() {
        delete ptr;
    }

    // 解引用运算符
    T& operator*() const { return *ptr; }
    T* operator->() const { return ptr; }

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

    // 释放所有权
    T* release() {
        T* temp = ptr;
        ptr = nullptr;
        return temp;
    }

    // 重置指针
    void reset(T* p = nullptr) {
        delete ptr;
        ptr = p;
    }
};

这里可以看出,unique_ptr默认在析构函数里析构包装的裸指针,因此它是不可以拷贝的,因为拷贝就是将A的内容拷贝给B, 然后A, B就会共享内置的裸指针,因此,A和B的析构函数,会对裸指针进行两次释放,继而崩溃。

使用要领

  1. 所以平时有需要进行unique_ptr传递时,就需要使用移动语义。尤其是当我们使用容器包装unique_ptr时,例如:std::vector<std::unique_ptr<int>>
   std::vector<std::unique_ptr<int>> vec;

    // 创建一个 unique_ptr
    std::unique_ptr<int> ptr(new int(42));

    // 使用 std::move 将所有权转移到 vector
    vec.push_back(std::move(ptr)); 

    // 检查 ptr 是否为空(所有权已转移) 
    // !!! 这里是重点,所有权一旦转移,原对象就置空了。
    if (!ptr) {
        std::cout << "ptr is now null\n";
    }

    // 使用 emplace_back 直接构造 unique_ptr
    vec.emplace_back(new int(100));

切记不能使用push_back(ptr)这种,这个会编译报错,错误大概描述就是ptr不支持拷贝的意思。

  1. unique_ptr核心要领就是独占,当前的类对象对某一个指针居于独占所有权(绝对所有权),简单的说这个指针就是在当前类对象构建,也是由当前类对象释放时,可以考虑使用unique_ptr。
  2. 记住,其它地方使用时,不是将unique_ptr传递过去,而是提取裸指针传过去。因为unique_ptr->unique_ptr只能是独占(所有权)转移,所以其它地方使用时,只能传递裸指针给它使用。
  3. 另外独占是唯一占有的意思,千万不要想着,你独占你的,我独占我的,两个地方对同一个指针进行unique_ptr包装,那么就会造成Double Free的情况。
  4. 其实这玩意,就是减少主动释放,没有别的好处,所以尽量不要用容器存这玩意。

 

std::shared_ptr和std::weak_ptr

std::shared_ptr 和 std::weak_ptr 是 C++11 引入的智能指针,用于管理动态分配的内存。它们的核心特点是支持共享所有权和弱引用,能够有效解决资源管理和循环引用问题。


1. std::shared_ptr 的底层原理

std::shared_ptr 是一种共享所有权的智能指针,多个 std::shared_ptr 可以指向同一个对象,并通过引用计数来管理对象的生命周期。

底层实现原理

  1. 引用计数
    • 每个 std::shared_ptr 内部维护一个控制块(control block),其中包含一个引用计数器(use_count)。
    • 当一个新的 std::shared_ptr 指向对象时,引用计数加 1。
    • 当一个 std::shared_ptr 被销毁或重置时,引用计数减 1。
    • 当引用计数为 0 时,对象被自动释放。
  2. 控制块
    • 控制块通常包含以下信息:
      • 引用计数(use_count):记录当前有多少个 std::shared_ptr 指向对象。
      • 弱引用计数(weak_count):记录当前有多少个 std::weak_ptr 指向对象。
      • 删除器(deleter):用于释放对象的函数或函数对象。
  3. 线程安全
    • std::shared_ptr 的引用计数操作是线程安全的,但对象本身的访问需要额外的同步机制。

// 控制块是所有强引用和弱引用对象共享的一个东西,简单的说共享指针转移的所有其他指针内部的控制块指针是同一个玩意。
//当shared_ptr释放时,强引用计数-1, 当强引用计数为0, 执行控制模块中析构器。
//当弱引用计数 == 0 和强引用计数==0时, 控制块释放。

// 例如 ref_count 就是一个 类似 控制块!
template <typename T>
class SimpleSharedPtr {
private:
    T* ptr;
    int* ref_count;

public:
    // constuctor
    explicit SimpleSharedPtr(T* p = nullptr) : ptr(p), ref_count(nullptr) {
        if (ptr) {
            ref_count = new int(1);
        }
    }

    // copy construactor
    SimpleSharedPtr(const SimpleSharedPtr& other) : ptr(other.ptr), ref_count(other.ref_count) {
        if (ref_count) {
            (*ref_count)++;
        }
    }

    // copy assign operator
    SimpleSharedPtr& operator=(const SimpleSharedPtr& other) {
        if (this != &other) {
            release();
            ptr = other.ptr;
            ref_count = other.ref_count;
            if (ref_count) {
                ++(*ref_count);
            }
        }
        return *this;
    }

    ~SimpleSharedPtr() {
        release();
    }

    T& operator*() const { return *ptr; }

    T* operator->() const { return ptr; }

private:
    void release() {
        if (ref_count) {
            --(*ref_count);
            if (*ref_count == 0) {
                delete ptr;
                delete ref_count;
            }

        }

    }
};

2. std::weak_ptr 的底层原理

std::weak_ptr 是一种弱引用的智能指针,它不增加引用计数,通常与 std::shared_ptr 一起使用,用于解决循环引用问题。

底层实现原理

  1. 弱引用
    • std::weak_ptr 指向由 std::shared_ptr 管理的对象,但不增加引用计数。
    • 它通过控制块中的弱引用计数(weak_count)来跟踪对象的存在。
  2. 提升为 std::shared_ptr
    • 使用 std::weak_ptr::lock() 方法可以将 std::weak_ptr 提升为 std::shared_ptr
    • 如果对象仍然存在(引用计数 > 0),则返回一个有效的 std::shared_ptr,否则返回空的 std::shared_ptr
  3. 解决循环引用
    • std::weak_ptr 可以打破 std::shared_ptr 之间的循环引用,避免内存泄漏。

内存模型

控制块是所有强引用和弱引用对象共享的一个东西,简单的说共享指针转移的所有其他指针内部的控制块指针是同一个玩意。

当第一个shared_ptr构建时,构建控制块,前引用计数+1(此时为1)。

shared_ptr->shared_ptr时, 强引用计数+1。

shared_ptr->weak_ptr时,弱引用计数+1

weak_ptr->weak_ptr时,弱引用计数+1

weak_ptr->shared_ptr时,如果强引用计数>=1时,强引用计数+1

当shared_ptr释放时,强引用计数-1, 当强引用计数为0, 执行控制模块中析构器。

当weak_ptr释放时,弱引用计数-1,。

当弱引用计数 == 0 和强引用计数==0时, 控制块释放。

下面使用代码举一个例子:

{

 
    //构建一个智能指针对象,此时控制块被构建,且强引用计数=1, 弱引用计数为0
    std::shared_ptr<int> bbb = std::make_shared<int>(3);
    {
        //控制块赋值到cc对象中, 此时弱引用计数 = 1, 强引用计数为1
        std::weak_ptr<int> cc = bbb;
        //控制块赋值到cc1对象中,此时弱引用计数为1, 强引用计数为2,
        std::shared_ptr<int> cc1 = cc.lock();
        //作用域退出时,先析构cc1, 前引用计数-1, 再析构cc对象,弱引用计数-1
    }
    //弱引用计数+1
    std::weak_ptr<int> aa = bbb; 
    //强引用计数-1,此时强引用计数为0, 控制模块的析构器执行,指针被析构
    bbb = nullptr;
    //此作用退出时,弱引用计数-1, 控制模块被析构
}

 

问题: auto pt = weak_ptr.lock(); weak_ptr是一个弱指针, 请问这行代码可能发生一些什么?

使用要领

  1. 一定要避免出现环引用

例如:

    StatePtr state = std::make_shared<State>();
    state->onEnter = [=]()
    {
          state->data = 1; 
    };
    //这段代码已经出现了环引用,其中state->onEnter是一个函数对象,它通过拷贝的方式持有state,这就会导致state无法释放,因为只有state释放了,才会释放onEnter,而onEnter持有state一份引用计数,导致state永远有一份引用计数。

 

提供一种解决思路:

你遇到的是 C++ 中典型的循环引用问题。在这段代码里,lambda 表达式以值捕获的方式捕获了state,而state自身又包含了这个 lambda 表达式,这就构成了循环引用,会让引用计数无法降为 0,进而造成内存泄漏。

 

下面为你介绍几种解决循环引用的办法:

1. 采用弱引用(Weak Reference)

借助std::weak_ptr来打破循环引用是一种常用的方式:
#include <memory>
#include <iostream>

struct State;
using StatePtr = std::shared_ptr<State>;
using StateWeakPtr = std::weak_ptr<State>;

struct State {
    int data;
    std::function<void()> onEnter;
};

int main() {
    StatePtr state = std::make_shared<State>();
    StateWeakPtr weakState = state; // 创建弱引用
    
    state->onEnter = [weakState]() {
        if (auto sharedState = weakState.lock()) { // 临时获取shared_ptr
            sharedState->data = 1;
        }
    };
    
    state->onEnter(); // 调用lambda
    
    // state离开作用域,内存被正确释放
    return 0;
}

2. 捕获原始指针(需谨慎使用)

如果能保证对象的生命周期,捕获原始指针也是可行的:
StatePtr state = std::make_shared<State>();
State* rawPtr = state.get(); // 保存原始指针

state->onEnter = [rawPtr]() {
    if (rawPtr) { // 手动检查指针有效性
        rawPtr->data = 1;
    }
};

 

不过这种方法存在风险,要是对象提前被释放了,使用原始指针就会引发悬空指针的问题。

3. 延迟绑定(更安全的做法)

在调用时再绑定上下文:
struct State {
    int data;
    std::function<void(StatePtr)> onEnter; // 接受StatePtr作为参数
    
    void executeOnEnter() {
        if (onEnter) onEnter(shared_from_this()); // 传递shared_ptr
    }
};

StatePtr state = std::make_shared<State>();
state->onEnter = [](StatePtr s) {
    s->data = 1;
};

state->executeOnEnter(); // 调用时传递上下文

解决方案总结

  • 推荐方案:优先考虑使用std::weak_ptr,因为它能安全地打破循环引用。
  • 性能考量:使用std::weak_ptr::lock()会有少量的性能开销,不过在大多数情况下可以忽略不计。
  • 使用场景:如果能确定对象的生命周期,捕获原始指针会更简单,但需要格外小心。

 

通过这些方法,就能避免循环引用,保证资源被正确释放。

 

循环引用除了使用弱引用来解决还有其他办法吗?
除了智能指针,还有哪些技术可以避免内存泄漏?
解释一下在上述代码中`std::weak_ptr`是如何工作的?

 

  1. 切记不要对同一个指针进行多次智能指针构造

思考:为什么C++会提供std::make_shared? 它其实就是简单的包装了 std::shared_ptr(new T(xx))。

原因:因为一旦使用std::make_shared的方式构造一个智能指针,则会构造一个控制块,该控制块已经持有裸指针的所有权,对于初学者来说,容易犯的错误就是使用已经有所有权的指针去构造std::shared_ptr。

例如:一个指针在使用时,被其它对象释放,于是就想着,强行用std::shared_ptr把传递过来的指针进行智能指针包装,但是该指针之所以被释放,是因为当前使用者不具备完全所有权,强行包装不会任何作用。

结论: 因此一个智能指针是不能进行半道性封装的,必须从new开始就包装成智能指针,简单的说:

std::shared_ptr(new T(xx))是不能分开的,一般不会写成 :

T* t = new T(xx);

std::shared_ptr<T> ss(t);

尤其是上诉代码隔离很远,甚至在构造ss, 你都不清楚当前ss是否具备t指针绝对所有权时去构造。

当然如果你非常清楚,t指针的所有权,或者原本就在ss释放时,delete t时,是可以使用这种方案的

备注:我记得最开始使用std::shared_ptr<T> ss(t)构造是有编译警告的,并推荐你使用std::make_shared。

  1. 不要对一个指针进行多次智能指针构造

在第二点已经讲的很清楚,每次智能指针构造,都会构造一个控制块,该控制块对指针是由独占所有权,只不过是所有的智能指针对控制块有共享所有权。

因此当你第二次对同一个指针进行智能指针构造时,那么就会导致指针Double Free。

  1. 智能指针的传递只能是智能指针传递给智能指针

存在特殊情形,必须使用裸指针,但是明知道当前裸指针没有释放,但如果想获取当前裸指针的强引用对象时,切记不要进行二次构造,而是需要使用std::shared_from_this的方法,参考如下代码:

#include <iostream>
#include <memory>

class MyClass : public std::enable_shared_from_this<MyClass> {
public:
    void doSomething() {
        // 获取当前对象的 shared_ptr
        std::shared_ptr<MyClass> self = shared_from_this();
        std::cout << "use_count: " << self.use_count() << "\n";
    }
};

int main() {
    // 创建 shared_ptr
    std::shared_ptr<MyClass> ptr(new MyClass());

    // 调用成员函数
    ptr->doSomething(); // 输出 use_count: 2

    return 0;
}

要领:

  • MyClass必须继承自std::enable_shared_from_this
  • MyClass只能通过智能指针构造,如果new MyClass,调用shared_from_this会抛出异常,然后崩溃(可以补获)。
  • 一般着这种类,需要屏蔽构造函数,并提供静态构造智能指针的函数。如:
class MyClass : public std::enable_shared_from_this<MyClass> {
public:
    void doSomething() {
        // 获取当前对象的 shared_ptr
        std::shared_ptr<MyClass> self = shared_from_this();
        std::cout << "use_count: " << self.use_count() << "\n";
    }
    static std::shared_ptr<MyClass> create()
    {
        return std::make_shared<MyClass>();
    }
private:
    MyClass(){}
};

总结:

智能指针初次构造时,会构造控制块,控制块对指针有独占所有权,且无法剥离,控制块在析构析构前一定会析构指针,智能可以通过拷贝传递控制块的共享权,通过赋空,剥离与控制块的共享权。

切记不要传已经有所有权的指针进来,例如,从其它智能指针抠出来的,或者不明来源可能存在其它所有权的指针。

所谓所有权,就是该指针的生命周期被别人管理,最重要是它的析构被其调用。

 

QPointer

QPointer是一个非常特殊的玩意,下午用一个简图表示:

QObject构造时,内置构造控制块,当我们使用QPointer<QObject>(obj)构造一个QPointer对象,它会将obj的控制块传递给QPointer, 且引用计数+1,QPointer并不对布局指针所有权,它仅仅共享了控制块,当QObject对象释放时,则会置空控制块的对象地址,且引用计数-1, 当所有持有当前OQbject的控制块的东西全部释放时,也就是引用计数为0, 释放控制块。

控制块由OQbject构造,但不一定由QObject释放,当QPointer获取控制块内部对象地址为空时,表示引用的指针已经释放。

可以参考FFPointer和FFObject的实现, 就是FFObject构造了std::shared_ptr<char>指针,FFPointer则持有std::weak_ptr<char>, 当FFObject析构时,std::weak_ptr<char>锁定时会获得空指针,因此就可以判定FFObject是否析构,原理上是差不多,只不过借助了引用计数的控制块而已。

 

使用要领

QPointer的底层原理非常的简单,但是很容易进行误用,尤其是在跨线程使用的时候。

例如:

A线程想用使用某一个指针B的时候,由于A线程不具备指针B的所有权,致使A线程运行时,B指针被其它线程释放致使崩溃。

于是有人就想着,在使用QPointer对B指针进行包装,通过判定QPointer是否为空,来决定后续是否使用B指针,这种解决是错误的。例如:

QPointer<QObject> bbb; //这个是前置封装

//--------------------------------
//以下是模拟线程的线程代码
if (bbb == nullptr) //bbb已经被释放
   return; 

//不为空则则对bbb进行操作
QString objName = bbb->objectName();
//----------------------------------

这样处理,能够一定程度降低崩溃概率,但是仍然会崩溃。原因是判定bbb是否为空到对bbb进行操作这段代码并非原子,也就是说,在判定没有析构之后,到对bbb进行操作期间,bbb是可以被其它线程析构的。

所以QPointer是不适合应用于上诉场景的,上诉场景修复方案只有一种,就是线程必须具备bbb指针的所有权,或者通过其它手段保证线程的生命周期低于bbb的生命周期。

 

Window的COM指针

COM(Component Object Model)是 Windows 平台上的一种组件对象模型,用于实现跨语言和跨进程的组件化编程。COM 对象通过接口(Interface)暴露其功能,而 COM 指针则是指向这些接口的指针。

1. COM 指针的特点

  • 引用计数:COM 对象使用引用计数来管理生命周期。每个 COM 对象内部维护一个引用计数,当引用计数为 0 时,对象会被销毁。
  • 接口查询:COM 对象可以实现多个接口,通过 QueryInterface 方法可以查询对象是否支持某个接口。
  • 跨语言支持:COM 接口是二进制的,因此可以在不同编程语言中使用(如 C++、C#、VB 等)。
  • 跨进程支持:COM 支持进程内组件(DLL)和进程外组件(EXE),并通过代理和存根实现跨进程通信。

2. COM 指针的类型

  • 裸指针:直接使用 IUnknown* 或具体接口指针(如 IDispatch*)。
  • 智能指针:使用 ATL::CComPtr 或 Microsoft::WRL::ComPtr 等智能指针类,自动管理引用计数。

3. COM 指针的生命周期

  • AddRef:增加引用计数。
  • Release:减少引用计数,当引用计数为 0 时销毁对象。
  • QueryInterface:查询对象是否支持某个接口。

4. COM与标准库的引用计数指针对比

  1. COM严格依赖IUnkown的,即便ATL::CComPtr也是智能封装IUnkown以及IUnkown派生的类。
  2. COM的控制块是在IUnkown对象内部,是指针本身具备引用计数,与智能指针无关,智能指针只是做了简单RAII包装,它在构造时,执行com->AddRef(), 在析构时执行com->Release, 同时拷贝构造时也执行com->AddRef()。
  3. COM指针本身是没有弱引用计数的,当然它提供了弱引用只能指针,例如:WRL::WeakRef,这都是智能指针上层的包装,跟COM指针是没有关系的。
  4. COM指针是指针管理的控制包,而标准库的引用计数指针则是智能指针管理控制包,包控制指针,COM的智能指针可以多次构造,而标准库的智能指针则不行。

4. COM指针与标准库智能指针联用

裸用COM需要手动调用AddRef和Release, 而微软提供的相关智能指针库是更上层的库,有时候包含它挺麻烦,又或者说COM是自定义实现的,例如FFVBLMODEL::IUnkown。

这个时候就可以考虑使用标准库的智能指针联用,减少手动调用AddRef和Release。

void MyDeleter(MyComObject* obj)
{
	obj->Release();
}
std::shared_ptr<MyComObject> comObject(new MyComObject, MyDeleter);

如果你是半道上拿到的COM智能指针,记得先AddRef, 在构造。

 

这样处理之后,comObject其实有两个引用计数包(控制块), 指针的生命周期仍然由COM本身,只不过std::shared_ptr持有一份COM指针的计数,同时智能指针的计数包,仅仅管理这一份引用计数的生命周期。

参考文档 : https://mp.weixin.qq.com/s/b_xlJF1-Cplgs-uawWuUow

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。