代码中的软件工程-软件工程思想分析
本文以VS Code + GCC工具集为主要环境编译调试课程项目案例 https://github.com/mengning/menu ,并结合代码分析其中的软件工程思想。
课程中的内容没有过多叙述,主要是将一些我对课程内容的简单理解用通俗的语言表述出来。
编译与调试
环境配置
GCC
首先我们需要 GCC 来编译 C 语言代码,在 Linux 系统中,安装比较简单,如果是 Ubuntu,可以使用apt install gcc命令,如果是 CentOS,则可以使用yum install gcc。在 Windows 下,相对会复杂一点,我们需要下载安装 MinGW-w64。
💡 关于 MinGW-w64 和 MinGW
MinGW 的全称是:Minimalist GNU on Windows 。它实际上是将经典的开源 C 语言编译器 GCC 移植到了 Windows 平台下,并且包含了 Win32API ,用一句话概括:MinGW 就是 GCC 的 Windows 版本。
MinGW-w64 与 MinGW 的区别在于 MinGW 只能编译生成32位可执行程序,而 MinGW-w64 则可以编译生成 64位或32位可执行程序,并且是开源的。所以这里更推荐使用 MinGW-w64。
下载完成后,将压缩包解压,注意解压的路径不要包含空格、中文或其他特殊字符。找到gcc.exe文件所在的bin文件夹的路径,将其添加到 PATH 环境变量中。
VSCode
Visual Studio Code(简称 VSCode)是一个轻量且强大的代码编辑器,支持 Windows,OS X 和 Linux。内置JavaScript、TypeScript 和 Node.js 支持,而且拥有丰富的插件生态系统,可通过安装插件来支持 C++、C#、Python、PHP等其他语言。
想要使用 VSCode 开发 C/C++ 项目,需要进行以下配置。
- 在扩展商店安装 Microsoft C/C++ 扩展
- 配置
task.json - 配置
launch.json - 配置
c_cpp_propeties.json
接下来将详细介绍配置过程,如果您已经配置好环境,则可以跳过。
安装 Microsoft C/C++ 扩展
在 VSCode 内置的扩展商店中搜索并安装 C/C++ 插件。除此之外,Code Runner 插件也可以方便地运行 C/C++ 代码。
配置 task.json
task.json文件用于告诉 VSCode 如何构建 C/C++ 程序。
在顶部菜单栏中选择 终端->配置默认生成任务。
选择C/C++: gcc.exe build active file选项。
此时 VSCode 会在 .vscode目录下自动生成一个task.json文件,内容如下。
{
"version": "2.0.0",
"tasks": [
{
"type": "cppbuild",
"label": "C/C++: gcc.exe build active file",
"command": "D:\\Environment\\MinGW\\bin\\gcc.exe",
"args": [
"-g",
"${file}",
"-o",
"${fileDirname}\\${fileBasenameNoExtension}.exe"
],
"options": {
"cwd": "D:\\Environment\\MinGW\\bin"
},
"problemMatcher": [
"$gcc"
],
"group": {
"kind": "build",
"isDefault": true
},
"detail": "compiler: D:\\Environment\\MinGW\\bin\\gcc.exe"
}
]
}
配置 launch.json
launch.json文件用于配置调试环境
在顶部菜单栏选择 运行->添加配置,选择 C++(GDB/LLDB),此时列出了预定义的调试配置,再选择gcc.exe 生成和调试活动文件,此时会在 .vscode目录下自动生成一个launch.json文件,内容如下。
{
// 使用 IntelliSense 了解相关属性。
// 悬停以查看现有属性的描述。
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "gcc.exe - 生成和调试活动文件",
"type": "cppdbg",
"request": "launch",
"program": "${fileDirname}\\${fileBasenameNoExtension}.exe",
"args": [],
"stopAtEntry": false,
"cwd": "${workspaceFolder}",
"environment": [],
"externalConsole": false,
"MIMode": "gdb",
"miDebuggerPath": "D:\\Environment\\MinGW\\bin\\gdb.exe",
"setupCommands": [
{
"description": "为 gdb 启用整齐打印",
"text": "-enable-pretty-printing",
"ignoreFailures": true
}
],
"preLaunchTask": "C/C++: gcc.exe build active file"
}
]
}
配置 c_cpp_propeties.json
使用Ctrl+Shift+P快捷键打开命令面板,输入并执行C/C++: Edit Configuration(UI)命令,打开C/C++设置页面。在设置页面中的配置会同步到c_cpp_propeties.json文件中。
WSL
WSL(Windows Subsystem for Linux)是在 Windows 系统下使用 Linux 的一种解决方案。VSCode 的 Remote-WSL 插件可以直接调试 WSL 中的项目。详细使用说明请参考 https://docs.microsoft.com/zh-cn/windows/wsl/
构建项目
对于 Menu 的早期版本,项目结构比较简单,可以使用 gcc命令进行调试
$ gcc menu.c -o menu
$ ./menu

