UML软件工程组织

C/C++项目中的代码复用和管理

 

2008-06-06 作者:沈立力 秦志强 来源: IT168

 

一 模块功能单一化

模块的功能要单一,这似乎是人尽皆知的原则。但是在编码设计过程中,并不是谁都能小心的处理这个问题。

首先举一个实际中的例子:在我们的Capsuit的“安全检查”部件的开发过程中,我们开发了一个模块,用于其他模块输出Log.假设这个模块输出一个函数,叫做LogOutput,只要调用这个函数,就可以输出Log到某一个文件中。这个函数定义如下:

void LogOutput(const TCHAR *format,…);

这个模块需要初始化,初始化的过程,有一步是从配置文件中,得到Log文件的路径。

bool LogInit() 
{ 
CString log_file_path = CfgFile::GetLogFilePath(); 
if(log_file_path.IsEmpty()) 
return false; 
… 
} 

这时候我们有另一个需求:我们要开发一个新的组件,称为“生存通知”。很自然这个模块里面也要用到Log.我们试图简单的拷贝代码来重用Log这个组件。但是这时出现了问题。我们#include “log.h”.

同时log.h中有#include “cfgfile.h”.cfgfile是“安全检查”模块独有的配置文件,和“生存通知”没有任何关系。但是我们不得不拷贝cfgfile.h和cfgfile.c。不过更糟糕的是,cfgfile.c中的处理非常复杂,用到了XML解析。为此,我们必须再包含XML.c和XML.h.此外,几乎所有的“安全检查模块”都包含了一个称为“def.h”的头文件。def.h中#include了几乎所有的头文件。如果我们使用这些.c文件,也必须同时拥有所有的这些头文件。其结果为,我们无法重用Log.c和Log.h组成的Log模块。除非我们把两个工程合并成一个。或者修改Log.c.

其实这个问题的核心在于,Log.c这个模块的功能不够单一。作为一个Log模块,打开文件并输出Log是其功能目标,而读取配置文件找到Log文件的路径,看似和Log相关,但是实质上并非Log的目标功能。一个Log应该是可以向任何位置的文件输出Log的。所以我们修改 Log.c中的LogInit()这个函数,给他传入一个Log文件路径,而不是调用配置文件去读取.  

bool LogInit(const CString &str) 
{ 
if(str.IsEmpty()) 
return flase; 
log_file_path = str; 
… 
} 

这个修改看似简单,但是实际上,却使Log.c解除了对cfgfile.c的依赖。也就是说,Log这个模块不再依赖于配置文件。由于配置文件依赖于XML,那么Log也不再依赖于XML.链式依赖关系已经断裂,所以Log.c这个模块基本上可以重用了。从可以看出,在编码设计阶段的稍有不注意,都会给后继开发带来巨大的麻烦。不可不小心谨慎的进行设计。

原则1: 模块的功能要单一。在模块中调用其他模块的时,要慎之又慎。只有必要时才这样做。

二 头文件包含其他头文件

    此外,如果Log.c中还#include了def.h,那注定不能被轻易的“拷贝”。这处于工程开发阶段的一个方便的考虑:假设我把所有的头文件、宏定义、或者函数声明都包含在一个叫做 def.h的头文件中。那么,我编写.c文件的时候会非常方便,一般只要#include “def.h”就可以了,不用担心任何缺少头文件之类的问题。但是事实上,在代码重用的时候,最害怕碰到的,就是”def.h”之类的头文件。因为,打开这样的头文件之后,常常看到的是下面的情况:

#include “cfgfile.h” 
#include “genutl.h” 
#include “mysocket.h” 
…… 

换句话说,如果我要在我的工程中使用这个头文件,我必须得拷贝“cfgfile.h”,”genutl.h”,”mysocket.h”这三个文件,而且这必须在cfgfile.h等几个文件中,没有再度#include别的头文件的情况。一般的说,我们现在代码的现状,都是很轻易的在头文件中包含其他的头文件。最终的结果,发现我们包含这个头文件是不可能的。因为需要拷贝的文件太多了。

    原则2:在头文件中包含其他的头文件往往是不必要的,是应该禁止的。只有万不得已的情况,才能这样做。

    有时你会觉得,原则2是荒唐的。似乎违犯了一贯编程的原则。但是实际上,几乎99%的情况都可以证明,在头文件中包含其他的头文件,是没有必要的。举一个例子如下:我编写了一个类的头文件class_a.h:

class MyClassA 
{
public:

private:
MyClassB m_bObject;
};

这时候,似乎#include “class_b.h”是唯一的选择。否则MyClassB m_bObject这一句无法通过编译。但是实际上,在这里定义MyClassB的对象作为MyClassA的一个成员是不对的。后面会重点讲述:为何使用对象的指针总是比使用对象更好。看下面的代码:

class MyClassB; 
class MyClassA 
{ 
public: 
… 
private: 
MyClassB *m_bObject; 
}; 

实际上功能完全一样,甚至比现在更节约内存(当m_bObject不用的时候,可以只是一个空指针)。而且此时class_a.h中不需要包含class_b.h。没有违反上面的原则2.

但是不可否认,有时头文件中是必须使用头文件的,比如:

class MyClassA : public MyClassB 
{ 
}; 

此时,#include “class_b.h”是有必要的。但是继承一般的来说,不是一个很好的主意。一般继承仅仅用于:基类是一个纯虚类的情况。现在多不主张多层次的复杂的继承关系。当然并不仅仅因为这样会带来多层次嵌套的#include头文件。后文再详细的讨论这些问题。

    另一个常见的必须使用头文件的情况是:我在类或者函数定义中用到了stl模块类。

void my_function(const string &str);

此时在前面简单的声明:class string,往往是行不通的。必须#include <string>.但是,由于stl的头文件非常通用,几乎不会有人抱怨找不到这些头文件,所以在头文件中包含它们是一个可以接受的例外。
下面继续讨论头文件的问题。 

三. 头文件极简化

头文件往往是代码质量的关键所在。因为我们往往是通过头文件,来提供给对方,可以使用的类或者函数。.c文件的部分可以重写,不影响其他的部分。而头文件则往往牵一发而动全身。所以头文件不可以不做小心谨慎的设计。随意的编写头文件是绝对错误的。

头文件里包含其他头文件,还常常是因为用到特有的数据结构来返回结果导致的。下面举出另一个虚拟的例子:我打算编写一个模块,提供一个功能,让别人可以获得我本机上插的U盘的序列号。这是一个很明确的需求。我编写了usb_disk_id.c和usb_disk_id.h来提供这些功能。而使用这个模块的人,只要#include “usb_disk_id.h”然后调用我的函数就可以了。

在开发的过程中,我借鉴了DDK中一个应用程序,名字叫做”usbview.exe”的代码。这个代码能显示每个USB盘的信息。所有的信息返回在一个链表中,每个节点定义如下:

typedef struct _STRING_DESCRIPTOR_NODE 
{ 
struct _STRING_DESCRIPTOR_NODE * Next; 
UCHAR DescriptorIndex; 
USHORT LanguageID; 
USB_STRING_DESCRIPTOR StringDescriptor[0]; 
} STRING_DESCRIPTOR_NODE, *PSTRING_DESCRIPTOR_NODE;

这样,最简单的考虑,我就是返回这个链表的头给使用者就可以了。StringDescriptor中有所有的信息,包括U盘的序列号,那么我应该这样写我的头文件:

#ifndef … 
#define … 
#include <usbiodef.h> 
typedef struct _STRING_DESCRIPTOR_NODE 
{ 
struct _STRING_DESCRIPTOR_NODE * Next; 
UCHAR DescriptorIndex; 
USHORT LanguageID; 
USB_STRING_DESCRIPTOR StringDescriptor[0]; 
} STRING_DESCRIPTOR_NODE, *PSTRING_DESCRIPTOR_NODE; 

PSTRING_DESCRIPTOR_NODE umsGetAllDisks(); 
#endif 

