比较不同的编码方法 - 第二部分






4.49/5 (35投票s)
第二部分:多个项目、模板和继承。
引言
在上一篇文章[^]中,我描述了向其他开发人员提供代码的各种方法。
在这篇文章中,我想比较一些特定的设计技术,尤其是
- 近线编码
- 离线编码
- 继承式设计
- 模板包装式设计
示例
让我们设想设计一个应用程序,该应用程序允许用户在屏幕上放置(编辑和保存...)由圆形和矩形表示的形状,并带有边框和填充颜色。
框架
想法是“您”开发应用程序,“我”提供实现它所需的类。重要的是要生成“可比较的代码”,因此需要特殊的设计体系结构。
总体考虑
无论哪种编码风格,“近线”还是“离线”,最好将类分布在不同的项目中,以便使这些类可供可能不同的应用程序使用。特别是,将应用程序数据类保留在特定库中,与应用程序 GUI 类分开,可能会很方便:这允许将该库链接到其他可执行文件中进行某些测试,而无需审查整个应用程序。
同样,无论您使用哪个框架(WTL、MFC 或其他任何框架),您都可能会添加一些功能(一些新控件或一些自定义功能),这些功能很可能与您的应用程序没有严格关系。这些类可以作为适当库的候选。
再说一次,您可能会开发一些与任何框架或任何应用程序都不相关的类。只是“通用部署类”(例如 STL 容器或算法)。这些可以作为另一个库的候选。
当我在开发类供您使用时,我也可以采取同样的方法。为此,可以组织项目,以便“您”的应用程序与我开发的类保持独立。而且由于我必须测试这些类,我可以创建一个骨架应用程序,代表已部署类的用户。因此,sln 文件通常包含以下项目
App | 代表“您”的应用程序,使用“我”的类和一个工作框架。将依赖于所有以下项目。 |
AppClasses | 我用于表示应用程序文档的类。将依赖于所有以下项目。 |
GEWin | WTL、MFC 或 W32 功能扩展。将依赖于以下项目。 |
GECommon | 通用 C++ 类。将包括一些 STL 和 CRT(本身与 Windows 无关)。 |
YourWin | 您对 MFC、WTL 或其他任何内容的扩展。 |
YourCommon | 您的通用类。 |
为避免名称冲突(我的、他的和您的),建议使用命名空间。特别是,我所有的代码通常都包含在一个“根”命名空间中(我通常称之为 GE_
:只是我的首字母缩写),以及一个对类进行分类的第二级命名空间。
也许 GE_
会与您的名称冲突:如果发生这种情况,在我的所有文件中全局“查找和替换”字符串 GE_
,是安全的。
设计模型
为了避免重写源代码以部署不同的模型,我通常遵循以下设计风格
- 每个库都有自己的 stdafx.h(作为预编译头),其中包含该库所需的头文件(注意:不是该库的头文件)。这些头文件永远不会包含在其他库文件中,因此,依赖这些库的项目需要将它们自己的 stdafx.h(如果有)或头文件(如果未使用预编译头)包含到它们自己的 stdafx.h 中。
- 每个源文件都包含所需的头文件(h),这些头文件是:不在预编译头中且编译源文件所需的头文件。
- 前向内联声明在“内联文件”(inl)中,在各自的头文件末尾包含。
- 每个文件(h 和 inl)始终包含一个
#pragma
once。 - 可编译代码在 CPP 文件中。
- CPP 文件中的每个实例化都使用像
GE_INLINE
和GE_SELECTANY
这样的宏完成。这些宏通常定义为空。但如果您定义符号_FORCE_GE_INLINE
,它们将分别定义为_inline
和_declspec(selectany)
。这使得 CPP 本身不生成代码,而是可以被“包含”。 - 如果定义了
_FORCE_GE_INLINE
,CPP 文件将被包含在 HPP 文件中,您可以在您的源文件或预编译头文件中包含它们。
下图展示了这些概念。
蓝色箭头表示“离线编码”中的包含,红色箭头表示“近线编码”中的包含。作为此体系结构的结果,如果使用“离线”,则 CPP 在其项目中编译,将其符号合并到 lib 中,然后链接到 EXE。如果使用“内联”,则 CPP 不编译任何源文件,lib 保持为空,所有内容都将在“exe”项目中编译(取决于您如何使用包含,在预编译头中或在源文件中)。
编码中的命名约定
存在许多“样式”。我发现匈牙利命名法有时有用,有时无用。
例如,所有类都以“C”开头,我们说一个 class 是一个 class
。但我们没有说明它是什么以及它是如何被使用的。
总的来说,我发现自己在引入其他“字母”作为类名前缀方面感到舒适,如下表所示。
N* |
命名空间。所有命名空间始终在 namespace GE_ 中。 |
I* |
接口。关键字 |
C* |
类,当包含 private 和 public 数据时,数据由访问器函数管理。 |
S* |
Struct ,或类,仅包含数据(公共且直接可访问)以及一些辅助函数(通常:默认构造函数初始化值)。 |
X* |
内部嵌套类或密封类。通常,您不需要派生,甚至不需要知道的类。 |
E* |
扩展类:提供接口实现,应作为多重继承的基类的类。不应从此类型创建实例(没有 new E..... )。通常需要虚继承。 |
P*、Q* |
智能指针:通常定义为 typedef 。P 用于强指针(影响对象生命),Q 用于弱指针(仅“观察”)指针(参见 智能指针 [^],但在此示例中实现可能有所不同)。 |
T*、t_* |
Typedef s。大写形式用于全局可用,小写形式用于内部。也可能是“空类”,组合扩展器。 |
也许这种约定乍一看似乎相当烦人。我反而体验到,给出类存在的原因的线索,有助于在重用代码时记住正确解释代码的方式。毕竟,如前所述,如果一切都以“C”开头,我们提供的唯一“信息”是“class”是 class。
数据类与多态
在我们的示例中,矩形和圆形是形状,它们必须在特定位置以特定属性进行绘制。必须可选、可收集和可引用。必须可调整大小、可移动和可序列化。
所有这些特性都将在特定的类中实现,设计为模板或接口。然后,我们必须混合、嵌入或派生。无论如何,我们的对象都必须是“可转换的”。可能是通过 RTTI,也可能是通过模板参数。
至此,我们的对象(圆形和矩形)是更通用对象(“形状”)的多态实现,用户必须能够创建任意数量的这些对象。
多态的实现可能以不同的方式进行:模板和继承可能是最重要的。
这里有一个粗略的比较
// classwrap.cpp // #include <tchar.h> #include <iostream> template<class T> class W { private: T _t; public: void Hallo() { std::cout << _t.GetMsg(); } }; class A { public: const char* GetMsg() const {return "I'm A\n";} }; class B { public: const char* GetMsg() const {return "I'm B\n";} }; class C { public: const char* GetMsg() const {return "I'm C\n";} }; /////////////////////////////////// void wait_enter() { std::cout << "press enter\n"; char ch; std::cin.get(ch); } int _tmain(int argc, _TCHAR* argv[]) { W<A> wa; W<B> wb; W<C> wc; wa.Hallo(); wb.Hallo(); wc.Hallo(); wait_enter(); return 0; } |
// classhinerit.cpp // #include <tchar.h> #include <iostream> #ifndef interface #define interface struct #endif interface I { virtual const char* GetMsg() =0; virtual ~I() {;} }; class W { private: I* _p; public: W(I* p=NULL) { _p = p; } ~W() { if(p) delete _p; } void Set(I* p) { if(_p) delete _p; _p = p; } void Hallo() { std::cout << _p->GetMsg();} }; class A: public I { virtual const char* GetMsg() {return "I'm A\n";} }; class B: public I { virtual const char* GetMsg() {return "I'm B\n";} }; class C: public I { virtual const char* GetMsg() {return "I'm C\n";}; }; ////////////////////////////////////// void wait_enter() { std::cout << "press enter\n"; char ch; std::cin.get(ch); } int _tmain(int argc, _TCHAR* argv[]) { W wa(new A); W wb(new B); W wc(new C); wa.Hallo(); wb.Hallo(); wc.Hallo(); wait_enter(); return 0; } |
在这两个代码中,W
是一个类,它根据与 A
、B
或 C
的工作方式不同而以不同的方式说“hello”。区别在于它的工作方式:在左侧窗格中,嵌入的内容在编译时定义:W<A>
、W<B>
和 W<C>
是不同的类型,而 wa
、wb
和 wc
是不相关的类型。在右侧窗格中,W
始终相同,而 wa
、wb
和 wc
是相同的类型。通过不同的方式获得不同的消息:三个类派生自同一个接口并在运行时创建,重写一个虚函数。
如果我们想 - 例如 - 拥有一个像 W w[3]
这样的数组,那么左侧的编码范例是不可能的,而右侧的编码范例是可能的。
使用模板编码
总的来说,有各种技术可以创建模板多态。以下是一些
- 泛型基类
- 泛型派生
- 混合类
- 可嵌套包装器
泛型基类
当需要添加通用接口实现时,它们很常见。典型情况如下
template<class TDerived> class EGenericBase { protected: // thype conversion TDerived* This() { return static_cast<TDerived*>(this) } //overridables void AnOverridable() { std::cout << "base implementations\n"; } public: void CallOverridble() { This()->AnOverridable(); } };
假设 EGenericBase
用作扩展器基类,如本例所示
class CMyClass1: public EGenericBase<CMyClass1> { public: void AnOverridable() { std::cout << "overridden1 implementations\n"; } }; class CMyClass2: public EGenericBase<CMyClass2> { public: void AnOverridable() { std::cout << "overridden2 implementations\n"; } };
如果您调用 CallOverridable()
,This()
中的 static_cast
会让您调用派生类提供的 AnOverridable
。但是...请注意,这之所以有效,仅仅是因为派生类的类型在编译时对基类是已知的。
在我们的例子中,我们可以定义一个 EDrawable<derived>
,其中 derived
是 CCircle
或 CRectangle
。
泛型派生类
当必须将给定行为归因于不同基类时,它们很常见,每个基类本身都提供该行为
class Base1 { void Method() { std::cout <<"Base1\n"; } }; class Base2 { void Method() { std::cout <<"Base2\n"; } }; template<class TBase> class CGenericDerived: public TBase { void UniqueMethod() { TBase::Method(); } };
混合类
当提供多个基类实现时,这是一个常见的方案
class CYourClass: public EMyFunctions1<CYourClass>, public EMyFunctions2<CYourClass>, public EMyFunctions3<CYourClass> { // ... };
功能依赖(EMyFunctions3
中的一个方法需要调用 EMyFunctions1
中的一个方法)通过双重 static_cast
来解决
template<class T> class EMyFunctions3 { void Calling1() { EMyFunctions1* p1 = static_cast<EMyFunctions1<T>* >(static_cast<T*>(this)); p1->Method1(); } };
这对所有派生自 EMyFunction1
和 3
的 T
都有效。此方案通常用于 WTL 等。
嵌套包装器
当基类之间的依赖关系不是“任意到任意”而是仅线性时,此技术可以简化“转换”的使用:类 A
调用 B
方法,类 B
调用 C
方法,但 B
或 C
都不需要调用 A
。
template<class T> class C: public T { //C methods }; template<class T> class B: public T { //B methods (may call C methods) }; template<class T> class A: public T { //A methods (may call B and C methods) }; //here comes the magic class CYourClass { //this is the class that provide // some data and accessors } typedef A<B<C<CYourClass> > > TDecoratedCMyClass;
这里 TDecoratedCMyClass
具有应用于 CYourClass
的 A
、B
和 C
的所有功能。
当 CYourClass
是 W32 类型(通常是 struct
或句柄)时,C
是初始化程序(将内存设置为零,可能还设置了最终的 cbSize
),B
是“无效值管理器”(设置和检查 0 或 -1),而 A
是“附加器”(管理“Attach”和“Detach”)时,这可能很有用。
模板还是非模板?这是个问题
现在的问题是:假设我们定义了 CCircle
和 CRectangle
,假设我们定义了 EDrawable<T>
、EPlaceable<T>
等等,我们可以将这两个类收集到同一个集合中吗?
不:我们不能。它们之间没有共同的类型。模板本身不是类型:两个实例化是完全不相关的类型。这种方法(在 WTL 中大量使用)对于为已知编译时类型的预定义对象添加功能很有用。而不是用于在同一运行时上下文中可以具有不同类型的对象。
我们可以创建 CCollection<CCircle>
和 CCollection<CRectangle>
,但它们将是不同的。
在这种情况下,需要虚函数和基类指针。我们可能有一个 CCollection<EShape*>
(CCircle
和 CRectangle
可以派生自 EShape
)。但现在编译器将不知道 EShape*
指向哪个类型的 EShape
。因此,EShape
必须以虚函数的形式提供所有可区分的方法。
要管理 EShape
的生命周期,最好存储一个 PShape
,而不是一个 EShape*
,它会在从集合中移除“形状”时销毁它。
我们仍然可以使用模板来定义那些我们可以设想也适用于 EShape
以外的类型的实现。(例如:智能指针可以是,尽管用于更通用的用途,但保留 EShape
的生命周期)
使用抽象类编码
概念非常简单,来自 OOP 文献:定义执行某些操作的抽象方法,并将它们分组到我们称为“接口”的抽象类中。然后,我们将对象派生自我们要实现的接口。
两级模型
在我们的示例中,我们将有
#define interface struct interface IDrawable { virtual void Draw(HDC hDC)=0; virtual void Invalidate()=0; }; interface IPlaceable { virtual void Place(POINT point)=0; virtual void Move(SIZE size)=0; }; class CCircle: public IDrawable, public IPlaceable { public: virtual void Draw(HDC hDC); virtual void Invalidate(); virtual void Place(POINT point); virtual void Move(SIZE size); }; class CRectangle: public IDrawable, public IPlaceable { public: virtual void Draw(HDC hDC); virtual void Invalidate(); virtual void Place(POINT point); virtual void Move(SIZE size); };
我们可以拥有任意数量的 IDrawable
类型,IDrawable*
仍然相同。从这个意义上说,我们拥有真正的运行时多态(与模板相反,模板中的多态发生在编译时)。
但是这个模型提供的可重用性不高:CRectangle
和 CCircle
都可能存储一个边界矩形,而 IPlaceable
很可能以相同的方式实现。
多级模型:部分实现
此时,可以在接口(无实现)和类(完全实现)之间引入中间实现级别。所以我们可能有一个 EPlaceable
派生自 IPlaceable
,它提供了一些数据和一些默认实现(我过去称它们为“扩展器”:将它们作为基类应用于类,它们会“扩展”该类的功能)。我们可以从 EPlaceable
派生 CCircle
和 CRectangle
,而不是从 IPlaceable
派生。
当接口的部分实现需要调用另一个接口的方法时怎么办?使用模板,我们可以进行双重 static_cast
,但在这里……我们必须进行 dynamic_cast
。这需要 RTTI。
class EPlaceable: public IPlaceable { private: CRect _rcBounds; public: virtual void Place(POINT point) { IDrawable* pDrw = dynamic_cast<IDrawable*>(this); if(pDrw) pDrw->Invalidate(); // DO THE REPLACEMENT } }; class CCircle: public IDrawable, public EPlaceable { // .... };
信息隐藏
如果以“离线模式”编码,我可以决定在头文件中公开什么,在库源代码中隐藏什么。我只能公开接口加上一个创建返回接口指针类型的工厂,或者我可以公开所有类。
在第一种情况下,您将可以访问 IDrawabe
和 IPlaceable
的虚函数,以及全局的 CreateRectangle
和 CreateCircle
,它们返回 IDrawable*
。但您看不到 CRectangle
和 CCircle
类本身。
当使用多级模型时,事情变得有点复杂:假设您想为我的 CRectangle
添加一些功能。您可能会希望从中派生,但如果您只有一个全局的 CreateRectangle
函数和一组接口,您该如何做到?
答案是通过“委托和嵌入”进行聚合。
// Embed.cpp // #include <tchar.h> #include <iostream> #ifndef interface #define interface struct #endif interface Int { virtual void IfMethod()=0; virtual ~Int() {;} }; namespace { //this may be secret if in another file class A: public Int { virtual void IfMethod() { std::cout << "A method\n"; } }; } //exposed creator Int* CreateInt() { return new A; } //now the override of A functionality class B: public Int { private: Int* _pEmb; virtual void IfMethod() { std::cout << "B calling ...\n"; _pEmb->IfMethod(); std::cout << "... with something else\n"; } public: B() { _pEmb = CreateInt(); } ~B() { delete _pEmb; } }; Int* CreateB() {return new B; } ///////////////////////////////////////////////// void wait_enter() { std::cout << "Prss Enter"; char a; std::cin.get(a); } void _tmain() { Int* pI = CreateB(); pI->IfMethod(); delete pI; wait_enter(); }
在示例中,B
通过从创建者函数获取 A
来扩展 A
的功能……对 A
一无所知。这或多或少是 COM 聚合模型所做的事情。它有一个缺点:假设接口有一百个方法,由一个秘密对象实现。假设您只需要覆盖其中一对……您必须重新实现所有方法,其中 98 个将是简单的委托给嵌入式对象。这是真正的可重用性吗?
接口继承和主导
假设我们有一个接口,它继承自另一个接口,并且我正在提供该接口的实现。我适合从基接口的实现派生派生接口的实现。
现在,假设您正在定义一个必须实现嵌套接口的类。您可能会派生我的实现。现在的问题是:如果许多接口继承自同一个接口,那么实现会发生什么?为了避免同一接口的多个实现,我们需要虚继承。
这导致了这种层次结构
这就引出了虚继承的另一个方面:主导。假设 E2
想重写 E1
实现的一个方法,而 E3
不想。
CX
将从 E1
(通过 E3
)接收该方法,并直接从 E2
接收,并将使用 E2
,因为它“更近”。这就是“主导”。假设 E2
和 E3
都重写了 E1
的一个方法。现在 CX
的继承变得含糊不清,这取决于 CX
的实现者来决定如何处理。
这会如何影响 RTTI、虚拟表和虚拟基类的性能?
比较
继承和模板之间存在一些宗教战争。我不喜欢这些战争的一个方面是某些原教旨主义,例如:我喜欢 STL,所以我必须讨厌 MFC。我喜欢 Linux,所以我必须讨厌 Windows。我喜欢继承,所以我必须讨厌模板等等等等等等。
模板爱好者总是说虚拟函数和虚拟基类需要“表”,这会降低“性能”。他们经常忘记说的是,每次他们用另一个类型实例化模板时,“代码”就会针对该类型重新创建:std::list<long>
和 std::list<double>
,在编译时,会生成两组不同的例程,这就是 std::list
方法。或者 - 就像一些编译器所做的那样 - 相同的代码应用于一个参数化类,其方法存储在别处。但这实际上是在后台“将模板还原为继承”。
这样比较,仍然存在一种二元性,就像编码“内联”或“离线”一样。在这种情况下,“二元性”是继承创建更多数据,而模板生成更多代码。
这里有一个演示
// templatehack.cpp: // // #include <tchar.h> #include <iostream> template<class T> class A { public: void Do(const T& t) { std::cout << "A::Do on " << t << std::endl; } }; struct I { virtual Out()=0; virtual ~I() {;} }; class C: public virtual I { virtual Out() { std::cout << "C::Out" << std::endl; }; }; void _tmain() { // create thwo instatiation of A A<int> ai; A<double> ad; //call A method, to force the compiler to create the code ai.Do(1); ad.Do(3.14); //gest A method addressed void(A<int>::*pDoI)(const int&) = A<int>::Do; void(A<double>::*pDoD)(const double&) = A<double>::Do; I* pI = new C; pI->Out(); std::cout << std::endl; std::cout << "A<int>::Do address = " << *(unsigned long*)&pDoI << std::endl; std::cout << "A<double>::Do address = " << *(unsigned long*)&pDoD << std::endl; std::cout << std::endl; std::cout << "size of C = " << sizeof(C) << std::endl; std::cout << "size of A(int) = " << sizeof(ai) << std::endl; std::cout << "size of A(double) = " << sizeof(ad) << std::endl; delete pI; char a; std::cin.get(a); }
产生以下输出
A::Do on 1
A::Do on 3.14
C::Out
A<int>::Do address = 4203456
A<double>::Do address = 4203520
size of C = 8
size of A(int) = 1
size of A(double) = 1
A
是一个“无数据”类(因此它的大小为一字节),而 C
具有虚函数和虚拟基类:因此它的大小为 8 字节,以容纳 v-table 和 b-table 指针(没有虚拟基类,其大小应为 4)。还为 I
和 C
生成了一个 v-table(包含两个成员:方法和析构函数)。
相比之下,A
的两个实例化产生了两个 A::Do
函数(并且有两个不同的地址)。
现在,考虑一个应用程序在内存中运行一千个对象,每个对象 100 字节,有 10 种不同类型(即每种类型 100 个对象)。考虑每个对象提供 50 个函数,每种类型只有 10 个不同(20%),每个函数 200 字节长,让我们估计代码和数据的内存量。
模板 |
继承 | |
Data | 1000 个对象,每个 100 字节,共 100KB | 1000 个对象,每个 108 字节,共 108KB(8% 开销) |
代码 | 每 10 种类型 50 个函数 = 500 个编译函数体(100KB) | 每 10 种类型 10 个函数 + 40 个通用函数:140 个函数体(28KB) |
总计 | 200 KB | 136 KB |
偏好 |
|
|
总的来说,这不一定是一个代码问题,而是要管理的数据量(以及它们之间的差异程度)与使用的代码量之间的平衡问题。假设代码非常简单(函数短),模板包装数据类可能是最好的:这就是 WTL 程序代码量相对较少的原因。WTL 的大部分函数都只是简单的 W32 API 委托。即使对象很多,而且相当小:8 字节的开销可能不容忽视,如果添加到 4 字节的对象中。
但是,当需要运行时多态(例如用户创建并一起收集的对象)时,并且对象在数据和代码方面都变得相当复杂,那么就该转向继承了。
用公式表示,给定
N
:所需对象的总数L
:每个对象的平均大小F
:每个类型的平均函数数量C
:函数的平均长度T
:不同类型的数量P
:“差异化”(介于 0 和 1 之间):相对于总数,每个类型的函数中有多少是不同的或被重写的。v
:虚拟函数和基表指针的开销(4)I
:“平衡比率”(<1 有利于继承,>1 有利于模板)
则
值得注意的是,当 P
=0(每种类型都有自己的重写)时,模板总是优先。提供的函数数量相同,并且不需要提供任何 v-table 的东西。但那时,独立编码对象可能更好!
更一般地说,模板在以下情况下变得方便,
(注意:根据定义,P
是 (0...1),分子是表的开销,分母是重写代码的开销。)
P
相对于 N 的值,以 T
为参数,在下图中,其中 v
=4,C
=500 且 F
=10。
总的来说,对象数量越多,切换到模板所需的差异化就越少。但类型数量越多,继承就越好。
代码越短(C
或 F
越小),行就越快(模板更受欢迎),反之亦然,函数越长或函数数量越多。
结论
我比较了“内联”与“离线”以及“模板”与“继承”。
我不强求您同意所有这些分析。我希望的是一种不那么宗教化(或理想化)的方法来做出这些选择,而是一种务实的方法。当然,这里进行的分析并非最严谨的,但我不想写一本书,只想让任何人都能了解这些方面,并-也许-为他们自己的特定情况进行自己的分析。