65.9K
CodeProject 正在变化。 阅读更多。
Home

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

2003年12月9日

CPOL

17分钟阅读

viewsIcon

74075

第二部分:多个项目、模板和继承。

引言

上一篇文章[^]中,我描述了向其他开发人员提供代码的各种方法。

在这篇文章中,我想比较一些特定的设计技术,尤其是

  1. 近线编码
  2. 离线编码
  • 继承式设计
  • 模板包装式设计

示例

让我们设想设计一个应用程序,该应用程序允许用户在屏幕上放置(编辑和保存...)由圆形矩形表示的形状,并带有边框和填充颜色。

框架

想法是“”开发应用程序,“”提供实现它所需的类。重要的是要生成“可比较的代码”,因此需要特殊的设计体系结构。

总体考虑

无论哪种编码风格,“近线”还是“离线”,最好将类分布在不同的项目中,以便使这些类可供可能不同的应用程序使用。特别是,将应用程序数据类保留在特定库中,与应用程序 GUI 类分开,可能会很方便:这允许将该库链接到其他可执行文件中进行某些测试,而无需审查整个应用程序。

同样,无论您使用哪个框架(WTL、MFC 或其他任何框架),您都可能会添加一些功能(一些新控件或一些自定义功能),这些功能很可能与您的应用程序没有严格关系。这些类可以作为适当库的候选。

再说一次,您可能会开发一些与任何框架或任何应用程序都不相关的类。只是“通用部署类”(例如 STL 容器或算法)。这些可以作为另一个库的候选。

当我在开发类供您使用时,我也可以采取同样的方法。为此,可以组织项目,以便“”的应用程序与我开发的类保持独立。而且由于我必须测试这些类,我可以创建一个骨架应用程序,代表已部署类的用户。因此,sln 文件通常包含以下项目

App 代表“”的应用程序,使用“”的类和一个工作框架。将依赖于所有以下项目。
AppClasses 用于表示应用程序文档的类。将依赖于所有以下项目。
GEWin WTL、MFC 或 W32 功能扩展。将依赖于以下项目。
GECommon 通用 C++ 类。将包括一些 STL 和 CRT(本身与 Windows 无关)。
YourWin 您对 MFC、WTL 或其他任何内容的扩展。
YourCommon 您的通用类。

为避免名称冲突(我的他的您的),建议使用命名空间。特别是,我所有的代码通常都包含在一个“根”命名空间中(我通常称之为 GE_:只是我的首字母缩写),以及一个对类进行分类的第二级命名空间。

也许 GE_ 会与您的名称冲突:如果发生这种情况,在我的所有文件中全局“查找和替换”字符串 GE_,是安全的。

设计模型

为了避免重写源代码以部署不同的模型,我通常遵循以下设计风格

  1. 每个库都有自己的 stdafx.h(作为预编译头),其中包含该库所需的头文件(注意:不是该库的头文件)。这些头文件永远不会包含在其他库文件中,因此,依赖这些库的项目需要将它们自己的 stdafx.h(如果有)或头文件(如果未使用预编译头)包含到它们自己的 stdafx.h 中。
  2. 每个源文件都包含所需的头文件(h),这些头文件是:不在预编译头中且编译源文件所需的头文件。
  3. 前向内联声明在“内联文件”(inl)中,在各自的头文件末尾包含。
  4. 每个文件(hinl)始终包含一个 #pragma once。
  5. 可编译代码在 CPP 文件中。
  6. CPP 文件中的每个实例化都使用像 GE_INLINEGE_SELECTANY 这样的宏完成。这些宏通常定义为空。但如果您定义符号 _FORCE_GE_INLINE,它们将分别定义为 _inline_declspec(selectany)。这使得 CPP 本身不生成代码,而是可以被“包含”。
  7. 如果定义了 _FORCE_GE_INLINE ,CPP 文件将被包含在 HPP 文件中,您可以在您的源文件或预编译头文件中包含它们。

下图展示了这些概念。

蓝色箭头表示“离线编码”中的包含,红色箭头表示“近线编码”中的包含。作为此体系结构的结果,如果使用“离线”,则 CPP 在其项目中编译,将其符号合并到 lib 中,然后链接到 EXE。如果使用“内联”,则 CPP 不编译任何源文件,lib 保持为空,所有内容都将在“exe”项目中编译(取决于您如何使用包含,在预编译头中或在源文件中)。

