C++ 智能指针:原理与实现

从思想与原理说起,直到一个简单的实现。不是大而全的细致讨论,而是个人的总结笔记。

请注意,本文编写于 106 天前,最后修改于 106 天前,其中某些信息可能已经过时。

若问起 Java 与 C++ 在使用体验上的差别,许多人都会提到「垃圾回收」这个词,Java 自带垃圾回收机制,而 C++ 没有。同样是面向对象的程序语言,这一点上的设计理念却如此大相径庭。有个古老的段子说,Java 的设计者认为:“内存管理这么重要的事情怎么能交给愚蠢的程序员呢!”,而 C++ 设计者认为:“内存管理这么重要的事情怎么能交给愚蠢的机器呢!”二者好像都有些道理。

C/C++ 的指针把计算机底层暴露给了程序员,在使其变得灵活强大的同时也增加了程序员的负担,特别是对初学者来说,内存忘记释放、野指针满天飞都是常见问题,而且没那么容易克服。即使是对有经验的程序员来说,随着项目规模变大,资源管理也会成为一个令人头疼的问题。

为了解决内存泄漏的难题,C++ 11 标准引入了三种智能指针:std::shared_ptrstd::unique_ptrstd::weak_ptr。但只会用是不足够的,这篇文章就来说说智能指针的基本思想与实现方法。注意,文章中的代码自然与 std 的实现有所不同,旨在抓住思想本质,免去为了适应特殊情况而引入的更多细节。若实际项目中真遇到那些所谓「特殊情况」,则仔细研读 std 的代码是很好的方案。

裸指针有什么问题?

首先要明确的是我们究竟想要解决什么问题。在 C++ 中通过 new 关键字在堆上申请空间后,除非使用 delete 关键字释放这块资源,否则这块资源对系统来说就是被占用的,直到整个程序结束才会被回收。即使当前程序已经不再需要这块资源,或者已经丧失了对这块资源的控制,也是如此。一个简单的例子:

for(int i=0; i<100; i++)
    char* p = new char[100];

这段代码如果出现在程序中,运行完毕之后计算机可用内存会减少 10000 个字节,这 10000 个字节就像凭空蒸发了一样,既无法在本程序的其它地方使用(p 的作用域仅限 for 循环内),也无法被其它程序使用(系统不会回收这块内存),这就是内存泄漏(memory leak)。如果你的程序多次运行了上面的代码,只要次数够多,迟早耗尽物理内存导致崩溃。这是忘了写 delete 的情况,还有一种情况:

int* p = new int[100];
try {
    // 这里抛出了异常
} catch (...) {
    // 这里程序结束
}
delete []p;

上面的例子中,由于抛出了异常而进入了 catch 块,程序无法执行后面代码而导致了内存泄漏。这就是异常不安全的问题。

分析问题需抓住本质。内存泄漏的根本原因是:指针变量与其指向的资源不一定会同时被释放。比如上面的例子,指针变量 p 离开 for 语句块则立即被释放,但是指向的资源却没有释放,这就导致没有指针是指向那块资源的,也就是程序丧失了对资源的控制。

从指针的行为来理解,这是理所当然的:如果有多个指针指向同一块资源,不可能其中一个指针变量释放了就去把资源也释放掉,因为如果这样的话,下面这段代码就会变得莫名其妙了:

char* getArray(int n) {
    char *p = new char[n];
    return p;
}
char* pArray = getArray(100); // 申请 100 个字节的资源

若指针行为真变成前面所说,即指针变量总是与指向资源同时释放,那么上面这段代码显然是错误的:p 在离开 getArray 函数后就被释放了,对应的那 100 字节也被释放掉,那么 pArray 就根本没用了。

由此可见指针与其指向的资源需要保持一定的独立性,不能做过分严格的绑定。那么如何做内存管理?

聪明的程序员们发现,虽然指针不应该与其对应资源同生同死,但是反过来想有一点是明确的:任何一块 new 出来的内存资源,如果没有任何指针指向它,那么它肯定是没用的,应该被释放掉。这就是智能指针的设计基础。

秘技:引用计数

如果 C++ 足够聪明,每块 new 出来的资源都知道去数一数有多少个指针变量指向它,并且在没有指针指向它的时候释放资源就好了。可惜 C++ 没有那么聪明,此事需要程序员自己动手。

Idea 已经有了,也就是对每一块 new 出来的资源都维护一个计数,保存所有指向它的指针数量,这个数就叫做引用计数。新增指针指向这块资源,引用计数自增;指针释放或者改去指向别的地方,引用计数自减。当引用计数减到零,就去释放资源。具有这个性质的指针即可称为「智能指针」。

智能指针设计

智能指针肯定不是一个简单结构,它至少要有以下性质:

  1. 包含两个信息:引用计数与所指向资源的裸指针
  2. 表现出指针的行为,对程序员来说它是透明的
  3. 对所有的数据类型都适用

