C++ 智能指针解析

为什么需要智能指针

众所周知,Java 和 C/C++ 中间隔着一堵由内存动态分配和垃圾回收机制所围成的墙。

java 大佬们经常吐槽 C++ 没有垃圾回收(Gabage Collector)机制,而 C++ 爱好者也经常攻击 Java 限制太死,不够灵活。

其实 Java 并不是最早实践内存动态分配和垃圾自动回收机制的语言,这个构想在 1960 年就已经在MIT 的教学语言 Lisp 中提出。

在 C/C++ 中最为灵活的工具就是指针了,但指针也是很多噩梦的源头,内存泄露(memory leak)和内存非法访问应该算是 C++ 程序员的家常便饭了。

但是又不能抛弃指针带来的灵活性,不过幸好 C++ 里有了智能指针,虽然在使用上有局限性,但是能够最大程度减少程序员手动管理指针生命周期的负担。

指针的强大很大程度上源于它们能追踪动态分配的内存。通过指针来 管理这部分内存是很多操作的基础,包括一些用来处理复杂数据结构 的操作。要完全利用这些能力,需要理解C的动态内存管理是怎么回 事。

C++是面向内存编程,Java是面向数据结构编程。

C++里,内存是裸露的,可以拿到地址,随意徜徉,增了删了,没人拦你,等到跑的时候再崩给你看。

Java里,能操作的都是设计好的数据结构,array有长度,String不可变,每一个都是安全的,在内存和程序员之间,隔着JVM,像是包住了边边角角的房间,随便小孩折腾,不会受伤。

Java程序员是孩子,嚷嚷要这个那个,玩完了就丢,JVM是家长,买买买,还要负责收拾。有的孩子熊点,屋子很乱,收拾起来费劲,但房子还在。

C++程序员是神,操纵着江河湖海,日月星辰,但能力越大,责任越大,万一新来的神比较愣,手一滑,宇宙就退出了。

新手写C++,像是抱着一捆指针,在浩瀚的内存中裸奔。跑着跑着,有的针掉了,不知踪影,内存就泄露了;跑着跑着,突然被人逮住,按在地上打的error纷飞,内存就越界了;终于到了,舒了口气,把针插在脚下,念出咒语,

“delete”

系统就崩溃了

C/C++ 常见的内存错误

在实际的 C/C++ 开发中,我们经常会遇到诸如 coredump、segmentfault 之类的内存问题,使用指针也会出现各种问题,比如:

  • 野指针:未初始化或已经被释放的指针被称为野指针
  • 空指针:指向空地址的指针被称为空指针
  • 内存泄漏:如果在使用完动态分配的内存后忘记释放,就会造成内存泄漏,长时间运行的程序可能会消耗大量内存。
  • 悬空指针:指向已经释放的内存的指针被称为悬空指针
  • 内存泄漏和悬空指针的混合:在一些情况下,由于内存泄漏和悬空指针共同存在,程序可能会出现异常行为。
  • ...

智能指针

而智能指针是一种可以自动管理内存的指针,它可以在不需要手动释放内存的情况下,确保对象被正确地销毁。

这种指针可以显著降低程序中的内存泄漏和悬空指针的风险。智能指针的核心思想就是 RAII, C++中,智能指针常用的主要是两个类实现:

  • std::unique_ptr
  • std::shared_ptr

std::unique_ptr

std::unique_ptr是一个独占所有权的智能指针,它保证指向的内存只能由一个unique_ptr拥有,不能共享所有权。

当unique_ptr超出作用域时,它所指向的内存会自动释放。

#include <memory>
#include <iostream>

int main() {
    std::unique_ptr<int> ptr(new int(10));
    std::cout << *ptr << std::endl; // 输出10
    // unique_ptr在超出作用域时自动释放所拥有的内存
    return 0;
}

std::shared_ptr

std::shared_ptr是一个共享所有权的智能指针,它允许多个shared_ptr指向同一个对象,当最后一个shared_ptr超出作用域时,所指向的内存才会被自动释放。

#include <memory>
#include <iostream>

int main() {
    std::shared_ptr<int> ptr1(new int(10));
    std::shared_ptr<int> ptr2 = ptr1; // 通过拷贝构造函数创建一个新的shared_ptr,此时引用计数为2
    std::cout << *ptr1 << " " << *ptr2 << std::endl; // 输出10 10
    // ptr2超出作用域时,所指向的内存不会被释放,因为此时ptr1仍然持有对该内存的引用
    return 0;
}

