引用计数智能指针
本文介绍了一种管理 C++ 中指针的解决方案,其方式类似于 COM。
引言
随着 WinRT 等技术的出现,微软正将 C++ 重新推到舞台中央。众所周知,C++ 作为一种原生语言可以产生相当优化的代码,如果使用得当,能够为您的应用程序带来良好的性能。然而,C++ 的一个缺点是您必须自己管理系统的内存和资源。
对于像 Java、C#、VB.NET 或 Python 这样以托管语言为起点的开发者来说,自己管理内存可能显得棘手,甚至过时!
由于 WinRT 基于经典的 COM,而 COM 的原则之一是引用计数,因此我尝试将 COM 智能指针的引用计数安全性引入到普通的 C++ 类实例中。这篇短文介绍了一个引用计数智能指针,它使用了 COM 组件的一些原理。它并不声称是完美的解决方案,但它为原生编程世界带来了一些实例管理功能,并说明了 C++ 开发和内存管理的一些原理。
注意:提供的解决方案适用于 Visual Studio 2012 Premium。然而,智能指针的代码可以与任何其他 Visual Studio 版本以及从 Windows XP 到 Windows 8 的操作系统一起使用。Visual Studio 2012 为 C++ 代码提供了一个新的单元测试框架,因此该解决方案需要此版本。
背景
最好能充分理解 C++ 中的指针和引用意味着什么,这与 C# 等托管语言中的引用不同,但本文的目标也是帮助那些还不熟悉这些概念的开发者正确理解它们。
C++ 的其他概念,如运算符重载、不同类型构造函数(如复制构造函数)的使用,以及简单的模板类,都在本文中使用。我将简要解释它们的用法以及可能有点棘手的地方。
一个简单的架构
我最初的要求是提供一种自动机制来管理指针所引用的对象的内存,并支持一种场景:当指针或引用被一个对象持有,而该对象被删除时,其指针仍被该其他对象使用。如果您不支持计数机制,程序将会崩溃,因为它会尝试使用一个已被删除对象的指针。
我已经找到了一些带有引用计数的智能指针实现,但它们要求您在任何使用指针的地方都使用智能指针类,甚至在参数中也是如此。我想要一种类似于 COM 智能指针的解决方案,即在传递指针的方法签名中仍然可以使用普通指针,但在您使用指向您对象的指针的任何地方,都将其放入一个智能指针类中来管理引用的生命周期。
我还试图使其与使用 delete
运算符兼容,如果您不使用智能指针类来管理指针的话。
实现这个智能指针需要两个类。一个是类似于 COM 的 IUnknown
接口,但没有 QueryInterface()
方法;另一个类是一个模板类,非常类似于 COM 的 _com_ptr
类,它是在管理 COM 引用时使用的智能指针。
要被我的智能指针管理,一个类必须实现抽象类 IRefCount
,该类定义了 AddRef()
和 Release()
方法。
C++ 不支持像 C# 那样的模板约束,因此您的模板定义可能会编译通过,直到您真正用具体类实例化它。对于 _refcnt_ptr
,它期望使用的类实现 IRefCount
接口。在本文中,我可能会将只有一个纯虚方法的类称为接口。标准 C++ 没有像 Java 或 C# 那样的接口关键字。C++/CX 中的 interface
关键字是微软特有的语言扩展,用于支持语言中的 WinRT。
C++ 的一个范例是它允许开发者完全用类来定义一个新类型。只要其每个部分都创建得当,这个新类型就会表现得像编译器原生类型一样。这就是为什么在 C++ 中您可以重载类中的每一个运算符,包括 new
和 delete
运算符以及转换运算符。运算符在 C++ 中具有非常精确的行为,取决于它们应用于指针、引用还是值对象。例如,一行简单的代码可能会自动调用运算符和构造函数,而您却没有意识到。
C++ 中的复制构造函数极其重要,大多数时候您的类都必须正确实现它,因为编译器会隐式调用它,如果您不提供它,它将使用默认的逐位复制,而在大多数情况下是行不通的。
这个简单的类图显示了类的层次结构以及它们之间的关系。智能指针类是一个模板类,我已经描述了模板类型是 IRefCount
的事实,通过将 _refcnt_ptr
与 IRefCount
接口关联起来。
让我们深入了解智能指针类。
智能指针类
我提供的实现可以用作创建更完整的智能指针的基础,或者可以根据特定需求进行调整。
智能指针在使用时需要像普通指针一样表现,因此需要重载一些有用的运算符。
我重载了以下运算符:
->
,这样您就可以直接调用被包装指针的方法,就像直接使用它一样。*
,这样您可以编写*myPtr
。=
,因为编译器可能会在您不期望的地方调用它……T*
转换运算符,以便您可以自动提取底层指针。
为您的智能指针提供正确的复制构造函数也很重要,因为编译器会再次静默地调用它。
template<class T> class _refcnt_ptr sealed : public _refcnt
{
private:
IRefCount* m_ptr; // The pointer to manage
public:
_refcnt_ptr() : m_ptr(nullptr)
{
}
_refcnt_ptr(const _refcnt_ptr<T>& otherPtr) : m_ptr(otherPtr.m_ptr)
{
_AddRef();;
}
_refcnt_ptr(T* ptr) : m_ptr(ptr)
{
_AddRef();
}
~_refcnt_ptr()
{
_Release();
}
T* operator->()
{
return static_cast<T*>(m_ptr);
}
T& operator* ()
{
return *static_cast<T*>(m_ptr);
}
operator T*()
{
return static_cast<T*>(m_ptr);
}
_refcnt_ptr<T>& operator=(const _refcnt_ptr<T>& otherPtr)
{
if (this != &otherPtr)
{
_Release();
m_ptr = otherPtr.m_ptr;
_AddRef();
}
return *this;
}
private:
void _AddRef()
{
if (m_ptr != nullptr)
{
m_ptr->AddRef(true);
}
}
void _Release()
{
if (m_ptr != nullptr)
{
m_ptr->Release(true);
}
}
};
完整的代码在这里给出,但正如我所说,这只是一个说明概念的起点,在不同的实际情况中使用时,可能需要进行增强。
被包装的指针类型是 IRefCount
,这并非必需,因为 C++ 模板不像 C# 那样是类型安全的,但它使代码更具可读性,并且编译器会在您尝试将其与非 IRefCount
类一起使用时发出警告。您可以使用 T*
代替 IRefCount*
来编写相同的代码,编译器会在您的类没有 AddRef()
和 Release()
方法时发出警告。
我密封了该类,因为它是一个独立的类,您不会对其进行继承。
此智能指针需要一个基于 IRefCount
的类才能工作。让我们看看这个类的代码。
class REFCNTDECL CRefCount : public IRefCount
{
private:
int refCount; // The reference counter
int refPtrCount; // Number of _refcnt_ptr monitoring that class instance
protected:
CRefCount();
public:
void operator delete(void *ptr);
virtual void AddRef(bool isSmartPtr = false);
virtual void Release(bool isSmartPtr = false);
protected:
virtual bool CanReleaseRef();
};
此类比仅仅计算对象引用数的数量做得更多。它还负责在没有更多引用使用时删除对象。由于被智能指针管理的对象必须继承自此类,当调用其 Release
方法且引用计数为 0 时,Release
方法会像一个“自毁”方法一样,它会删除对象本身。
AddRed()
和 Release()
方法有一个参数,其默认值为 false。此参数用于告知引用计数器它是否由智能指针使用。此信息用于删除运算符实现和 CanReleaseRef
方法。
这对于以下场景很有用:
TEST_METHOD(TestPersonAndAddress)
{
_refcnt_ptr<CPerson> person = new CPerson();
person->SetName(T_OLIVIER);
person->SetSurname(T_ROUIT);
_refcnt_ptr<CAddress> address = new CAddress(T_STREET, T_ZIP, T_CITY);
person->SetAddress(address);
Assert::AreEqual(T_OLIVIER, person->GetName().c_str());
Assert::AreEqual(T_ROUIT, person->GetSurname().c_str());
Assert::AreEqual(T_STREET, person->GetAddress().GetStreet().c_str());
Assert::AreEqual(T_ZIP, person->GetAddress().GetZipCode().c_str());
Assert::AreEqual(T_CITY, person->GetAddress().GetCity().c_str());
_refcnt_ptr<CAddress> refAddress = &person->GetAddress();
delete person; // manually delete the person
std::string city = refAddress->GetCity();
} // Compiler will try to delete the person again using the smart pointer
CPerson
的指针实例由智能指针管理,因此当我调用 delete
person 时,它会删除 person 及其关联的 CAddress
对象。但是,由于在 CPerson
类中,CAddress
实例是用智能指针管理的,我从 CPerson
实例获得的 CAddress
引用仍然有效,程序不会崩溃。
另一个效果是,由于我重载了 delete 运算符并使用了一个监视该引用的智能指针计数器,delete
调用实际上并没有删除 CPerson
实例,而是让智能指针在它超出作用域时进行删除。
之前我说过 CAddress
会在调用 delete person
时被删除,但由于 delete
实际上并没有真正删除 person
实例,所以您拥有的地址引用受到两种机制的保护。
我只测试了少数场景,我很确定我肯定遗漏了一些,并且在实际应用程序中,这个智能指针需要……更加智能!
使用调试器对上面的单元测试方法进行调试,您可以看到编译器在简单的函数调用中如何调用重载的运算符和构造函数。例如,在 person->SetAddress(address)
中设置一个断点,并在 _refcnt_ptr code
中设置一些断点。您会惊讶于即使是这么简单的调用和该方法中只有一行代码,却调用了多少行代码!
关注点
我曾经花了近 10 年的时间从事 COM 和 C++ 的工作,当时 COM 是一项热门技术,而 Java 才刚刚开始被使用。然后 .NET 出现了,COM 被暂时搁置了。那是托管语言的时代,运行时为您处理了所有的内存问题,或者您是这么认为的。现在我已经几乎 10 年没有接触过 C# 等托管语言了,它们无疑在内存管理方面带来了更多的安全性,但也带来了其他有时更难解决的问题,因为您对所做的事情不再拥有精细的控制权。
所以,如果您必须用 C++ 编写 WinRT 组件,C++/CX 编译器将负责处理 COM 中一些纯 C++ 中繁琐的底层工作,但当涉及到您自己的 C++ 类时,您将不得不自己管理内存和对象的生命周期。