这些需求决定智能指针一定是类的对象,并且为了让它适用于所有类型,它还得是模板类的对象。基本的结构如下:

template<class T>
class SmartPointer
{
public:
    SmartPointer();
    ~SmartPointer();
    // other methods
    // ...
private:
    T* m_Pointer; // 指向资源的指针
    int* m_RefPointer; // 指向引用计数的指针
}

由于每块资源都只对应一个引用计数,因此智能指针中的引用计数要存储为其指针,这样每个智能指针都能操作这内存中唯一的一个引用计数。

现在要做的是围绕这个结构,完善其成员函数。

构造函数

通常,我们至少需要实现这几种构造函数:默认构造函数;从对象指针构造;从另一智能指针构造。为了方便使用,可再实现一种静态方法,用于替代 C++ 的 new 关键字,使封装性更高。

默认构造函数

template<typename T>
SmartPointer<T>::SmartPointer()
    : m_Pointer(nullptr), m_RefPointer(nullptr){}

默认的,不指向任何资源,不存在引用计数。

从对象指针构造

template<typename T>
SmartPointer<T>::SmartPointer(T* target)
    :m_RefPointer(nullptr), m_Pointer(target)
{
    addReference();
}

从对象指针构造时,初始化成员指针为对象指针,并初始化引用计数为 1。这里特别需要注意,由代码实现可见,引用计数实际上保存的是指向资源的智能指针数,并不是所有指针数。这个问题的原因以及带来的问题及解决方法后面会提到。

从另一智能指针构造

template<typename T>
SmartPointer<T>::SmartPointer(const SmartPointer<T>& from)
    : m_RefPointer(from.m_RefPointer), m_Pointer(from.m_Pointer)
{
    addReference();
}

从另一智能指针构造时,除了初始化成员变量,由于增加了新的智能指针指向对应资源,还应该增加引用计数。

静态 New 方法

这本身不属于构造函数之列,但是有必要单独说明。观察上文的「从对象指针构造」的构造函数,构造完成后引用计数是 1,而不是 2。这说明引用计数保存的是指向资源的智能指针数,并不包括裸指针。这是因为只有智能指针才能维护引用计数,因此要避免裸指针与智能指针的混用。因此一般推荐再多实现一个静态的 New() 方法,提高智能指针的封装程度,避免直接使用 C++ 的 new 关键字。

// 声明
static T* New();

// 定义
template<typename T>
T* SmartPointer<T>::New()
{
    return new T();
}

// 使用(这里需要重载 = 操作符,后文会说)
SmartPointer<T> pointerT = SmartPointer<T>::New();

上面第 11 行执行时会调用「从对象指针构造」的构造函数,但是并没有出现指向所开辟资源的裸指针,并且封装了 new 关键字,提高安全性的同时使智能指针的使用体验更统一。

析构函数

析构函数需要完成两件事:引用计数自减,若减至零,则释放资源。

template<typename T>
SmartPointer<T>::~SmartPointer()
{
    removeReference();
}

引用计数管理函数

至少需要包括两个部分:增加引用计数与减少引用计数。

增加引用计数

template<typename T>
void SmartPointer<T>::addReference()
{
    if (m_RefPointer)
        (*m_RefPointer)++;
    else
        m_RefPointer = new int(1);
}

减少引用计数

template<typename T>
void SmartPointer<T>::removeReference()
{
    if (m_RefPointer)
    {
        (*m_RefPointer)--;
        if (*m_RefPointer == 0) // 若引用计数归零
        {
            delete m_RefPointer; // 删除引用计数
            delete m_Pointer; // 释放指向资源
            m_RefPointer = nullptr;
            m_Pointer = nullptr;
        }
    }
}

操作符重载

为了使智能指针表现如同普通指针,需要对某些操作符进行重载。

相等判断

判断两个智能指针是否相等,也即是否指向相同的资源。

template<typename T>
bool SmartPointer<T>::operator == (const SmartPointer<T>& other) const
{
    return m_Pointer == other.m_Pointer;
}

对应的有不等判断:

template<typename T>
bool SmartPointer<T>::operator != (const SmartPointer<T>& other) const
{
    return !operator==(other);
}

作为赋值语句的左值

此时代表本智能指针不再指向原来的资源了,需要将原本资源的引用计数减一,并对新指向资源的引用计数加一。

template<typename T>
SmartPointer<T>& SmartPointer<T>::operator = (const SmartPointer<T>& that)
{
    if (this != &that) // 避免自己给自己赋值
    {
        removeReference();
        this->m_Pointer = that.m_Pointer;
        this->m_RefPointer = that.m_RefPointer;
        addReference();
    }
    return *this;
}

解引用