总的来说,智能指针可以提高程序的安全性和可靠性,避免内存泄漏和悬空指针等问题。

但需要注意的是,智能指针不是万能的,也并不是一定要使用的,有些场景下手动管理内存可能更为合适。

深入理解 C++ shared_ptr之手写

正如这篇文章 智能指针 (opens new window)所说,智能指针是一种可以自动管理内存的指针,它可以在不需要手动释放内存的情况下,确保对象被正确地销毁。

可以显著降低程序中的内存泄漏和悬空指针的风险。

而用得比较多的一种智能指针就是 shared_ptr ,从名字也可以看出来,shared 强调分享,也就是指针的所有权不是独占。

shared_ptr 的使用

shared_ptr 的一个关键特性是可以共享所有权,即多个 shared_ptr 可以同时指向并拥有同一个对象。当最后一个拥有该对象的 shared_ptr 被销毁或者释放该对象的所有权时,对象会自动被删除。这种行为通过引用计数实现,即 shared_ptr 有一个成员变量记录有多少个 shared_ptr 共享同一个对象。

#include <iostream>
#include <memory>

class MyClass {
public:
    MyClass() { std::cout << "MyClass 构造函数\n"; }
    ~MyClass() { std::cout << "MyClass 析构函数\n"; }
    void do_something() { std::cout << "MyClass::do_something() 被调用\n"; }
};

int main() {
    {
        std::shared_ptr<MyClass> ptr1 = std::make_shared<MyClass>();
        {
            std::shared_ptr<MyClass> ptr2 = ptr1; // 这里共享 MyClass 对象的所有权
            ptr1->do_something();
            ptr2->do_something();
            std::cout << "ptr1 和 ptr2 作用域结束前的引用计数: " << ptr1.use_count() << std::endl;
        } // 这里 ptr2 被销毁,但是 MyClass 对象不会被删除,因为 ptr1 仍然拥有它的所有权
        std::cout << "ptr1 作用域结束前的引用计数: " << ptr1.use_count() << std::endl;
    } // 这里 ptr1 被销毁,同时 MyClass 对象也会被删除,因为它是最后一个拥有对象所有权的 shared_ptr

    return 0;
}
MyClass 构造函数
MyClass::do_something() 被调用
MyClass::do_something() 被调用
ptr1 和 ptr2 作用域结束前的引用计数: 2
ptr1 作用域结束前的引用计数: 1
MyClass 析构函数

引用计数如何实现的

说起 shared_ptr 大家都知道引用计数,但是问引用计数实现的细节,不少同学就回答不上来了,其实引用计数本身是使用指针实现的,也就是将计数变量存储在堆上,所以共享指针的shared_ptr 就存储一个指向堆内存的指针,文章后面会手动实现一个 shared_ptr。

shared_ptr 的 double free 问题

double free 问题就是一块内存空间或者资源被释放两次。

那么为什么会释放两次呢?

double free 可能是下面这些原因造成的:

  • 直接使用原始指针创建多个 shared_ptr,而没有使用 shared_ptr 的 make_shared 工厂函数,从而导致多个独立的引用计数。
  • 循环引用,即两个或多个 shared_ptr 互相引用,导致引用计数永远无法降为零,从而无法释放内存。

如何解决 double free

解决 shared_ptr double free 问题的方法:

  • 使用 make_shared 函数创建 shared_ptr 实例,而不是直接使用原始指针。这样可以确保所有 shared_ptr 实例共享相同的引用计数。
  • 对于可能产生循环引用的情况,使用 weak_ptr。weak_ptr 是一种不控制对象生命周期的智能指针,它只观察对象,而不增加引用计数。这可以避免循环引用导致的内存泄漏问题。
#include <iostream>
#include <memory>
class B; // 前向声明
class A {
public:
    std::shared_ptr<B> b_ptr;
    ~A() {
        std::cout << "A destructor called" << std::endl;
    }
};

class B {
public:
    std::shared_ptr<A> a_ptr;
    ~B() {
        std::cout << "B destructor called" << std::endl;
    }
};

