1. 程式人生 > 實用技巧 >程式碼中的軟體工程——從一個menu程式開始

程式碼中的軟體工程——從一個menu程式開始

參考資料見:
原始碼 https://github.com/mengning/menu
一步步實現原始碼

我們的目的是實現一個命令列的選單小程式,最終目標是完成一個通用的命令列的選單子系統便於在不同專案中重用。通過這樣一個小程式,理解軟體工程的程式碼規範,見微知著。


首先修改text.c中的Quit函式

int Quit(int argc, char *argv[])
{
    /* add XXX clean ops */
    exit(0);
    return 0;
}

不然使用quit命令無法退出。

1. 環境搭建

1.1 檢視gcc版本

mac系統下,開啟命令列,輸入

$ gcc -v
Configured with: --prefix=/Applications/Xcode.app/Contents/Developer/usr --with-gxx-include-dir=/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/c++/4.2.1
Apple clang version 12.0.0 (clang-1200.0.32.21)
Target: x86_64-apple-darwin19.6.0
Thread model: posix
InstalledDir: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin

可以看到我們系統預設的是clang,關於gcc和clang的異同,我們可以參考
https://blog.csdn.net/fengbingchun/article/details/79252110
Clang是一個C、C++、Objective-C和Objective-C++程式語言的編譯器前端。它採用了底層虛擬機器(LLVM)作為其後端。它的目標是提供一個GNU編譯器套裝(GCC)的替代品。
簡單理解,gcc有的巨集clang也有,而且clang編譯速度快,記憶體佔用小,也方便ide整合。

1.2. 編譯和除錯環境的配置

其實我們環境應該來說已經完成了,是的,啥都不用幹。
Unix和類Unix系統下,我們是可以直接通過命令列編譯c/cpp檔案的使用命令
gcc -o hello hello.c

,生成可執行的hello二進位制檔案。

我們也可以通過vscode幫我們這樣幹,但是需要兩個檔案tasks.jsonlaunch.json。tasks用於在launch前執行任務,launch用於讀取執行檔案。

參考
https://code.visualstudio.com/docs/cpp/config-clang-mac
https://zhuanlan.zhihu.com/p/92175757

cd 到專案路徑,使用命令code . 當前工作資料夾中開啟VS Code,該資料夾成為工作區。但是並沒有生成.vscode檔案。


那我們就來手動建立,我的專案下有兩個檔案,編輯器開啟menu.c,再點選左邊執行按鈕,點選執行和除錯,選擇 GDB/LLDB
選擇第一個

終端列印下面語句,說明配置成功


可以看到資料夾下多了.vscode資料夾和menu.dSYM資料夾

同時還生成了名為memu的二進位制檔案
這裡的menu.c是lab2的內容
輸出一下

解釋一下tasks.json和launch.json

tasks.json的主要作用就是執行類似 gcc -g main.c -o main 的命令,建立一個tasks.json檔案告訴VS程式碼如何構建(編譯)程式。

需要注意的一點是,tasks.json的"label"引數值和launch.json的"preLaunchTask"引數值需要保持一致

launch.json檔案,是用來配置VS Code以在按F5除錯程式時啟動LLDB偵錯程式。

2. 如何編寫高質量程式碼

2.1 註釋

如果我們從Github上檢視一些大牛的專案,我們可以看到他們的程式碼中,註釋很都很工整清晰,格式統一規範。

總計有幾種註釋的方法,

  • 最精簡的是無註釋,理想的狀態是即便沒有註釋,也能通過函式、變數等的命名直接理解程式碼。
  • 還有就是一句話的簡短註釋
  • 最後是將函式功能、各引數的含義和輸入/輸出用途等一一列舉,這往往是模組的對外介面,以方便自動生成開發者文件。

給程式碼寫上工整的註釋是一個優秀程式設計師的良好習慣。工整簡潔的程式碼未必就有較高的可讀性,在一些業務比較繁瑣,引數比較多的函式中,閱讀程式碼的人會在各種引數的用法中糾纏不清,但是如果在引數或者業務操作的程式碼旁加上工整的註釋,可以讓既有的程式碼脈絡清晰,也方便自動生成開發者文件


