1. 程式人生 > 實用技巧 >給程式碼注入靈魂——軟體工程思想

給程式碼注入靈魂——軟體工程思想

給程式碼注入靈魂——軟體工程思想

參考:
Doxygen 的 C/C++註釋風格
Makefile教程
孟寧老師的ppt
程式碼來源
menu實驗程式碼

從程式碼規範說起

良好的程式碼風格的原則,簡而言之就是:

簡明易讀無二義性

書寫規範:

  • 縮排:4個空格
  • 行寬:< 100個字元
  • “=”、“+=” “>=”、“<=”、“+”、“*”、“%”、“&&”、“||”、“<<”,“^”等二元操作符的前後應當加空格。
  • 比較長的for語句和if語句,為了緊湊起見可以適當地去掉一些空格,如for (i=0; i<10; i++)和if ((a<=b) && (c<=d))
  • 邏揖上密切相關的語句之間不加空行,邏輯上不相關的程式碼塊之間要適當留有空行以示區隔
  • 在複雜的表示式中要用括號來清楚的表示邏輯優先順序
  • 所有 ‘{’ 和 ‘}’ 應獨佔一行且成對對齊
  • 不要把多條語句和多個變數的定義放在同一行

命名規範:

  • 類名、函式名、變數名等的命名一定要與程式裡的含義保持一致
  • 型別的成員變數通常用_來做字首以示區別
  • 一般變數名、物件名等使用LowerCamel風格,即第一個單詞首字母小寫,之後的單詞都首字母大寫,第一個單詞一般都表示變數型別,比如int型變數iCounter
  • 型別、類、函式名等一般都用Pascal風格,即所有單詞首字母大寫
  • 型別、類、變數一般用名詞或者組合名詞,如Member
  • 函式名一般使用動詞或者動賓短語,如get/set,RenderPage

註釋規範:

  • 以C++為例,建議使用doxygen的註釋風格:
    Doxygen 的 C/C++註釋風格

  • 理想的狀態是即便沒有註釋,也能通過函式、變數等的命名直接理解程式碼。

  • 糟糕的狀態是程式碼本身很難理解,而作者又“惜字如金”。

實踐出真知:一個命令列選單小程式的成長過程

運用軟體工程常用的思想,從零開始完成一個c語言的小程式:命令列選單小程式

一.明確需求

這裡我們的需求是:

  1. 某個應用程式內的選單模組
  2. 選單的選項有:檢視幫助、查詢版本、更新程式以及退出。
  3. 該模組的輸入是命令列,輸出是在終端上顯示的字串

二.先寫虛擬碼的程式碼結構更好一些

  • 虛擬碼忽略細節,著重於設計上的框架結構,使得框架更清晰,避免程式碼的無序生長而破壞設計
  • 從虛擬碼到實現程式碼的過程就是反覆重構的過程,這樣避免了順序翻譯轉換所造成的結構性損失。

既然輸入是命令列,那麼最簡單的邏輯結構就是:
一個一直運轉的選單引擎,每次有命令輸入,判斷是否存在該命令,存在即執行相應操作。虛擬碼如下:

    while(true)
    {
        scanf(cmd);
        int ret = strcmp(cmd, "help");
        if(ret == 0)
        {
            dosth();
        }
        int ret = strcmp(cmd, "others");
        if(ret == 0)
        {
            dosth();
        }
    }

補充一些細節,第一版可以執行的選單程式就好了:

int main()
{
    char cmd[128];
    while(1)
    {
        scanf("%s", cmd);
        if(strcmp(cmd, "help") == 0)
        {
            printf("This is help cmd!\n");
        }
        else if(strcmp(cmd, "quit") == 0)
        {
            exit(0);
        }
        else
        {
            printf("Wrong cmd!\n");
        }
    }
}

顯然它過於簡陋了,完全沒有實用性!

根據我們樸實的人生經驗和軟體工程素養,至少我們不應該把所有的程式碼都寫在main裡面,必須要有些自己的資料結構。那麼如何設計我們的資料結構呢?

