代码中的软件工程
前言
本文中所涉及到的实验项目是一个实现一个命令行的菜单小程序,最终目标是完成一个通用的命令行的菜单子系统便于在不同项目中重用。
结合这个项目的代码,我们将会一步一步的分析其中的软件工程方法、规范或软件工程思想。
编译和调试环境配置
C/C++编译调试环境配置
- 安装c/c++ extensions

如上图所示,我的VSCode中已经安装好了。
-
安装C/C++的编译器和调试器
经过Mingw-w64的安装以及环境变量的配置,在cmd通过gcc -v命令检查发现,gcc已经配置成功。如下图:
-
配置Vscode项目
在文件夹路径下输入code .命令,可以在VScode中打开此文件夹,在工作区的.vscode文件夹下有三个文件。
- tasks.json: 构建说明。这个文件是用来告诉VS Code如何构建(编译)程序。该任务将调用gcc编译器以基于源代码创建可执行文件。
- launch.json:调试器设置
- c_cpp_properties.json:编译器路径和IntelliSense 设置
-
设置配置文件
- tasks.json: 修改command和cwd为自己的路径
- launch.json:修改miDebuggerPath为自己的路径
- c_cpp_properties.json:修改compilerPath为自己的路径
代码最初是如何生长起来的
- 以前面的hello.c作为基础来开发menu.c开始项目
编译运行可以看到shell终端上不断打印出"hello world",这证明这个主循环结构是可以工作的,这样每添加一点代码就要编译运行一下,不断迭代即使发现编码错误,而不是一次性写很多代码积累很多编码错误。
- 让menu可以接收命令
和生活中的菜单类似,作为一个菜单小程序,需要具备根据用户点的菜,准确给出相应操作应答的功能。因此,我们需要给我们的小程序一个接受命令的变量cmd。修改后的代码以及运行结果如所示:
此时编译运行,可以看出终端上总是会打印输入的命令。这时的menu小程序已经初具雏形了,只不过还没能够为我们提供一些有用的功能。
3. 给menu添加命令
菜单是需要根据用户的需要来提供一些服务的,因此,我们应该在执行某个命令时调用一个特定的函数作为执行动作。
编译运行之后,此程序可以根据用户的需要来针对性的提供服务,输入help会打印this is help cmd,输入quit则程序结束,输入其他的命令,会打印wrong cmd。
代码规范和代码风格
- 代码风格的原则:简明、易读、无二义性
- 程序块头部的注释:
最精简的是无注释,理想的状态是即便没有注释,也能通过函数、变量等的命名直接理解代码。糟糕的状态是代码本身很难理解,而作者又惜字如金。最后是将函数功能、各参数的含义和输入/输出用途等一一列举,这往往是模块的对外接口,以方便自动生成开发者文档,下面给出了一个接口函数的注释范例。
编写高质量代码的基本方法
-
通过控制结构简化代码
代码的基本结构分为:顺序执行、条件分支、循环结构、递归结构。 -
通过数据结构简化代码
从需求挖掘和需求分析中发现业务层面的操作规律,可能会设计出合适的数据结构 -
一定要有错误处理
模块化软件设计
模块化的基本原理
-
模块化是指在软件系统设计时保持系统内各部分相对独立,以便使每一个部分可以被独立地进行设计和开发。这个做法背后的基本原理是关注点的分离,即“分而治之”。
-
一般我们使用耦合度和内聚度来衡量软件模块化的程度。在软件设计中我们追求松散耦合
-
模块化代码的基本写法
将数据结构及它的操作与菜单业务处理进行分离处理,因此我们需要设计合适的接口,以便于模块之间互相调用。包容变化是模块化主要的作用。 -
程序=算法+数据结构
软件=程序+软件工程
在代码中尽量不要有magic number, 这里都将magic number定义为了宏,方便以后的维护。
-
开-闭原则:
我们的代码应该是允许扩展的,对于扩展是开放的,对于修改是封闭的。如果想要复用代码,只能把代码copy过去,然后再复用。软件的一些模块部分复用(模块复用),或者是整个软件模块变成可复用的(系统复用) -
为什么要模块化:
这个代码(lab3.1)中仍然具有一些重复的部分:例如,在main和help之中都具有一些链表的查询操作,如果说代码中的数据结构从链表变成了哈希表的结构,那么代码的修改就要同时修改两个地方,带来了一些麻烦。在功能性需求方面,此代码解决的很好;但是在非功能性需求方面,这个代码是不可维护的。所以我们要考虑一个很重要的思想:代码内部模块化的时候,把数据存储变成一个层级,数据处理变成一个层级,即分为业务逻辑层和数据存储层。
-
模块化的具体实现:
lab3.2 中将数据结构以及对它的操作与菜单功能拆分开来,其中Findcmd和ShowAllCmd都是对链表的操作,这种写法实际上做了进一步的模块化。但是虽然做了进一步的模块化,但是代码并不能重用,实际上定义的这两个对链表的操作是根据需求随意定义的,但不是定义了一个可以复用的接口。
将系统模块放在不同的源文件中,将数据结构及其操作独立到另外一个源文件中;这个改变在lab3.3中有所实现,linklist.h中存放数据结构和它的操作的定义,linklist.c中详细实现了这两个操作。 -
KISS(Keep it Simple & Stupid)原则:
一行代码只做一件事;一个块代码只做一件事;一个函数只做一件事;一个软件模块只做一件事。 -
使用本地化外部接口来提高代码的适应能力。
可重用软件设计
接口
-
尽管已经做了初步的模块化设计,但是分离出来的数据结构和它的操作还有很多菜单业务上的痕迹,我们要求这个软件模块只做好链表数据结构和对链表的操作部分,而不应该涉及菜单业务功能上的东西;同时我们希望这一个软件模块与其他软件模块之间松散耦合,就需要定义简洁、清晰、明确的接口。
-
基本概念:接口就是互相联系的双方共同遵守的一种协议规范。
-
软件系统内部的接口:定义一组API函数来约定软件模块之间的沟通方式,接口具体定义了软件模块对系统的其他部分提供了怎样的服务,以及系统的其他部分如何访问所提供的服务。
-
前置条件:接口使用前所需要满足的条件
后置条件:接口使用之后的效果 -
两种函数接口方式:
Call-in方式和Callback方式 -
通用linktable模块的接口
lab4之前的代码都是使用链表来做数据结构,把模块化之后的东西的其中的一些模块设计成可重用的接口,不用再“重复造轮子”。lab4 实现了把数据结构以及对数据结构的操作 设计成可重用的接口(linktable.c),用户不需要关心在linktable.c中是如何实现的,只需要关心linktable.h中的接口要如何使用就可以了。 -
将通用的Linktable模块集成到我们的menu程序中
在使用通用的Linktable模块之后menu程序业务代码变得复杂了一些,使用起来比较繁琐,是因为我们的接口定义的还不够好,后面我们会进一步改进接口设计,此外,对于这种设计出来是用于重用的模块,我们应该尽量保证此模块的健壮,实行比较严格的单元测试。 -
给Linktable增加Callback方式的接口
callback回调函数就是一个通过函数指针调用的函数。把函数的指针(地址)作为参数传递给另一个函数,当这个指针调用其所指向的函数时,就称这是回调函数。回调函数不是该函数的实现方直接调用,而是在特定的事件或条件发生时由另外的一方调用的,用于对该事件或条件进行响应。
- 给linktable增加callback方式的接口,需要两个函数接口,一个是call-in方式函数
lab5.1中的接口文件增加了一个新的接口,如下图:
这个接口叫searchLinkTableNode, 第二个参数实际上是一个函数
可以在condition, 也就是在callback函数中实现查询链表的操作;
callback接口方式非常像谍战剧里派遣卧底,这里SearchLinkTableNode函数派遣了一个卧底Condition并指定了卧底负责收集的情报范围(参数化上下文),一旦发现目标情报卧底就被激活return pNode。
- 利用callback函数参数使Linktable的查询接口更加通用
上面的代码虽然定义了卧底condition, 但是condition使用了全局变量cmd,这种写法增加了模块之间的耦合度,因此我们考虑再增加一个args参数,这样相当于在派遣卧底的同时指定了目标情报的内容,卧底不需要和基地联系,只有在搜集到目标情报是args的时候才需要向基地汇报。
这种写法仍然可以把cmd写成局部变量,减少外部调用接口的开发者有意或者无意的破坏软件模块的内部数据。这种方式增加了接口的通用性。
10. 我们还通过把linktable.h中不是在接口调用时必需的内容转移到linktable.c中,这样可以有效地隐藏软件模块内部的实现细节。例如:
linktable.h中:
这部分内容隐藏在了linktable.c中:
可重入函数与线程安全
- 线程安全: 代码所在的进程中可能有多个线程在同时运行,而这些线程可能会同时运行这段代码。假如每次运行结果和单线程运行的结果一样,那么就是线程安全的。
- 根据线程安全和可重入函数的关系,我们得知,要对linktable软件模块进行线程安全分析,可以从以下几个角度入手:
- 检查所有的函数是不是都是可重入函数
- 不同的可重入函数有没有可能同时进入临界区(写互斥、读写互斥)
- 对于不可重入函数要具体分析其对线程安全带来的影响,有没有潜在的破坏性
- createLinkTable: 两个线程都是各自独立创建了一块存储区,不会有资源共享的情况,此函数可重入。
DeleteLinkTable: 可能会存在出错,free一块已经被free掉的存储区
AddLinkTableNote: 加了互斥锁
DeleLinkTable: 加了互斥锁
其他函数都只是读链表,不是写链表,因此都是可重入的
经过分析,我们得知本模块中的所有函数都是可重入的。
Linktable链表的数据结构中包含pthread_mutex_t mutex用于临界区的互斥,在创建Linktable链表时使用了pthread_mutex_init来初始化mutex,清空链表之后彻底删除链表之前使用pthread_mutex_destroy销毁了mute。显然对链表的查询没有进行临界区互斥方面的处理。从整个软件模块看也就是写操作都是互斥的,而读写可以并行进行。
但是可能存在读和写同时进行的情况,因此本代码不是绝对线程安全的,读写锁可以有效地解决这里的线程安全问题。
menu子系统的接口设计
menu子系统的可重用接口设计
-
这部分的通用接口设计不能参照Linktable链表模块的设计来,因为Linktable链表是一个非常基础的软件模块,重用的机会非常多,但是menu模块的重用机会比较有限,因此不用设计的十分通用,因为通用通常对应着接口的使用不够直接明了。
-
具体方法 :我们可以在menu.h中定义以下两个接口:
一个是通过给出命令的名称、描述和命令的实现函数定义一个命令;另一个是启动menu引擎。
这两个接口的具体实现和之前函数中的一部分代码仍然相同,只不过将它们独立出来作为接口。
带参数的复杂命令函数接口的写法
参照main函数的写法,我们可以把menu子系统的接口升级如下:
总结
软件设计的方法和原则
- 设计方法论
不断地重构 - 几个重要的设计指导原则
- Modularity
- Interfaces
- information hiding
- Incrmental development
- Abstraction
- Generality

浙公网安备 33010602011771号