在這裡我就推薦一下vscode註釋外掛koroFileHeader,商店下載安裝cmd+ctr+i是頭註釋,cmd+ctr+t是函式註釋,效果如下

/*
 * @Author: your name
 * @Date: 2020-11-04 09:53:04
 * @LastEditTime: 2020-11-04 10:03:47
 * @LastEditors: Please set LastEditors
 * @Description: In User Settings Edit
 * @FilePath: /se_code/menu/menu.c
 */

言歸正傳,以menu這個程式為例,我們看看孟老師是怎麼寫出優秀的頭部註釋的

/**************************************************************************************************/
/* Copyright (C) mc2lab.com, SSE@USTC, 2014-2015                                                  */
/*                                                                                                */
/*  FILE NAME             :  menu.c                                                               */
/*  PRINCIPAL AUTHOR      :  Mengning                                                             */
/*  SUBSYSTEM NAME        :  menu                                                                 */
/*  MODULE NAME           :  menu                                                                 */
/*  LANGUAGE              :  C                                                                    */
/*  TARGET ENVIRONMENT    :  ANY                                                                  */
/*  DATE OF FIRST RELEASE :  2014/08/31                                                           */
/*  DESCRIPTION           :  This is a menu program                                               */
/**************************************************************************************************/

/*
 * Revision log:
 *
 * Created by Mengning, 2014/08/31
 *
 */

我們可以看到在優秀的頭部註釋中,最主要的有包名、檔名和描述資訊,其次還有copyright和author之類的資訊,讓人一目瞭然清晰易懂。
再看看函式註釋

/* show all cmd in listlist */
int ShowAllCmd(tLinkTable * head)
{
  ...
}

註釋中儘量使用/* xxx */的格式,而不使用//這樣的註釋風格(阿里巴巴JAVA開發守則不推薦這種註釋風格)

2.2 程式碼風格規範

最重要的一致性規則是命名管理. 命名的風格能讓我們在不需要去查詢型別宣告的條件下快速地瞭解某個名字代表的含義: 型別, 變數, 函式, 常量, 巨集, 等等, 甚至. 我們大腦中的模式匹配引擎非常依賴這些命名規則.

命名規則具有一定隨意性, 但相比按個人喜好命名, 一致性更重要, 所以無論你認為它們是否重要, 規則總歸是規則.

這裡推薦閱讀google c++命名規範https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/naming/
和阿里巴巴的java開發手冊

總結程式碼風格規範

  • 縮排:4個空格;
  • 行寬:< 100個字元;
  • 程式碼行內要適當多留空格,如“=”、“+=” “>=”、“<=”、“+”、“*”、“%”、“&&”、“||”、“<<”,“^”等二元操作符的前後應當加空格。對於表示式比較長的for語句和if語句,為了緊湊起見可以適當地去掉一些空格,如for (i=0; i<10; i++)和if ((a<=b) && (c<=d));
  • 在一個函式體內,邏揖上密切相關的語句之間不加空行,邏輯上不相關的程式碼塊之間要適當留有空行以示區隔;
  • 在複雜的表示式中要用括號來清楚的表示邏輯優先順序;
  • 花括號:所有 ‘{’ 和 ‘}’ 應獨佔一行且成對對齊;這是C++的通常做法,Java中將'{'放在函式宣告的同一行末尾
  • 不要把多條語句和多個變數的定義放在同一行;
  • 命名:合適的命名會大大增加程式碼的可讀性;
    • 類名、函式名、變數名等的命名一定要與程式裡的含義保持一致,以便於閱讀理解;
    • 型別的成員變數通常用m_或者_來做字首以示區別;
    • 一般變數名、物件名等使用LowerCamel風格,即第一個單詞首字母小寫,之後的單詞都首字母大寫,第一個單詞一般都表示變數型別,比如int型變數iCounter;
    • 型別、類、函式名等一般都用Pascal風格,即所有單詞首字母大寫;
    • 型別、類、變數一般用名詞或者組合名詞,如Member
    • 函式名一般使用動詞或者動賓短語,如get/set,RenderPage;

