軟件重構是改善代碼可讀性、可擴展性、可維護性等目的的常見技術手段。圈復雜度作為一項軟件質量度量指標,能從一定程度上反映這些內部質量需求(當然並不是全部),所以圈復雜度往往被很多項目采用作為軟件質量的度量指標之一。
C語言開發的項目中,switch/case代碼塊是一個很容易造成圈復雜度超標的語言特性,所以本文主要介紹下降低switch/case圈復雜度的重構方法(如下圖)。switch圈復雜度優化重構可分為兩部分:程序塊的重構和case的重構。程序塊重構是對代碼的局部優化,而case重構是對代碼的整體設計,所涉及的重構手段也各不相同。
程序塊重構
程序塊重構指的是每個case內的代碼段重構。Martin Fowler 的《重構——改善既有代碼的設計》(電子版)書中總結了80多種重構方法。書中針對每種技術都給出了示例說明,另外這里、這里還提供了其他語言的示例和進一步介紹。因為存在大量示例,所以本文針對這些方法不再給出示例,有興趣的同學可以通過上面幾種途徑了解學習。不過這些技術中有些是改善代碼的可讀性,有些是改善代碼的可擴展性,並不是每項技術都能有效減低圈復雜度。其中可以降低圈復雜度的方法有如下幾種:
- 提煉函數(Extract Method)。你有一段代碼可以被組織在一起並獨立出來。將這段代碼放進一個獨立函數中,並將函數名稱解釋該函數的用途。
- 分解條件表達式(Decompose Conditional)。你有一個復雜的條件(if-then-else)語句。從if、then、else三分段落中分別提煉出獨立函數。
- 合並條件表達式(Consolidate Conditional Expression)。你有一系列條件測試,都得到相同結果。將這些測試合並為一個條件表達式,並將這個條件表達式提煉成為一個獨立函數。
- 合並重復的條件片段(Consolidate Duplicate Conditional Fragments)。在條件表達式的每個分支上有着相同的一段代碼。將這段重復的代碼搬移到條件表達式之外。
- 移除控制標記(Remove Control Flag)。在一系列布爾表達式中,某個變量帶有“控制標記”的作用。以break語句或return語句取代控制標記。
這些重構方法除了降低圈復雜度外,還有如下好處:
- 滿足單一職責設計原則,提高代碼可讀性。
- 去除重復冗余代碼。你可以刪除大量相同的條件語句。
- 滿足“Tell, Dont Ask”原則,告訴對象需要做什么,而不是怎么做。
case重構
對於一個switch有幾十個case的情況,其圈復雜度往往上百,程序塊重構顯然已不能解決其本質復雜度。如果要降低其圈復雜度,必然需要對代碼進行重新設計。
C語言的switch/case語言特性本質是描述一種查表邏輯,其中表結構和表的控制(即查表)都通過軟件來表達。表通過代碼來描述,這顯然不是一種最佳的實現方式。我們需要做的就是,避免控制中的復雜性,將精力集中在數據的組織上,以反映所模擬世界的真實結構,並將數據與控制進行分離。
表的設計由兩部分組成:對象(表項)的抽象和表的構建。對象如何抽象,對象粒度如何划分,對象間的關系如何設計?這些問題涉及抽象思維能力的訓練,而且也與具體業務邏輯強相關,不是本文重點。讀者可閱讀《計算機程序的構造和解釋》來進一步了解軟件抽象等相關技術細節。
表的構建方法是本文的重點,其可分為編譯期構建、鏈接期構建和運行時構建。3種方法各有所長和不足,可根據自身需要進行選擇。
編譯期表構建
問題背景
boot啟動支持3種啟動方式,每種啟動方式的用戶菜單流程也不盡相同。啟動菜單支持輸入檢查、存儲、菜單回退等功能。原有設計中函數設計臃腫,菜單項通過switch/case來進行選擇處理,有十幾個函數圈復雜度超過40,最大的圈復雜度為147,代碼維護困難。
重構方法
boot啟動用戶菜單本質是一個優先狀態機,每個菜單項是其中一個狀態。抽象菜單項對象T_PROMT,其包含提示打印、輸入檢查、存儲、狀態跳轉等成員。構建T_PROMT aPromtArray[]菜單表描述所有菜單項對象,通過MenuFsm實現狀態機的控制:通過對象T_PROMT的jumpto接口實現狀態的跳轉,通過check接口實現輸入檢查,通過setvalue接口實現存儲,通過parent實現菜單回退到上級菜單(因為上級菜單是動態變化的,無法靜態初始化,所以在jumpto中進行動態賦值)。示例代碼如下:
typedef struct prompt { WORD32 type; CHAR *name;/*env name*/ CHAR *prompt;/*prompt info to user*/ WORD32 (*check)(CHAR *src);/*check func for user's input*/ struct prompt* (*jumpto)(struct prompt*, WORD32); struct prompt *parent; VOID (*setvalue)(CHAR *name); }T_PROMT; static T_PROMT aPromtArray[] = { /* env name prompt string check func jump func parent set func */ {TYPE_NORMAL, ENV_LOCAL_IP, "Local IP:", CheckIpAddr, LocalIpJump ,NULL, SetCltIpAddr }, {TYPE_NORMAL, ENV_SERVER_IP, "Server IP:", CheckIpAddr, ServeripJump ,NULL, SetSerIpAddr }, /* 共 22 個表項,以下略 */ }; static SWORD32 MenuFsm(struct prompt *menu) { SWORD32 dwRet = BSP_OK; WORD32 dwIndex; while(menu != NULL) { if (menu == GetPrompt(ENV_NULL)) { dwRet = MODE_MENU_BACK; break; } dwIndex= PrintPromptAndGetUserInput(menu); if (dwIndex != NORMAL_MENU_BACK ) { menu = menu->jumpto(menu, dwIndex); } else { menu = menu->parent; } } return dwRet; } static struct prompt* GetPrompt(char *name) { WORD32 i = 0; struct prompt *pt = NULL; WORD32 dwSize = sizeof(aPromtArray)/sizeof(aPromtArray[0]); for (i = 0; i < dwSize; i++) { if (strcmp(name, aPromtArray[i].name) == 0) { pt = &aPromtArray[i]; break; } } return pt; }
運行時表構建
問題背景
內核模塊通過ioctl對外部提供接口,而此模塊ioctl控制碼有84個,原ioctl函數通過switch/case完成ioctl的分發和處理,此實現方案導致函數代碼長度達767行,圈復雜度達124,難以維護,不滿足項目軟件質量要求(函數圈復雜度在12以下)。
重構方法
抽象ioctl接口對象ctrl_operations並實例化;通過bsp_iocmds_init構建字典(哈希表),實現ioctl控制碼到ioctl接口的映射;在board_dev_init模塊初始化中完成哈希表的初始化;在boardctrl_do_ioctl中通過哈希查表接口bsp_dict_get獲取ioctl控制碼的處理接口。
示例代碼
struct ctrl_operations { SWORD32 (*board_init)(struct board *bd); SWORD32 (*board_exit)(struct board *bd); /* 共 92 個表項,以下略 */ }; struct ctrl_operations ioctl_ops = { .inherits = &extern_ops, .epld_op = bsp_epld_op, .epldrw = bsp_epld_rw, /* 共 84 個字段,以下略 */ }; void bsp_iocmds_init(struct board *bd, pt_bsp_dict pdict) { bsp_dict_add(pdict, BSP_IOCMD_ROV_WR, bd->ops->rov_wr); bsp_dict_add(pdict, BSP_IOCMD_TCAM_INFO, bd->ops->tcam_info); /* 共 84 個key,以下略 */ } static SWORD32 __init board_dev_init(void) { struct board *bd = get_board(); /* 刪除無關代碼 */ bd->iocmds = bsp_dict_new(DICT_HINT, bsp_cmp, bsp_hash); bsp_iocmds_init(bd, bd->iocmds); return BSP_OK; } WORD32 boardctrl_do_ioctl(unsigned int cmd, void *pParam) { WORD32 dwIoNum = _IOC_NR(cmd); struct board *bd = get_board(); WORD32 dwRet = BSP_E_BRDCTRL_NOTSUPPORT; PT_OPS_FUNC ops; ops = bsp_dict_get(bd->iocmds, dwIoNum); if(likely(ops)) { dwRet = ops(bd, pParam); } return dwRet; }
當然除了使用哈希表,也可以使用鏈表等數據結構來組織數據。
鏈接期表構建
問題背景
編譯期表構建和運行時表構建2種方法,能優化設計,降低圈復雜度,但有一件事情沒有做完美:新增一個表項時,必須修改公共的靜態表(編譯期表構建,如需要修改aPromtArray)或注冊函數(運行時表構建,如需要修改bsp_iocmds_init),無法做到完全滿足“開發封閉原則”。
鏈接期表構建方法則可以解決這個問題。
重構方法
通過gcc的section屬性,把所有(ioctl控制碼,接口)數據對(即元組)定義在同一個section數據段中。在鏈接階段,鏈接器會構建初始化此section數據段,話句話說,連接器幫助我們完成了這個對象數組的初始化和構建。然后利用gcc導出的__start_ctrl_op_section和__stop_ctrl_op_section符號,boardctrl_do_ioctl即可完成對section數據表的查表操作。
此項技術在u-boot、Linux kernel中大量使用。當添加一個新表項時,只需要添加一句ctrl_op_init,不需要修改任何公共代碼或數據。
示例代碼:
typedef void (*ctrl_op)(struct board *bd); #define _init __attribute__((section("ctrl_op_section"))) #define ctrl_op_init(num, func) ctrl_op __no_##func _init = (ctrl_op)num; \ ctrl_op __fn_##func _init = func extern ctrl_op __start_ctrl_op_section; extern ctrl_op __stop_ctrl_op_section; ctrl_op_init(BSP_IOCMD_ROV_WR, bsp_rov_wr); ctrl_op_init(BSP_IOCMD_TCAM_INFO, bsp_tcam_info); /* 共 84 個ctrl_op_init,以下略 */ WORD32 boardctrl_do_ioctl(unsigned int cmd, void *pParam) { WORD32 dwIoNum = _IOC_NR(cmd); struct board *bd = get_board(); WORD32 dwRet = BSP_E_BRDCTRL_NOTSUPPORT; ctrl_op * ptr = &__start_ctrl_op_section; do { if((WORD32)*ptr == dwIoNum) { ptr++; if(likely(ptr)) return (*ptr)(bd, pParam); } ptr += 2; } while (ptr < &__stop_ctrl_op_section); return dwRet; }