代码中的软件工程思维
本篇博客基于孟宁老师的高级软件工程课程,尝试分析menu项目中运用的软件工程思维。
参考资料:https://gitee.com/mengning997/se/blob/master/README.md
项目链接:https://github.com/mengning/menu
1. C/C++ 编译调试环境配置
要让 VSCode 能运行 C/C++ 代码,我们首先需要安装 C/C++ 扩展。但由于 C/C++ 扩展并不包括编译器或调试器,目前的VSCode依然是无法运行代码的。
为了在不同环境下保持一致,我们选择安装 GCC 的 Windows 版本 Mingw-w64/GCC 作为编译器。
在MinGM安装程序中勾选C++的包,在左上角选择【Installation】->【Update Catalogue】开始安装。安装后对应方块会变为绿色。

接下来在环境变量 path 中添加 MinGM 的安装路径

完成后在命令行输入 gcc --version,正确显示 gcc 版本号则说明配置成功:

新建文件夹 test (文件路径不要出现中文),其中包含一个 helloworld.cpp 文件,用 VSCode 打开:

点击【运行】->【启动调试】选择 C++(GDB/LLDB) 来运行代码,成功打印 hello world

此时可以发现工作区文件夹下生成了两个 json 文件:launch.json 和 tasks.json 。

用VSCode打开文件夹 menu-master ,运行test.c
成功运行需要针对tasks.json做出配置上的调整
关于json文件的配置可参考https://go.microsoft.com/fwlink/?LinkId=733558
test运行结果如下

可以发现,MenuOS 是一个命令行菜单程序。
2. 项目中的软件工程
本部分结合代码,主要针对软件工程中模块化设计、可重用接口、线程安全三个问题进行讨论和分析。
2.1 模块化设计
模块化是指在设计软件系统时自顶向下逐层把整个系统按照功能划分成若干模块的过程。其旨在保证各部分相对独立,让每一个部分可以被独立地进行设计和开发,降低系统各部分之间的相互作用,避免出现“牵一发而动全身”。
模块化程度往往是衡量软件设计是否足够优秀的一个重要指标,一般我们使用 耦合度(Coupling)和 内聚度(Cohesion)来衡量软件模块化的程度。
2.1.1 耦合度: 耦合度是指软件模块之间的依赖程度,一般可以分为 紧密耦合、松散耦合 和 无耦合。