三.老生常談的模組化

  • 關注點的分離在軟體工程領域是最重要的原則,我們習慣上稱為模組化。關注點的分離的思想背後的根源是由於人腦處理複雜問題時容易出錯,把複雜問題分解成一個個簡單問題,從而減少出錯的情形。
  • 一般我們使用耦合度(Coupling)和內聚度(Cohesion)來衡量軟體模組化的程度

我們追求的是鬆散耦合

  • 內聚度是指一個軟體模組內部各種元素之間互相依賴的緊密程度。理想的內聚是功能內聚,也就是一個軟體模組只做一件事,只完成一個主要功能點或者一個軟體特性(Feather)。

  • KISS(Keep It Simple & Stupid)原則:

    • 一行程式碼只做一件事
    • 一個塊程式碼只做一件事
    • 一個函式只做一件事
    • 一個軟體模組只做一件事

因此我設計了一種結構體tDataNode,每個tDataNode儲存了一種命令及其相關操作;

typedef struct DataNode
{
    char*   cmd;      
    char*   desc;
    int     (*handler)();
    struct  DataNode *next;
} tDataNode;
  • cmd:儲存有命令字元,與輸入的字元進行比較查詢是否是此命令
  • desc:該命令的補充介紹或是要輸出的資訊
  • handler:該命令對應的操作
  • next:指想下一個命令節點

並且我需要一個容器來儲存這些節點,這裡可以看作是一個頭節點為head的連結串列

static tDataNode head[] = 
{
    {"help", "this is help cmd!", Help,&head[1]},
    {"version", "menu program v1.0", Version, &head[2]},
    {"quit", "Quit from menu", Quit,&head[3]},
    {"update","Starting update",Update,NULL}
};

每種命令對應的實際操作都是一個獨立的函式。暫時,我們不關心這些函式的具體實現。

Quit();
Version();
Help();
Update();

同樣主函式即選單業務處理部分也做出了相應修改

int main()
{
    /* cmd line begins */
    while(1)
    {
        char cmd[CMD_MAX_LEN];
        printf("Input a cmd number > ");
        scanf("%s", cmd);
        tDataNode *p = head;
        while(p != NULL)
        {
            if(strcmp(p->cmd, cmd) == 0)
            {
                printf("%s - %s\n", p->cmd, p->desc);
                if(p->handler != NULL)
                {
                    p->handler();
                }
                break;
            }
            p = p->next;
        }
        if(p == NULL) 
        {
            printf("This is a wrong cmd!\n ");
        }
    }
}

現在我們將資料結構和它的操作與選單業務處理進行分離處理,儘管還是在同一個原始碼檔案中,但是已經在邏輯上做了切分,可以認為有了初步的模組化。

四.介面設計與可重用軟體設計

儘管已經做了初步的模組化設計,但是分離出來的資料結構和它的操作還有很多選單業務上的痕跡。我們要求這一個軟體模組只做一件事,也就是功能內聚,那就要讓它做好連結串列資料結構和對連結串列的操作,不應該涉及選單業務功能上的東西;同樣我們希望這一個軟體模組與其他軟體模組之間鬆散耦合,就需要定義簡潔、清晰、明確的介面。這時進一步優化這個初步的模組化程式碼就需要設計合適的介面。

下面來學習一些理論:

  • 介面規格包含五個基本要素:
    • 介面的目的;
    • 介面使用前所需要滿足的條件,一般稱為前置條件或假定條件;
    • 使用介面的雙方遵守的協議規範;
    • 介面使用之後的效果,一般稱為後置條件;
    • 介面所隱含的質量屬性。

對於可重用的設計有兩個方向:

  • 消費者重用是指軟體開發者在專案中重用已有的一些軟體模組程式碼,以加快專案工作進度。軟體開發者在重用已有的軟體模組程式碼時一般會重點考慮如下四個關鍵因素:

    • 該軟體模組是否能滿足專案所要求的功能;
    • 採用該軟體模組程式碼是否比從頭構建一個需要更少的工作量,包括構建軟體模組和整合軟體模組等相關的工作;
    • 該軟體模組是否有完善的文件說明;
    • 該軟體模組是否有完整的測試及修訂記錄;
  • 我們清楚了消費者重用時考慮的因素,那麼 生產者 在進行可重用軟體設計時需要重點考慮的因素也就清楚了,但是除此之外還有一些事項在進行可重用軟體設計時牢記在心,我們簡要列舉如下:
    - 通用的模組才有更多重用的機會;
    - 給軟體模組設計通用的介面,並對介面進行清晰完善的定義描述;
    - 記錄下發現的缺陷及修訂缺陷的情況;
    - 使用清晰一致的命名規則;
    - 對用到的資料結構和演算法要給出清晰的文件描述;
    - 與外部的引數傳遞及錯誤處理部分要單獨存放易於修改;