对于后期的复杂工程,可以使用make命令进行构建。

软件工程思想分析
模块化思想
模块化(Modularity)是在软件系统设计时保持系统内各部分相对独立,以便每个部分可以被独立地进行设计和开发。
这里我们总结一下 Menu 应用的模块化步骤:
- 数据结构与菜单业务逻辑的分离
- 模块之间源代码文件的分离
模块化思想要贯穿软件开发周期的始终,虽然看起来很容易实现,但又不容小觑。
可重用接口
在软件开发过程中, 有很多重复性的工作。为了避免重复造轮子,我们可以将一些需要复用的模块与其他软件模块之间解耦合,专注于做一件事,将功能内聚,同时定义简洁、清晰、明确的接口。一般来说,接口定义了一组对外开放的数据结构和对这些数据结构的操作。这里我们结合Linktable链表模块来分析可重用接口思想在软件工程中的应用。
Linktable 的重用
链表作为一种常见的数据结构,在很多场景下都会用到。我们首先看一下 Menu 小程序中对于链表这种数据结构的定义。
typedef struct DataNode
{
char* cmd;
char* desc;
int (*handler)();
struct DataNode *next;
} tDataNode;
上面这种定义方式的缺点是:将当前菜单应用所需的数据定义在了链表数据结构中。菜单应用与链表结构紧密耦合,这导致这段结构定义无法被其他模块复用。那么应该如何修改链表结构的定义呢?
typedef struct LinkTableNode
{
struct LinkTableNode * pNext;
} tLinkTableNode;
typedef struct LinkTable
{
tLinkTableNode *pHead;
tLinkTableNode *pTail;
int SumOfNode;
pthread_mutex_t mutex;
} tLinkTable;
// 链表的相关操作
tLinkTable * CreateLinkTable();
int DeleteLinkTable(tLinkTable *pLinkTable);
int AddLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);
int DelLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);
tLinkTableNode * GetLinkTableHead(tLinkTable *pLinkTable);
tLinkTableNode * GetNextLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);
现在链表结构的定义中只包含链表的相关数据,不再包含其他信息。同时,链表的相关操作,也只是针对链表的数据结构。
那么问题又来了,其他模块例如 Menu 该如何使用链表并将自己所需要的数据嵌入链表之中呢?
熟悉面向对象编程的同学应该能够想到继承。通过继承可以对原有的结构/类进行扩展。如果是使用其他支持 OOP 的语言,这里只需要继承链表类就好了。但是 C 语言是一种面向过程的语言,并不支持继承。这里我们可以用一种巧妙的方式来实现类似继承的效果。
使 C 语言中的结构体能够继承,需要利用结构体中成员在内存中的顺序与定义顺序一致的特性。
typedef struct DataNode
{
tLinkTableNode * pNext;
char* cmd;
char* desc;
int (*handler)();
} tDataNode;
当前结构体中的第一个成员是一个指向tLinkTableNode类型的指针,当DataNode类型的指针被强制转换成一个tLinkTableNode类型的指针时,其第一个成员也是pNext,即一个tLinkTableNode类型的指针。此时这两个类型的指针指向同一块内存,只不过这两种类型表示的内存大小不同,对成员的解释不同。通过这种方式,我们就可以将DataNode用到tLinkTableNode的操作中了,从而间接实现了继承。这里的指的继承不是严格意义上的继承,而是一种类似于嵌入的方式,只是实现了结构的扩展。
这样,我们就将链表接口从菜单应用中独立出来了。
Callback
Callback,即回调函数,是一个通过函数指针调用的函数。把函数的指针(地址)作为参数传递给另一个函数,当这个指针调用其所指向的函数时,就称这是回调函数。回调函数不是该函数的实现方直接调用,而是在特定的事件或条件发生时由另外的一方调用的,用于对该事件或条件进行响应。
回调函数提供了很强的灵活性,在函数式编程中,回调也起到了至关重要的作用。例如很多编程语言标准库中的排序(sort)函数,都支持通过回调函数的方式指定排序规则,这样可以使排序接口适用于更加丰富的场景。在有λ表达式语法的编程语言中,Callback 能展示出更大的作用。
下面我们先以 C++ 的排序函数为例,假如我们要对二维坐标Point排序
struct Point
{
int x, y;
};
第一种方法:利用普通回调函数,将比较器函数当作参数传入sort函数中。
bool compare(const Point &a, const Point &b)
{
return a.x < b.x || (a.x == b.x && a.y < b.y);
}
void SortPoint1(vector<Point> &points)
{
sort(points.begin(), points.end(), compare);
}
第二种方法:传入一个λ表达式
void SortPoint2(vector<Point> &points)
{
sort(points.begin(), points.end(),
[](auto p1, auto p2) -> bool { return p1.x < p2.x || (p1.x == p2.x && p1.y < p2.y); });
}
由此可见,只要编写回调函数,即可实现任意类型任意规则的排序。
接下来,再看 Menu 应用中的例子
tLinkTableNode * SearchLinkTableNode(tLinkTable *pLinkTable, int Conditon(tLinkTableNode * pNode))
{
if(pLinkTable == NULL || Conditon == NULL)
{
return NULL;
}
tLinkTableNode * pNode = pLinkTable->pHead;
while(pNode != pLinkTable->pTail)
{
if(Conditon(pNode) == SUCCESS)
{
return pNode;
}
pNode = pNode->pNext;
}
return NULL;
}
在这里,Condition函数指针即为注册的回调函数,利用 Callback 机制可以灵活地指定链表搜索的条件,提高了接口的可重用性。
在多线程编程中,回调还可以分为阻塞式回调(同步回调)和延迟式回调(异步回调),有的编程语言的代码中会大量运用延迟式回调(例如 Javascript)。
可重入函数与线程安全
什么是线程安全
这里举一个 Golang 的小例子来说明什么是线程安全。
func main() {
count := 0
for i := 0; i < 2; i++ {
go func() {
for j := 0; j < 100000; j++ {
count++
}
}()
}
time.Sleep(2 * time.Second)
fmt.Println(count) // < 200,000
}
上面的程序功能是创建两个线程分别对一个计数器做 100,000 次加一操作,我们期望的最终计数器结果应该是 200,000,然而实际上得到的结果总是会小于 200,000,原因是两个线程会同时对同一变量即同一块内存区域进行读写。
要保证线程安全,即是要避免这种情况发生。其中一种解决方案是对临界区进行加锁操作,使得可重入函数不能同时进入临界区。于是可以修改上面的代码。
func main() {
count := 0
var mu sync.Mutex
for i := 0; i < 2; i++ {
go func() {
for j := 0; j < 100000; j++ {
mu.Lock()
count++
mu.Unlock()
}
}()
}
time.Sleep(2 * time.Second)
fmt.Println(count) // = 200,000
}
这次结果就一定是 200,000了。现在我们再继续回到 Menu 应用中来,下面是创建链表节点的部分,其实和刚刚的例子是同样的道理。
int AddLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode)
{
if(pLinkTable == NULL || pNode == NULL)
{
return FAILURE;
}
pNode->pNext = NULL;
// 加锁
pthread_mutex_lock(&(pLinkTable->mutex));
if(pLinkTable->pHead == NULL)
{
pLinkTable->pHead = pNode;
}
if(pLinkTable->pTail == NULL)
{
pLinkTable->pTail = pNode;
}
else
{
pLinkTable->pTail->pNext = pNode;
pLinkTable->pTail = pNode;
}
pLinkTable->SumOfNode += 1 ;
// 释放锁
pthread_mutex_unlock(&(pLinkTable->mutex));
return SUCCESS;
}
线程安全分析
怎么样进行一个软件模块的线程安全分析呢?
-
首先要逐一分析所有的函数是不是都是可重入函数。
- 函数有没有访问临界资源(全局变量、静态存储区等),如果有访问临界资源的话需要仔细分析竞争互斥的处理能不能有效避免临界资源冲突问题。
- 对于不可重入函数要具体分析其对线程安全带来的影响,有没有潜在的破坏性。实际上软件测试技术及软件质量保证体系的发展已经无奈地默许软件缺陷的存在。
-
然后进一步研究不同的可重入函数有没有可能同时进入临界区。
- 写互斥。
- 读写互斥。
更多内容可参考孟宁老师的公众号读行学

浙公网安备 33010602011771号