2.1.2 内聚度:内聚度是指一个软件模块内部各种元素之间互相依赖的紧密程度。 分为 偶然内聚、逻辑内聚、时间内聚、过程内聚、通信内聚、顺序内聚 以及 功能内聚七种。最理想的状态是功能内聚:一个软件模块只做一件事,只完成一个主要功能点或者一个软件特性。
软件设计中我们追求的是 松散耦合 与 功能内聚,也就是说要减少模块间的联系,提升模块内的联系。系统被分成若干模块后肯定无法做到完全独立,显然,模块之间的联系多,则模块的相对独立性就差,系统结构就混乱;相反,模块间的联系少,各个模块相对独立性就强,系统结构就比较理想。同时,一个模块内部各成份联系越紧密,该模块越易理解和维护。
代码 linktable.h
/* * LinkTable Node Type */ typedef struct LinkTableNode { struct LinkTableNode * pNext; }tLinkTableNode; /* * LinkTable Type */ typedef struct LinkTable 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,void * args); */ tLinkTableNode * SearchLinkTableNode(tLinkTable *pLinkTable, int Conditon(tLinkTableNode * pNode, void * args), void * args); /* * get LinkTableHead */ tLinkTableNode * GetLinkTableHead(tLinkTable *pLinkTable); /* * get next LinkTableNode */ tLinkTableNode * GetNextLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode); #endif /* _LINK_TABLE_H_ */
在代码中可以看到,项目在逻辑上做了切分,函数与数据结构定义在 linktable.h 中,而实现在 linktable.c 中,这样将数据结构和它的操作与菜单业务处理进行分离处理,降低了耦合度,数据结构或者函数功能更新迭代的影响只会最大程度地停留在本功能模块中。进行了模块化设计之后我们往往将设计的模块与实现的源代码文件有个映射对应关系,因此我们需要将数据结构和它的操作独立放到单独的源代码文件中,这时就需要设计合适的接口,以便于模块之间互相调用。
2.2 可重用接口
2.2.1 接口
模块接口是模块之间进行对接交互的门户,是互相联系的双方共同遵守的一种协议规范,在我们软件系统内部一般的接口方式是通过定义一组API函数来约定软件模块之间的沟通方式。我们在设计时至少应该遵循以下四个原则:
1)简单:所谓简单主要体现在模块接口的使用方法上,可以让模块的使用者在不借助或只借助很少的文档就可以轻松使用模块。接口的命名要规范,让使用者可以通过名称猜测到方法的主要用途;接口中的相关参数的数据类型要尽可能的简单,尽量少使用嵌套层次多的数据结构;
2)封闭:封闭原则要求的的是,模块功能的实现细节要完全对外封闭,而且在对模块内部的处理逻辑进行修改时,不会影响模块使用者的调用逻辑。
3)完整:做为功能模块,它所提供的功能应该是一个全面的整体,一些具有细微差别的功能应该被集中到一个模块中,这样我们可以方便利用继承、重载 、覆写等技术手段来提高代码复用率,同时也可以提升模块使用的灵活度。
4)可置换:我们很难保证一个功能模块所提供的功能会永不过时,因此在接口设计时应该尽可能的应用接口编程思想,为接口提供标准的接口规范,这样将来可以轻松的用遵循接口规范的新的模块置换原有的模块,而不会影响其到他相关模块的调用方式。
2.2.2 可重用
为了避免大量重复工作,我们需要尽可能设计出可重用接口,这就需要接口能够做到接受多种参数输入,减少被不同对象或参数间的差异性所影响。
以代码 Linktable.c 中的 SearchLinkTableNode 函数为例:
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; }
可以看出SearchLinkTableNode的功能是要在链表中查找一个指定的结点,参数表中要求输入一个链表,一个查找参数外,还出现了一个 Condition 函数。
函数定义如下:
int Conditon(tLinkTableNode * pLinkTableNode,void * arg) { char * cmd = (char*)arg; tDataNode * pNode = (tDataNode *)pLinkTableNode; if(!strcmp(pNode->cmd, cmd)) { return SUCCESS; } return FAILURE; }
为什么要特意去加入一个Conditon函数呢?分析代码之后我们发现,如今的SearchLinkTableNode执行的主要业务逻辑只停留在遍历链表,这就让它无需关心数据,只需关心遍历这一个逻辑。对比的功能现在对接口隔离,完成对比需要借助Condition,这就实现业务与接口的分离。这样一来,SearchLinkTableNode从一个只有在需要查找链表中结点时才能用到的接口,变成了一个涉及到遍历链表的工作都可以被调用的接口,这就实现了一个可重用接口。
2.2.3通用接口定义的基本方法
- 参数化上下文:通过参数来传递上下文的信息,而不是隐含依赖上下文环境
- 移除前置条件:减少输入的限制条件
- 简化后置条件:进一步移除前置条件,后置条件也会随之简单清晰
2.3 线程安全
2.3.1线程
线程(thread)是操作系统能够进行运算调度的最小单位。它包含在进程之中,是进程中的实际运作单位,每一个线程都有一个独自拥有的函数调用堆栈空间,其中函数参数和局部变量都存储在函数调用堆栈空间中,因此函数参数和局部变量也是线程独自拥有的。除了函数调用堆栈空间,同一个进程的多个线程是共享其他进程资源的,如全局变量。一个线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
2.3.2可重入函数与不可重入函数
可重入函数简单来说就是可以被中断的函数,它可以由多于一个任务并发使用而不必担心数据错误。也就是说可以在这个函数执行的任何时刻中断它,而返回控制时不会出现什么错误;
可重入函数的基本原则:
1)不为连续的调用持有静态数据;
2)不返回指向静态数据的指针;
3)所有数据都由函数的调用者提供;
4)使用局部变量,或者通过制作全局数据的局部变量拷贝来保护全局数据;
5)使用静态数据或全局变量时做周密的并行时序分析,通过临界区互斥避免临界区冲突;
6)绝不调用任何不可重入函数。
不可重入函数由于使用了一些系统资源,比如全局变量所以不能由超过一个任务所共享,除非能确保函数的互斥(或者使用信号量,或者在代码的关键部分禁用中断)。它如果被中断的话,可能会出现问题,这类函数不能运行在多任务环境下。
2.3.3线程安全
如果你的代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。 线程安全问题都是由全局变量及静态变量引起的。若每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说,这个全局变量是线程安全的;若有多个线程同时执行读写操作,一般都需要考虑线程同步,否则就可能影响线程安全。
可重入的函数不一定是线程安全的,可能是线程安全的也可能不是线程安全的。
不可重入的函数一定不是线程安全的。
可以看到代码Linktable.c 中 DeleteLinkTableNode 函数为了使线程安全使用了线程锁
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; }
删除结点的时候相当于进行读后写操作,此时需要进行加锁,保证在同一时刻至多仅有一个线程在执行该段代码;结点删除完毕后解锁,这样通过互斥锁实现了函数的可重入。
3. 总结
借助孟宁老师提供的囊括历代版本的menu项目,可以看到一个项目不断发展优化的过程。用软件工程思维去构建系统的同时,开发人员也需要明白系统的模块化设计、可重用接口、线程安全等问题是无法一蹴而就的,一份优秀的项目代码往往需要不断地打磨。

浙公网安备 33010602011771号