int main() {
    {
        std::shared_ptr<A> a = std::make_shared<A>();
        std::shared_ptr<B> b = std::make_shared<B>();
        a->b_ptr = b; // A 指向 B
        b->a_ptr = a; // B 指向 A
    } // a 和 b 离开作用域,但由于循环引用,它们的析构函数不会被调用

    std::cout << "End of main" << std::endl;
    return 0;
}

上面这种循环引用问题可以使用std::weak_ptr来避免循环引用。

std::weak_ptr不会增加所指向对象的引用计数,因此不会导致循环引用。

下面👇这个代码就解决了循环引用问题:

#include <iostream>
#include <memory>

class B; // 前向声明

class A {
public:
    std::shared_ptr<B> b_ptr;
    ~A() {
        std::cout << "A destructor called" << std::endl;
    }
};

class B {
public:
    std::weak_ptr<A> a_ptr; // 使用 weak_ptr 替代 shared_ptr
    ~B() {
        std::cout << "B destructor called" << std::endl;
    }
};

int main() {
    {
        std::shared_ptr<A> a = std::make_shared<A>();
        std::shared_ptr<B> b = std::make_shared<B>();
        a->b_ptr = b; // A 指向 B
        b->a_ptr = a; // B 对 A 使用 weak_ptr
    } // a 和 b 离开作用域,它们的析构函数会被正确调用

    std::cout << "End of main" << std::endl;
    return 0;
}

但是使用 weak_ptr 也有几点注意事项:

  • 如果需要访问 weak_ptr 所指向的对象,需要将std::weak_ptr 通过 weak_ptr::lock() 临时转换为std::shared_ptr.
  • 在使用lock()方法之前,应当检查使用 std::weak_ptr::expired() 检查 std::weak_ptr是否有效,即它所指向的对象是否仍然存在。

enable_shared_from_this

从名字可以看出几个关键词:enable: 允许 shared 指 shared_ptr, from_this 则是指从类自身this 构造 shared_ptr。

struct SomeData;
void SomeAPI(const std::shared_ptr<SomeData>& d) {}

struct SomeData {
    void NeedCallSomeAPI() {
        // 需要用this调用SomeAPI
    }
};

上面这段代码需要在NeedCallSomeAPI函数中调用SomeAPI,而SomeAPI需要的是一个std::shared_ptr的实参。这个时候应该怎么做? 这样吗?

struct SomeData {
    void NeedCallSomeAPI() {
        SomeAPI(std::shared_ptr<SomeData>{this});
    }
};

上面的做法是错误的,因为SomeAPI调用结束后std::shared_ptr对象的引用计数会降为0,导致 this 被意外释放。

这种情况下,我们需要使用std::enable_shared_from_this ,使用方法很简单,只需要让SomeData继承std::enable_shared_from_this,然后调用shared_from_this,例如:

#include <memory>

struct SomeData;
void SomeAPI(const std::shared_ptr<SomeData>& d) {}

struct SomeData:std::enable_shared_from_this<SomeData> {
    static std::shared_ptr<SomeData> Create() {
        return std::shared_ptr<SomeData>(new SomeData);
    }
    void NeedCallSomeAPI() {
        SomeAPI(shared_from_this());
    }
private:
    SomeData() {}
};


int main()
{
    auto d{ SomeData::Create() };
    d->NeedCallSomeAPI();
}

总结一下,当下面👇这些场景用到 shared_ptr 时,需要搭配上 enable_shared_from_this:

  • 当你需要将this指针传递给其他函数或方法,而这些函数或方法需要一个std::shared_ptr,而不是裸指针。

  • 当你需要在类的成员函数内部创建指向当前对象的std::shared_ptr,例如在回调函数或事件处理中。

线程安全性

其实 shared_ptr 线程不安全主要来自于引用计数有并发更新的风险,当然引用计数本身也可以使用原子atomic。

所以在多线程环境中使用智能指针时,需要采取额外的措施来确保线程安全,

如互斥锁(std::mutex)或原子操作(std::atomic)来确保线程安全。

手写 shared_ptr

这是 C++ 面试常考的一个环节,有的会让你说实现思路,有的则直接需要手写一个。