编码中的命名约定

存在许多“样式”。我发现匈牙利命名法有时有用,有时无用。

例如,所有类都以“C”开头,我们说一个 class 是一个 class。但我们没有说明它是什么以及它是如何被使用的。

总的来说,我发现自己在引入其他“字母”作为类名前缀方面感到舒适,如下表所示。

N* 命名空间。所有命名空间始终在 namespace GE_ 中。
I*

接口。关键字 interface 用作 struct 的别名以强制执行。除可能的静态成员或函数外,所有函数都应为虚函数且纯虚函数(virtual ret_type fn(params)=0)。

C* 类,当包含 privatepublic 数据时,数据由访问器函数管理。
S* Struct,或类,仅包含数据(公共且直接可访问)以及一些辅助函数(通常:默认构造函数初始化值)。
X* 内部嵌套类或密封类。通常,您不需要派生,甚至不需要知道的类。
E* 扩展类:提供接口实现,应作为多重继承的基类的类。不应从此类型创建实例(没有 new E.....)。通常需要虚继承
P*、Q* 智能指针:通常定义为 typedefP 用于强指针(影响对象生命),Q 用于弱指针(仅“观察”)指针(参见 智能指针 [^],但在此示例中实现可能有所不同)。
T*、t_* Typedefs。大写形式用于全局可用,小写形式用于内部。也可能是“空类”,组合扩展器。

也许这种约定乍一看似乎相当烦人。我反而体验到,给出类存在的原因的线索,有助于在重用代码时记住正确解释代码的方式。毕竟,如前所述,如果一切都以“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 是一个类,它根据与 ABC 的工作方式不同而以不同的方式说“hello”。区别在于它的工作方式:在左侧窗格中,嵌入的内容在编译时定义:W<A>W<B>W<C> 是不同的类型,而 wawbwc 是不相关的类型。在右侧窗格中,W 始终相同,而 wawbwc 是相同的类型。通过不同的方式获得不同的消息:三个类派生自同一个接口并在运行时创建,重写一个虚函数。

如果我们想 - 例如 - 拥有一个像 W w[3] 这样的数组,那么左侧的编码范例是不可能的,而右侧的编码范例是可能的。

使用模板编码

总的来说,有各种技术可以创建模板多态。以下是一些

  1. 泛型基类
  2. 泛型派生
  3. 混合类
  4. 可嵌套包装器

泛型基类

当需要添加通用接口实现时,它们很常见。典型情况如下

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>,其中 derivedCCircleCRectangle

泛型派生类

当必须将给定行为归因于不同基类时,它们很常见,每个基类本身都提供该行为

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();
    }
};

这对所有派生自 EMyFunction13T 都有效。此方案通常用于 WTL 等。

嵌套包装器

当基类之间的依赖关系不是“任意到任意”而是仅线性时,此技术可以简化“转换”的使用:类 A 调用 B 方法,类 B 调用 C 方法,但 BC 都不需要调用 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 具有应用于 CYourClassABC 的所有功能。

CYourClass 是 W32 类型(通常是 struct 或句柄)时,C 是初始化程序(将内存设置为零,可能还设置了最终的 cbSize),B 是“无效值管理器”(设置和检查 0 或 -1),而 A 是“附加器”(管理“Attach”和“Detach”)时,这可能很有用。

模板还是非模板?这是个问题

现在的问题是:假设我们定义了 CCircleCRectangle,假设我们定义了 EDrawable<T>EPlaceable<T> 等等,我们可以将这两个类收集到同一个集合中吗?

不:我们不能。它们之间没有共同的类型。模板本身不是类型:两个实例化是完全不相关的类型。这种方法(在 WTL 中大量使用)对于为已知编译时类型的预定义对象添加功能很有用。而不是用于在同一运行时上下文中可以具有不同类型的对象。

我们可以创建 CCollection<CCircle>CCollection<CRectangle>,但它们将是不同的。

在这种情况下,需要虚函数和基类指针。我们可能有一个 CCollection<EShape*>CCircleCRectangle 可以派生自 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* 仍然相同。从这个意义上说,我们拥有真正的运行时多态(与模板相反,模板中的多态发生在编译时)。

