1、前言
通过软件工程的学习,孟宁老师通过一个菜单小程序,深入浅出地讲解了一些软件项目构建的注意事项。我深刻地了解到,好的软件,不仅仅是要实现用户的功能,还要从软件的可读性、通用性、扩展性等多方面考虑问题。
本篇博客结合孟宁老师的menu小程序的不断迭代简要的分析一下隐藏在代码中的软件工程思想。
参考文献:https://gitee.com/mengning997/se/blob/master/README.md
源代码:https://github.com/mengning/menu
2.编译和调试环境配置
- 官网下载mingw-64
- 配置环境变量 C:\MinGW\mingw64\bin(根据自己的安装目录决定)
- 在命令行上 gcc --version测试一下 ,出现如下图标表示安装成功
- 在vscode中按如下配置好launch.json和tasks.json文件
launch.json
{ // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: 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": false, "MIMode": "gdb", "miDebuggerPath": "C:\\MinGW\\mingw64\\bin\\gdb.exe", "setupCommands": [ { "description": "为 gdb 启用整齐打印", "text": "-enable-pretty-printing", "ignoreFailures": true } ], "preLaunchTask": "C/C++: gcc.exe build active file" } ] }
tasks.json
{ "tasks": [ { "type": "shell", "label": "C/C++: gcc.exe build active file", "command": "C:\\MinGW\\mingw64\\bin\\gcc.exe", "args": [ "-g", "${file}", "menu.c", "linktable.c", "-o", "${fileDirname}\\${fileBasenameNoExtension}.exe", "-lpthread" ], "options": { "cwd": "${workspaceFolder}" }, "problemMatcher": [ "$gcc" ], "group": { "kind": "build", "isDefault": true } } ], "version": "2.0.0" }
- 运行test.c文件,并测试命令
到此,完成编译和调试环境的配置。
3.如何写一个好的项目
3.1 不要重复造*
首先,一个项目的起始不是应该直接写代码,而是应该看是否有人在之前实现了类似的功能,然后决定是重新写一个还是维护已有的项目来达成目标。
3.2 良好的代码风格
首先,代码要符合编写规范(可以是社区规范,也可以是本项目的通用编码风格)。其次,代码要逻辑清晰,让其他人看代码能跟上你的思路。最后,设计要优雅,这是代码的最高追求。
3.3 代码迭代
有句经典的老话,“罗马不是一日建成的”。这在软件的编写中非常实用。一个软件在设计之初,设计者不可能思考的面面俱到,也需要在不断的调试中修正自己的思路,最终实现自己的目的。
例如在实现menu程序时,可以先从一个经典的输出“hello world”的小程序开始。
hello.c
#include <stdio.h> int main() { while (1) { printf("hello world!\n"); } return 0; }
为什么要从这个程序开始呢?这就要从这个程序的特点说起,这个程序有一个特殊的地方,就是一个while(1)的无限循环,为什么要加这个,我们可以想一想,在常见的命令行程序比如cmd中,我们可以不断地输入命令,不断地获得响应,倘若我们一直持续输入,这个程序是不是也不会停止,因此我们就发现了命令行程序的一个特点就是无限循环,只有出现特殊的情况才会退出(比如exit),因此从这个不断打印的程序开始,我们好像真的在一步步接近最终的命令行程序。
既然是命令行小程序,那么肯定得加一些命令,那么先加一个最常见的help命令。
menu.c--version1.0
int main() { while(true) { scanf(cmd); int ret = strcmp(cmd, "help"); if(ret == 0) { dosth(); } int ret = strcmp(cmd, "others"); if(ret == 0) { dosth(); } } }
这里使用的是伪代码,使用伪代码可以摒弃掉繁杂的实现,让人理清逻辑。
理清好思路后,可以具体实现了。
menu.c--version2.0
int main() { char cmd[128]; while(1) { scanf("%s", cmd); if(strcmp(cmd, "help") == 0) { printf("This is help cmd!\n"); } else if(strcmp(cmd, "quit") == 0) { exit(0); } else { printf("Wrong cmd!\n"); } } }
到这为止其实一个可以互动的命令行程序已经完成,之后不过是不断加入更多功能而已。
从这个例子可以看出,代码迭代可以帮我们理清实现思路,同时一步步地迭代,可以及时发现错误,而不是一次编译很多代码发现众多错误纠缠在一块。
可以这么说,每一个成熟的软件都是从一个小幼苗开始,不断迭代最终才能成长为参天大树。
4.模块化设计
4.1不使用模块化设计的代码有什么特点呢?
1.函数内代码太长
2.命名可能不规范(命名冲突)
3.功能复杂(功能应该按需加载而非全部加载)
4.可读性不好
5.不好维护
4.2模块化设计优点
1.简单化(相当于把复杂问题分解成一个个简单问题)
2.可维护性,模块化编程存在逻辑边界,单个模块的修改对其他部分影响低
3.可重用性(多次使用无需再编程)
4.命名不冲突
5.容易定位bug
从lab3.1 - lab3.3具体分析一下
lab3.1
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 "); } } }
lab3.2
int main() { /* cmd line begins */ while(1) { char cmd[CMD_MAX_LEN]; printf("Input a cmd number > "); scanf("%s", cmd); tDataNode *p = FindCmd(head, cmd); //查找函数 if( p == NULL) { printf("This is a wrong cmd!\n "); continue; } printf("%s - %s\n", p->cmd, p->desc); if(p->handler != NULL) { p->handler(); } } }
可以看到,将查找过程封装到FindCmd()函数中,业务代码变得更加易读。
lab3.3 引入了linklist.h,linklist.c 更进一步实现了将数据结构、业务、具体操作(功能实现)的分离。这样的接口设计,可以让各个接口模块耦合性更低,每个接口只了解自身相关的内容,与自身无关的则完全交给其他模块实现,这样各个接口独立性增强,出现bug时,可以很快定位bug,同时也不会影响到其他模块的使用。
linklist.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);
4.3模块化设计要遵守的原则
KISS(Keep It Simple & Stupid)原则
5.可重用接口
如果想要软件让更多人使用,那么接口就必须要有通用性,而不能只针对某个特定的业务,这就需要将业务代码和功能实现完全分离。
从lab4 - lab5.1,删除了原来的linklist.c和linklist.h,引入了linktable.c和linktable.h,从下面代码可以看出,这些接口是通用化的接口,不针对某个具体业务,只要满足接口的前置条件就可以使用,这使得接口和业务完全隔离。
linktable.h
typedef struct LinkTableNode { struct LinkTableNode * pNext; }tLinkTableNode; /* * LinkTable Type */ typedef struct LinkTable { tLinkTableNode *pHead; tLinkTableNode *pTail; int SumOfNode; pthread_mutex_t mutex; }tLinkTable; /* * Create a LinkTable */ 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); */ tLinkTableNode * SearchLinkTableNode(tLinkTable *pLinkTable, int Conditon(tLinkTableNode * pNode)); /* * get LinkTableHead */ tLinkTableNode * GetLinkTableHead(tLinkTable *pLinkTable); /* * get next LinkTableNode */ tLinkTableNode * GetNextLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode); #endif /* _LINK_TABLE_H_ */
这里有一个小插曲,即一个回调函数(call-back)的实现,因为通用接口SearchLinkTableNode()只是简单的遍历整个链表,通用接口因为与业务隔离,完全不知道具体链表中的节点有什么内容,因此就无法进行比较,这就要借助业务代码中的接口,在通用接口中使用业务接口这就是函数回调。这样通用接口就可以完全不用知道业务内容是什么,从而实现链表节点的查找,真正的实现业务与接口的分离。
在软件接口设计当中,我们一般追求松散耦合,在这里SearchLinkTableNode()还有一点小小的问题。
具体看一下引用SearchLinkTableNode()的代码。
SearchCondition()
int SearchCondition(tLinkTableNode * pLinkTableNode) { tDataNode * pNode = (tDataNode *)pLinkTableNode; if(strcmp(pNode->cmd, cmd) == 0) { return SUCCESS; } return FAILURE; }
FindCmd()
tDataNode* FindCmd(tLinkTable * head, char * cmd) { return (tDataNode*)SearchLinkTableNode(head,SearchCondition); }
在SearchCondition()函数实现的过程中,我们依然使用了全局变量cmd,这就会造成公共耦合,这是软件接口设计中要避免的。
一般来说,我们会通过添加参数的方法将其进行转化,这里我们从函数调用的源端进行修改,可以发现函数的调用过程是 FindCmd---SearchLinkTableNode---SearchCondition,要想SearchCondition获得一个cmd参数,那么就只能从顶层的调用方获得,因为FindCmd函数已经存在cmd参数,因此只需要在 SearchLinkTableNode和SearchCondition中加入一个(void*)args, 这样可以传入一个或者多个参数。这正是lab5.2所做的工作。
SearchCondition()
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()
tLinkTableNode * SearchLinkTableNode(tLinkTable *pLinkTable, int Conditon(tLinkTableNode * pNode, void * args), void * args);
6.线程安全
线程是轻量级进程,与进程相比,其不用进行环境的切换,因此提高了并发程度,但是并发程度的提高带来了一个问题,那就是数据的共享,这就会造成一定的安全问题。
如果函数仅使用局部变量,那么线程切换就不会出现数据不一致的问题,因为数据都被独立保存在各自的线程栈中,如果使用了全局变量,如果不进行防护,就会导致数据不一致的问题。
那么线程的安全怎么实现?
可以先考虑实现一个简单的问题,可重入函数。
6.1可重入函数
可重入(reentrant)函数可以由多于一个任务并发使用,而不必担心数据错误。
6.1.1可重入函数的基本要求
- 不为连续的调用持有静态数据;
- 不返回指向静态数据的指针;
- 所有数据都由函数的调用者提供;
- 使用局部变量,或者通过制作全局数据的局部变量拷贝来保护全局数据;
- 使用静态数据或全局变量时做周密的并行时序分析,通过临界区互斥避免临界区冲突;
- 绝不调用任何不可重入函数。
6.2可重入函数与线程安全
可重入的函数不一定是线程安全的。因为不同线程同时访问不同的可重入函数依然会导致线程安全问题,可能需要更高级的技术,比如读写锁,这里就不讨论。
不可重入的函数一定不是线程安全的。
尽管可重入函数只是必要条件,但对实现线程安全有一定的帮助。
lab7.1中就对各个函数加入互斥锁实现了可重入函数,这样不同线程访问同一个可重入函数时,就不会导致数据不一致的情况。
int DeleteLinkTable(tLinkTable *pLinkTable) { if(pLinkTable == NULL) { return FAILURE; } while(pLinkTable->pHead != NULL) { tLinkTableNode * p = pLinkTable->pHead; pthread_mutex_lock(&(pLinkTable->mutex)); //加锁 pLinkTable->pHead = pLinkTable->pHead->pNext; pLinkTable->SumOfNode -= 1 ; pthread_mutex_unlock(&(pLinkTable->mutex)); //解锁 free(p); } pLinkTable->pHead = NULL; pLinkTable->pTail = NULL; pLinkTable->SumOfNode = 0; pthread_mutex_destroy(&(pLinkTable->mutex)); //释放锁 free(pLinkTable); return SUCCESS; }
7.总结
阅读完menu程序的源码后,对软件的编写有了一些初步的认识,希望能在以后的编码生涯中能够充分应用出来。