刚才上面说过了,shared_ptr 的关键就是在于 引用计数。要实现一个简化版本的 shared_ptr,需要考虑以下几点:

  • 在智能指针类中存储裸指针(raw pointer)和引用计数。
  • 在构造函数中为裸指针和引用计数分配内存。
  • 在拷贝构造函数和赋值操作符中正确地更新引用计数。
  • 在析构函数中递减引用计数,并在引用计数为零时删除对象和引用计数。

实现要点:

  1. 引用计数机制
    • 使用std::atomic<int>来管理对象的引用计数,确保多线程环境下的线程安全性。
    • 引用计数器在堆上分配,多个SharedPtr共享同一个对象时引用计数递增。
    • 当引用计数为0时,自动删除对象和引用计数器。
  2. 线程安全
  • 通过std::atomic保证引用计数递增、递减的原子性。
  • 使用std::memory_order_acq_rel等内存顺序保证多线程下的数据同步和一致性。
  1. 拷贝和移动语义
  • 拷贝构造:增加引用计数,多个SharedPtr共享同一资源。
  • 拷贝赋值:先释放当前持有资源,之后递增新对象的引用计数。
  • 移动构造:通过“偷取”资源的方式,避免不必要的拷贝,提高效率。
  • 移动赋值:释放当前对象的资源,然后接管其他对象的资源。
  1. 自定义删除器 - 支持模板化的自定义删除器,默认删除器DefaultDeleter使用delete释放对象。
  • 可以通过传入自定义删除器来灵活管理不同类型的资源(如内存池、文件句柄等)。
  1. 资源管理与异常安全 - 使用RAII(资源获取即初始化)原则,确保异常发生时,资源能够正确释放。
    • release()函数负责减少引用计数,引用计数为0时删除对象和计数器,避免资源泄漏。
  2. 智能指针的基本操作
  • operator* 和 operator->:支持类似于原生指针的解引用和成员访问操作。
  • operator bool:提供对象有效性判断,可以用于if条件语句中。
  • reset:允许重置指针为新的资源,并管理新的引用计数。
  • swap:支持高效交换两个SharedPtr对象管理的资源。
  • use_count:返回当前引用计数,用于判断资源是否有多个所有者。

以下是一个简化版的 shared_ptr 的实现:


#include <atomic>

template <typename T>
class DefaultDeleter
{
    void operator()(const T* ptr){
        delete ptr;
    }
};


template <typename T, typename Deleter = DefaultDeleter<T> >
class SharedPtr
{
public:
    explicit SharedPtr(T* ptr = nullptr)
    : _use_count(ptr ? new std::atomic<int>(1) : nullptr),
      _ptr(ptr)
    {}

    SharedPtr(const SharedPtr& other)
    {
        if(this != &other)
        {
            _ptr = other._ptr;
            _use_count = other._use_count;
            if(_ptr) _use_count->fetch_add(1, std::memory_order_acq_rel);
        }
    }

    SharedPtr(SharedPtr && other) noexcept
    {
        if(this != other)
        {
            _ptr = other._ptr;
            _use_count = other._use_count;
            other._ptr = nullptr;
            other._use_count = nullptr;
        }
    }

    ~SharedPtr()
    {
        release();
    }

    T& operator*() const {return *_ptr;}
    T* operator->() const {return _ptr;}
    explicit operator bool() const {return _ptr != nullptr;}

    SharedPtr& operator=(const SharedPtr& other)
    {
        if(this != &other)
        {
            release();
            _use_count = other._use_count;
            _ptr = other._ptr;
            if(_ptr) _use_count->fetch_add(1, std::memory_order_acq_rel);
        }
        return *this;
    }
    SharedPtr& operator=(SharedPtr && other) noexcept
    {
        if(this != &other)
        {
            release();
            _use_count = std::move(other._use_count);
            _ptr = std::move(other._ptr);
            other._use_count = nullptr;
            other._ptr = nullptr;
        }
        return *this;
    }

    T* get() const {return _ptr;}

    void reset(T* ptr)
    {
        release();
        _ptr = ptr;
        _use_count = _ptr ? new std::atomic<int>(1) : nullptr;
    }

    void swap(SharedPtr& other) noexcept
    {
        std::swap(_ptr, other._ptr);
        std::swap(_use_count, other._use_count);
    }

