防止循环引用的 ATL 对象集合
使用 ATL 和 STL 创建 COM 对象集合,
引言
COM 对象集合的一个常见问题是,当集合的每个成员都需要能够回溯引用到父集合对象时。网络上有许多文章试图使用弱引用来解决这个问题,但如果弱引用的对象被客户端销毁,这些方法存在严重的可靠性问题。当需要维护集合的集合时,这个问题会变得更加严重。
本文演示了一种可能的解决方案,该方案实现起来非常容易。我在演示文件中包含了小的测试程序,它们除了确认所有对象引用计数在正确的时间归零外,并没有做其他事情。它包含一个 ATL COM exe 项目(在 VC++ 2003 中)来实现模板,以及一个小的 VB6.0 项目来演示按任意顺序创建和删除对象。通过查看 Windows 任务管理器,观察可执行文件自行卸载,可以很容易地看到,当所有对象都被销毁时,引用计数都回到了零。
使用代码
我设计了 2 个文件,它们构成了源代码下载,可以包含在任何需要此类集合的 ATL 项目中。CollectionDefs.idl 可以在项目 IDL 中 #include,在导入 oaidl.idl 和 ocidl.idl 之后。然后,在任何需要这些接口实现的模块头文件中,包含 CollectionHelpers.h 文件。
从这里开始,我将使用 Department/Employee 示例来演示这些头文件的用法。要使用这些模板,首先定义你的集合的 Coclass 和 Interface。此时,你也应该至少声明你的集合成员的 Coclass 和 Interface。然后我们开始实现集合对象。
在 Helpers 模块中实现的标准属性和方法包括 Add
和 Remove
方法,以及 get_Item
、get__NewEnum
和 get_Count
属性。你可以为你的集合接口公开你自己的接口,然后简单地调用辅助类来实现这些函数。还包括了一个 IEnumVARIANT
的实现,因此 _NewEnum
属性返回一个 VB 兼容的集合。
// CollectionTest.idl // ... interface IDepartment : IDispatch{ [propget, id(DISPID_VALUE), helpstring("property Item")] HRESULT Item([in] VARIANT Index, [out, retval] IEmployee** pVal); [id(DISPID_NEWENUM), propget] HRESULT _NewEnum( [out, retval]IUnknown **ppUnk); [propget, id(1), helpstring("property Name")] HRESULT Name([out, retval] BSTR* pVal); [propput, id(1), helpstring("property Name")] HRESULT Name([in] BSTR newVal); [id(2), helpstring("method Add")] HRESULT Add([in] IEmployee* pEmployee); [id(3), helpstring("method Remove")] HRESULT Remove([in] VARIANT Index); [propget, id(4), helpstring("property Count")] HRESULT Count([out, retval] long* pVal); };
一旦在 C++ 头文件中定义了 Coclass,只需从 CContainer
继承它即可。CContainer
类是模板化的,将你的集合成员暴露的接口和应使用的 STL 容器类型作为模板参数。第一个模板参数几乎是不言自明的(在我们的例子中是 IEmployee
接口),但第二个参数需要一些考虑,并且必须遵守一些先决条件。我们的例子使用了一个 map 类,以便能够根据名称快速查找员工。
class ATL_NO_VTABLE CDepartment : public CComObjectRootEx<CComSingleThreadModel>, public CComCoClass<CDepartment, &CLSID_Department>, public IDispatchImpl<IDepartment, &IID_IDepartment, &LIBID_CollectionTestLib, /*wMajor =*/ 1, /*wMinor =*/ 0>, public CContainer<IEmployee, std::map<CComBSTR, IMemberData*> > { ... };
我已经为头文件中的容器编写了实现代码的 4 种 STL 容器类型:list、vector、set 和 map。每种容器都保存着一个 IMemberData
接口实现的引用,最终由你来编写。map 容器仅实现为接受 CComBSTR
类型索引。
实际集合存储在 CContainer
对象的成员变量 m_coll
中,其类型为 CSTLContainer<YourSTLContainerType>
。此类直接继承自 YourSTLContainerType,但重载了 STL 集合中许多常见的 find、append、insert 和 erase 方法。因此,无论使用哪种集合类型,都可以在你自己的方法中以一致的方式操作 m_coll。它还有一个 Reference()
方法,该方法接受一个 STL 迭代器作为参数,并返回指向你的 IMemberObject
的指针。
为了在你的集合中实现标准的集合类型方法和属性,只需将它们引用到 CContainer
对象中的实现即可。
STDMETHODIMP CDepartment::Remove(VARIANT Index) { return Container::Remove(Index); } STDMETHODIMP CDepartment::get_Count(long* pVal) { return Container::get_Count(pVal); } STDMETHODIMP CDepartment::get__NewEnum(IUnknown **ppUnk) { return Container::get__NewEnum(ppUnk); } STDMETHODIMP CDepartment::Add(IEmployee* pEmployee) { CComQIPtr<IMember> pMember = pEmployee; if (!pMember) return E_INVALIDARG; return Container::Add(pEmployee); } STDMETHODIMP CDepartment::get_Item(VARIANT Index, IEmployee** pVal) { CComPtr<IMember> pMember; HRESULT hr = Container::get_Item(Index, &pMember); if (SUCCEEDED(hr)) hr = pMember.QueryInterface(pVal); return hr; }
请注意,Remove
、get_Count
和 get_NewEnum
直接调用 CContainer
的实现(Container 只是 CContainer<typename IExposed, typename STLContainer>
的内部 typedef)。Add
方法顶部有几行代码,只是为了确保传递的对象确实实现了 IMember
(这是一个先决条件)。否则,它也只是调用 CContainer
方法。CContainer
的 get_Item
方法实际上返回 IMember
接口,然后该接口被查询以获取 IEmployee
接口返回给客户端。
下一步是实现你的成员对象。这需要更多的工作,因为这是处理循环引用情况的地方。它的工作方式是,你的成员对象的实现(减去任何父引用)在一个完全不同的 Coclass 中实现。你的实际暴露对象仅引用此 CMemberData 类,以设置和检索其状态属性。
实现此目的的方法是定义成员对象的接口,然后复制并重命名类定义。这意味着需要修改一些 ATL 生成的代码,但工作量不大。当然,你需要重命名构造函数,以及 BEGIN_COM_MAP
条目。由于此类不向客户端公开,因此应删除其对 ComCoClass
的继承,并将 DECLARE_REGISTRY
条目更改为 DECLARE_NO_REGISTRY
。之后,让你的新数据类继承自 CMemberData
,并让你的暴露的 coclass 继承自 CMember
。再次强调,这些类是模板化的,以允许辅助类相互交叉引用。
CMemberData
类接受一个模板参数,即你暴露的 coclass 的实现。这允许 CMemberData
对象创建你的 coclass 的实例,插入对自身的引用,并将此新创建的对象返回给客户端。这是避免循环引用的方法。重要的是 CMemberData
对象**不包含对父集合的任何引用**。所有向上的引用都由你暴露对象中的 CMember 实现持有。
// CEmployeeData class ATL_NO_VTABLE CEmployeeData : public CComObjectRootEx<CComSingleThreadModel>, //public CComCoClass<CEmployee, &CLSID_Employe>, public IDispatchImpl<IEmployee, &IID_IEmployee, &LIBID_CollectionTestLib, /*wMajor =*/ 1, /*wMinor =*/ 0>, public CMemberData<CEmployee> { public: CEmployeeData() { } DECLARE_NO_REGISTRY ( ) BEGIN_COM_MAP(CEmployeeData) COM_INTERFACE_ENTRY(IEmployee) COM_INTERFACE_ENTRY(IDispatch) COM_INTERFACE_ENTRY(IMemberData) END_COM_MAP() //... protected: // Property Data CComBSTR m_sName; public: STDMETHOD(get_Name)(BSTR* pVal) { *pVal = m_sName.Copy(); return S_OK; } STDMETHOD(put_Name)(BSTR newVal) { m_sName = newVal; return S_OK; } STDMETHOD(get_Department)(IDepartment** pVal) { return E_NOTIMPL; } };
请注意,此数据类也实现了你的暴露接口。这并非完全必要,但可以简化实现。实际上,它不需要实现任何接口,只需要实现 IMemberData
,而 IMemberData
实际上已经被你继承的 CMemberData
类实现了。它需要能够为你的暴露成员类提供最小的状态信息。但是,通过实现暴露接口,你的暴露对象只需通过其 m_pData
成员将所有状态信息请求直接传递给数据类。引用父集合的属性(在这种情况下是 get_Department
)仅返回 E_NOTIMPL
。
此外,还有许多与 IMemberData
类相关的辅助方法,用于提高 STL 集合的效率或数据检索。这些方法允许你的 set 或 map 集合基于对象本身或从对象获取的字符串键执行比较。涉及的方法有五种:两种用于对象比较,三种用于键比较。通过实现其中一个或两个方法组,STL 能够根据你的对象内容快速查找它们。
用于对象比较的方法原型是
HRESULT IMemberData::CanCompareObjects(void); HRESULT IMemberData::CompareObject(IMemberData* pObject, short *pResult);
CanCompareObjects
仅在实现 CompareObject
方法时返回 S_OK
,否则返回 S_FALSE
。CompareObject
实现后,其工作方式与库字符串比较函数相同,根据正在比较的对象是否小于、等于或大于正在比较的对象,分别返回负数、零或正数。实现这些方法可以高效地存储和引用 IMemberObjects
集合。
或者,可以使用 map 集合来索引对象,索引基于 BSTR
键。通过实现键比较方法,可以有效地将具有嵌入式键的对象添加到集合中,然后根据该键值访问这些对象。键访问方法的原型是
HRESULT IMemberData::CanCompareKeys(); HRESULT IMemberData::CompareKey(BSTR Key, short *pResult); HRESULT IMemberData::get_Key(BSTR *pKey);
这些工作方式与 CompareObject
方法基本相同,只是增加了 get_Key()
方法来从完整对象中提取键。
最后,要完成集合的实现,你需要从 CMember
派生你的暴露的 coclass。此类接受 2 个模板参数,即你的 IMemberData
实现的 Coclass,以及此 coclass 暴露的接口。如果此接口与你的 CMemberData
派生类中实现的接口相同,则在此类中实现接口就像调用数据类中的相同方法一样简单。
HRESULT CExposedClass::MyMethod(Param1, Param2) { return CDataClass::MyMethod(Param1, Param2); }
这种设置的唯一区别在于你想引用父集合时。请记住,数据类不能有对父集合的引用,否则整个系统将失效,并导致循环引用。因此,CMember
实现持有一个对集合的完整(非弱)引用,称为 m_pParent
,它可以返回给客户端应用程序。
HRESULT CExposedClass::get_Parent(IMyCollection **ppVal) { return m_pParent->QueryInterface(ppVal); }
一旦所有这些都实现完毕,你就会拥有一个引用安全的集合,集合中的每个成员都持有指向父集合的安全链接。只要客户端持有对集合中任何成员、集合对象本身或集合枚举器的引用,就可以保证所有对象都将保持完整。一旦客户端释放了所有这些引用,对象就会被正确销毁,资源就会被释放回你的应用程序。
工作原理
除了实现 STL 容器上的索引的那些棘手的代码细节之外,这个集合背后的思想非常简单。CContainer
类有一个成员,即 STL 容器本身。当它被填充时,它是由 CMemberData
派生对象填充的。这些对象包含正确实现你要收集的对象所需的状态信息。然而,这些对象不引用容器本身,因此对于客户端持有的每个引用,容器的引用计数都保持为 1。MemberData
对象的引用计数也为 1,因为它是被容器对象引用的。在容器被销毁之前,它们不会被销毁。
如果客户端创建一个新的 Member 对象,它还会在内部创建一个 MemberData
对象来保存其状态信息。这个 MemberData 对象被 CMember
的 m_pData
成员引用。当这个对象被添加到集合中时,CContainer
的 add 方法实际上将 m_pData
对象放入集合中,然后将 CMember
类的 m_pParent
成员设置为指向自身。此时,Container 和 MemberData 对象的引用计数增加到 2,而 CMember
的引用计数保持为 1。释放客户端中的 CMember
对象将导致对象被销毁,而其状态信息在集合中保持不变。此时,Container
和 MemberData
对象的引用计数将减回到 1。
当客户端从集合中检索成员时,实现实际上会创建一个新的 CMember
实例,然后用集合中的 CMemberData
对象替换此新对象的 C++ 状态数据。它还会设置新对象中的 m_pParent
引用。
如果客户端释放了对 Container
对象的持有,同时保留对 Member 对象的引用,则由于 Member 中的 m_pParent
引用,Container
将保持不变。因此,集合中的所有 MemberData
对象都将保持不变,因为它们被 Container 引用。只有当 Container 和 Container 的任何 Member 的所有客户端引用都被释放后,Container 才会被销毁。实际的 Member 对象仅由客户端引用,因此客户端对其生命周期拥有完全控制权。
实现摘要
总之,请遵循以下 10 个简单步骤来实现一个防循环引用的集合。
- 使用 ATL 向导定义你的集合类和接口。
- 使用 ATL 向导定义你的集合成员的类和接口。
- 让你的集合类继承自
CContainer<IExposed, STLType>
,并在COM_MAP
中添加对IContainer
接口的引用。 - 复制并重命名生成头文件中的成员类定义,记住删除复制的类对
CComCoClass
的继承,将其改为DECLARE_NO_REGISTRY
,并在构造函数和COM_MAP
声明中更改类名。 - 让你的复制的类继承自
CMemberData<CMemberCoClass>
。在类的COM_MAP
中添加对 IMember 接口的引用。 - 让你的原始成员类继承自
CMember<CMemberDataClass, IExposed>
,并在类的COM_MAP
中添加对IMember
的引用。 - 在你的原始对象的副本(继承自
CMemberData
的那个)中编写你的成员实现代码。 - 通过
CMember
的m_pData
成员来引用 MemberData 的属性和方法。 - 通过返回对
CMember
的m_pParent
成员生成的引用来实现(get_)Parent
属性或方法。 - 通过引用
CContainer
类中预定义的 C++ 方法来 C++ 实现你的集合对象。