活到老,学到老!
分类: C/C++
2018-04-03 17:03:21
原文链接:https://mp.weixin.qq.com/s/cuy0PEVRR4f4h_RCiQeTdQ
本文是学习宋宝华老师的《C语言大型软件设计的面向对象》课程
(地址:)后的一些收获。
命令解析器的核心功能其实就是字符串比较,调用相应函数,使用 C 语言的选择结构便可轻松实现,你甚至能直接想到对应代码,于是你写出了像这样的程序: SHAPE \* MERGEFORMAT
你非常机智地采用模块化编程,每个子功能都用单独的 .c 文件存放。在 cmd.c 中进行命令的处理,通过条件语句比较命令,匹配后调用 gpio.c、spi.c、i2.c 文件中对应的操作函数,代码一气呵成。我的第一反应也是这样写,嗯,没毛病。
这是典型的过程式思维 —— 先干什么后干什么,把所有零零散散的操作通过一根时间轴串起来,没有丝毫拐弯抹角,非常直接。但这样的过程式设计存在明显的两个问题:
1.命令增加引起跨模块修改
2.大量的外部函数,模块间高耦合
下面来具体解释一下遇到的这两个问题。
1. 命令增加引起跨模块修改
假设现在需求变化,要求增加 GPIO翻转 命令产生对应的电平变化。你赶紧在 gpio.c 文件中需要增加一个电平翻转操作函数 gpio_toggle(),同时在 cmd.c 的 switch-case 语句内部添加新增的命令及函数……
等等,这不是很怪么?只是增加了 GPIO 相关功能,命令处理逻辑没变(依然只是判断字符串相等),为什么却要改动 cmd.c 的命令处理逻辑?而且还是没啥技术含量地加了一条 case 语句……
改两个文件或许咬咬牙就算了,如果工程日益增大,导致每增加一条命令都要像「砌墙」或者「拧螺丝」一样做一堆机械重复的工作,这样的代码一点都不酷。
2. 大量的外部函数,模块间高耦合
如果说跨模块修改只是一个「麻烦点儿」的问题,勤快的人毫不在乎(好吧你们赢了),那模块间高耦合则直接影响了代码的复用性 —— 代码不通用!这就不是小问题了。高复用性可谓码农的一大追求,谁不想只写一次代码就可以拼凑成各种大项目,轻轻松松躺着赚钱呢?
某年后,你遇到了一个新系统,其中也需要命令解析器功能模块,于是你兴冲冲把之前写的 cmd.c和 cmd.h 直接拿过来用,却发现编译报错找不到 gpio_high()、gpio_low()、spi_send()……你的内心是崩溃的。
由于 gpio_high()、gpio_low() 等函数都是 gpio.c 中的外部函数,在 cmd.c 中直接通过函数名调用,两个文件像缠绵的情侣般高度耦合,这种紧密的联系破坏了C 程序设计的一个基本原则 —— 模块的独立性。采用了模块化编程,然而每个模块却不能独立使用,意义何在?
面向对象设计
在前面发现的两个问题上对症下药,可以得到程序的改进目标:
1.增加或减少命令不影响 cmd.c
2.命令的处理函数要成为 static,去耦合
OO思想
在解决这两个问题前,让我们回到思维层面,对比「面向对象」与「面向过程」思想的区别。当我们谈论面向过程思维时,程序员的角色像一个统治者,掌管一切、什么都要插一手。
举个典型例子,要把大象装到冰箱需要三步:
1.打开冰箱门
2.将大象放进冰箱
3.关闭冰箱门
这一系列步骤的主动权都牢牢掌握在操作者手里,操作者按部就班地把具体操作与时间轴绑定起来,是典型的过程思维。再回到前面匹配命令的 switch-case 语句上,每增加一条新命令都需要程序员手把手地把命令和函数写死在程序中。于是我们就会想,能不能让命令解析器作为一个主动的个体自己增加命令?
这里就引入了「对象」的概念,什么是对象?我们所关注的一切事物皆为对象。在「把大象装到冰箱」问题中,把「大象」、「冰箱」这两个名词提取出来,就是两个对象。过程式思维解决问题时考虑「需要哪些步骤」,而 OO 思想考虑「需要哪些对象」。
还是这个例子,要把大象装到冰箱只需要两个对象:
1.冰箱
2.大象
如何描述一个对象呢?可以通过两个方面,一是对象的特征(属性),二是对象的行为(方法/函数)。由此可以列举出描述大象和冰箱的一些属性和方法:
?大象的属性(特征):品种、体形、鼻长……
?大象的方法(行为):进食、走路、睡觉……
?冰箱的属性(特征):价格、容量、功耗……
?冰箱的方法(行为):开关机、开关门、除霜去冰……
对象有如此多的属性和方法,但实际上并不都能用得上。不同问题涉及到对象的不同方面,因此可以忽略无关的属性、方法。对于「把大象装到冰箱」这个问题,我们只关心「大象的体形」、「冰箱的容量」、「大象走路(说不定能让大象自己走进冰箱)」、「冰箱开关门」等这些与问题相关的属性和方法。
于是程序就成了「冰箱开门、大象走进冰箱并告诉冰箱关门」的模式,将操作的主动权归还对象本身时,程序员不再是霸道的统治者,而是扮演管理员的角色,协调各对象基于自身的属性和方法完成所需功能。
OO 版命令解析器
回归正题,如何才能解决前面的两个问题、让命令解析器更「OO」呢?首先对最终功能 ——「命令解析器解析命令」这句话深度挖掘,注意到「命令」、「命令解析器」这两个名词可以抽象成对象。
命令类型的封装
首先是「命令」本身可以封装为包含「命令名」和「对应操作」两个成员的结构体,前者是属性,可用字符数组存储,后者在逻辑上是行为/函数,但由于 C 语言结构体不支持函数,可用函数指针存储。这相当于把「命令」定义成了新的数据类型,将命令与操作联系起来。
// 文件名称: cmd.h
#define MAX_CMD_NAME_LENGTH 20 // 最大命令名长度,过大 51 内存会炸
#define MAX_CMDS_COUNT 10 // 最大命令数,过大 51 内存会炸
typedef void (*handler)(void); // 命令操作函数指针类型
/* 命令结构体类型 */
typedef struct cmd
{
char cmd_name[MAX_CMD_NAME_LENGTH + 1]; // 命令名
handler cmd_operate; // 命令操作函数
} CMD;
其中宏 MAX_CMD_NAME_LENGTH 表示所存储命令名的最大长度,handler 为指向命令操作函数的指针,所有命令操作函数均为无参无返回值。
命令解析器的封装
同理,「命令解析器」这一模块也可以看做一个对象,对功能模块的封装已经在文件结构上体现,就没必要用结构体了,我们重点关注对象的内部(即成员变量与成员函数)。
成员变量
命令解析器要从一堆命令中匹配一个,因此需要一种能存储命令集合的数据结构,这里使用数组实现线性表:
// 文件名称: cmd.h
/* 命令列表结构体类型 */
typedef struct cmds
{
CMD cmds[MAX_CMDS_COUNT]; // 列表内容
int num; // 列表长度
} CMDS;
通过结构体封装数据类型定义成员变量类型,方便在 cmd.c 中使用:
// 文件名称: cmd.c
static xdata CMDS commands = {NULL, 0}; // 全局命令列表,保存已注册命令集合
为了简化程序,线性表的「增删改查」等基本操作就不一一独立实现了,而是与命令处理过程结合(命令的注册与匹配其实就是插入与查找过程)。下面考虑对象的成员函数。
成员函数
命令解析器涉及到那些行为呢?首要任务当然是匹配并执行指令。其次,要对外提供增加命令的接口函数,由处理命令功能模块主动注册命令,而不是通过代码写死,从而就避免了跨模块修改,硬件无关的代码也提高了程序的可移植性。
编写 match_cmd() 函数实现命令匹配,该函数接收一个待匹配的命令字符串作为参数,对命令列表进行遍历比较操作:
// 文件名称: 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();
}
}
}
接着再实现注册命令函数,该函数接收一个命令类型数组,插入到命令解析器的命令列表中:
// 文件名称: 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++;
}
}
}
至此,命令解析器便大功告成!通过调用两个函数即可完成命令的添加与匹配功能,接下来编写 LED 灯和蜂鸣器的操作函数,测试命令解析器功能。
命令解析器的使用
注册和匹配命令
编写 led.c 文件,实现 LED 的亮灭操作函数,在 led_init() 函数中注册命令并初始化硬件:
// 文件名称: led.c
static void led_on(void)
{
LED1 = 0;
}
static void led_off(void)
{
LED1 = 1;
}
void led_init(void)
{
/* 填充命令结构体数组 */
CMD led_cmds[] = {
{"led on", led_on},
{"led off", led_off}
};
/* 注册命令 */
register_cmds(led_cmds, ARRAY_SIZE(led_cmds));
/* 初始化硬件 */
led_off();
}
可以看到,命令处理函数 led_on() 和 led_off() 都是 static 修饰的内部函数,在其他模块中不能通过函数名直接调用,而是通过函数指针的方式传递,实现了模块间解耦。再者,使用结构体数组注册命令,大大增加程序扩展性。
按照同样的套路编写 beep.c 文件实现蜂鸣器控制命令。
最后,在主函数 while(1) 循环中接受串口字符串、解析命令并执行:
// 文件名称: main.c
void main()
{
unsigned char str[20];
uart_init();
led_init();
beep_init();
while (1)
{
/* 获取串口命令字符串 */
uart_get_string(str);
/* 匹配命令并执行 */
match_cmd(str);
/* 命令回显 */
uart_send_string(str);
uart_send_byte('\n');
}
}
增加命令
在经过了高度抽象封装的命令解析器上增加一条命令,如 LED 翻转,只需要在 led.c 中增加 led_toggle() 函数,并往待注册的命令结构体数组初始化列表中添加一个元素,然后……就完了,即使加 100 条新命令也完全不需要动 cmd.c 中的代码,两个模块彼此独立。
// 文件名称: led.c
static void led_toggle(void) // 增加 LED 翻转函数
{
LED1 = ~LED1;
}
void led_init(void)
{
/* 填充命令结构体数组 */
CMD led_cmds[] = {
{"led on", led_on},
{"led off", led_off},
{"led toggle", led_toggle} // 增加 LED 翻转命令
};
/* 注册命令 */
register_cmds(led_cmds, ARRAY_SIZE(led_cmds));
/* 初始化硬件 */
led_off();
}
此外,如果 cmd.c 中改用其他数据结构存储命令集合,也与 led.c 无关,彻底切断两个文件的强耦合。cmd.c 现已升级为一个通用的命令解析器。