如果我以上设计了这个模块,那么对使用者来说,将是一个巨大的困扰。首先,这违反了前面说的原则2.在头文件中包含了另外一个头文件<usbiodef.h>.此外,这个头文件是DDK的头文件。但是使用者只想获得U盘序列号,并不曾想,自己必须改变VC设置,去包含DDK的头文件。此外DDK的头文件和SDK的头文件同时使用,常常出现版本冲突之类的问题,难以配置。但是实际上完全没有必要的。此外,使用者还必须学会如何操作USB_STRING_DESCRIPTOR。而且使用者必须自己操作链表。这又带来更多的问题:使用者能否安全的操作链表呢?操作过程中是否要加锁呢?

原则3. 头文件只提供给使用者必要的东西,绝不把任何多余的东西包含进去。

下面做一个简单的修改。实际上,我们返回的依然是链表。但是,我们却不让使用者看见链表,以及DDK特有数据结构的存在。

#ifndef … 
#define … 
void *umsGetAllUDisk( ); 
const wchar_t* umsGetNextDiskID(void * umsDescHandle);                      
void umsFreeAllUDisk(void * umsDescHandle); 
#endif 

这里用了一个void *代替返回链表。用umsGetNextDiskID来遍历链表。用户只能看见一个const wchar_t*返回的U盘序列号。不需要包含其他任何头文件,也不需要担忧链表使用的安全性。这是一个符合原则3的设计。 

四 解除依赖

依赖关系是往往是代码复用最大的羁绊。下面再举一个实际中的例子。我们在开发驱动的过程中,编写了一个模块,这个模块可以在驱动中把计算机名转化为ip地址。我把这个模块命名为WNS,编译出一个WNS.lib的静态库给别人使用。

但是我们遇到了第一个问题。在Infocage项目中,客户要求所有的组件在异常情况都要出Log,必须调用规定的IcLog函数.此外,还有所有的组件都要使用规定的函数IcMemAllocate和IcMemFree来分配和释放内存。

这样一来,我的WNS中也必须调用IcLog来出Log,同时必须使用IcMemAllocate来分配内存。

在另一个工程,假设名字叫Capsuit,则完全不同。他们要求所有的组件都要用CsLog模块来出Log,并要用CsMem模块来分配内存。

   那么WNS如何适应呢?此时很多人就认为,独立出这样的模块给两个工程使用,本来是可行的。但是由于客户的需求,所以实际不能做到。

   但是这个想法是错误的。关键在于,我们没有很好的理解“解除依赖”的方法。

   WNS可以使用IcLog模块来输出 Log.但这并不意味者,WNS必须依赖Log.我们假设上面的说法成立,那么WNS必须依赖IcLog.如果IcLog的Log实质上是写入Oracle数据库的,那么你会发现所有要出Log的组件都依赖于Oracle,那么独立模块根本就是不可能存在的。

    实际上,WNS可以不依赖于IcLog.在C++中,很容易用虚函数实现这一点。在C中,也很容易设置回调函数来实现。

    WNS要出Log,我们可以假设依赖于如下一个Log函数:

void wnsLogOutput(const wchar_t *format,…);

但是这个函数实际并不存在,我定义一个函数类型:

typedef void (*WNS_LOG_OUTPUT_F)(const wchar_t *format, …);

然后定义一个函数指针:

static WNS_LOG_OUTPUT_F sMyLogFunction = NULL;

之后我在WNS中,我都只用这个函数指针来输出 Log:

if(sMyLogFunction != NULL) 
sMyLogFunction(…); 

当然,我在初始化WNS的时候,要根据客户的要求,指定这个函数指针。比如说在Infocage项目中,客户要求使用IcLog().

void wnsInitialize(WNS_LOG_OUTPUT_F log_function) 
{ 
… 
sMyLogFunction = log_function; 
} 

在另一个项目中我可以使用另外的实际接口。

如果函数原型不同,我总是可以定义一个简单的中间函数来满足两边的接口匹配。

同样,内存分配函数也是如此。