重载 * 操作符,实现裸指针的 * 方法。

template<typename T>
T& SmartPointer<T>::operator*() const
{
    return *m_Pointer;
}

公共成员调用方法

重载 -> 操作符。

template<typename T>
T* SmartPointer<T>::operator->() const
{
    return m_Pointer;
}

测试

以上就是最基本的一个智能指针实现了,完整的代码可以查看:

SmartPointer.h
#pragma once
/* 声明 */
template<typename T>
class SmartPointer
{
public:
    SmartPointer(); // 默认构造函数
    SmartPointer(T* target); // 从对象创建
    SmartPointer(const SmartPointer<T>& from); // 从智能指针创建
    ~SmartPointer();

    static T* New(); // 静态方法
    
    // 操作符重载
    bool operator==(const SmartPointer<T>& other) const;
    bool operator!=(const SmartPointer<T>& other) const;
    SmartPointer<T>& operator=(const SmartPointer<T>& that);
    T& operator*() const;
    T* operator->() const;
    
    // 获取引用计数
    int getRefCount();

protected:
    void addReference();
    void removeReference();

private:
    T*      m_Pointer;
    int*    m_RefPointer;
};


/* 实现 */
template<typename T>
SmartPointer<T>::SmartPointer()
    : m_Pointer(nullptr), m_RefPointer(nullptr){}

template<typename T>
SmartPointer<T>::SmartPointer(const SmartPointer<T>& from)
    : m_RefPointer(from.m_RefPointer), m_Pointer(from.m_Pointer)
{
    addReference();
}

template<typename T>
SmartPointer<T>::SmartPointer(T* target)
    :m_RefPointer(nullptr), m_Pointer(target)
{
    addReference();
}

template<typename T>
SmartPointer<T>::~SmartPointer()
{
    removeReference();
}

template<typename T>
T* SmartPointer<T>::New()
{
    return new T();
}

template<typename T>
void SmartPointer<T>::addReference()
{
    if (m_RefPointer)
        (*m_RefPointer)++;
    else
        m_RefPointer = new int(1);
}

template<typename T>
void SmartPointer<T>::removeReference()
{
    if (m_RefPointer)
    {
        (*m_RefPointer)--;
        if (*m_RefPointer == 0) // 若引用计数归零
        {
            delete m_RefPointer; // 删除引用计数
            delete m_Pointer; // 释放指向资源
            m_RefPointer = nullptr;
            m_Pointer = nullptr;
        }
    }
}

template<typename T>
bool SmartPointer<T>::operator == (const SmartPointer<T>& other) const
{
    return m_Pointer == other.m_Pointer;
}

template<typename T>
bool SmartPointer<T>::operator != (const SmartPointer<T>& other) const
{
    return !operator==(other);
}

template<typename T>
SmartPointer<T>& SmartPointer<T>::operator = (const SmartPointer<T>& that)
{
    if (this != &that) // 避免自己给自己赋值
    {
        removeReference();
        this->m_Pointer = that.m_Pointer;
        this->m_RefPointer = that.m_RefPointer;
        addReference();
    }
    return *this;
}

template<typename T>
T& SmartPointer<T>::operator*() const
{
    return *m_Pointer;
}

template<typename T>
T* SmartPointer<T>::operator->() const
{
    return m_Pointer;
}

template<typename T>
int SmartPointer<T>::getRefCount()
{
    if (m_RefPointer)
        return *m_RefPointer;
    return -1;
}

我们编写代码测试一下:

main.cpp
#include <iostream>
#include "SmartPointer.h"

using namespace std;

class A // a class for test
{
public:
    A() { cout << "创建对象:" << this << endl; };
    ~A() { cout << "释放对象:" << this << endl; };

    void Print()
    {
        cout << "智能指针对程序员是透明的,当前对象地址:" << this << endl;
    }
};

int main()
{
    SmartPointer<A> sp_outer;

    {
        cout << "开始一个作用域\n";
        SmartPointer<A> sp_1 = SmartPointer<A>::New();
        cout << "从对象新建智能指针后,引用计数:" << sp_1.getRefCount() << endl;
    
        SmartPointer<A> sp_2 = sp_1;
        cout << "从智能指针复制后,引用计数:" << sp_2.getRefCount() << endl;
    
        sp_1 = SmartPointer<A>::New();
        cout << "智能指针作为左值被赋值,原对象引用计数:" << sp_2.getRefCount()
            << ",新对象引用计数:" << sp_1.getRefCount() << endl;
    
        sp_1->Print();
    
        sp_outer = sp_1;
    
        cout << "\n********\n走出作用域,自动释放对象:\n";
    }
    
    cout << "\n********\n保存在外部作用域的资源在程序结束时才会释放:\n";
}

编译运行后输出:

开始一个作用域
创建对象:013F07B0
从对象新建智能指针后,引用计数:1
从智能指针复制后,引用计数:2
创建对象:013F0900
智能指针作为左值被赋值,原对象引用计数:1,新对象引用计数:1
智能指针对程序员是透明的,当前对象地址:013F0900

********
走出作用域,自动释放对象:
释放对象:013F07B0

********
保存在外部作用域的资源在程序结束时才会释放:
释放对象:013F0900
请按任意键继续. . .

可见目的已经达到。

其它讨论

上文所编写的 SmartPointer 类即是一个智能指针雏形,与之起相似作用的是 std::shared_ptr。但是 C++11 标准还引入了两种指针:std::unique_ptrstd::weak_ptr,它们是干嘛的?

weak_ptr

智能指针有一个不能忽略的、很恼人的问题,那就是环状引用。看这个例子:

class ClassB;

class ClassA
{
public:
    ClassA() { cout << "ClassA Constructor..." << endl; }
    ~ClassA() { cout << "ClassA Destructor..." << endl; }
    SmartPointer<ClassB> pb;  // 在A中引用B
};

class ClassB
{
public:
    ClassB() { cout << "ClassB Constructor..." << endl; }
    ~ClassB() { cout << "ClassB Destructor..." << endl; }
    SmartPointer<ClassA> pa;  // 在B中引用A
};

int main()
{
    {
        // 创建对象,spa 指向对象引用计数为 1
        SmartPointer<ClassA> spa = SmartPointer<ClassA>::New();
        // 创建对象,spb 指向对象引用计数为1
        SmartPointer<ClassB> spb = SmartPointer<ClassB>::New();
        spa->pb = spb; // 复制指针,spb 与 spa->pb 指向相同对象,引用计数为 2
        spb->pa = spa; // 复制指针,spa 与 spb->pa 指向相同对象,引用计数为 2
    }
    // 作用域结束
    // spa 销毁,spa 指向对象引用计数为 1
    // spb 销毁,spb 指向对象引用计数为 1
    // 引用计数都没有归零,因此不会释放资源
    cout<< "作用域结\n" << endl;
}

运行一下就知道,在作用域结束后,资源并不会被释放。这就是环形引用,由于环的存在,引用计数始终不能清零,导致无法释放资源,造成内存泄漏。

环形引用是不能通过程序语言的设计来解决的,我们只能打破这个环,来避免这个情况,这就是 weak_ptr 的作用。

严格来说 weak_ptr 并不能算作是一个智能指针,因为它不能影响对象的生命周期,也就是它的创建与释放不会影响引用计数,它只是一个观测者而已。一个环形引用,若其中一个节点被换成了 weak_ptr,那么这个环就不能称之为环,问题便解决了。

weak_ptr 并没有重载 *-> 操作符,程序员是不能用它直接访问对象方法的。这是合理的,因为既然 weak_ptr 不能影响引用计数,就有可能存在一种情况:weak_ptr 指向的对象被销毁了,但自身还存在。此时需要特别地实现一种方法,来检测其指向的对象是否存在,若存在,返回一个真正的智能指针。std 的做法是 weak_ptr::lock() 方法。

unique_ptr

unique_ptr 是一种更严格的、更自私的智能指针,当它自身被析构时,它所指向的对象也一并被析构。 并且还有一个性质:它不允许除它以外的智能指针指向同一对象(也就是没有实现 copy 方法),不过可以通过 move 来实现转移所有权,也就是转移到另一个unique_ptr 来管理对象。unique_ptr 有利于异常安全,但是似乎用的稍微少一些。

写在后面

最近重新梳理 C++ 中的一些基础知识,总结出一个规律:越是安全越是高级的语言,越是不信任程序员。这些语言的设计理念就是要把可能出错的地方都自动处理好,而不是指望程序员处理。

所谓程序语言是否安全,难道是指机器在执行代码时出错的可能性吗?这种理解是错误的。一旦代码写定,机器执行二进制指令都是一样的,没有 C++ 编译出的 binary 比 Java 的 binary 更容易出错的说法。机器几乎不会出错,不论是在跑什么语言。

所谓安全性,是指程序员把事情搞砸的可能性。高级语言的从语言特性、语法规则设计的角度,尽可能地去把程序员瞎写带来的隐患降低,这是提高安全性的本质。这一点在 C++ 的 const 关键字上体现得尤为明显。降低了自由度是肯定的,但是带来了更强的鲁棒性,一切也变得和平起来。

这么一想,其中的政治意味竟然是如此浓厚。

添加新评论

已有 4 条评论

许久不见,主题这么高端了

熊猫小A 熊猫小A 回复 @枂下

好久不见,看来老哥最近很忙啊

打破无评论惨案,另,为什么不放到维基站点里。

可以在那边再发一份(不过这篇文章太长了,好像不适合那边简短的风格