現在,將我們的介面和模組設計得可重用一些!

  1. 將連結串列及其操作徹底的獨立出去,消除與選單業務模組的耦合度

重新設計連結串列的資料結構及其操作

typedef struct LinkTableNode
{
    struct LinkTableNode * pNext;          ///<指向下一個節點
}tLinkTableNode;

typedef struct LinkTable
{
    tLinkTableNode *pHead;                  ///<連結串列頭節點
    tLinkTableNode *pTail;                  ///<連結串列尾節點
    int			SumOfNode;                  ///<節點總數
    pthread_mutex_t mutex;                  ///<執行緒鎖,現在請忽略
}tLinkTable;

tLinkTable * CreateLinkTable();                                                         ///<建表
int DeleteLinkTable(tLinkTable *pLinkTable);                                            ///<刪除連結串列
int AddLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);                    ///<新增節點
int DelLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);                    ///<刪除節點
tLinkTableNode * GetLinkTableHead(tLinkTable *pLinkTable);                              ///<獲取連結串列的頭節點
tLinkTableNode * GetNextLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);   ///<獲取當前節點的下一個節點

現在的連結串列與之前選單的業務邏輯部分沒有絲毫關係了!

接下來我們必須做連結串列模組的單元測試以保證該部分程式碼被使用時不會出現問題,沒錯必須是現在,不能等到別的模組寫好了一起測試!(不討論怎麼測試了)

在這裡,資料(cmd,desc,handler)不應該內嵌到連結串列的node中。我們應該讓資料像貼紙一樣貼在連結串列的Node上,如果下次換成hash表了,自然就可以很方便的再貼在hash節點上。因此我們的tDataNode節點應該做出相應改變:

typedef struct DataNode
{
    tLinkTableNode * pNext;
    char*   cmd;
    char*   desc;
    int     (*handler)();
} tDataNode
  1. 進一步減少耦合,KISS原則,本地化外部介面

所謂本地化外部介面,即如下圖所示

根據KISS、可重用的想法,將選單引擎中比較命令是否被支援(連結串列中是否有)的操作封裝,說不定還有別處也想呼叫這個查詢的API查一查呢?

tDataNode* FindCmd(tDataNode * head, char * cmd)

接下來需要對這個連結串列進行初始化存命令。顯然我們不能寫一堆樸實的節點初始化然後再插入的操作。萬一我以後忘記了連結串列節點的資料結構,我又不想重新看,或者萬一以後不用連結串列呢。因此我們將初始化命令容器這個操作封裝起來

int MenuConfig(char * cmd, char * desc, int (*handler)(int argc, char*argv[]))

這個初始化函式的輸入分別是:cmd命令,desc命令備註,handler對應的實際操作

即使把連結串列換成了別的資料結構,只需要改MenuConfig內部的實現,而那些呼叫MenuConfig的程式碼都不需要改,減少了耦合度。

同樣的還有那些涉及連結串列的操作可能都需要再封裝一層來減少耦合度,如:

  • 為Help增加ShowAllCmd,
  • Version()呼叫FindCmd查詢本地版本
  • Update()也呼叫FindCmd查詢本地版本,然後與遠端最新版本做比較。

另外,選單引擎不要再放在主函數了,封裝成ExecuteMenu(),沒有為什麼!

  1. callback

突然我想到,我們的選單引擎在判斷某個命令是否存在時,用的是
tDataNode* FindCmd(tDataNode * head, char * cmd)
從引數可以看出,這裡只有一種比較條件就是當前連結串列的節點中是否有包含cmd這個字串的節點。
我想換個條件或者新增些別的條件時.我就要去更改FindCmd的引數、內部實現以及所有呼叫該函式的其他函式。顯然耦合度太高了!這個介面設計的根本不可重用!