依赖关系是可以被解除的。关键只在于解除的花费与所得的比例。小心的设计编码,微妙的改变代码架构,往往可以巧妙解除依赖关系链,使代码变得可重用。

五. 接口的应用

这里所谓的接口是指:我试图要使用一个功能,但是我不确定这个功能是如何实现的时,我所调用的一个函数指针,或者一个虚函数,或者一个纯虚类。

由于接口总是空的,或者虚的,不实现任何东西,所以可以有以下的结论:

定理1:接口是依赖的终点。接口不需要依赖任何东西。

推论1:依赖接口是安全的。不会带来更多的依赖关系。

推论2:当我们需要依赖时,我们必须尽量做到:我们依赖的是接口。而不是实际的东西。

前面的WNS的例子中,是函数指针接口的应用。下面举出一个纯虚类的例子。

假设我们制作了一个对话框(MyDlg)。我在对话框上添加了一个控件(MyCtrl)。MyCtrl派生于一个基类MyCtrlBase,该Base类有一个虚函数:

virtual void OnClick() = 0; 

该控件被点击的时候,则OnClick会被调用。现在的意图是,该控件被点击的时候,我的对话框发生某种变化,比如说,MyDlg::OnMyCtrlClick()被调用。这如何实现呢?

最常见的但是也是错误的方法如下:

首先是MyDlg:

class MyDlg : public MyDlgBase 
{ 
public 
virtual void OnMyCtrlClick() { … } 
private: 
MyCtrl * m_myCtrl; 
} 
class MyCtrl : public MyCtrlBase 
{ 
public: 
virtual void OnClick(); 
private: 
MyDlgCtrl *m_parentDlg; 
}; 
void MyCtrl::OnClick() 
{ 
m_parentDlg-> OnMyCtrlClick(); 
} 

我确实实现了。但是这个实现方法真的很愚蠢。因为MyCtrl和MyDlg完全依赖了对方。任何一个都不能脱离对方而被重用。MyDlg依赖MyCtrl尚可以理解。因为这个对话框中含有这个控件。但是MyCtrl为何要依赖MyDlg呢?这是完全没有必要的。我自己是一个控件,没有理由理会我在哪个窗口里。无论在哪个窗口里,都是一样的作用。 当对话框上有多个不同控件时,情况会更加复杂。最终的结果,导致全部的组件之间都互相依赖,没有任何一个部分是可以重用的。 正确的方法是抽象出一个接口。这个接口叫做“点击接收者”。

很显然我的对话框是一个点击接收者。它接受来自控件的点击:

class MyCtrl : public MyCtrlBase, 
public Clickreceiver 
{ 
public: 
virtual void OnClick(); 
private: 
MyDlgCtrl *m_parentDlg; 
MyCtrl * m_myCtrl; 
} 

至于控件方面:

class MyCtrl : public MyCtrlBase 
{ public: virtual void OnClick(); private: ClickReceiver *m_receiver; }; void MyCtrl::OnClick() { m_receiver -> OnMyCtrlClick();
}  

控件没有再依赖复杂的对话框类。而是依赖了一个接口。符合前面的推论2.

使用接口是OO设计最基本的原则之一,然而在我们的实际开发中,往往得不到贯彻。

六.总是使用指针或引用

这个问题看似和代码的复用无关。比如说一个函数:

  void my_function(const string &str); 
    以上是最常见的写法。为何不能写成:
void my_function(string str)

许多人都知道这个道理。把对象直接放入函数接口中,结果这些对象将整个被压栈,出栈,内存操作往往比单独操作指针大了许多,这个消耗是完全没有必要的。此外,类似下面的写法:

vector< MyCfgItem > items; 
map<string, MyCondition > conditons; 

也曾经在我们的Capsuit项目中经常出现。这样用法也是有理由的:

“这样使用起来方便。不用new,不用判断内存是否足够。不用delete,用delete的话万一忘记了就会内存泄漏。要说效率的话,拷贝内存,能有多少效率问题呢?”