但是这个模型提供的可重用性不高:CRectangleCCircle 都可能存储一个边界矩形,而 IPlaceable 很可能以相同的方式实现。

多级模型:部分实现

此时,可以在接口(无实现)和类(完全实现)之间引入中间实现级别。所以我们可能有一个 EPlaceable 派生自 IPlaceable,它提供了一些数据和一些默认实现(我过去称它们为“扩展器”:将它们作为基类应用于类,它们会“扩展”该类的功能)。我们可以从 EPlaceable 派生 CCircleCRectangle,而不是从 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
{
    // ....

};

信息隐藏

如果以“离线模式”编码,我可以决定在头文件中公开什么,在库源代码中隐藏什么。我只能公开接口加上一个创建返回接口指针类型的工厂,或者我可以公开所有类。

在第一种情况下,您将可以访问 IDrawabeIPlaceable 的虚函数,以及全局的 CreateRectangleCreateCircle,它们返回 IDrawable*。但您看不到 CRectangleCCircle 类本身。

当使用多级模型时,事情变得有点复杂:假设您想为我的 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 个将是简单的委托给嵌入式对象。这是真正的可重用性吗?

接口继承和主导

假设我们有一个接口,它继承自另一个接口,并且我正在提供该接口的实现。我适合从基接口的实现派生派生接口的实现。

现在,假设您正在定义一个必须实现嵌套接口的类。您可能会派生我的实现。现在的问题是:如果许多接口继承自同一个接口,那么实现会发生什么?为了避免同一接口的多个实现,我们需要虚继承。

这导致了这种层次结构

Hierarchy chart

这就引出了虚继承的另一个方面:主导。假设 E2 想重写 E1 实现的一个方法,而 E3 不想。

CX 将从 E1(通过 E3)接收该方法,并直接从 E2 接收,并将使用 E2,因为它“更近”。这就是“主导”。假设 E2E3 都重写了 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)。还为 IC 生成了一个 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
偏好
  • 编译时多态
  • 小数据对象(几字节)数量巨大
  • 简单函数(相对较短的函数体)
  • 运行时多态
  • 大数据对象(100 字节以上)数量相对较少
  • 复杂函数,通常是许多类型的通用函数。

总的来说,这不一定是一个代码问题,而是要管理的数据量(以及它们之间的差异程度)与使用的代码量之间的平衡问题。假设代码非常简单(函数短),模板包装数据类可能是最好的:这就是 WTL 程序代码量相对较少的原因。WTL 的大部分函数都只是简单的 W32 API 委托。即使对象很多,而且相当小:8 字节的开销可能不容忽视,如果添加到 4 字节的对象中。

但是,当需要运行时多态(例如用户创建并一起收集的对象)时,并且对象在数据和代码方面都变得相当复杂,那么就该转向继承了。

用公式表示,给定

  • N:所需对象的总数
  • L:每个对象的平均大小
  • F:每个类型的平均函数数量
  • C:函数的平均长度
  • T:不同类型的数量
  • P:“差异化”(介于 0 和 1 之间):相对于总数,每个类型的函数中有多少是不同的或被重写的。
  • v:虚拟函数和基表指针的开销(4)
  • I:“平衡比率”(<1 有利于继承,>1 有利于模板)

formula

值得注意的是,当 P=0(每种类型都有自己的重写)时,模板总是优先。提供的函数数量相同,并且不需要提供任何 v-table 的东西。但那时,独立编码对象可能更好!

更一般地说,模板在以下情况下变得方便,

(注意:根据定义,P 是 (0...1),分子是表的开销,分母是重写代码的开销。)

P 相对于 N 的值,以 T 为参数,在下图中,其中 v=4,C=500 且 F=10。

Graph1

总的来说,对象数量越多,切换到模板所需的差异化就越少。但类型数量越多,继承就越好。

代码越短(CF 越小),行就越快(模板更受欢迎),反之亦然,函数越长或函数数量越多。

结论

我比较了“内联”与“离线”以及“模板”与“继承”。

我不强求您同意所有这些分析。我希望的是一种不那么宗教化(或理想化)的方法来做出这些选择,而是一种务实的方法。当然,这里进行的分析并非最严谨的,但我不想写一本书,只想让任何人都能了解这些方面,并-也许-为他们自己的特定情况进行自己的分析。

© . All rights reserved.