所以我重新設計了這樣的介面:

int SearchCondition(tLinkTableNode * pLinkTableNode,void * arg)

tLinkTableNode * SearchLinkTableNode(tLinkTable *pLinkTable, int Condition(tLinkTableNode * pNode, void * args), void * args)

SearchLinkTableNode代替了FindCmd來遍歷連結串列的節點,查詢是否滿足某個條件
從其引數2可以看出,代表查詢條件的引數是一個函式指標,因此具體的條件是在SearchCondition這個函式中給出的。

SearchCondition就叫做回撥函式(callback)

為什麼回撥函式降低了耦合度?

  • 顯然,我們再去修改比較條件時,只需要在回撥函式的內部修改就行了,而SearchLinkTableNode的任何部分都可以不更改,即改變條件與查詢邏輯無關了。

另外注意到,一開始的FindCmd中第二個引數是char* cmd
現在在SearchCondition中改成了void* arg
這麼做當然是為了讓該介面更具有通用性,你呼叫這個介面,只要保證傳入了一個指標就行,至於具體是什麼型別的資料,待會兒再商量,有點泛型程式設計那味了。

五.可重入函式與執行緒安全

又一個老生常談的話題,長話短說

執行緒是什麼?

  • 一個執行流,想讓幾個執行流並行就建立幾個執行緒(其他百度)

函式呼叫幀棧是什麼?

  • 這裡主要說明,棧是執行緒各自獨有的,壓棧的內容(函式引數,區域性變數等)不會引起安全問題(其他百度)

可重入函式是什麼?

  • 可以由多個執行緒併發呼叫的函式(其他百度)

執行緒安全問題是什麼?

  • 因為執行緒是併發執行的,萬一他們都修改了同一個共享的內容,就會產生錯亂。比如某個條件變數在a執行緒裡是true的且不會更改,再該執行流判斷之前,b執行緒把它修改成false了顯然有問題,因此我們必須保證,在a執行緒用完該條件變數之前,它都不會被其他執行緒修改。同理可以想象到其他的共享的內容都會有這種問題。

為了執行緒安全,我們必須要加鎖!

回到我們的選單程式中,可能出現執行緒不安全的地方就是:

沒錯就是你!連結串列!

因為連結串列是公用的,顯然當連結串列增刪查改節點的操作併發執行時會產生問題。
所以這些操作都得加鎖
舉個例子:

int AddLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode)
{
    if(pLinkTable == NULL || pNode == NULL)
    {
        return FAILURE;
    }
    pNode->pNext = NULL;
    pthread_mutex_lock(&(pLinkTable->mutex));   ///<上鎖
    if(pLinkTable->pHead == NULL)
    {
        pLinkTable->pHead = pNode;
    }
    if(pLinkTable->pTail == NULL)
    {
        pLinkTable->pTail = pNode;
    }
    else
    {
        pLinkTable->pTail->pNext = pNode;
        pLinkTable->pTail = pNode;
    }
    pLinkTable->SumOfNode += 1 ;
    pthread_mutex_unlock(&(pLinkTable->mutex));   ///<開鎖
    return SUCCESS;		
}

四.1. 中LinkTable中的mutex就是初始化的鎖。


這是視覺疲勞的分割線:)


現在看看我們已經寫了些什麼東西了:

  1. 連結串列模組linktable.h linktable.c
typedef struct LinkTableNode
{
    struct LinkTableNode * pNext;
}tLinkTableNode;

struct LinkTable
{
    tLinkTableNode *pHead;
    tLinkTableNode *pTail;
    int			SumOfNode;
    pthread_mutex_t mutex;

};


tLinkTable * CreateLinkTable();
int DeleteLinkTable(tLinkTable *pLinkTable);
int AddLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);
int DelLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);
tLinkTableNode * SearchLinkTableNode(tLinkTable *pLinkTable, int Conditon(tLinkTableNode * pNode, void * args), void * args);
tLinkTableNode * GetLinkTableHead(tLinkTable *pLinkTable);
tLinkTableNode * GetNextLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);
  1. 選單業務模組menu.h menu.c