2.3 編寫高質量程式碼基本準則

  1. 通過控制結構簡化程式碼
  2. 通過資料結構簡化程式碼
  3. 一定要有錯誤處理
    程式的主要功能(80%的工作)大約僅用20%時間,而錯誤處理(20%的工作)卻要80%的時間

引數處理的基本原則:

  • Debug版本中所有的引數都要驗證是否正確;Release版本中從外部(使用者或別的模組)傳遞進來的引數要驗證正確性。
  • 肯定如何時用斷言;可能發生時用錯誤處理。

3.模組化

軟體工程發展到今天,特別注重解耦合的思想。模組化是指將我們整個軟體系統按照功能的不同劃分成不同的模組,每個模組只有單一的功能目標並相對獨立於其他模組,使得開發和維護變得簡單,同時模組的分離增加了可重用性。

我們使用耦合度(Coupling)和內聚度(Cohesion)來衡量軟體模組化的程度。
一般在軟體設計中我們追求鬆散耦合,理想的內聚是功能內聚,也就是一個軟體模組只做一件事,只完成一個主要功能點或者一個軟體特性(Feather)

軟體設計中的一些基本方法

  • KISS(Keep It Simple & Stupid)原則

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

  • 使用本地化外部介面來提高程式碼的適應能力
    不要和陌生人說話原則
  • 先寫虛擬碼的程式碼結構更好一些
    using design to frame the code(matching design with implementation)

我們來看看menu程式中是如何實現KISS的

專案路徑下,有test、linktable.*和menu.*,linktable和menu是兩個不同的模組,linktable是menu中用到的資料結構,linktable定義了管理連結串列資料結構和對其操作的方法,這個模組看不到呼叫它的上層結構做了什麼,這就做到了linktable和menu業務的切分

menu是選單的功能的實現,同樣的,menu也不知道linktable具體是怎麼操作連結串列的,但是能通過linktable提供的功能並將他們組裝起來實現自己的功能。這就做到了一個軟體模組只做一件事。

menu程式的入口是test,我們在test中呼叫menu的方法實現對應的操作,也就是說menu模組給我們提供了他的一系列功能,但是怎麼使用是否使用是由test決定的,test並不清楚這些功能的具體實現,只要在我需要的時候呼叫並能得到想要的結果就行了。

再通過一個函式說明如何實現一行程式碼只做一件事,一個塊程式碼只做一件事,一個函式只做一件事

/* find a cmd in the linklist and return the datanode pointer */
tDataNode* FindCmd(tLinkTable * head, char * cmd)
{
    tDataNode * pNode = (tDataNode*)GetLinkTableHead(head);
    while(pNode != NULL)
    {
        if(!strcmp(pNode->cmd, cmd))
        {
            return  pNode;  
        }
        pNode = (tDataNode*)GetNextLinkTableNode(head,(tLinkTableNode *)pNode);
    }
    return NULL;
}

我們可以看到我們 一行程式碼不會有定義兩個變數的情況,做到了第一點,同時也可以看到,顯然這裡一個{ }只執行它該乾的事情,這個函式只實現了找cmd的功能,目標明確,功能單一。所以這段程式碼也做到了第二點第三點。

但是系統模組間總會多少有些依賴。當一個模組變化時,其它模組可能也需要隨之而改變。模組化設計的目標就是最小化模組間的依賴

為了管理依賴,我們可以把模組看成兩部分:介面和實現

4. 可重用介面

實現模組化的方法就是介面化設計

介面只描述模組做什麼,但不會包含怎麼做。完成介面所做出的承諾的程式碼被稱為實現。通過將模組的介面和實現分離,我們可以對系統的其它部分隱藏實現的複雜度。模組的使用者只需要理解介面提供的抽象。在設計類和其它模組時,最重要的問題是讓它們深,它們要對常見用例有足夠簡單的介面,但同時依然提供強大的功能。這就最大化地隱藏了複雜度。

