伴随Menu的“成长”,学习软件工程思想
最近的几节高级软件工程课上,孟宁老师在讲解一个命令行的菜单小程序的生长过程中,引导我们去思考实战中的软件工程用到哪些方法、规范和思想以及为什么要这样做。编写这个Menu子程序的愿景是实现可以广泛通用的命令行菜单子系统组件,其他程序员定制一下就可以用。麻雀虽小,五脏俱全,尽管整个程序体量不大,但一个菜单该有的基本功能都有了,而且很适合帮助我们将关注点放到软件工程思想本身而不是复杂的程序结构。
参考文献:代码中的软件工程
环境:ubuntu16.04、VSCode
(一)编译和调试环境配置
1、安装VMware和Ubuntu16.04。(装虚拟机的过程比较简单,不做赘述)
2、在Ubuntu中安装VSCode。
(1) 通过官方PPA安装Ubuntu make,在Terminal中执行以下命令。
1 sudo add-apt-repository ppa:ubuntu-desktop/ubuntu-make 2 sudo apt-get update 3 sudo apt-get install ubuntu-make
(2) 使用命令 umake ide visual-studio-code 安装VSCode。
(3) 若桌面出现VSCode图标,说明安装成功:
  3、在VSCode中配置C/C++开发环境。
(1) 首先下载VSCode提供的C/C++扩展包:


(2) 配置launch.json和tasks.json文件,网上有很多方法来生成这两个文件,这里说一个我使用的最简单的方法:直接在menu-master文件夹下创建一个名为.vscode的文件夹,然后在.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": "(gdb) Launch", "type": "cppdbg", "request": "launch", "program": "${workspaceFolder}/${fileBasenameNoExtension}.out", "args": [], "stopAtEntry": false, "cwd": "${workspaceFolder}", "environment": [], "externalConsole": true, "MIMode": "gdb", "preLaunchTask": "build", "setupCommands": [ { "description": "Enable pretty-printing for gdb", "text": "-enable-pretty-printing", "ignoreFailures": true } ] } ] }
② tasks.json
{ // See https://go.microsoft.com/fwlink/?LinkId=733558 // for the documentation about the tasks.json format "version": "2.0.0", "tasks": [ { "label": "build", "type": "shell", "command": "g++", "args": ["-g", "${file}", "-std=c++11", "-o", "${fileBasenameNoExtension}.out"] } ] }
(3) 然后运行hello.c,可以看到能成功运行,说明配置成功了。

(4) 接着在Terminal中打开menu-master项目文件夹,然后直接用 make all 命令编译文件夹下的.c文件,并执行 ./test 命令来运行程序,结果如下,菜单小程序成功跑起来了。

至此,编译和调试环境的配置工作就完成了。
(二)跟随Menu的成长
这部分内容主要是跟踪观察Menu程序从lab1到lab7的成长轨迹,这里用到的项目名称是se_code。一是为了搞明白整个Menu程序的构建过程,二是为了更清晰地体会其中涉及到的软件工程方法、规范和思想。
1、lab1中的menu.c文件是项目开发者编写的类似于伪代码的程序,为项目的最初构建提供了思路。

2、lab2中的menu.c文件实现了Menu小程序的雏形,能够不断处理不同的命令。

3、lab3引入了一些软件工程编码中的规范和思想:
(1) 编码规范问题:
① 头部的版权信息、程序描述信息和修改日志。
② 不要出现Magic Number,在头部定义宏,方便维护数据。
③ 缩进层次(四个空格),花括号独占一行。
(2) 模块化思想:
使用了一种包容功能变化的写法,当加入一个新功能的时候,只需要为函数功能链表新加一个结点再写一个功能函数即可,无需修改主函数。
(3) 程序要简明、易读、无二义:
比如 if(strcmp(p -> cmd, cmd) == 0) 就比 if(! strcmp(p -> cmd, cmd)) 更易读; while(p != NULL) 就比 while(p) 更易读。
(4) 多态特性
在C程序中是通过函数指针体现出来的。
(5) 开-闭原则(lab3.2和lab3.3)
对扩展开放,对修改关闭。分离业务逻辑层和数据存储层,将系统模块放在不同的源文件。具体来说就是将数据结构和对数据结构的操作与业务逻辑分离开,也体现了模块化的思想。

4、lab4将通用的、可重用的linktable模块集成到Menu程序中,所谓通用链表就是用户可以自定义链表中的各种数据,而无需重新去实现对链表的各种操作。同时增加了testlinktable.c文件,testlinktable.c是一个开发者指南程序,告诉用户(其他开发者)怎么使用linktable。

5、 (可重用接口) lab5.1用callback的机制为其他开发者提供了一个更方便、更通用的遍历链表的函数接口:(linktable.h)

我们无需关心这个函数是如何实现的,直接去看menu.c中如何使用这个函数:

在menu.c中,首先定义了一个SearchCondition函数,这个函数用来定义用户想按什么条件去查找链表,接着就在FindCmd函数中调用了SearchLinkTableNode函数,而且将SearchCondition函数作为参数传入。其实这样反而由于使用了回调函数让整个接口看起来变得更复杂了,那么这么做有什么好处呢?好处很明显,就是SearchLinkTableNode这个函数没有用到任何业务逻辑层的数据,所以当其他开发者想按一定条件遍历链表时,只需要定制自己的Condition函数就能方便地来调用这个遍历函数,而无需重复“造轮子”。
尽管这种callback方式的接口已经很棒了,但是FindCmd和SearchCondition这两个代码却是标准的公共耦合,耦合性很高,如图所示:

进一步解耦合就是在lab5.2中实现的内容,其实解除公共耦合比较简单,只要把使用的全局变量改为参数传入即可,但如果这里传入一个字符数组的话就要在SearchLinkTable中的SearchCondition函数指针中也传入这个字符数组,这就将业务逻辑层的数据引入到数据结构层了,这不符合我们设计可重用接口的思想。于是,这里用了一种巧妙的方法——要传入参数,但这个参数并不指定是什么类型的,用户在使用的时候想传入什么类型的数据都可以,现在三个函数的设计如下图所示:


lab5.2还将linktable.h中不是在接口调用时必须的内容转移到linktable.c中,这样可以有效地隐藏软件模块内部的实现细节,为外部调用接口的开发者提供更加简洁的接口信息,同时也减少外部调用接口的开发者有意或无意的破坏软件模块的内部数据,如图所示:


6、lab6主要对linktable.c文件进行线程安全分析,线程安全分析首先要逐一分析所有的函数是不是都是可重入函数,然后进一步研究不同的可重入函数有没有可能同时进入临界区。经过对linktable.c中的各个函数分析后,可以看到凡是要对链表中的结点进行写操作时,操作前后会对临界区加锁和解锁,而读操作又不会改变链表中的结点,所以linktable.c中的每一个函数都是可重入函数。那么这个程序就是线程安全的吗?不是这样的,可重入函数是线程安全的必要条件,而不是充要条件。就拿本程序来说,当一个线程在搜索某个结点时,另一个线程刚好删除了这个结点,这明显就是一种错误的情况,不满足读写互斥。可以用读写锁来解决读写互斥问题,当某个线程要读链表时,一定要等其他所有写链表的线程结束后才能进行;同样,一个线程要写链表时,一定要等所有读链表的线程结束后才能进行。
7、(可重用接口) lab7给整个命令行菜单menu程序定义一套接口。menu程序本身是一个引擎,无需像linktable链表那样做一个相当通用的接口,通用往往意味着接口的使用不够直接明了,而menu子系统的接口只要做到“够用”就行。下图是menu子系统的接口函数:

MenuConfig函数用来配置菜单的命令名字、命令描述和命令操作函数,而ExecuteMenu函数用来执行函数。
具体调用menu子系统的程序在test.c中完成:

可以看到,其他程序通过MenuConfig函数和ExecuteMenu函数 可以方便地使用menu子系统。
至此,Menu就成长为一个符合软件工程设计思想的命令行小程序了。
(三)理解分析议题(模块化设计、可重用接口、线程安全)
模块化设计、可重用接口、线程安全是上一章Menu成长过程中的三个重要节点,当Menu符合这三个重要的软件工程设计思想时,它才是一个好的程序。
1、模块化设计
模块化是在软件系统设计时保持系统内各部分相对独立,以便每一个部分可以被独立地进行设计和开发。这个做法背后的基本原理是关注点的分离,即把大问题分解为小问题,分而治之。一般使用耦合度和内聚度来衡量软件模块化的程度,在软件设计中我们一般追求松散耦合。

在项目中,体现出模块化思想的地方有三处:
(1) lab3.1中,当加入一个新功能的时候,只需要为函数功能链表新加一个结点再写一个功能函数即可,无需修改主函数。
1 static tDataNode head[] = 2 { 3 {"help", "this is help cmd!", Help,&head[1]}, 4 {"version", "menu program v1.0", NULL, &head[2]}, 5 {"quit", "Quit from menu", Quit, NULL} 6 //若要添加一个功能,只需加入一个tDataNode结构的数据并写一个相应的处理函数 7 };
(2) lab3.3中,将数据结构和对数据结构的操作与业务逻辑分离开。
1 //linklist.h用来存放数据结构和它的操作 2 3 typedef struct DataNode 4 { 5 char* cmd; 6 char* desc; 7 int (*handler)(); 8 struct DataNode *next; 9 } tDataNode; 10 11 /* find a cmd in the linklist and return the datanode pointer */ 12 tDataNode* FindCmd(tDataNode * head, char * cmd); 13 /* show all cmd in listlist */ 14 int ShowAllCmd(tDataNode * head); 15 16 //其他的业务逻辑层代码都在menu.c中
(3) lab4实现了一个更加通用的linktable模块,menu.c只需要知道怎么去使用这个通用模块即可。在上一章中已经提过,不再赘述。
2、可重用接口
接口具体定义了软件模块对系统的其他部分提供了怎样的服务,以及系统的其他部分如何访问所提供的服务。在面向过程的编程中,接口一般定义了数据结构及操作这些数据结构的函数;而在面向对象的编程中,接口是对象对外开放的一组属性和方法的集合。
接口规格是软件系统的开发者正确使用一个软件模块需要知道的所有信息,那么这个软件模块的接口规格定义就必须清晰明确地说明正确使用本软件模块的信息。一般来说,接口规格包含五个基本要素:
- 接口的目的;
 - 接口使用前所需要满足的条件,一般称为前置条件或假定条件;
 - 使用接口的双方遵守的协议规范;
 - 接口使用之后的效果,一般称为后置条件;
 - 接口所隐含的质量属性。
 
回到项目中,体现可重用接口设计思想的地方有两处:(在第二章中有详细分析,点击下面链接跳转)
(1) lab5(为通用链表linktable设计良好的接口)
3、线程安全
- 要了解线程安全就要先了解什么是可重入函数和不可重入函数,可重入(reentrant)函数可以由多于一个任务并发使用,而不必担心数据错误。相反,不可重入(non-reentrant)函数不能由超过一个任务所共享,除非能确保函数的互斥(或者使用信号量,或者在代码的关键部分禁用中断)。可重入函数可以在任意时刻被中断,稍后再继续运行,不会丢失数据。可重入函数要么使用局部变量,要么在使用全局变量时保护自己的数据。
 
- 那么什么是线程安全呢?如果代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。
 
- 函数的可重入性和线程安全之间的关系:可重入的函数不一定是线程安全的,可能是线程安全的也可能不是线程安全的;可重入的函数在多个线程中并发使用时是线程安全的,但不同的可重入函数(共享全局变量及静态变量)在多个线程中并发使用时会有线程安全问题; 不可重入的函数一定不是线程安全的。
 
回到项目中,menu小程序的线程安全主要体现在对链表的各种操作上,下面一一分析函数的可重入性:
首先,linktable的结构体中定义了用于线程安全的互斥锁:
1 struct LinkTable 2 { 3 tLinkTableNode *pHead; 4 tLinkTableNode *pTail; 5 int SumOfNode; 6 pthread_mutex_t mutex; //用于线程安全的互斥锁 7 };
然后,在创建链表的CreatLinkTable函数中初始化这把锁,初始化代码是 pthread_mutex_init(&(pLinkTable->mutex), NULL); 。
在删除链表的DeleteLinkTable函数中销毁这把锁,销毁代码是 pthread_mutex_destroy(&(pLinkTable->mutex)); 。
同时,在DeleteLinkTable函数中删除每一个结点的前后都使用了加锁和解锁操作,所以这个函数是可重入的,如下图所示:

而删除一个结点的本质还是对链表进行写操作,所以其他要对链表进行写操作的函数也都使用了加锁和解锁,所以这类函数都是可重入的。这类函数有AddLinkTableNode函数(增加一个结点)、DelLinkTableNode函数(删除特定节点)。
而另一类函数就是对链表进行读操作的函数,这类函数本身不会对链表有任何改变,所以它们无需使用互斥锁,本身就是可重入函数。这类函数有SearchLinkTableNode函数(按条件查找链表结点)、GetLinkTableHead函数(获取头结点)和GetNextLinkTableNode函数(获取特定结点的下一结点)。
经过分析可知,linktable中的所有函数都是可重入函数,但由可重入函数和线程安全的关系可知,这个程序不一定是线程安全的,因为还有一种读写互斥是我们没有考虑到的,可以再增加一个读写锁使menu小程序线程安全。
至此,对模块化设计、可重用接口、线程安全的议题分析就结束了。
(四)心得体会
其实在本科阶段的软件工程课上也接触过编程规范、模块化设计、可重用接口、线程安全等等知识内容,但只是停留在知识本身,没有体会到这些设计方法、思想在实战中的作用。在跟随Menu的“成长”过程中,我切实地看到在一个菜单小程序中如何应用这些软件工程方法,这加深了我对这些知识的理解。比如说一些合理的、简洁的、符合规范的注释不仅能帮助其他开发者快速理解自己的代码,还能帮助自己维护或是迭代开发程序;模块化设计让整个系统的维护和变更更加容易;可重用接口降低了软件模块和软件模块之间的耦合度,方便其他开发者重用一些通用模块,避免重复“造轮子”的事情;线程安全让我们的程序可以并发执行却不会发生数据错误。这些软件工程规范、方法和思想应该被我们融入到今后的所有编程实战中。
最后,感谢孟宁老师用一个精练的项目传授给我这些知识。
                    
                
                
            
        
浙公网安备 33010602011771号