如果MyCfgItem内部结构不复杂,确实效率问题并不是很大。但是这样使用一旦形成习惯,在MyCfgItem中再内含一个vector < MyClassB >,然后在MyClassB中再内含一个vector< MyClassA > 也是完全有可能的。这样一下来,多重拷贝,其效率的损失,就非常的客观了。与其到出了问题再手忙脚乱的修改代码,何如一开始就注意最基本的原则呢。

原则4 除非是轻量级的常用类,否则我们永远只使用类的对象的指针。

我个人认为,string这样的常用的stl模板类,又并非巨大的字符串的情况下,使用对象尚是可以接受到。但是自己开发的类,或者是使用别人开发的类无疑应该使用指针。这不仅仅是效率的问题。下面的写法才是合理的:

vector< MyCfgItem* > items; 
map<string, MyCondition* > conditons; 

为何说不仅仅是效率的问题,我们再看下面的例子:

下面再举我们在Capsuit的开发中,碰到的一个问题。情况是这样的:我们的软件,要对计算机进行全面的检查。包括检查硬件,检查操作系统信息,检查注册表,检查进程,以及运行的服务等等,来判断当前计算机是否正常。本人负责开发检查部分。这个部分的任务是,根据外部输入的需求,来调用相应的实际进行检查的函数。这些函数则由各个不同部门的同仁实现好。本人只要调用他们就可以了。

外部总是输入一组条件:假设每个条件是这样的:

struct condition { 
string check_type; // 告诉我检查的类型, 
string param1; // 检查的参数,比如说是哪个注册表项要检查,等等 
string param2; // 同上,都是取决于不同类型的检查而不同的参数 
}; 

最直觉的做法,就是这样来实现:

bool check( const vector< condition * > &conditions) 
{ 
unsigned int i; 
bool result = true; 
for(i=0;i<conditions.size();++i) 
{ 
if(conditions[i]->check_type == “Hardware”) 
resulte &&= HardwareCheck(condition->param1,condition->param2); else if(conditions[i]->check_type == “Registry”)
resulte &&= RegistryCheck(condition->param1,condition->param2); else if(conditions[i]->check_type == “OS”)
resulte &&= OSCheck(condition->param1,condition->param2); else if(conditions[i]->check_type == “Process”) resulte &&= ProcessCheck(condition->param1,condition->param2); … … } }

以上的if … else if不但难看而且长。更重要的是,这非常的没有可扩展性。这个check组件,必须依赖于一系列的实现非常复杂的模块,比如HardwareCheck, RegisterCheck, OsCheck, ProcessCheck,没有其中任何一个的实现就无法操作。实施上,这个check是没有任何可复用性的。

原则5 当我要创建我并不关心其实现的类的时候,我使用工厂类创建他们。

七 如何复用代码

上面讲了很多,都是说如何让代码具有可复用性。但是如果我们不知道如何复用代码,那么再有可复用性的代码,也是浪费。

在我们的实际开发中,常常以拷贝代码的方式来复用代码。这包括某段代码的拷贝,或者是几个文件的拷贝。我倒是要提出一个我认为最基本的编码原则:

    原则6 除非万不得已,永远也不要拷贝代码。

如果我们把代码在一个工程内部进行拷贝,说明这个工程内部有部分代码必然是重复的。作为高效率的开发者,为何要编写重复的代码,而不直接复用他们呢?这说明代码的设计有问题,或者是开发人员出于一时的方便起见,做出了敷衍的操作。

如果我们把代码在一个工程拷贝到另外一个工程。说明我们实际上已经写出了可以在工程之间通用的代码。这样的代码,是经过至少一个工程的考验的,我们为何不直接使用它们,而要另外拷贝一份呢?代码的拷贝,至少有以下几个缺点:

1. 如果这份代码是没有bug的。那么在拷贝过程中,可能出现bug。

2. 如果这份代码是有bug的,那么在拷贝过程中,bug也被复制了。bug会传染到其他的工程组件,甚至其他的工程项目中。

所谓的代码复用,我打算给出一个定义如下:

定义1. 所谓代码的复用,是指不拷贝的使用同一份代码。