复合-访问者模式:面向对象的实现方式






4.37/5 (9投票s)
实现了 Composite-Visitor 模式,避免使用递归的泛型代码。
引言
本文是“比较编码方法”系列的自然演进,并且可以是对 Dave_H 关于同一主题的相应文章的回应。
免责声明(或……一些看法)
本文无意参与宗教辩论:我已经(希望)解释过,我对此类宗教纠纷不感兴趣。我感兴趣的是评估可能的替代方案。
Dave 提出的解决方案非常出色且优雅,完全基于泛型编程技术,但需要一些 RTTI 特性来处理多态。我也使用过这些技术,主要是在我之前创建 W32 引用计数包装器 的代码中。在这里,我只想展示另一面,主要使用 RTTI 和 OOP,并带有一些模板修饰。何时使用一种或另一种……主要取决于您部署的应用的需求,以及……为什么不,您个人的看法。
所有这些“基于模板”的解决方案的共同点是所谓的“奇特的递归模板模式”:模板类以一个假定从模板类自身派生的类作为模板参数。这允许基类通过将基类“this
”转换为 derived*
来调用假定在派生类中实现的函数。有关所有示例,请参阅引用的文章。简而言之
template <class TDerived> class CBase { void basefn() { static_cast<TDerived*>(this)->derivedfn(); } }; class CDerived: public CBase<CDerived> { void derivedfn() { /* do something specific here */ } };
另一种方法是使用模板派生而不是模板基类,就像这篇其他文章一样。简而言之
class CBase { void basefunction() { /* do something specific */ } }; template<class TBase> class CDecorator: public TBase { void decorate() { basefunction(); } };
这两种方法的限制是无法处理运行时多态:不同类型的对象在同一数据结构中协作。
这种情况(如引用的文章中所述)必然需要 RTTI 和 dynamic_cast
。
但关键点来了:假定您无法摆脱 V 表和 RTTI 描述符,您是否仍然需要 CRTP?
毕竟,它允许从基类调用派生类中的函数。但是……这可以通过虚函数来完成。
因此,我在这里提出一种方法来实现该范例,主要使用泛型代码,大部分代码使用传统的 OOP。
结果是编译器可以翻译成一系列类的实现。而不是每次新类需要时都重新注入代码。
这使我能够将实现代码(放在库中)与应用程序代码(放在各自的项目中)分开。由链接器将(各种)OBJs 绑定到 EXE,而不是编译器将所有代码生成到一个(或几个)OBJ 中。
主要设计规则
有些人认为多重继承是疯狂的,但认为带有许多参数的模板是“好东西”。我看不到两者之间有任何特别的区别:它们是完全对偶的。因此,我将使用多重继承和多参数模板,主要参考“比较”文章中描述的“多重部分实现”方法。
总体方案
在此实现中,我将遵循此总体方案
我不想担心继承、虚基类或多重继承,以及模板注入的真正或假定的低效率。我只想拥有一个可以独立翻译的代码,并利用所有可用功能来保持设计足够优雅和模块化。性能不是“关键因素”,尽管我也会注意这一点。
对象所有权
由于我们将实现多态对象的集合,因此我们需要以“指向共同基类的指针”来引用它们。这就带来了“谁拥有该对象?”的老问题(即:谁有权销毁它?)
尽管它不是一个万能的解决方案,但引用计数是一个不错的折衷方案。然而,在这种情况下,我不会深入研究多少复杂的策略驱动的智能指针,而是通过“模板和接口”的混合。
Composite - Visitor
Composite 是包含其他对象的对象。它们的自然聚合是树,但不是一种 `std_like::tree<T>`(其中 `T` 元素不知道自己是树的一部分),而是由树节点组成的树,其中对象本身就是节点,并且是多态的。
Visitor 是一种对象迭代器,能够遍历结构并解引用其中包含的对象。
数据转换器
如果结构是根据指向共同基类的指针构建的,那么访问派生类中的对象组件需要从共同基类进行 dynamic_cast
到所需的组件。这段(二进制)代码显然因组件而异,所以这是一个模板具有真正优势的好地方:它们可以从通用源生成代码。
集合
集合是同类类型的容器,它们的活动不具体依赖于所托管的类型,并且所托管的对象对它们被收集的事实一无所知。因此,集合可以有一个共同的接口,但它们不能有一个共同的二进制代码,因为这段代码依赖于托管对象的大小。所以,模板作为该接口的实现确实非常有效。就此而言,STL 集合在 OOP 应用中也扮演着其应有的角色!
相反,多态集合可以实现为指向共同基类的同类指针的集合。这个考虑可以引出同类集合作为多态集合“偶然”填充了相同类型对象的特例的想法。但有一种情况不能这样做:数组。数组要求同类对象存储在连续的内存中。(也就是说,“this+1”必须是下一个对象,而这不能通过存储指针来实现。)而这在某种程度上使得模板总是更适合这些任务。
现在所有论点都已介绍完毕,让我们来描述提出的实现。
数据转换器和可转换的 Visitor
由于所有 Composite 都将存在于堆上,一个非常基本的数据转换器将是一个具有指针语义的小对象,它注入动态类型转换并通过装饰(继承)一个“可转换”的类来执行适当的赋值。
struct
Ptr
,因此,是一个用于通用 TBase
的“模板装饰器”,它可以被 Ptr
继承,为其提供一些函数
template<class T, class TBase> //T dervied from TBase struct Ptr: public TBase { //TBase must provide: // typedef xxx _TRefType //defines the interface it visit // void _assign(_TRefType* p) //required for assignment // _TRefType* _access() const //required for asignment and dereference // void _inc(); //required for ++ operator // void _dec(); //required for -- operator // _TRefType* _subscript(int); //required for [] operator typedef typename TBase::_TRefType _TRefBaseType; typedef typename T _TPtrType; ... };
Ptr
实现所有赋值、复制、转换、下标、解引用运算符(所有可以接受指针作为参数的经典运算符),通过 dynamic_cast
机制自动将 _TBaseRefType
转换为 _Type
。
特别是
private: friend struct Ptr; T* _pCached; public: typedef typename TBase::_TRefType _TRefBaseType; typedef typename T _TPtrType; struct exception: public XException {}; //the exception of this operations _TPtrType* _convert() const { return dynamic_cast<_TPtrType*>(_access()); } Ptr() {_pCached = NULL;} Ptr(const Ptr& p) { _assign(p._access()); _pCached = p._pCached; } template<class A, class B> Ptr(const Ptr<A,B>& p) { _assign(p._access()); _pCached = _convert(); } Ptr(_TRefBaseType* p) { _assign(p); _pCached = _convert();} Ptr& operator=(const Ptr& p) { _assign(p._access()); _pCached = p._pCached; return *this; } Ptr& operator=(_TRefBaseType* p) { _assign(p); _pCached = _convert(); return *this; } template<class A, class B> Ptr& operator=(const Ptr<A,B>& p) { _assign(p._access()); _pCached = _convert(); return *this;} operator _TPtrType*() const { return _pCached; } _TPtrType* operator->() const {if(!_pCached) ThrowExcp<exception>(); return _pCached; } _TPtrType& operator*() const {if(!_pCached) ThrowExcp<exception>(); return *_pCached; } bool operator!() const { return !_pCached; } //no convertible object available bool operator~() const { return !_access(); } //no object available void New() { _assign(new T); _pCached = _convert(); } template<class I> void New(const I& i) { _assign(new T(i)); _pCached = _convert();} Ptr& operator++() { _inc(); _pCached = _convert(); return *this; } Ptr& operator--() { _dec(); _pCached = _convert(); return *this; } Ptr operator++(int) { Ptr r(*this); _inc(); _pCached = _convert(); return r; } Ptr operator--(int) { Ptr r(*this); _dec(); _pCached = _convert(); return r; } _TPtrType& operator[](int i) const { _TPtrType* p = _convert(_subscript(i)); if(!p) ThrowExcp<exception>(); return *p; }
实际上,一个纯粹的“装饰器”,引用了之前的方案。注意 `_convert` 是从基类提供的类型到 Ptr
操作的类型(假定是基类类型派生而来)的动态转换。
Ptr
的实现包含一个私有的 T* _pChache
值,并在赋值或复制期间执行类型转换。所有其他“读取”运算符(如 `*`、`->` 等)都返回缓存的值。这有助于限制正常执行中进行的转换次数。
另外请注意,尽管 Ptr
是一个模板,但其定义中没有使用递归。
可引用计数对象
以完美的 OOP 风格,让我们从一个interface
开始。
首先:对我而言,interface
仅仅是 struct
的一个别名。因此,该关键字的使用纯粹是建议性的。虽然完全为空,但它有一个 v-table,所以最好为它们提供一个虚析构函数。它什么都不做……但它是虚的。如果使用接口指针调用 delete
,这是正确工作的必需品!
可引用计数对象可以实现 IReferable
,为其中立函数提供含义
interface IReferable { private: virtual void AddRef()=0; virtual void Release()=0; virtual void Delete()=0; protected: virtual ~IReferable() {} }
它们都是私有的,因为除了明确声明为 friend 的类之外,任何人都不能直接调用它们。
为了有一个指向它的智能指针,我们需要一个“可转换的 Visitor”,它将 `_assign` 转换为 `Addref` 和 `Release`。
由于这是 IReferable
特有的,我将其定义为 Ireferable::XPtr
(内部 struct
),并使其成为 IReferable
的 friend(因此它可以调用 IReferable
函数的重写)。
struct XPtr //smart pointer to a referable: a TBase for XPtr<TTBASE,> { protected: void _assign(IReferable* pR); IReferable* _access() const; public: typedef IReferable _TRefType; XPtr(); ~XPtr(); private: IReferable* _pRef; };
其中 `_assign` 被编码为……
void IReferable::XPtr::_assign(IReferable* pR) { if(pR) pR->AddRef(); if(_pRef) _pRef->Release(); _pRef = pR; }
由于我们讨论的是一个独立的对象,因此实现 `_inc`、`_dec` 和 `_subscript` 没有意义。
请注意,为了确保在 `pR == _pRef` 的情况下,指向的对象在 Release
之后不会被销毁,必须在调用 Release
之前调用 AddRef
。
指向 IReferable
的智能指针可以这样获得:typedef Ptr<IReferable, IReferable::XPtr> PReferable
。
通常,给定一个继承自 IReferable
的 CSomethingReferable
,可以定义一个 Ptr<CSomethingReferable, IReferable::XPtr>
,其 `*` 和 `->` 运算符将自动将持有的对象转换为 CSomethingReferable
。
IReferable 实现:EReferable
为了避免在每个对象上重新实现接口,可引用对象可以继承一个已实现的 IReferable
,例如 EReferable
。
它处理引用计数器,并通过增加和减少它来实现 AddRef
和 Release
。
由于我们无法知道复合对象的继承图可能有多复杂,但由于此接口每个实例只需要一次,因此需要虚继承。
AddRef
和 Release
也通过调用其他虚拟“钩子”函数来实现,这些函数声明为“无操作”,但可以在继承的对象中重写:OnAddRef
、OnFirstRef
、OnRelease
和 OnLastRelease
。
现在,可以通过虚继承 EReferable
并将其指针定义为 Ptr<yourclass, yourclass::XPtr>
来定义一个可引用的类。
EReferable 的通用装饰器:CReferable<U>
对于不能继承自 EReferable
的类或简单类型,CReferable
通过继承 EReferable
来实现,它包含一个 U
成员,提供 U
的值语义,并typedef
一个智能 Ptr
。
template <class U> class CReferable: public virtual EReferable { private: U _u; public: friend class CReferable; CReferable(const CReferable& c) { _u = c._u; } CReferable(const U& u) { _u = u; } CReferable& operator=(const CReferable& c) { _u = c._u; return *this; } CReferable& operator=(const U& u) { _u = u; return *this; } operator U&() {return _u;} operator const U&() const {return _u;} typedef NTypes::Ptr<CReferable, XPtr> Ptr; };
测试单元
非常简单
using namespace GE_; typedef NTypes::CReferable<int> CInt; typedef NTypes::CReferable<double> CDouble; int _tmain(int argc, _TCHAR* argv[]) { _CrtSetDbgFlag ( _CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF ); try { STRACE(trc,1,("Try Block\n")) CInt::Ptr pi; pi.New(5); int r = *pi; trc("r = %d\n", r); CDouble::Ptr pd = pi; //assign, but cannot convert int* to double* double d = *pd; //will throw trc("will never reach this message"); } catch(NTypes::XException* pe) { STRACE(trc,1,("Catch Block\n")) pe; } trc("End Program\n"); return 0; }
//// 调试输出 ///
( 1).Begin test
( 1)..Try Block
( 1)..r = 5
( 0)...First reference for 002F0FF0
First-chance exception at 0x796be592 in CompVisit.exe: Microsoft C++ exception:
GE_::NTypes::Ptr<GE_::NTypes::CReferable<double>,
GE_::NTypes::IReferable::XPtr>::exception @ 0x0012fbc0.
( 0)...Last release for 002F0FF0
( 1)..Catch Block
( 1).End Program
Il programma "[2100] CompVisit.exe: Nativo" è terminato con il codice 0 (0x0).
请注意,表达式 pd = pi
是合法的,因为 CInt::Ptr
和 CDouble::Ptr
依赖于 IReferable::XPtr
,而 CInt
和 CDouble
都是 EReferable
。
当所持有的值不是 CDouble
时,无法解引用 pd
(例如使用 d = *pd
):这会按设计抛出异常。
这显然是一个运行时类型转换错误,而不是模板中的静态转换。编译器无法检测到它,但我们可以支持多态,如本例所示
CInt::Ptr pi, pj; CDouble::Ptr pd; pi.New(7); pd = pi; //legal: both convert to and from IReferable* pj = pd; //legal: both convert to and from IReferable* int r = *pj; //legal: pj holds a CInt pd.New(*pi); //legal: create a CDouble and initialized to a promoted integer double s = *pd; //legal: pd holds a CDouble
请注意,pj
从 pd
接收了它从 pi
接收的内容,而 pi
创建了一个 CInt
。使 pj
能够解引用的,是所持有的 IReferable
的性质。而不是传递值的来源。
Composite - Visitor
Composite 对象的行为可以用一个接口来描述:ITreeNode
。
interface ITreeNode: public virtual IReferable { virtual ITreeNode* GetFirstChild()=0; virtual ITreeNode* GetLastChild()=0; virtual int GetChildrenCount()=0; virtual ITreeNode* GetParent()=0; virtual ITreeNode* GetPrev()=0; virtual ITreeNode* GetNext()=0; virtual bool SetParent(ITreeNode* pParent, ITreeNode* pPrevious=NULL)=0; virtual ~ITreeNode() {} typedef NTypes::Ptr<ITreeNode, IReferable::XPtr> Ptr; struct XShallowIterate { ... }; typedef NTypes::Ptr<ITreeNode, XShallowIterate> ItShallow; struct XDeepIterate { ... }; typedef NTypes::Ptr<ITreeNode, XDeepIterate> ItDeep; };
它定义了在父节点、子节点和兄弟节点之间导航以及设置这种结构所需的功能。
它还定义了自己的指针,用于浅层迭代(在兄弟节点之间迭代)和深层迭代(在子节点中迭代)的迭代器。
区别在于 `XShallowIterate` 和 `XDeepIterate` 中 `_inc` 和 `_dec` 函数的实现方式。
`_subscript` 函数也实现为重复迭代。
所有这些都提供了 `ItShallow` 和 `ItDeep` 迭代器,具有完美的 `++`、`--` 和 `[]` 运算符。
实现者的函数
虽然提供的函数从外部来看已经足够,但对于实现者来说却不够:如果我想实现 SetParent
,我必须设置我对新父节点和兄弟节点的引用,但我还必须告诉父节点和兄弟节点设置它们对我的引用。
为此,有两种选择
- 将此问题视为实现内部问题:这意味着实现者必须访问父节点和兄弟节点实现的相应数据结构。这可以通过 `class` 或 `struct` 中的受保护成员来实现,但这会使同一树中的所有对象必须使用或派生自相同的实现。
- 将其视为实现外部问题,通过向接口添加更多函数。但这些函数不应被其他对象访问……除非是实现者。
第一个可能是“快速而粗糙的方法”。第二个是“正确的方法”。但有一个问题:这些函数应该如何声明?它们不能是 public
,因为它们不应该被任何人调用。但它们必须被派生类的其他实例调用。甚至 protected
的方法也不好:它使得这些函数无法访问。
因此,我们需要一个代理对象(XProxy
),由接口声明为 protected
,但又是接口本身的 friend,它拥有公共函数:只有派生类才能获得这种代理,并且其函数可以调用接口的 protected
函数,这些函数将被派生实现覆盖。
请注意,拥有空接口定义抽象函数和操纵器,并带有可继承的部分实现(Exxx
类)的模型是多余的:它允许同一接口的不同实现;但如果您没有此类需求,则可以避免定义 Ixxx
接口,并直接从 Exxx
类开始部署。
这使您的灵活性降低了一个层次,但使您能够定义关于 Exxx*
(而不是 Ixxx*
)的函数,并且可以避免使用代理。
ITreeNode 实现:ETreeNode
由于 ITreeNode
需要 IReferable
,因此 ETreeNode
可以继承…… EReferable
,以便其实现可以支配 IReferable
虚基类。
class ETreeNode: public virtual ITreeNode, public virtual EReferable { public: virtual ITreeNode* GetFirstChild(); virtual ITreeNode* GetLastChild(); virtual ITreeNode* GetParent(); virtual ITreeNode* GetPrev(); virtual ITreeNode* GetNext(); virtual bool SetParent(ITreeNode* pParent, ITreeNode* pPrevious=NULL); protected: virtual void SetFirstChild(ITreeNode* pN); virtual void SetLastChild(ITreeNode* pN); virtual void SetPrev(ITreeNode* pN); virtual void SetNext(ITreeNode* pN); protected: //hooks virtual bool OnSettingParent(ITreeNode* pParent, ITreeNode* pPrevious) {return true;} virtual void OnSettedParent() {} private: ITreeNode::Ptr _pChildFirst, _pChildLast, _pNext; ITreeNode *_pParent, *_pPrev; //backpointer don't refcount. protected: ETreeNode(); virtual ~ETreeNode(); };
注意 EReferable
设计与 ETreeNode
的区别
IReferable
具有所有私有函数,其私有 XPtr
为 friend,公共 Ptr
装饰 XPtr
。这是因为我希望 IReferable::Ptr
是唯一可以操作 IReferable
函数的对象。
ITreeNode
具有一些公共函数,因为我希望这些函数可以从任何情况访问。但它也具有一些私有函数,以及一个受保护的代理作为 friend。这是为了让任何后代都能获得代理并通过它调用其函数。
SetParent
是唯一可以修改结构的函数。它通过两个钩子实现:OnSettingParent
和 OnSettedParent
。
第一个返回一个 bool
:如果返回 false,则拒绝设置父节点。这为实现对象之间某种“兼容性检查”提供了机会,这些对象被提名用于关联。
测试单元和示例
让我们想象一些形状,每个形状都有一个边界矩形。一个形状可以包含其他形状。然后我们可以有各种形状:特别是:矩形、圆形和组。
示例实现位于“shapes.h”。
每个形状都有自己的属性,并且默认情况下继承其所属组的属性。
interface IShape: public virtual NTypes::ITreeNode { //define our own pointers and iterators typedef NTypes::Ptr<IShape, XPtr> Ptr; typedef NTypes::Ptr<IShape, XShallowIterate> ItShallow; typedef NTypes::Ptr<IShape, XDeepIterate> ItDeep; //defines our own public functions enum e_shapeattributes { attr_linecolor, attr_fillcolor, attr_textcolor, attr__count }; virtual NTypes::UINT GetAttribute(e_shapeattributes a) =0; virtual NTypes::UINT GetDeepAttribute(e_shapeattributes a) =0; virtual void SetAttribute(e_shapeattributes attr, NTypes::UINT value)=0; virtual void GetBound(SRect& r)=0; virtual void SetBound(const SRect& r)=0; virtual void Invalidate()=0; };
GetDeepAttribute
应该检索所需的属性,并且如果发现它等于 == -1
,则从父组获取。
IShape
由 EShape
实现,它也继承 ETreeNode
(用于实现 ITreeNode
)。
从 EShape
派生出 EGraphicShape
,它还继承 IDrawable
并重写 OnSettingParent
钩子,以阻止形状被设置为非 CGroup
对象的子对象。
最后,从 EGraphicShape
派生出 CCircle
、CRectangle
和 CGroup
。每个都有自己的 Draw
函数。
总而言之,这给出了此继承图
注意:我没有描绘 Ptr<...>
装饰器:任何类都可以拥有自己的装饰器,重新装饰 XPtr
、XIt
……等等。
我还引入了 IDrawer
和 CDrawer
作为什么都不做的可引用对象(只是为了有一个可以模拟 Windows 程序中 HDC 的东西)。
测试代码
我没有部署一个完整的 Windows 应用程序来绘制图形,因为本文的范围仅在于说明编码技术。但是一个控制台 main
可以是这样的
int _tmain(int argc, _TCHAR* argv[]) { _CrtSetDbgFlag ( _CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF ); STRACE(trc, 1, ("Begin test\n")) //create a drawing NApp::CGroup::Ptr pDrawing; pDrawing.New(); //add in it teo circles new NApp::CCircle(pDrawing, NApp::SRect(10,10,60,60)); new NApp::CCircle(pDrawing, NApp::SRect(80,10,140,60), 0, 7); //create a subgroup NApp::CGroup* pSubDraw = new NApp::CGroup; pSubDraw->SetParent(pDrawing); //palce it into the drawing pSubDraw->SetAttribute(NApp::CGroup::attr_linecolor, 4); //add two rectangles in the subgroup new NApp::CRectangle(pSubDraw, NApp::SRect(20,70,130,120)); new NApp::CRectangle(pSubDraw, NApp::SRect(20,125,130,150), 5); NApp::CDrawer::Ptr pDrw; pDrw.New(); pDrawing->Draw(pDrw); std::cout << "\nPress any key ...\n"; while(!getch()); trc("End Program\n"); return 0; }
这会产生以下调试跟踪
( 1).Begin test
( 0)..First reference for 002F10E0
( 0)..First reference for 002F11A8
( 0)..First reference for 002F1278
( 0)..First reference for 002F29D0
( 0)..First reference for 002F2A98
( 0)..First reference for 002F2B68
( 0)..First reference for 002F2C38
( 2)..Drawing class GE_::NApp::CGroup at 3084512
( 2)...Drawing class GE_::NApp::CGroup at 3090896
( 2)....Drawing class GE_::NApp::CRectangle at 3091304
( 2)....Drawing class GE_::NApp::CRectangle at 3091096
( 2)...Drawing class GE_::NApp::CCircle at 3084920
( 2)...Drawing class GE_::NApp::CCircle at 3084712
( 1).End Program
( 0)..Last release for 002F2C38
( 0)...002F2C38 suicide
( 0)..Last release for 002F10E0
( 0)...002F10E0 suicide
( 0)....Last release for 002F29D0
( 0).....002F29D0 suicide
( 0)......Last release for 002F1278
( 0).......002F1278 suicide
( 0)........Last release for 002F11A8
( 0).........002F11A8 suicide
( 0)......Last release for 002F2B68
( 0).......002F2B68 suicide
( 0)........Last release for 002F2A98
( 0).........002F2A98 suicide
以及以下输出
Drawing group at 002F10E0 ...
Drawing group at 002F29D0 ...
Drawing rectangle at 002F2B68
Line color = 5 Fill color = 2 Text color = 3
placement: (20,125,130,150)
Drawing rectangle at 002F2A98
Line color = 4 Fill color = 2 Text color = 3
placement: (20,70,130,120)
end drawing group at 002F29D0 ...
Drawing circle at 002F1278
Line color = 0 Fill color = 7
placement: (80,10,140,60)
Drawing circle at 002F11A8
Line color = 1 Fill color = 2
placement: (10,10,60,60)
end drawing group at 002F10E0 ...
Press any key ...
注意:“绘图”是通过在 std::cout
上写入消息来实现的。
<H2>结论</H2>代码被组织成一个 static 库和两个 using 它的项目。
该库名为 LTypes,项目名为 CompVisit 和 ReferableTest。
当然,我不指望这些项目有什么用。我希望这个说明可以作为多重虚继承和支配可以有效替代模板的示例,从而允许更“模块化”的代码,并且可以单独编译。