菜单小程序中的软件工程
1、编译和调试环境配置
该项目运行在windows环境下的visual studio code
编辑器上。首先下载visual studio code编辑器(其安装过程较为简洁),同时为了使该编辑器可以成功运行C
语言代码,还需要下载MinGW
。
1.1、MinGW配置
在MinGW下载界面中选最新版本中的 x86_64-posix-seh
进行下载:
解压后需要将该文件的bin目录配置到环境变量中,配置过程如下图所示:
配置完成后如果需要验证是否配置成功可以通过在cmd命令行窗口输入gcc -v
来进行验证,出现下图结果即配置成功。
1.2、VSCODE配置
为了能在visual studio code
使用C/C++环境,最简单的方法可以在vscode中让其自动生成,步骤如下图所示:
完成如上操作后,该目录下就会生成.vscode
文件夹,文件夹内有launch.json
和tasks.json
两个配置文件(在配置文件中我只修改了launch.json
文件中的externalConsole
选项,从默认值false
改为了true
,作用是为调试对象启动控制台)。该配置文件中每一项的具体作用以及其它的相关配置方式可参考Visual Studio Code 配置C/C++环境。配置结束后可以尝试运行一个简单的程序如下图所示,若出现以下结果则证明配置成功。
2、代码的演变过程
本文使用的代码大致功能为实现一个命令行的菜单小程序,最终目标是完成一个通用的命令行的菜单子系统便于在不同项目中重用。
最终源码地址:https://github.com/mengning/menu
“成长”阶段源码地址:https://gitee.com/mengning997/se/tree/master/src
代码“成长”过程如下:
- lab1:在
lab1
中定义了hello.c
和menu.c
文件,hello.c
文件可以用来测试运行环境是否能正确工作,除此之外,我们可以通过在该代码中添加入while
循环从而实现菜单小程序的主循环结构,而menu.c
文件提供了菜单小程序的思路,通过伪代码进行对应描述。 - lab2:在
lab2
中的menu.c
文件通过lab1中的伪代码实现了接收help
命令和quit
命令并执行相应操作,同时也添加了头部的注释。该部分清晰描述了该菜单小程序的相关信息(例如编写语言、目标环境、功能描述),这使得代码阅读起来更加的清晰高效。 - lab3.1:在
lab3.1
中将命令都封装在了链表中,并将对应命令执行的逻辑代码封装在了函数中,当输入指令时通过遍历链表中的令从而判断是否应该执行什么操作。 - lab3.2:在
lab3.1
的基础上进一步从main
函数中提取出了命令的遍历过程,使得主函数及其它相关函数的逻辑功能更加明确。 - lab3.3:在
lab3.2
的基础上将命令链表的相关定义和操作进行分离,从而提取出作为单独的文件linklist.h
和linklist.c
,使得menu.c
文件中的代码表更加简洁清晰。 - lab4:在
lab4
中,用户可以通过linktable.h
和linktable.c
文件实现动态创建链表,并将命令的具体操作与链表的操作进行分离,其中的testlinktable.c
可对该链表模块的使用进行相应的测试。 - lab5.1:在
lab5.1
中,给Linktable
增加Callback
方式的接口使得链表的遍历更加方便,在menu.c
中定义了SearchCondition
函数来传入搜索条件,并将其作为参数传入该接口中。 - lab5.2:在lab5.1的基础上,在函数接口
SearchLinkTableNode
增加了一个参数args
,callback
函数Conditon
也增加了一个参数args
参数避免同时调用全局变量cmd
,同时将不是linktable.h
文件接口中使用的内容移到linktable.c
中,使模块更加简洁同时提高了模块的安全性。 - lab7.1:将菜单独立成单独的模块,将其初始化和执行抽象为单独的接口,并在
menu.c
中实现其具体逻辑,最终调用执行在test.c
中,使代码逻辑更加清晰。 - lab7.2:新增了菜单小程序的使用说明文档
readme.txt
,对程序的使用进行了具体说明。
3、代码思想分析
通过观察上节中菜单小程序在lab1
到lab7
这几个阶段的“成长”,在这几个阶段里,代码从可执行的 hello world 开始不断迭代调试使代码长的越来越像一个命令行的菜单小程序。从上述的分析中,我们也可以从中看到许多软件工程的思想和方法。
3.1、模块化设计
模块化是在软件系统设计时保持系统内各部分相对独立,以便每一个部分可以被独立地进行设计和开发。这个做法背后的基本原理是关注点的分离,即将复杂问题分解为一个个简单的问题。一般我们使用耦合度和内聚度来衡量软件模块化的程度。在软件设计过程中,我们追求松散耦合,理想的内聚是功能内聚(即一个软件模块只做一件事,只完成一个主要功能点或者一个软件特性)。
耦合度:指软件模块之间的依赖程度,一般可以分为紧密耦合、松散耦合和无耦合。
内聚度:指一个软件模块内部各种元素之间互相依赖的紧密程度。
模块化设计的思想在菜单小程序中已有体现,具体表现如下:
-
在
lab3.1
中的menu.c
中,将数据结构和它的操作与菜单业务处理进行分离处理,尽管还是在同一个源代码文件中,但是已经在逻辑上做了切分,可以认为有了初步的模块化,主要相关代码如下所示: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 */ /* 进行相应处理 */ } 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); }
此处模块化的思想在于当我们需要对命令进行扩充或修改时只需要对命令描述部分的代码以及处理命令的函数,主函数main不需要改动。
-
上述虽然实现了简单的模块化,但是其代码的冗余度还是较高,因此我们需要将数据结构和它的操作独立放到单独的源代码文件中,这时就需要设计合适的接口,以便于模块之间互相调用。部分实现代码如下:
/* 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); /* linklist.c*/ tDataNode* FindCmd(tDataNode * head, char * cmd) { /* 具体实现 */ } int ShowAllCmd(tDataNode * head) { /* 具体实现 */ }
3.2、可重用接口
接口就是互相联系的双方共同遵守的一种协议规范,在我们软件系统内部一般的接口方式是通过定义一组API函数来约定软件模块之间的沟通方式。换句话说,接口具体定义了软件模块对系统的其他部分提供了怎样的服务,以及系统的其他部分如何访问所提供的服务。
接口规格:接口规格是软件系统的开发者正确使用一个软件模块需要知道的所有信息,那么这个软件模块的接口规格定义就必须清晰明确地说明正确使用本软件模块的信息。一般来说。
接口规格五大要素:1、接口的目的;2、接口使用前所需要满足的条件;3、使用接口的双方遵守的协议规范;4、接口使用之后的效果;5、接口所隐含的质量属性。
在菜单小程序中,接口的使用最为特殊的一个是SearchLinkTableNode
函数,其中有一个函数作为参数,这个作为参数的函数就是callback
函数,如代码中Conditon
函数。接口定义代码如下所示:
/* linktable.h */
/*
* Search a LinkTableNode from LinkTable
* int Conditon(tLinkTableNode * pNode,void * args);
*/
tLinkTableNode * SearchLinkTableNode(tLinkTable *pLinkTable, int Conditon(tLinkTableNode * pNode, void * args), void * args);
这个接口相较于上一个版本的接口而言,该接口增加了一个参数args
,callback
函数Conditon
也增加了一个参数args
。这个接口的定义是为了将查询操作与具体的实现逻辑分离开,这样使得不同的用户只需通过该接口即可进行查询无需重复实现相应代码。该参数的引入是为了消除对全局变量cmd的使用,从而消除公共耦合,这可以使得模块对数据的处理更加安全,同时也增强了模块的可靠性和适应性。该接口实现代码如下所示:
/* linktable.c */
/*
* Search a LinkTableNode from LinkTable
* int Conditon(tLinkTableNode * pNode);
*/
tLinkTableNode * SearchLinkTableNode(tLinkTable *pLinkTable, int Conditon(tLinkTableNode * pNode, void * args), void * args)
{
if(pLinkTable == NULL || Conditon == NULL)
{
return NULL;
}
tLinkTableNode * pNode = pLinkTable->pHead;
while(pNode != NULL)
{
if(Conditon(pNode,args) == SUCCESS)
{
return pNode;
}
pNode = pNode->pNext;
}
return NULL;
}
3.3、可重入函数和线程安全
线程安全是指当代码所在的进程中存在多个线程在运行,而且线程可能会同时运行某段代码,如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,则称为该线程时安全的。
线程安全问题都是由全局变量及静态变量引起的。若每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说,这个全局变量是线程安全的;若有多个线程同时执行读写操作,一般都需要考虑线程同步,否则就可能影响线程安全。那么可重入函数和不可重入函数在多线程的使用中是否会引发线性安全呢?
可重入函数:如果一个函数只访问自己的局部变量或参数,称为可重入函数。
不可重入函数: 当函数访问一个全局的变量或者参数时,有可能因为重入而造成混乱,像这样的函数称为不可重入函数。
-
可重入的函数不一定是线程安全的,可能是线程安全的也可能不是线程安全的;可重入的函数在多个线程中并发使用时是线程安全的,但不同的可重入函数(共享全局变量及静态变量)在多个线程中并发使用时会有线程安全问题;
-
不可重入的函数一定不是线程安全的。
在这个菜单小程序中,通过在
lab4
版本中的linktable.h
中LinkTable
数据结构中加入线程互斥量mutex
来对线程实现互斥。数据结构实现代码如下:/* * LinkTable Type */ typedef struct LinkTable { tLinkTableNode *pHead; tLinkTableNode *pTail; int SumOfNode; pthread_mutex_t mutex; }tLinkTable;
该线程互斥量在函数
CreateLinkTable
和DeleteLinkTable
中分别初始化和销毁,代码如下所示:/* * Create a LinkTable */ tLinkTable * CreateLinkTable() { /* 相关操作 */ pthread_mutex_init(&(pLinkTable->mutex), NULL); ... } /* * Delete a LinkTable */ int DeleteLinkTable(tLinkTable *pLinkTable) { /* 相关操作 */ pthread_mutex_destroy(&(pLinkTable->mutex)); ... }
对需要保证其线程安全性的操作部分可分别通过
pthread_mutex_lock
和pthread_mutex_unlock
函数进行加锁和解锁操作,部分加解锁代码如下所示:pthread_mutex_lock(&(pLinkTable->mutex)); //加锁 pLinkTable->pHead = pLinkTable->pHead->pNext; pLinkTable->SumOfNode -= 1 ; pthread_mutex_unlock(&(pLinkTable->mutex)); //解锁
在项目实际运行过程中,我们经常需要多个线程保持同步。通过对可重入代码引入互斥锁的概念,这个标记用来保证在任一时刻, 只能有一个线程访问该对象,从而保证了线程的安全。
4、总结
通过对菜单小程序项目的各个阶段中的学习,我从中了解到了许多代码设计的工程化的思想和方法,例如上述所提的模块化设计、可重用接口、线程安全等。在实际项目中使用这些方法使我对这些思想和方法也了解的更加透彻,同时也让我明白了软件工程方法在实际项目中的重要性。