C 語言對象化設(shè)計實例 —— 命令解析器
掃描二維碼
隨時隨地手機看文章
之前有朋友問面向?qū)ο笙嚓P(guān)例子,這篇文章分享的就是面向?qū)ο蟮膶嵗?,可以學(xué)一學(xué)。文章出自RTT工程師國際哥,首發(fā)于Linux閱碼場。
前言
傳統(tǒng)單片機 MCU 編程大多使用過程式的思維來組織程序,在單片機資源少、功能簡單、代碼規(guī)模小的情況下,「想到啥寫啥」的方法也確實能解決大部分問題。但隨著硬件的快速升級,如今的大部分嵌入式工程師已經(jīng)不再需要「掐著內(nèi)存」來寫代碼了。當(dāng)軟件的規(guī)模越發(fā)龐大、復(fù)雜,這時如何編寫可復(fù)用、便于維護的代碼顯得尤為重要。本文通過一個在 51 單片上實現(xiàn)的簡單「串口命令解析器」例子,分析如何通過面向?qū)ο笏枷刖帉懗觥父邇?nèi)聚低耦合」的 C 語言程序。
本文是學(xué)習(xí)宋寶華老師的《C語言大型軟件設(shè)計的面向?qū)ο蟆氛n程(地址:http://edu.csdn.net/course/detail/6496)后的一些收獲。
相關(guān)閱讀:《C語言的面向?qū)ο螅嫦蜉^大型軟件)》ppt分享和ppt注解
C 語言也能面向?qū)ο螅?/span>
在許多年輕人眼里,C 是一門既「老土」又「古板」的編程語言,更可怕的是,「C 老頭」常年被人貼上「面向過程」的標(biāo)簽,與 Java、Pyhon 等面向?qū)ο蟮母呒壵Z言格格不入。
事實上,面向?qū)ο笾皇且环N思想,與語言無關(guān)(只不過C++、Java 在語法形式上天然支持 OO),靈活的 C 語言當(dāng)然也能實現(xiàn)面向?qū)ο蟮木幊?—— 這些觀點我以前也都聽過,但僅僅停留在字面意思的感受。直到看了宋老師的直播中的幾個實例,我才加深了對 C 語言面向?qū)ο蟮睦斫?,更進一步體會到 OO 思想的強大。其中課程里提到的「命令解析器」便是典型例子,下面和大家分享一下其中的思想精髓與具體實現(xiàn),體會傳統(tǒng)過程式思維與 OO 思維的差異。
PS:由于筆者真是個菜雞,個人理解難免會有偏差,更多只是拾人牙慧,歡迎指正。
命令解析器
通過命令操控計算機是一件很酷的事情,在 DOS、Linux 系統(tǒng)中也廣泛使用命令行的方式。命令操作的核心便是命令解析器(如 Linux 中的 Shell)。命令解析器實現(xiàn)接收命令字符串,解析命令并執(zhí)行相應(yīng)操作,在單片機程序中也常常通過串口命令為用戶提供操作接口(如 AT 指令)。
過程式設(shè)計
簡單來說,命令解析器的核心功能其實就是字符串比較,調(diào)用相應(yīng)函數(shù),使用 C 語言的選擇結(jié)構(gòu)便可輕松實現(xiàn),你甚至能直接想到對應(yīng)代碼,于是你寫出了像這樣的程序:
你非常機智地采用模塊化編程,每個子功能都用單獨的 .c 文件存放。在 cmd.c 中進行命令的處理,通過條件語句比較命令,匹配后調(diào)用 gpio.c、spi.c、i2.c 文件中對應(yīng)的操作函數(shù),代碼一氣呵成。我的第一反應(yīng)也是這樣寫,嗯,沒毛病。
這是典型的過程式思維 —— 先干什么后干什么,把所有零零散散的操作通過一根時間軸串起來,沒有絲毫拐彎抹角,非常直接。但這樣的過程式設(shè)計存在明顯的兩個問題:
1. 命令增加引起跨模塊修改
2. 大量的外部函數(shù),模塊間高耦合
下面來具體解釋一下遇到的這兩個問題。
1. 命令增加引起跨模塊修改
假設(shè)現(xiàn)在需求變化,要求增加 GPIO翻轉(zhuǎn) 命令產(chǎn)生對應(yīng)的電平變化。你趕緊在 gpio.c 文件中需要增加一個電平翻轉(zhuǎn)操作函數(shù) gpio_toggle(),同時在 cmd.c 的 switch-case 語句內(nèi)部添加新增的命令及函數(shù)……
等等,這不是很怪么?只是增加了 GPIO 相關(guān)功能,命令處理邏輯沒變(依然只是判斷字符串相等),為什么卻要改動 cmd.c 的命令處理邏輯?而且還是沒啥技術(shù)含量地加了一條 case 語句……
改兩個文件或許咬咬牙就算了,如果工程日益增大,導(dǎo)致每增加一條命令都要像「砌墻」或者「擰螺絲」一樣做一堆機械重復(fù)的工作,這樣的代碼一點都不酷。
2. 大量的外部函數(shù),模塊間高耦合
如果說跨模塊修改只是一個「麻煩點兒」的問題,勤快的人毫不在乎(好吧你們贏了),那模塊間高耦合則直接影響了代碼的復(fù)用性 —— 代碼不通用!這就不是小問題了。高復(fù)用性可謂碼農(nóng)的一大追求,誰不想只寫一次代碼就可以拼湊成各種大項目,輕輕松松躺著賺錢呢?
某年后,你遇到了一個新系統(tǒng),其中也需要命令解析器功能模塊,于是你興沖沖把之前寫的 cmd.c和 cmd.h 直接拿過來用,卻發(fā)現(xiàn)編譯報錯找不到 gpio_high()、gpio_low()、spi_send()……你的內(nèi)心是崩潰的。
由于 gpio_high()、gpio_low() 等函數(shù)都是 gpio.c 中的外部函數(shù),在 cmd.c 中直接通過函數(shù)名調(diào)用,兩個文件像纏綿的情侶般高度耦合,這種緊密的聯(lián)系破壞了C 程序設(shè)計的一個基本原則 —— 模塊的獨立性。采用了模塊化編程,然而每個模塊卻不能獨立使用,意義何在?
面向?qū)ο笤O(shè)計
在前面發(fā)現(xiàn)的兩個問題上對癥下藥,可以得到程序的改進目標(biāo):
1. 增加或減少命令不影響 cmd.c
2. 命令的處理函數(shù)要成為 static,去耦合
OO思想
在解決這兩個問題前,讓我們回到思維層面,對比「面向?qū)ο蟆古c「面向過程」思想的區(qū)別。當(dāng)我們談?wù)撁嫦蜻^程思維時,程序員的角色像一個統(tǒng)治者,掌管一切、什么都要插一手。
舉個典型例子,要把大象裝到冰箱需要三步:
1. 打開冰箱門
2. 將大象放進冰箱
3. 關(guān)閉冰箱門
這一系列步驟的主動權(quán)都牢牢掌握在操作者手里,操作者按部就班地把具體操作與時間軸綁定起來,是典型的過程思維。再回到前面匹配命令的 switch-case 語句上,每增加一條新命令都需要程序員手把手地把命令和函數(shù)寫死在程序中。于是我們就會想,能不能讓命令解析器作為一個主動的個體自己增加命令?
這里就引入了「對象」的概念,什么是對象?我們所關(guān)注的一切事物皆為對象。在「把大象裝到冰箱」問題中,把「大象」、「冰箱」這兩個名詞提取出來,就是兩個對象。過程式思維解決問題時考慮「需要哪些步驟」,而 OO 思想考慮「需要哪些對象」。
還是這個例子,要把大象裝到冰箱只需要兩個對象:
1. 冰箱
2. 大象
如何描述一個對象呢?可以通過兩個方面,一是對象的特征(屬性),二是對象的行為(方法/函數(shù))。由此可以列舉出描述大象和冰箱的一些屬性和方法:
? 大象的屬性(特征):品種、體形、鼻長……
? 大象的方法(行為):進食、走路、睡覺……
? 冰箱的屬性(特征):價格、容量、功耗……
? 冰箱的方法(行為):開關(guān)機、開關(guān)門、除霜去冰……
對象有如此多的屬性和方法,但實際上并不都能用得上。不同問題涉及到對象的不同方面,因此可以忽略無關(guān)的屬性、方法。對于「把大象裝到冰箱」這個問題,我們只關(guān)心「大象的體形」、「冰箱的容量」、「大象走路(說不定能讓大象自己走進冰箱)」、「冰箱開關(guān)門」等這些與問題相關(guān)的屬性和方法。
于是程序就成了「冰箱開門、大象走進冰箱并告訴冰箱關(guān)門」的模式,將操作的主動權(quán)歸還對象本身時,程序員不再是霸道的統(tǒng)治者,而是扮演管理員的角色,協(xié)調(diào)各對象基于自身的屬性和方法完成所需功能。
OO 版命令解析器
回歸正題,如何才能解決前面的兩個問題、讓命令解析器更「OO」呢?首先對最終功能 ——「命令解析器解析命令」這句話深度挖掘,注意到「命令」、「命令解析器」這兩個名詞可以抽象成對象。
命令類型的封裝
首先是「命令」本身可以封裝為包含「命令名」和「對應(yīng)操作」兩個成員的結(jié)構(gòu)體,前者是屬性,可用字符數(shù)組存儲,后者在邏輯上是行為/函數(shù),但由于 C 語言結(jié)構(gòu)體不支持函數(shù),可用函數(shù)指針存儲。這相當(dāng)于把「命令」定義成了新的數(shù)據(jù)類型,將命令與操作聯(lián)系起來。
// 文件名稱:cmd.h
#define MAX_CMD_NAME_LENGTH 20 // 最大命令名長度,過大 51 內(nèi)存會炸
#define MAX_CMDS_COUNT 10 // 最大命令數(shù),過大 51 內(nèi)存會炸
typedef void (*handler)(void); // 命令操作函數(shù)指針類型
/* 命令結(jié)構(gòu)體類型 */
typedef struct cmd
{
char cmd_name[MAX_CMD_NAME_LENGTH + 1]; // 命令名
handler cmd_operate; // 命令操作函數(shù)
} CMD;
其中宏 MAX_CMD_NAME_LENGTH 表示所存儲命令名的最大長度,handler 為指向命令操作函數(shù)的指針,所有命令操作函數(shù)均為無參無返回值。
命令解析器的封裝
同理,「命令解析器」這一模塊也可以看做一個對象,對功能模塊的封裝已經(jīng)在文件結(jié)構(gòu)上體現(xiàn),就沒必要用結(jié)構(gòu)體了,我們重點關(guān)注對象的內(nèi)部(即成員變量與成員函數(shù))。
成員變量
命令解析器要從一堆命令中匹配一個,因此需要一種能存儲命令集合的數(shù)據(jù)結(jié)構(gòu),這里使用數(shù)組實現(xiàn)線性表:
// 文件名稱:cmd.h
/* 命令列表結(jié)構(gòu)體類型 */
typedef struct cmds
{
CMD cmds[MAX_CMDS_COUNT]; // 列表內(nèi)容
int num; // 列表長度
} CMDS;
通過結(jié)構(gòu)體封裝數(shù)據(jù)類型定義成員變量類型,方便在 cmd.c 中使用:
// 文件名稱:cmd.c
static xdata CMDS commands = {NULL, 0}; // 全局命令列表,保存已注冊命令集合
為了簡化程序,線性表的「增刪改查」等基本操作就不一一獨立實現(xiàn)了,而是與命令處理過程結(jié)合(命令的注冊與匹配其實就是插入與查找過程)。下面考慮對象的成員函數(shù)。
成員函數(shù)
命令解析器涉及到那些行為呢?首要任務(wù)當(dāng)然是匹配并執(zhí)行指令。其次,要對外提供增加命令的接口函數(shù),由處理命令功能模塊主動注冊命令,而不是通過代碼寫死,從而就避免了跨模塊修改,硬件無關(guān)的代碼也提高了程序的可移植性。
編寫 match_cmd() 函數(shù)實現(xiàn)命令匹配,該函數(shù)接收一個待匹配的命令字符串作為參數(shù),對命令列表進行遍歷比較操作:
// 文件名稱:cmd.c
void match_cmd(char *str)
{
int i;
if (strlen(str) > MAX_CMD_NAME_LENGTH)
{
return;
}
for (i = 0; i < commands.num; i++) // 遍歷命令列表
{
if (strcmp(commands.cmds[i].cmd_name, str) == 0)
{
commands.cmds[i].cmd_operate();
}
}
}
接著再實現(xiàn)注冊命令函數(shù),該函數(shù)接收一個命令類型數(shù)組,插入到命令解析器的命令列表中:
// 文件名稱:cmd.c
void register_cmds(CMD reg_cmds[], int length)
{
int i;
if (length > MAX_CMDS_COUNT)
{
return;
}
for (i = 0; i < length; i++)
{
if (commands.num < MAX_CMDS_COUNT) // 命令列表未滿
{
strcpy(commands.cmds[commands.num].cmd_name, reg_cmds[i].cmd_name);
commands.cmds[commands.num].cmd_operate = reg_cmds[i].cmd_operate;
commands.num++;
}
}
}
至此,命令解析器便大功告成!通過調(diào)用兩個函數(shù)即可完成命令的添加與匹配功能,接下來編寫 LED 燈和蜂鳴器的操作函數(shù),測試命令解析器功能。
命令解析器的使用
注冊和匹配命令
編寫 led.c 文件,實現(xiàn) LED 的亮滅操作函數(shù),在 led_init() 函數(shù)中注冊命令并初始化硬件:
// 文件名稱:led.c
static void led_on(void)
{
LED1 = 0;
}
static void led_off(void)
{
LED1 = 1;
}
void led_init(void)
{
/* 填充命令結(jié)構(gòu)體數(shù)組 */
CMD led_cmds[] = {
{"led on", led_on},
{"led off", led_off}
};
/* 注冊命令 */
register_cmds(led_cmds, ARRAY_SIZE(led_cmds));
/* 初始化硬件 */
led_off();
}
可以看到,命令處理函數(shù) led_on() 和 led_off() 都是 static 修飾的內(nèi)部函數(shù),在其他模塊中不能通過函數(shù)名直接調(diào)用,而是通過函數(shù)指針的方式傳遞,實現(xiàn)了模塊間解耦。再者,使用結(jié)構(gòu)體數(shù)組注冊命令,大大增加程序擴展性。
按照同樣的套路編寫 beep.c 文件實現(xiàn)蜂鳴器控制命令。
最后,在主函數(shù) while(1) 循環(huán)中接受串口字符串、解析命令并執(zhí)行:
// 文件名稱:main.c
void main()
{
unsigned char str[20];
uart_init();
led_init();
beep_init();
while (1)
{
/* 獲取串口命令字符串 */
uart_get_string(str);
/* 匹配命令并執(zhí)行 */
match_cmd(str);
/* 命令回顯 */
uart_send_string(str);
uart_send_byte('\n');
}
}
增加命令
在經(jīng)過了高度抽象封裝的命令解析器上增加一條命令,如 LED 翻轉(zhuǎn),只需要在 led.c 中增加 led_toggle() 函數(shù),并往待注冊的命令結(jié)構(gòu)體數(shù)組初始化列表中添加一個元素,然后……就完了,即使加 100 條新命令也完全不需要動 cmd.c 中的代碼,兩個模塊彼此獨立。
// 文件名稱:led.c
static void led_toggle(void) // 增加 LED 翻轉(zhuǎn)函數(shù)
{
LED1 = ~LED1;
}
void led_init(void)
{
/* 填充命令結(jié)構(gòu)體數(shù)組 */
CMD led_cmds[] = {
{"led on", led_on},
{"led off", led_off},
{"led toggle", led_toggle} // 增加 LED 翻轉(zhuǎn)命令
};
/* 注冊命令 */
register_cmds(led_cmds, ARRAY_SIZE(led_cmds));
/* 初始化硬件 */
led_off();
}
此外,如果 cmd.c 中改用其他數(shù)據(jù)結(jié)構(gòu)存儲命令集合,也與 led.c 無關(guān),徹底切斷兩個文件的強耦合。cmd.c 現(xiàn)已升級為一個通用的命令解析器。
總結(jié)
從最初手動往 cmd.c 中添加命令代碼,到最后通過函數(shù)「智能操作」,OO 思想實現(xiàn)把權(quán)利下放,每個模塊自己的事自己解決(功能模塊需要命令功能時自己主動注冊即可),程序員再也不用對所有細節(jié)親力親為,而是為每個對象賦予該有的能力,然后對它們說上一句:「你辦事我放心」!
工程示例代碼下載:鏈接:http://pan.baidu.com/s/1geKE2ll 密碼:e0ku
(END)
免責(zé)聲明:本文內(nèi)容由21ic獲得授權(quán)后發(fā)布,版權(quán)歸原作者所有,本平臺僅提供信息存儲服務(wù)。文章僅代表作者個人觀點,不代表本平臺立場,如有問題,請聯(lián)系我們,謝謝!