从menu项目了解代码中的软件工程
0.前言
本篇博客是由孟宁老师上课内容和所提供资料,在以VS Code + GCC工具集为主要环境,通过对孟宁老师提供的menu代码进行编译调试,了解软件工程中的模块化设计、可重用接口、线程安全等问题,了解代码一步一步如何健壮起来。
1.通过VSCode装C++及环境配置
1.1打开Visual Studio Code软件,在扩展的应用商店中下载安装c/c++。

1.2下载MinGW,安装MinGW,并配置环境变量。
打开此电脑的属性,选择高级选项,进入系统环境变量的选项,双击path,加入自己安装的MinGW的路径。

打开 CMD 控制台终端,执行 gcc -v 和 gdb -v,安装成功。


1.3.修改vs调试配置文件
在vscode创建一个hello.c文件,利用F5快捷键进行运行。

选择(GDB/LLDB),选择gcc.exe生产和调试活动文件


执行命令后会产生tasks.json与launch.json文件,以下是默认配置。

要注意配置文件command是不是自己安装的路径,还需要将externalConsole改为true.
以下是修改后的tasks.json与launch.json
{ "tasks": [ { "type": "shell", "label": "C/C++: gcc.exe build active file", "command": "C:\\mingw64\\bin\\gcc.exe", "args": [ "-g", "${file}", "-o", "${fileDirname}\\${fileBasenameNoExtension}.exe" ], "options": { "cwd": "${workspaceFolder}" }, "problemMatcher": [ "$gcc" ], "group": { "kind": "build", "isDefault": true } } ], "version": "2.0.0" }
{ // 使用 IntelliSense 了解相关属性。 // 悬停以查看现有属性的描述。 // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "name": "gcc.exe - 生成和调试活动文件", "type": "cppdbg", "request": "launch", "program": "${fileDirname}\\${fileBasenameNoExtension}.exe", "args": [], "stopAtEntry": false, "cwd": "${workspaceFolder}", "environment": [], "externalConsole": true, "MIMode": "gdb", "miDebuggerPath": "C:\\mingw64\\bin\\gdb.exe", "setupCommands": [ { "description": "为 gdb 启用整齐打印", "text": "-enable-pretty-printing", "ignoreFailures": true } ], "preLaunchTask": "C/C++: gcc.exe build active file" } ] }

至此,关于VsCode上C/C++编译调试环境配置完成。
2、代码的成长
通过老师给予menu的文件,可以发现代码的成长过程,lab1仅仅只有输出"hello world",到后面能实现越来越多的功能,到最后能具有较为完整的菜单功能。

3.模块化设计
模块化设计是指在对一定范围内的不同功能或相同功能不同性能、不同规格的产品进行功能分析的基础上,划分并设计出一系列功能模块,通过模块的选择和组合可以构成不同的产品,以满足市场的不同需求的设计方法。这样做可以有效地降低程序的复杂度,使程序设计、调试和维护等操作变得简单化。
软件设计中的模块化程度便成为了软件设计有多好的一个重要指标,一般我们使用耦合度(Coupling)和内聚度(Cohesion)来衡量软件模块化的程度。
耦合度是指软件模块之间的依赖程度,一般可以分为紧密耦合(Tightly Coupled)、松散耦合(Loosely Coupled)和无耦合(Uncoupled)。 一般在软件设计中我们追求松散耦合。
内聚度是指一个软件模块内部各种元素之间互相依赖的紧密程度。 理想的内聚是功能内聚,也就是一个软件模块只做一件事,只完成一个主要功能点或者一个软件特性(Feather)。

可以观察老师所给的lab3.1与lab3.3作为对比。
lab3.1只有一个menu.c文件,其代码如下:
#include <stdio.h> #include <stdlib.h> int Help(); int Quit(); #define CMD_MAX_LEN 128 #define DESC_LEN 1024 #define CMD_NUM 10 typedef struct DataNode { char* cmd; char* desc; int (*handler)(); struct DataNode *next; } tDataNode; static tDataNode head[] = { {"help", "this is help cmd!", Help,&head[1]}, {"version", "menu program v1.0", NULL, &head[2]}, {"quit", "Quit from menu", Quit, NULL} }; 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 "); } } } int Help() { printf("Menu List:\n"); tDataNode *p = head; while(p != NULL) { printf("%s - %s\n", p->cmd, p->desc); p = p->next; } return 0; } int Quit() { exit(0); }
其代码的耦合度较高。而lab3.3中,除了menu.c文件外,还多了linklist.c与linklist.h文件。
linklist.c文件代码如下:
#include <stdio.h> #include <stdlib.h> #include "linklist.h" tDataNode* FindCmd(tDataNode * head, char * cmd) { if(head == NULL || cmd == NULL) { return NULL; } tDataNode *p = head; while(p != NULL) { if(!strcmp(p->cmd, cmd)) { return p; } p = p->next; } return NULL; } int ShowAllCmd(tDataNode * head) { printf("Menu List:\n"); tDataNode *p = head; while(p != NULL) { printf("%s - %s\n", p->cmd, p->desc); p = p->next; } return 0; }
可以发现他将原来lab3.1中一些模块放入linklist.c文件中,并通过linkli.h文件声明
typedef struct DataNode { char* cmd; char* desc; int (*handler)(); struct DataNode *next; } tDataNode; /* find a cmd in the linklist and return the datanode pointer */ tDataNode* FindCmd(tDataNode * head, char * cmd); /* show all cmd in listlist */ int ShowAllCmd(tDataNode * head);
最后在menu.c文件中include此文件,就可使用模块方法,从而降低了耦合度,提高了灵活性。
#include <stdio.h> #include <stdlib.h> #include "linklist.h"
4、可重用接口
接口是双方共同遵守的一种协议规范,在软件系统内部通常是定义一组API函数约定模块之间的通信关系。
老师介绍两种函数接口方式,即Call-in方式的函数接口和Callback方式的函数接口。
给Linktable增加Callback方式的接口,需要两个函数接口,一个是call-in方式函数,如SearchLinkTableNode函数,其中有一个函数作为参数,这个作为参数的函数就是callback函数,如代码中Conditon函数。
在lab5中的listlink.h中加入了新的声明:
tLinkTableNode * SearchLinkTableNode(tLinkTable *pLinkTable, int Conditon(tLinkTableNode * pNode));
作为call-in方式函数。
menu.c文件中又新增了一SearchCondition函数:
int SearchCondition(tLinkTableNode * pLinkTableNode) { tDataNode * pNode = (tDataNode *)pLinkTableNode; if(strcmp(pNode->cmd, cmd) == 0) { return SUCCESS; } return FAILURE; }
利用callback函数参数使Linktable的查询接口更加通用,有效地提高了接口的通用性。
在lab.5.2的代码中,发现call-in方式的函数接口SearchLinkTableNode增加了一个参数args,callback函数Conditon也增加了一个参数args。
tLinkTableNode * SearchLinkTableNode(tLinkTable *pLinkTable, int Conditon(tLinkTableNode * pNode, void * 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; }
为了降低风险增加了args参数。
5. 线程安全
可重入函数:
可重入(reentrant)函数可以由多于一个任务并发使用,而不必担心数据错误。相反,不可重入(non-reentrant)函数不能由超过一个任务所共享,除非能确保函数的互斥(或者使用信号量,或者在代码的关键部分禁用中断)。可重入函数可以在任意时刻被中断,稍后再继续运行,不会丢失数据。可重入函数要么使用局部变量,要么在使用全局变量时保护自己的数据。
线程安全:
如果你的代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。 线程安全问题都是由全局变量及静态变量引起的。若每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说,这个全局变量是线程安全的;若有多个线程同时执行读写操作,一般都需要考虑线程同步,否则就可能影响线程安全。
函数的可重入性与线程安全之间的关系:
可重入的函数不一定是线程安全的,可能是线程安全的也可能不是线程安全的;可重入的函数在多个线程中并发使用时是线程安全的,但不同的可重入函数(共享全局变量及静态变量)在多个线程中并发使用时会有线程安全问题; 不可重入的函数一定不是线程安全的。
打开lab7.1的代码,发现在linktable.c文件中有定义了Linktable结构:
struct LinkTable { tLinkTableNode *pHead; tLinkTableNode *pTail; int SumOfNode; pthread_mutex_t mutex; };
其中定义了 mutex信号量,由所学知识可知,信号量可以用来进行多个进程间的同步与互斥。
在linktable.c文件下有删除结点的函数:
int DelLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode) { if(pLinkTable == NULL || pNode == NULL) { return FAILURE; } pthread_mutex_lock(&(pLinkTable->mutex));//P操作 if(pLinkTable->pHead == pNode) { pLinkTable->pHead = pLinkTable->pHead->pNext; pLinkTable->SumOfNode -= 1 ; if(pLinkTable->SumOfNode == 0) { pLinkTable->pTail = NULL; } pthread_mutex_unlock(&(pLinkTable->mutex));V操作 return SUCCESS; }
在删除结点时先将临界区上锁,防止其他线程使用,结束后执行解锁操作。
6.小结
通过阅读老师提供的代码,不仅认识到了代码一步一步健壮的过程,还在代码中引入了软件工程的重要思想,例如工程中的模块化设计、可重用接口、线程安全等问题,这些问题要在之后自己编写代码中加以注意实践。
7.参考资料
https://github.com/mengning/menu

浙公网安备 33010602011771号