欢迎访问 水平网    今天是:2017年11月22日 注册 | 登录 | 订阅 | 收藏
>> C/S程序开发 >> C/C++ >> C/C++项目中的代码复用和管理
推荐文章
热点文章
专题
JQuery框架
Prototype.js
HTML5

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

作者:未知,  来源:网络,  阅读:682,  发布时间:2015-04-10 【放入收藏夹
一 模块功能单一化

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

首先举一个实际中的例子:在我们的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 .但是,由于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 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.在头文件中包含了另外一个头文件.此外,这个头文件是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 conditons;


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

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

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

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

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


vector< MyCfgItem* > items; map 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;icheck_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. 所谓代码的复用,是指不拷贝的使用同一份代码。
TGAS:C++
评论【共有0条评论】查看所有评论
称呼:(*)   邮箱:   QQ:   验证码: 看不清楚?点击刷新验证码