    [[nodiscard]] int use_count() const {return _use_count->load(std::memory_order_acquire);}

private:
    void release()
    {
        if(_use_count)
        {
            _use_count->fetch_sub(1, std::memory_order_acq_rel);
            if(_use_count->load(std::memory_order_acquire) == 0)
            {
                Deleter()(_ptr);
                delete _use_count;
            }
        }


    }
    std::atomic<int> *_use_count;  // 指向同一个对象的shared_ptr需要共享引用,因此必须声明为堆对象
    T *_ptr;
};

深入理解 C++ weak_ptr

weak_ptr 是什么?

std::weak_ptr是C++11引入的一种智能指针,主要与std::shared_ptr配合使用。

它的主要作用是解决循环引用问题、观察std::shared_ptr对象而不影响引用计数,以及在需要时提供对底层资源的访问。

  1. 解决循环引用问题:当两个或多个std::shared_ptr对象互相引用时,会导致循环引用。这种情况下,这些对象的引用计数永远不会变为0,从而导致内存泄漏。std::weak_ptr可以打破这种循环引用,因为它不会增加引用计数。只需要将其中一个对象的std::shared_ptr替换为std::weak_ptr,即可解决循环引用问题。
  2. 观察std::shared_ptr对象:std::weak_ptr可以用作观察者,监视std::shared_ptr对象的生命周期。它不会增加引用计数,因此不会影响资源的释放。

深入理解weak_ptr: 资源所有权问题

虽然智能指针进入C++11标准库已经有十多年了,但是我们对部分细节的理解还是比较局限。以std::weak_ptr为例,很多人的理解只是停留在避免std::shared_ptr出现相互引用,导致对象无法析构,内存无法释放的问题。当然,并不是说这种用法有什么不对,恰恰相反,它是一个非常经典的使用场景。但是std::weak_ptr的使用场景或者说它诞生的理念却不仅仅是这些,如果没有更加透彻理解std::weak_ptr,也很难合理的使用std::shared_ptr

std::weak_ptr从概念上,它是一个智能指针,相对于std::shared_ptr,它对于引用的对象是“弱引用”的关系。

简单来说,它并不“拥有”对象本身。

如果我们去类比生活中的场景,那么它可以是一个房地产中介。房地产中介并不拥有房子,但是我们有办法找到注册过的房产资源。在客户想要买房子的时候,它起初并不知道房子是否已经卖出了,它需要找到房主询问后再答复客户。

std::weak_ptr做的事情几乎和房产中介是一模一样的。std::weak_ptr并不拥有对象,在另外一个std::shared_ptr想要拥有对象的时候,它并不能做决定,需要转化到一个std::shared_ptr后才能使用对象。所以std::weak_ptr只是一个“引路人”而已。

说了这么多,那么std::weak_ptr除了解决相互引用的问题,还能做什么?答案是:一切应该不具有对象所有权,又想安全访问对象的情况。

还是以互相引用的情况为例,通常的场景是:一个公司类可以拥有员工,那么这些员工就使用std::shared_ptr维护。另外有时候我们希望员工也能找到他的公司,所以也是用std::shared_ptr维护,这个时候问题就出来了。但是实际情况是,员工并不拥有公司,所以应该用std::weak_ptr来维护对公司的指针。

再举一个例子:我们要使用异步方式执行一系列的Task,并且Task执行完毕后获取最后的结果。所以发起Task的一方和异步执行Task的一方都需要拥有Task。但是有时候,我们还想去了解一个Task的执行状态,比如每10秒看看进度如何,这种时候也许我们会将Task放到一个链表中做监控。这里需要注意的是,这个监控链表并不应该拥有Task本身,放到链表中的Task的生命周期不应该被一个观察者修改。所以这个时候就需要用到std::weak_ptr来安全的访问Task对象了。

最后再来聊一个新手使用std::weak_ptr容易被坑的地方:对象资源竞争。以下代码在多线程程序中是存在很大风险的,因为wp.expired()和wp.lock()运行的期间对象可能被释放:

std::weak_ptr<SomeClass> wp{ sp };

if (!wp.expired()) {
    wp.lock()->DoSomething();
}

正确的做法是:

auto sp = wp.lock();
if (sp) {
    sp->DoSomething();
}

std::weak_ptrlock函数是一个原子操作。有趣的是,最开始的C++11标准是没有提到原子操作的,C++14标准才对这一点进行了补充。