版本迭代中的软件工程思想
目录:
1. Linux + VS Code编译和调试环境配置
- tasks.json
- launch.json
- c_cpp_properties.json
- Makefile
2. 迭代代码中的软件工程思想
- 模块化设计:抽象分离
- 可重用之接口设计
- 可重入函数和线程安全
一、编译和调试环境配置
本文基于C语言编写的menu菜单命令程序,所用到的工具及环境如下:
- 代码编辑器:VS Code (Visual Studio Code),并安装Microsoft C/C++ extension套件
- 代码编译器:GCC (GNU Compiler Collection, 包含在MinGW中)
- 系统环境:Windows10下的Linux子系统Ubuntu20.04 (WSL1, 详见 适用于 Linux 的 Windows 子系统文档 / 在WSL下使用VS Code进行C/C++编程)
关于上述工具的安装和使用,超链接和网上的资料中有非常详尽的文档,此处不再赘述。
在使用VS Code进行C/C++的开发过程中,有三个至关重要的配置文件,分别是 tasks.json, launch.json 和 c_cpp_properties.json;(我作为一个小白在刚刚接触vscode时,在面对这些配置文件时属实大伤脑筋,网上搜到的资源也过于零散,在此将这些内容做个总结)
此外,在Linux环境编程时Makefile文件可进行自动化部署,有助于提升程序的可移植性和易用性。
1. tasks.json
tasks.json是在vscode中辅助程序编译的模块,可以代你执行类似于在命令行输入“gcc hello.c -o hello”命令的操作,你只要在图形界面下操作即可生成可执行文件。
当你在项目文件夹下打开vscode后,选择“终端”,选择“配置任务”,选择你想使用的编译器(这里本人选择gcc),即可生成默认的tasks.json文件。
可以看到在文件夹下生成了名为.vscode的文件,tasks.json就放在其中。
⭐其中比较重要的几个变量:
{ "version": "2.0.0", "tasks": [ { "type": "cppbuild", //任务类型,默认即可 "label": "环境配置测试", //任务的名称,可以修改,但一定要和launch中的"preLaunchTask"项保持一致 "command": "/usr/bin/gcc", //编译器的路径 "args": [ //编译时使用的参数,和命令行下相同 "-g", "${fileDirname}/hello.c", "-o", "${fileDirname}/hello" ], //上述内容相当于在命令行下输入了: gcc hello.c -o hello "options": { "cwd": "/usr/bin" //编译器的目录 }, "problemMatcher": [ "$gcc" //使用gcc捕捉错误 ], "group": "build", "detail": "compiler: /usr/bin/gcc" //一些描述性信息 } ] }
2. launch.json
launch.json是用于运行 (run) 和调试 (debug) 的配置文件,可以指定语言环境,指定调试类型等等内容。
打开VS Code后,按照下图所示的提示,从左向右依次点击(也可以在上方菜单栏依次选择“运行”,“打开配置”,选择"C++ (GDB/LLDB) "即可生成launch.json),创建launch.json文件
生成的launch.json也放在.vscode文件夹中
⭐其中各种变量的内容及涵义如下:
{ "version": "0.2.0", "configurations": [ { "name": "运行和调试", //运行和调试任务的名称,可自定义 "type": "cppdbg", //配置类型,默认即可 "request": "launch", //launch模式允许我们打断点进行调试,默认即可 "program": "${fileDirname}/hello", //程序目录,这里相当于"./hello" "args": [], //程序(main函数)的入口参数 "stopAtEntry": false, //在入口处暂停,选true相当于在入口处增加断点 "cwd": "${workspaceFolder}",//当前的文件目录 "environment": [], //添加到程序的环境变量 "externalConsole": false, //外部控制台,true在调试时会开启系统控制台窗口,false会使用vscode自带的调试控制台 "MIMode": "gdb", //使用gdb进行调试 "setupCommands": [ //用来设置gdb的参数,默认即可 { "description": "为 gdb 启用整齐打印", "text": "-enable-pretty-printing", "ignoreFailures": true } ], "preLaunchTask": "环境配置测试", //运行和调试前要执行的task(编译)任务,名称要对应上task.json里的"label"任务名 "miDebuggerPath": "/usr/bin/gdb" //debug调试工具的路径,这里使用gdb所在的路径 } ]
也可以参考 Configuring C/C++ debugging官方文档 和 Debugging in VS Code官方文档 进行设置
3. c_cpp_properties.json
c_cpp_properties.json主要用来设置包含头文件的路径,设置C/C++支持的版本号等等。
点击Ctrl + Shift +P 弹出命令搜索框,选择C/C++: 编辑配置 (UI) 即可生成c_cpp_properties.json文件,此文件同样包含在.vscode文件夹中。
⭐其中主要的变量名称和涵义如下:
{ "configurations": [ { "name": "Linux", //配置名称,默认为系统名,可以自行更改 "includePath": [ //运行项目包含.h头文件的目录, "${workspaceFolder}/**"//此处会匹配工作文件下的所有文件 ], //添加"compilerPath"后,系统include路径可不写明 "defines": [], "compilerPath": "/usr/bin/gcc", //编译器的路径 "cStandard": "gnu17", //C标准的版本 "cppStandard": "gnu++14", //C++标准的版本 "intelliSenseMode": "gcc-x64" //IntelliSense的一些配置,默认即可 } ], "version": 4 }
也可参考 c_cpp_properties.json 官方文档 设置更多内容。
在上述三个配置文件中,我们会看到类似于 ${workspaceFolder} 等等类似的描述,这些描述是VS Code预定义变量名,可以用来代指工作目录的路径,环境变量的名称,生成文件的名称等等。使用这些预定义的变量名可以使得我们的开发过程更加高效,同时可移植性也大大增强。更多内容可以参照官方文档 Variables Reference。
${workspaceFolder} - VS Code当前打开工作区文件夹的路径 ${file} - 当前打开文件的绝对路径 ${fileBasename} - 当前打开文件的名称 ${fileBasenameNoExtension} - 当前打开文件的名称,但是不加后缀名 ${fileDirname} - 文件所在的文件夹路径
4. Makefile
如果不使用VS Code运行编译,我们还可以使用Makefile文件来管理项目的编译。Makefile文件描述了 Linux 系统下 C/C++ 工程的编译规则,它用来自动化编译 C/C++ 项目。一旦写编写好 Makefile 文件,只需要一个make命令,整个工程就开始自动编译,不再需要手动执行GCC命令。
Makefile是如何工作的呢?Makefile的基本结构如下:
target... : prerequisites ...
command
.............
target是目标文件,可以是.o文件,可执行文件,还可以是一个标签(伪目标,如all / clean等);
prerequisites是要生成target文件所依赖的文件或是目标;
command是make需要执行的shell命令,需另起一行并包含一个制表符<TAB>;
基本规则是这样的:一般情况下,在命令行输入make时,程序会自动检测目录下的Makefile文件,将第一个target作为最终的目标文件,依次检测target和prerequisites的依赖关系,判断prerequisites文件的修改时间,若晚于target修改,make就会执行command命令。
以menu实验lab7中的Makefile为例:
1 CC_PTHREAD_FLAGS = -lpthread 2 CC_FLAGS = -c 3 CC_OUTPUT_FLAGS = -o 4 CC = gcc 5 RM = rm 6 RM_FLAGS = -f 7 8 TARGET = test 9 OBJS = linktable.o menu.o test.o 10 11 all: $(OBJS) 12 $(CC) $(CC_OUTPUT_FLAGS) $(TARGET) $(OBJS) 13 14 .c.o: 15 $(CC) $(CC_FLAGS) $< 16 17 clean: 18 $(RM) $(RM_FLAGS) $(OBJS) $(TARGET) *.bak
1~9行定义了一些变量名,类似于C语言中的宏定义。
我们首先在命令行输入make clean(这里的"clean"就是一个伪目标,并且不含prerequisites,make就不会自动去找它的依赖文件,也不会执行它后面的shell命令。因此,要执行clean就需要显式的指出make clean),make会执行第18行命令 $(RM) $(RM_FLAGS) $(OBJS) $(TARGET) *.bak 这样一个命令,即删除所有的中间文件及可执行文件,使得项目回到初始的纯净状态。
当我们输入make命令时,make会首先找到第一个目标“all”,$(OBJS)包含的.o文件并没有生成,此时,14/15行代码的命令执行,找到对应的.c文件,从而生成$(OBJS)中的.o文件,进而执行第12行命令 $(CC) $(CC_OUTPUT_FLAGS) $(TARGET) $(OBJS) 生成名为task的可执行文件。
作为工程源码的一部分,Makefile也要经常进行维护,在编写时也有很多技巧。良好的Makefile文件可以增加程序的可移植性,提升抽象程度和易用性。
更多关于Makefile的规则和用法,可以参照ubuntu官方wiki文档:跟我一起写Makefile
二、代码中的软件工程思想
lab1 - 生长的代码
首先明确我们的目标:“完成一个通用的命令行的菜单子系统,便于在不同项目中重用”。
这个目标看起来挺多唬人的名词,又是菜单子系统又是不同项目重用,但明智的软件开发方法 (smart software development) 教导我们:软件必须从一个小的可运行的 skinny system 开始,逐渐充实生长称为 full-fledge 的成熟系统。
基于这种思想,我们应秉持一个basic idea,那就是:通过不断的软件迭代,一步步地添加调试我们想要实现的功能,从而实现我们的最终目标。不断的迭代不仅可以使得我们及时地发现代码中的bug,也便于在开发过程中对程序的结构进行调整,不至于面对 shit mountain 重构无门。
根据我们最终目标的关键词“菜单”,我们可以写出这样一段伪代码:
1 int main() 2 { 3 while(true) 4 { 5 scanf(cmd); 6 int ret = strcmp(cmd, "help"); 7 if(ret == 0) 8 { 9 dosth(); 10 } 11 int ret = strcmp(cmd, "others"); 12 if(ret == 0) 13 { 14 dosth(); 15 } 16 } 17 }
这段伪代码构建了menu程序的雏形,提供了一个 “输入命令 >> 得到反馈” 的程序思路。确立了基本路线,我们接下来的程序设计也将按照这个基本思路展开。
lab2 - 代码风格与注释
千里之行,始于足下。我们首先从实现上述基本的 skinny system 开始,于是有了我们第一版的代码:
/**************************************************************************************************/ /* Copyright (C) mc2lab.com, SSE@USTC, 2014-2015 */ /* */ /* FILE NAME : menu.c */ /* PRINCIPAL AUTHOR : Mengning */ /* SUBSYSTEM NAME : menu */ /* MODULE NAME : menu */ /* LANGUAGE : C */ /* TARGET ENVIRONMENT : ANY */ /* DATE OF FIRST RELEASE : 2014/08/31 */ /* DESCRIPTION : This is a menu program */ /**************************************************************************************************/ /* * Revision log: * * Created by Mengning, 2014/08/31 * */ #include <stdio.h> #include <stdlib.h> 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"); } } }
这一段内容将“输入命令”映射到三种情况,特定的输入可以得到特定反馈,实现了菜单的一些必备功能:帮助,退出,错误提示。
我们可以看到,每一段用来区分代码段的大括号都保持同样的风格(单独一行,缩进对应);每一行代码仅实现一个功能,且同样级别的代码使用相同的缩进级别;在一些操作符的前后添加了空格,在视觉效果上比较干净整洁;等等这些我们在写代码时保持的“习惯”被称作“代码风格”。
代码风格有三重境界:
一是规范整洁:不管使用哪种风格,都应严格遵守,并前后风格保持一致;
二是逻辑清晰:进入逻辑层面,就要考虑代码读者的理解力,可引入设计模式,在不同的层面进行逻辑的设计等,目的是要写出逻辑清晰,便于理解的代码;
三是优雅:这个就比较玄学了,以我现在的理解还无法给这一重境界下个定义,希望以后我会回来填上这个坑,哈哈~
一般来讲,我们对代码风格的基本原则要求是简明、易读、无二义性。
此外,这段代码最明显的改动是加入了注释(包括头部注释,函数注释和修订记录等)
注释作为代码的一部分,也是需要遵守一定的“风格”:如尽量用简练的语言表达核心的功能,使用英文进行编写,保持源代码是ASCII字符格式等。
在注释内容方面,头部对文件的整体描述,根据我们的需要应含有版权、作者、版本、描述等相关信息 ;对于代码本身的注释,应减少对代码逻辑本身的分析,尽量从更高层面(业务层面)解释程序的作用。
lab3.1&&3.2 - 初步抽象之模块化
对软件的不断迭代重构,要把握最基本的原则——抽象
在lab1中我们确定了菜单程序的基本思路:“输入-应答”,lab2中的代码实现"help" "quit"有着相似的pattern,我们可以将这种pattern抽象出来,使用数据结构tDataNode管理输入命令和信息反馈,从而简化代码:
1 int Help(); 2 int Quit(); 3 4 typedef struct DataNode 5 { 6 char* cmd; 7 char* desc; 8 int (*handler)(); 9 struct DataNode *next; 10 } tDataNode; 11 12 tDataNode* FindCmd(tDataNode * head, char * cmd); 13 int ShowAllCmd(tDataNode * head); 14 15 static tDataNode head[] = 16 { 17 {"help", "this is help cmd!", Help,&head[1]}, 18 {"version", "menu program v1.0", NULL, &head[2]}, 19 {"quit", "Quit from menu", Quit, NULL} 20 };
tDataNode中的cmd用于存储用户输入的命令,help和quit被抽象为函数,作为输入作为命令对应的handler(在抽象出的数据结构中引入函数指针,抽象之抽象);在下方,我们定义了一个静态数组,用于保存我们预设的命令组合;
有了数据结构来简化逻辑,在main函数中,我们就可以摆脱具体命令的干扰,专心设计通用的“输入-应答”菜单逻辑:
1 #define CMD_MAX_LEN 128 2 3 //不依赖具体命令的应答逻辑 4 char cmd[CMD_MAX_LEN]; 5 scanf("%s", cmd); 6 tDataNode *p = FindCmd(head, cmd); 7 ......
我们的初级抽象使得程序的可维护性和可读性有了一定的提升:修改命令长度上限仅需修改宏定义,一个应答函数的bug不会影响别的应答内容,新增命令仅需在head数组中添加内容。。。我们一般将这些特性称作模块化。
模块化(Modularity)是在软件系统设计时保持系统内各部分相对独立,以便每一个部分可以被独立地进行设计和开发。模块化的思想也可被替换为另一个更加广为人知的表述:“分而治之”。
模块化的程度是评价软件设计好坏的一个重要指标,一般我们使用耦合度(Coupling)和内聚度(Cohesion)来衡量软件模块化的程度。其中:
- 耦合度是指软件模块之间的依赖程度,一般可以分为紧密耦合(Tightly Coupled)、松散耦合(Loosely Coupled)和无耦合(Uncoupled)。一般在软件设计中我们追求松散耦合。
- 内聚度是指一个软件模块内部各种元素之间的依赖程度。理想的内聚是功能内聚,即一个软件模块只做一件事,只完成一个主要功能点或者一个软件特性(Feather)。
目前来看,我们使用 数据结构和命令操作函数,将 具体命令 与菜单的 “输入-应答”逻辑 剥离开来,实现了初步的模块化。但各函数和数据结构之间的耦合度仍然很高,如何进一步提升菜单程序的模块化程度呢?
lab3.3 - 进阶抽象之接口设计
在版本迭代过程中对项目不断进行合理地抽象重构,软件中的各模块必然会走向通用,从而达成低耦合高内聚的良好模块化设计。
在之前的几版菜单程序中,我们仅使用单个 “menu.c” 文件来实现。随着版本的不断迭代,单个文件的代码量越来越大,不利于开发者的管理,也不利于使用者的阅读。此外,我们从lab2一路走来,实现了初步的模块化设计,此时我们的改进方向就应该是进一步的“高内聚,低耦合”。因此在这一版代码中,我们将数据结构和它对应的操作实现放在 "linklist.h" 和 "linklist.c"。此时我们的main函数从lab3.2的107行降至68行,并且在"menu.c"中看不到数据结构的实现,在数据结构的实现中看不到有关菜单的信息,专注做一个单纯的链表,实现业务和底层代码的分离:
其中"linklist.h"的代码如下:
1 /**************************************************************************************************/ 2 /* Copyright (C) mc2lab.com, SSE@USTC, 2014-2015 */ 3 /* */ 4 /* FILE NAME : linklist.h */ 5 /* PRINCIPAL AUTHOR : Mengning */ 6 /* SUBSYSTEM NAME : menu */ 7 /* MODULE NAME : linklist */ 8 /* LANGUAGE : C */ 9 /* TARGET ENVIRONMENT : ANY */ 10 /* DATE OF FIRST RELEASE : 2014/09/10 */ 11 /* DESCRIPTION : linklist for menu program */ 12 /**************************************************************************************************/ 13 14 /* 15 * Revision log: 16 * 17 * Created by Mengning, 2014/09/10 18 * 19 */ 20 21 22 /* data struct and its operations */ 23 24 typedef struct DataNode 25 { 26 char* cmd; 27 char* desc; 28 int (*handler)(); 29 struct DataNode *next; 30 } tDataNode; 31 32 /* find a cmd in the linklist and return the datanode pointer */ 33 tDataNode* FindCmd(tDataNode * head, char * cmd); 34 /* show all cmd in listlist */ 35 int ShowAllCmd(tDataNode * head);
上述操作便是接口设计。
接口就是互相联系的双方共同遵守的一种协议规范,在我们软件系统内部一般的接口方式是通过定义一组API函数来约定软件模块之间的沟通方式。换句话说,接口具体定义了软件模块对系统的其他部分提供了怎样的服务,以及系统的其他部分如何访问所提供的服务。如下lab3.3的组织示意图:
接口设计是模块化思想的体现,目的是向代码的读者屏蔽不必要的信息(信息隐藏),接口设计本质上也是一种抽象。
lab4 - 接口设计原则
在引入接口后,我们对程序的维护工作便可分为两大块:接口的设计和接口的实现。我们在设计接口时一般遵循接口规格的五个基本要素:
- 接口的目的;
- 接口使用前所需要满足的条件,一般称为前置条件或假定条件;
- 使用接口的双方遵守的协议规范;
- 接口使用之后的效果,一般称为后置条件;
- 接口所隐含的质量属性。
我们以lab4中的 "linktable.h" 中的“GetLinkTableHead”为例:
1 /* 2 * get LinkTableHead 3 */ 4 tLinkTableNode * GetLinkTableHead(tLinkTable *pLinkTable);
目的:接口名称GetLinkTableHead很好地解释了接口的目的,即获得链表头节点,这同时也是良好代码风格的体现;
前置条件:入口参数中的 "tLinkTable *pLinkTable" 要求我们输入一个对应格式的链表;
协议规范:此处即为数据结构 "tLinkTableNode" "tLinkTable" 的定义;
后置条件:通过目的和返回值,我们知道接口的后置条件为能够接受 "tLinkTableNode" 类型节点指针的语句;
质量属性:针对获取节点这项任务,我们可以从运行时间、鲁棒性等方面进行考虑,并在接口实现过程中进行关注。
值得注意的是,除了进一步增加接口和接口的实现,lab4还增加了对程序的测试模块 "test.c" 和 "testlinktable.c" 。
编写高质量的代码要求我们不断发现bug,进行debug。在编程的实践中,程序的主要功能(80%的工作)大约仅用20%时间,而错误处理(20%的工作)却要80%的时间。
lab5.1&&5.2 - 设计可重用的接口
随着软件模块耦合度的不断下降,我们在lab5.1中引入了可重用软件设计的概念。重用又分为消费者重用和生产者重用,两种重用方式根据需求的不同有不同的准则:
消费者重用 |
该软件模块是否能满足项目所要求的功能; 采用该软件模块代码是否比从头构建一个需要更少的工作量,包括构建软件模块和集成软件模块等相关的工作; 该软件模块是否有完善的文档说明; 该软件模块是否有完整的测试及修订记录; |
生产者重用 |
通用的模块才有更多重用的机会; 给软件模块设计通用的接口,并对接口进行清晰完善的定义描述; 记录下发现的缺陷及修订缺陷的情况; 对用到的数据结构和算法要给出清晰的文档描述; 与外部的参数传递及错误处理部分要单独存放易于修改;等等。。 |
让我们暂且作为通用menu菜单程序的生产者,先重点考虑生产者重用的内容。在最新版的程序中,我们引入了 "Callback" 方式的接口:
1 /* 2 * Search a LinkTableNode from LinkTable 3 * int Conditon(tLinkTableNode * pNode); 4 */ 5 tLinkTableNode * SearchLinkTableNode(tLinkTable *pLinkTable, int Conditon(tLinkTableNode * pNode));
"SearchLinkTableNode" 中的前置条件要求我们准备一个指向链表的指针,和返回值为节点指针的函数。这种将函数作为参数的方式就叫 "Callback" 方式。
"Callback" 方式的引入使Linktable的查询接口更加通用:"SearchLinkTableNode" 中功能被进一步抽象,将搜索规则与搜索本身剥离开,有效地提高了接口的可重用性, 给了接口“消费者”更大的自由度。
我们还通过将linktable.h中不是在接口调用时必须内容转移到linktable.c中,有效地隐藏软件模块内部的实现细节,为外部调用接口的开发者提供更加简洁的接口信息,同时也减少外部调用接口的开发者有意或无意的破坏软件模块的内部数据。
在lab5.2中,除了在接口中该用void类型的指针参数进一步降低模块之间的耦合,增加实现的灵活度,我们还引入了第一章介绍了Makefile文件,允许我们在menu软件系统的层面隐藏掉底层的代码实现信息,是在menu子系统高度的可重用接口设计。
lab7.1&&7.2 - 可重入和线程安全
当我们并发地打开多个menu程序时,有可能会由于线程之间共享的全局变量/静态变量而导致数据的错乱。这样就出现了线程安全问题。
编写可重入的函数,有助于消除部分线程安全问题。可重入(reentrant)函数可以由多于一个任务并发使用,而不必担心数据错误。相反,不可重入(non-reentrant)函数不能由超过一个任务所共享,除非能确保函数的互斥(或者使用信号量,或者在代码的关键部分禁用中断)。可重入的函数不一定是线程安全的,不可重入的函数一定不是线程安全的。
类似操作系统的读写问题,我们可以根据软件各模块中可能会出现的临界资源访问,在合适的位置上锁,从而保证整个
1 #include <pthread.h> 2 3 /* 4 * LinkTable Type 5 */ 6 struct LinkTable 7 { 8 tLinkTableNode *pHead; 9 tLinkTableNode *pTail; 10 int SumOfNode; 11 pthread_mutex_t mutex; 12 13 }; 14 15 16 /* 17 * Create a LinkTable 18 */ 19 tLinkTable * CreateLinkTable() 20 { 21 tLinkTable * pLinkTable = (tLinkTable *)malloc(sizeof(tLinkTable)); 22 if(pLinkTable == NULL) 23 { 24 return NULL; 25 } 26 pLinkTable->pHead = NULL; 27 pLinkTable->pTail = NULL; 28 pLinkTable->SumOfNode = 0; 29 pthread_mutex_init(&(pLinkTable->mutex), NULL); //创建互斥锁 30 return pLinkTable; 31 } 32 33 /* 34 * Delete a LinkTable 35 */ 36 int DeleteLinkTable(tLinkTable *pLinkTable) 37 { 38 if(pLinkTable == NULL) 39 { 40 return FAILURE; 41 } 42 while(pLinkTable->pHead != NULL) 43 { 44 tLinkTableNode * p = pLinkTable->pHead; 45 pthread_mutex_lock(&(pLinkTable->mutex)); //上锁 46 pLinkTable->pHead = pLinkTable->pHead->pNext; //临界资源操作 47 pLinkTable->SumOfNode -= 1 ; 48 pthread_mutex_unlock(&(pLinkTable->mutex)); //解锁 49 free(p); 50 } 51 pLinkTable->pHead = NULL; 52 pLinkTable->pTail = NULL; 53 pLinkTable->SumOfNode = 0; 54 pthread_mutex_destroy(&(pLinkTable->mutex)); //释放锁 55 free(pLinkTable); 56 return SUCCESS; 57 }
除了线程安全的实现,在最终版的lab7中,我们的主程序 "menu.c" 文件也引入了接口 "menu.h" ,参照main函数参数的写法,将菜单程序做了抽象。这种操作是在menu子系统层面做了可重用的设计。
参考资料: