自动引用计数的实现

在帮同学调 Qt 的程序的时候,发现了很好用的 QString,和 MFC 的 CString 一样是对字符串的封装,提供了许多实用函数。不过 QString 给我感觉最实用的是自动的资源生命周期管理机制,而且很明显是基于引用计数的。由于新语言瘾又上来了,再加上想到了 TJS,我就想实现自己的用引用计数管理的类。原理很简单,不过由于我没有亲自写过,所以在实现的时候遇到了困难,幸好都分析解决了。

其实 C++ 新标准中有 std::shared_ptr<T> 类,但是我不喜欢 shared_ptr<int> p = new int[10]; 这样强制要用指针的写法,而且每次都要带一个 shared_ptr 太费劲。如果我需要一个自动数据类(例如字符串),如下写法哪个更简单:

shared_ptr<String> pStr = new String(); // 写法1
String str; // 写法2

在用的时候,我关注的重点是 String 内的数据而不是 shared_ptr 的实现方式,所以 shared_ptr 能省就省。而且不是有人说过吗,要会用 Boost(shared_ptr 这个标准来自 Boost),更要理解它所使用的“奇技淫巧”的原理。所以为了掌握自主知识产权,还是有必要自己动手写一个引用计数管理的。

引用计数的概念很简单,就是在对象内部维护一个计数器。引用到这个对象的时候,计数器增加1,这个对象的一个引用被解除的时候,计数器减少1。当计数器减少到0的瞬间,销毁这个对象持有的所有资源。以 QString 为例子,使用起来是这样的:

QString g_str;

void foo() {
    bar();
    g_str = QString(); // 0 (释放)
}

void bar() {
    QString str = "This is a string."; // 1
    QString str2 = str; // 2
    PrintString(str);
    g_str = str; // 3
} // 1

void PrintString(QString str) {
    std::cout << str.toStdString() << std::endl; // 3
} // 2

从上面的例子可以看清 str 所维护的数据的生存周期中,从分配到被定义域外的变量 g_str 引用,再到最后自动释放,整个引用计数的变化。由始至终没有一句手动释放缓冲区的代码,释放工作全部由 QString 自动完成,而且即使是函数内变量被外部变量引用,也能保证在使用着的时候一定不会提前释放。是不是很方便?

我们来看一下在 C++ 中对象是如何传递的。重点是两个地方:拷贝构造函数,和赋值运算符。

对于一个类 Class1,如果不指定(仅仅声明空类),编译器会生成如如下形式等价的代码:

class Class1 {
    Class1(); // 默认构造函数
public:
    Class1(const Class1 &); // 拷贝构造函数
    Class1 &operator=(const Class1 &); // 默认赋值运算
}

拷贝构造函数在什么时候调用呢?这种时候:

Class1 c = Class1();

在这个例子中,先构造了一个 Class1 的匿名实例,然后将这个实例的 const 引用作为参数,创建出 c 这个实例。

再看赋值运算符在什么时候调用呢?这种时候:

Class1 another;
c = another;

拷贝构造函数和赋值运算符一个在对象初始化(无值→有值)时调用,一个在对象赋值(有值→有值)时调用。不管是哪一个,都隐含了“所有权转移”的概念。所以如果要控制所有权(例如 RAII)的时候,必须同时重写这两个函数才能正确地处理转移逻辑。这里的“所有权”是数据所有权,在引用计数的情况下是比较简单的,对于单个实例,只有“持有”和“未持有”两种状态,只有在持有同一个对象的集合这个范围内才需要计算引用的次数,是最简单的情形了。

处理的时候也相对比较简单,我们声明增减计数的方法:

uint32 Object::DecRef() const {
      if (--*_pReferenceCounter == 0) {
            _internalCollectingReference = true;
            delete this;
            _internalCollectingReference = false;
            return 0;
      }
    return *_pReferenceCounter;
}

uint32 Object::IncRef() const {
      return ++*_pReferenceCounter;
}

当然,在实际应用中,必须保证增加计数和减少计数这两个操作是原子的(atomic),也即计数的更新是不可分割的,要不完全成功,要不完全失败。这里作为演示,直接使用自增和自减。常用库,例如 Qt,是使用 CPU 指令来实现原子操作的。

然后分别是构造和赋值:

Object::Object(const Object &other) {
      _internalCollectingReference = false;
      _pReferenceCounter = other._pReferenceCounter;
      IncRef();
}

Object &Object::operator=(const Object &other) {
      DecRef();
      __cloneFields(&other);
      IncRef();
      return *this;
}

在析构的时候需要更新计数:

Object::~Object() {
      if (!_internalCollectingReference) {
            if (_pReferenceCounter) {
                  --*_pReferenceCounter;
            }
      }
      if (ShouldDestruct()) {
            delete _pReferenceCounter;
            _pReferenceCounter = nullptr;
      }
}

这里 ShouldDestruct() 函数判断是否应该进行其他资源的回收工作(我觉得,统一用 Disposable 模式可能好一些)。要注意的是析构函数一定要是虚析构函数,才能利用 ShouldDestruct() 正确判断资源回收的时机。举个例子:

String::~String() {
      if (ShouldDestruct()) {
            delete[] _str;
            _str = nullptr;
      }
}

既然是引用计数,一个对象会被引用多次,就需要判断同一性。这个判断很简单,二者为同一个对象的条件是内部计数引用指向是一致的。

至此一个简单的引用计数基类已经完成,其他的类只需要继承它,注意处理自带资源的回收时机,以及关键的运算符覆盖(其中调用父类的函数)就可以实现自动的生命周期管理。试试输出一个对象的计数变化状况就知道了。

最后要提醒的是,单凭引用计数无法解决循环引用(A→B→C→A)的死锁销毁问题。

分享到 评论