介面規格包含五個基本要素:

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

linktable.h就是linktable模組的介面,對應的linktable.c是介面的實現,介面的設計應足夠的通用,不是和單一的專案緊密耦合的,而是在不同的專案都可以重複使用。
來看linktable.h

... ...
/*
 * Delete a LinkTable
 */
int DeleteLinkTable(tLinkTable *pLinkTable);
/*
 * Add a LinkTableNode to LinkTable
 */
int AddLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);
... ...

這裡定義介面,只暴露必要的資訊,對外隱藏不必要的資訊,一個介面的設計就應該足夠的“乾淨”。

給介面增加一個callback方法

lab5.1中linktable.h定義了這樣一個介面SearchLinkTableNode,顯然這個函式式找到連結串列中的一個節點,而這個函式的引數是一個函式condition(返回值為int),這個函式引數是tLinkTableNode * pNode,這就是callback方法。

給Linktable增加Callback方式的介面,需要兩個函式介面,一個是call-in方式函式,如SearchLinkTableNode函式,其中有一個函式作為引數,這個作為引數的函式就是callback函式,如程式碼中Conditon函式

/*
 * Search a LinkTableNode from LinkTable
 * int Conditon(tLinkTableNode * pNode);
 */
tLinkTableNode * SearchLinkTableNode(tLinkTable *pLinkTable, int Conditon(tLinkTableNode * pNode))
{
    if(pLinkTable == NULL || Conditon == NULL)
    {
        return NULL;
    }
    tLinkTableNode * pNode = pLinkTable->pHead;
    while(pNode != pLinkTable->pTail)
    {    
        if(Conditon(pNode) == SUCCESS)
        {
            return pNode;				    
        }
        pNode = pNode->pNext;
    }
    return NULL;
}

condition的實現,這個函式將傳進來的引數與全域性變數cmd比較,相同則返回SUCCESS,實現底層linktable模組和menu模組的通訊

/* condition實現 */
int SearchCondition(tLinkTableNode * pLinkTableNode)
{
    tDataNode * pNode = (tDataNode *)pLinkTableNode;
    if(strcmp(pNode->cmd, cmd) == 0)
    {
        return  SUCCESS;  
    }
    return FAILURE;	       
}

雖然這裡callback方法使得介面更加通用。

更通用是指底層不用關心condition是如何實現的,只要上層去判斷,覺得這兩個節點相同就可以了,底層只負責找到你想要的節點。

但是這會有一些問題,問題的關鍵在於全域性變數cmd的,這是最好的實現嗎?足夠的解耦合嗎?還有更好的實現嗎?

首先來看看介面與耦合度的關係

上面的方法就是一種公共耦合,我們希望更鬆散的耦合,即資料耦合,該怎麼做?
關鍵在於顯式地呼叫傳遞基本資料型別
給condition函式增加一個引數*args

int SearchCondition(tLinkTableNode * pLinkTableNode, void * args)
{
    char * cmd = (char*) args;
    tDataNode * pNode = (tDataNode *)pLinkTableNode;
    if(strcmp(pNode->cmd, cmd) == 0)
    {
        return  SUCCESS;  
    }
    return FAILURE;	       
}

修改SearchLinkTableNode函式,增加一個引數*args

tLinkTableNode * SearchLinkTableNode(tLinkTable *pLinkTable, int Conditon(tLinkTableNode * pNode, void * args), void * args)
{
    if(pLinkTable == NULL || Conditon == NULL)
    {
        return NULL;
    }
    tLinkTableNode * pNode = pLinkTable->pHead;
    while(pNode != NULL)
    {    
        if(Conditon(pNode,args) == SUCCESS)
        {
            return pNode;				    
        }
        pNode = pNode->pNext;
    }
    return NULL;
}

讓我們看看這兩個函式是怎麼工作的,SearchLinkTableNode新增的args實際上就是使用者的輸入cmd,被強轉為void*型別,condition的args其實就是同一個args。之所以要轉成void型別,就是為了更通用,是因為我們不想讓底層的linktable模組知道上層用的資料型別,甚至是任何型別都可以,不影響我找節點的功能。