typedef struct DataNode
{
    tLinkTableNode * pNext;
    char*   cmd;
    char*   desc;
    int     (*handler)(int argc, char *argv[]);
} tDataNode;

int SearchConditon(tLinkTableNode * pLinkTableNode,void * arg)
int ShowAllCmd(tLinkTable * head)
int Help(int argc, char *argv[])

/******************************一下是暴露給外部的介面menu.h中******************************/
int Version(int argc,char* argv[])
int Update(int argc,char* argv[])
int Quit(int argc,char*argv[])
int MenuConfig(char * cmd, char * desc, int (*handler)(int argc, char*argv[]))
int ExecuteMenu()

選單程式引擎ExecuteMenu()內部如下

int ExecuteMenu()
{
   /* cmd line begins */
    while(1)
    {
		int argc = 0;
		char *argv[CMD_MAX_ARGV_NUM];
        char cmd[CMD_MAX_LEN];
		char *pcmd = NULL;
        printf("Input a cmd > ");
        /* scanf("%s", cmd); */
		pcmd = fgets(cmd, CMD_MAX_LEN, stdin);
		if(pcmd == NULL)
		{
			continue;
		}
        /* convert cmd to argc/argv */
		pcmd = strtok(pcmd," ");
		while(pcmd != NULL && argc < CMD_MAX_ARGV_NUM)
		{
			argv[argc] = pcmd;
			argc++;
			pcmd = strtok(NULL," ");
		}
        if(argc == 1)
        {
            int len = strlen(argv[0]);
            *(argv[0] + len - 1) = '\0';
        }
        tDataNode *p = (tDataNode*)SearchLinkTableNode(head,SearchConditon,(void*)argv[0]);
        if( p == NULL)
        {
            printf("This is a wrong cmd!\n ");
            continue;
        }
        
        if(p->handler != NULL) 
        { 
            p->handler(argc, argv);
        }
    }
} 
  1. 測試檔案test.c
#include <stdio.h>
#include <stdlib.h>
#include "menu.h"


int main(int argc,char* argv[])
{

    MenuConfig("version","XXX V1.0(Menu program v1.0 inside)",Version);
    MenuConfig("quit","Quit from XXX",Quit);
    MenuConfig("update","Start Updating",Update);
    
    ExecuteMenu();
}

各部分呼叫關係如圖所示:

自動化編譯和說明文件

包括測試檔案在內,我們現在也只有5個檔案,一條編譯語句就可以完成編譯。

但是當做一個大型工程時,檔案數目到達幾十上百,各個檔案之間還存在錯綜複雜的依賴關係,編譯語句數量開始成倍增加,為了讓編譯的過程實現自動化,我們可以寫makefile,不會真的有人想每次測試新的例子都手動輸入編譯語句吧???一個勤快的程式設計師應當是就算一條語句就能編譯出來也要寫makefile!

makefile其實就是一個指令碼,裡面定義了一系列的規則指定哪些檔案需要先編譯,哪些檔案需要後編譯,哪些檔案需要重新編譯,它記錄了原始碼如何編譯的詳細資訊! makefile一旦寫好,只需要一個make命令,整個工程完全自動編譯,極大的提高了軟體開發的效率。

學習makefile的語法請移步: Makefile教程

這裡為我們的選單程式加上makefile

#
# Makefile for Menu Program
#

CC_PTHREAD_FLAGS			 = -lpthread
CC_FLAGS                     = -c 
CC_OUTPUT_FLAGS				 = -o
CC                           = gcc
RM                           = rm
RM_FLAGS                     = -f

TARGET  =   test
OBJS    =   linktable.o  menu.o test.o

all:	$(OBJS)
	$(CC) $(CC_OUTPUT_FLAGS) $(TARGET) $(OBJS)

.c.o:
	$(CC) $(CC_FLAGS) $<

clean:
	$(RM) $(RM_FLAGS)  $(OBJS) $(TARGET) *.bak

最後,一定要寫上介紹文件 README ,否則沒有人有耐心用你的程式了!

當然,我們這麼簡單的程式就寫簡單一點吧!

This is a menu program!

Build Procedure
    $ make clean
    $ make
    $ ./menu # you can input help/version/quit cmd.