C/C++項目中的代碼複用和管理


一 模塊功能單一化 

    模塊的功能要單一,這似乎是人盡皆知的原則。但是在編碼設計過程中,並不是誰都能小心的處理這個問題。 

    首先舉一個實際中的例子:在我們的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 ClickReceiver 
{
public:
virtual void OnClick() = 0;
};


很顯然我的對話框是一個點擊接收者。它接受來自控件的點擊:

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. 所謂代碼的複用,是指不拷貝的使用同一份代碼。 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章