/* find a cmd in the linklist and return the datanode pointer */
tDataNode* FindCmd(tLinkTable * head, char * cmd)
{
    return  (tDataNode*)SearchLinkTableNode(head,SearchCondition,(void*)cmd);
}

5. 執行緒安全

race condition

最後是執行緒安全的問題,多執行緒的應用會面臨一個race問題,是指多個程序或者執行緒併發訪問和操作同一資料且執行結果與訪問發生的特定順序有關的現象。換句話說,就是執行緒或程序之間訪問資料的先後順序決定了資料修改的結果,這種現象在多執行緒程式設計中是經常見到的。

函式呼叫堆疊

棧是每個執行緒獨有的,儲存其執行狀態和區域性自動變數的。棧線上程開始的時候初始化,每個執行緒的棧互相獨立,因此,棧是執行緒安全的。作業系統在切換執行緒的時候會自動切換棧。棧空間不需要在高階語言裡面顯式的分配和釋放。

執行緒不安全

對於執行緒間共享的記憶體區域,如果程序中的A執行緒操作了資料,切換到B執行緒執行,修改了同樣的資料,回到A執行緒時,資料就不是A執行緒切換時候的樣子,這樣一來,資料就被汙染了,我們就說這塊資料在多執行緒環境下是不安全的,即執行緒不安全的。

當多個執行緒訪問某個類時,這個類始終都能表現出正確的行為,那麼就稱這個類是執行緒安全的。

可重入函式和不可重入函式

可重入(reentrant)函式可以由多於一個任務併發使用,而不必擔心資料錯誤。相反,不可重入(non-reentrant)函式不能由超過一個任務所共享,除非能確保函式的互斥(或者使用訊號量,或者在程式碼的關鍵部分禁用中斷)。

可重入函式要求:

不為連續的呼叫持有靜態資料;
不返回指向靜態資料的指標;
所有資料都由函式的呼叫者提供;
使用區域性變數,或者通過製作全域性資料的區域性變數拷貝來保護全域性資料;
使用靜態資料或全域性變數時做周密的並行時序分析,通過臨界區互斥避免臨界區衝突;
絕不呼叫任何不可重入函式

分析程式碼中的執行緒安全問題

lab7.2給LinkTable 加了一把鎖 pthread_mutex_t,這個鎖的粒度是多大?是對這個LinkTable加的鎖,當加鎖時,對這個LinkTable的增刪改操作都將阻塞

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

};

一般來講讀操作是不需要加鎖的,對臨界區變數的改變操作就需要加鎖保證對該變數的順序執行。我們來逐一分析

/*
 * Create a LinkTable
 * 建立LinkTable時候開闢了新的空間,這個操作不需要加鎖,
 * 但這裡呼叫了pthread_mutex_init(&(pLinkTable->mutex), NULL);
 * 初始化了該連結串列的鎖
 */
tLinkTable * CreateLinkTable();
/*
 * Delete a LinkTable
 * 刪除操作整個連結串列,加鎖
 */
int DeleteLinkTable(tLinkTable *pLinkTable);
/*
 * Add a LinkTableNode to LinkTable
 * 增加節點操作,加鎖
 */
int AddLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);
/*
 * Delete a LinkTableNode from LinkTable
 * 刪除連結串列節點,加鎖
 */
int DelLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);
/*
 * Search a LinkTableNode from LinkTable
 * int Conditon(tLinkTableNode * pNode,void * args);
 * 查詢操作某個節點,不加鎖
 */
tLinkTableNode * SearchLinkTableNode(tLinkTable *pLinkTable, int Conditon(tLinkTableNode * pNode, void * args), void * args);
/*
 * get LinkTableHead
 * 拿到頭結點,查詢操作,不加鎖
 */
tLinkTableNode * GetLinkTableHead(tLinkTable *pLinkTable);
/*
 * get next LinkTableNode
 * 找到下一個節點,查詢操作,不加鎖
 */
tLinkTableNode * GetNextLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);