一 模块功能单一化
模块的功能要单一,这似乎是人尽皆知的原则。但是在编码设计过程中,并不是谁都能小心的处理这个问题。
首先举一个实际中的例子:在我们的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. 所谓代码的复用,是指不拷贝的使用同一份代码。
|