代码中的软件工程
前言
本文基于孟宁老师的上课内容、参考 PPT 和 Menu 项目的实验代码,深刻体会和思考了软件工程中的思想 —— 模块化设计、可重用设计和线程安全。
Menu 项目代码链接:https://github.com/mengning/menu
一、环境配置
1.1 安装编译器和插件
实验环境 Ubuntu 18.04,安装 gcc、gdb、make 和 Vscode C/C++ 插件。
- gcc、gdb、make
sudo apt install build-essential
- Vscode C/C++ 插件
1.2 配置文件
- 编译文件 tasks.json
{
"version": "2.0.0",
"tasks": [
{
"type": "shell",
"label": "gcc build active file",
"command": "/usr/bin/gcc",
"args": [
"-g",
"${file}",
"-o",
"${fileDirname}/${fileBasenameNoExtension}"
],
"options": {
"cwd": "/usr/bin"
},
"problemMatcher": [
"$gcc"
],
"group": "build"
}
]
}
- 调试文件 launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "gcc - 生成和调试活动文件",
"type": "cppdbg",
"request": "launch",
"program": "${fileDirname}/${fileBasenameNoExtension}",
"args": [],
"stopAtEntry": false,
"cwd": "${workspaceFolder}",
"environment": [],
"externalConsole": false,
"MIMode": "gdb",
"setupCommands": [
{
"description": "为 gdb 启用整齐打印",
"text": "-enable-pretty-printing",
"ignoreFailures": true
}
],
"preLaunchTask": "gcc build active file",
"miDebuggerPath": "/usr/bin/gdb"
}
]
}
1.3 测试
编译运行 lab1/hello.c ,结果如下:
1.4 编译运行 Menu
使用 make 命令编译运行 menu-master 项目
~/myTest > cd menu-master
~/myTest/menu-master > make
gcc -c test.c
gcc -o test linktable.o menu.o test.o
~/myTest/menu-master > ./test
二、模块化软件设计
2.1 基本原理
- 模块化(Modularity):软件系统设计时保持系统内各部分相对独立,以便每一部分可以被独立地进行设计和开发。
- 关注点的分离(SoC):软件工程领域最重要的原则,习惯上称为模块化,也是“分而治之”的中文表述。
- 根源:人脑处理复杂问题时容易出错,因此将复杂问题分解成简单问题,从而减少出错的情形。
- 耦合度(Coupling):软件模块之间的依赖程度,分为紧密耦合、松散耦合和无耦合。
- 内聚度(Cohesion):软件模块内部元素之间互相依赖的紧密程度。
- 功能内聚:最高层次内聚,软件模块中的各机能对模块中单一明确定义的任务有贡献。
2.2 main 函数的演变
1) 发现问题
查看 lab2/menu.c 中的代码:
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");
}
}
发现 2 个问题:
- 输入字符串 cmd 不仅与函数 strcmp 存在耦合,还与 if-else 语句中的代码存在耦合。
- 函数 strcmp 重复使用了 2 次。
2) 解决问题一
通过重新定义输入字符串 cmd 的数据结构,将 if-else 语句中的代码封装为独立函数,从而实现数据与操作的解耦。
查看 lab3.1/menu.c 中的代码:
typedef struct DataNode
{
char *cmd; // 输入
char *desc; // 注释
int (*handler)(); // 操作函数
struct DataNode *next; // 下一输入
} tDataNode;
static tDataNode head[] =
{
{"help", "this is help cmd!", Help, &head[1]},
{"version", "menu program v1.0", NULL, &head[2]},
{"quit", "Quit from menu", Quit, NULL}
};
分析代码:
- lab2 的输入字符串 cmd 被重新定义成了链表形式的数据结构 DataNode。
- 数据结构 DataNode 包含输入、注释、操作函数以及下一输入。
- 每个 DataNode 存放在数组 head 中,目的是方便直接读取输入字符串。
int Help()
{
printf("Menu List:\n");
tDataNode *p = head;
while (p != NULL)
{
printf("%s - %s\n", p->cmd, p->desc);
p = p->next;
}
return 0;
}
int Quit()
{
exit(0);
}
分析代码:
- lab2 的 if-else 语句中的代码在上述代码中被封装成了两个独立的函数。
- 数据结构和相应操作与具体的菜单业务处理进行分离处理,虽然还是在同一个源代码文件中,但是已经在逻辑上做了切分,可以认为有了初步的模块化。
3) 解决问题二
将 strcmp 的操作过程封装为独立函数,抽取为一个模块。
在 lab3.1/menu.c 中问题二仍未被解决,strcmp 的操作过程仍未被封装成独立函数。
// lab3.1/menu.c main 函数中的 strcmp 操作过程
while (p != NULL)
{
if (strcmp(p->cmd, cmd) == 0)
{
printf("%s - %s\n", p->cmd, p->desc);
if (p->handler != NULL)
{
p->handler();
}
break;
}
p = p->next;
}
查看 lab3.2/menu.c 中的代码:
// lab3.2/menu.c 封装 strcmp 操作过程
tDataNode *FindCmd(tDataNode *head, char *cmd)
{
if (head == NULL || cmd == NULL)
{
return NULL;
}
tDataNode *p = head;
while (p != NULL)
{
if (strcmp(p->cmd, cmd) == 0)
{
return p;
}
p = p->next;
}
return NULL;
}
对比两段代码:
- 匹配 cmd 的过程被封装为独立函数。
- 模块化设计之后,我们往往将设计的模块与实现的源代码文件有个映射对应关系,因此我们需要将数据结构和它的操作独立放到单独的源代码文件中,这时就需要设计合适的接口,以便于模块之间互相调用,lab3.3 就是这么做的。
三、可重用软件设计
3.1 基本概念
-
接口(Interface):互相联系的双方共同遵守的一种协议规范,一般通过定义一组 API 函数来约定模块之间的沟通方式。
- 接口具体定义了模块为系统其他部分提供了怎样的服务,系统其他部分该如何访问模块提供的服务。
- 在面向过程编程中,接口定义了数据结构及操作数据结构的函数。
- 在面向对象编程中,接口是对象对外开放的一组属性和方法集合。
注:两类编程语言的接口虽形式上不同,但本质上都是封装独立函数。
3.2 链表接口的设计
第二部分模块化软件设计中,lab3 对 lab2 做了模块化设计的优化,但是其定义的数据结构和操作数据结构的函数与具体实现存在耦合。若我们想进一步优化,就必须设计关于链表操作的接口,以达到可重用链表操作的目的。
以下是 lab4 对 lab3 进行的优化,编写了 linkedtable 接口,其包含两个数据结构和一组操作数据结构的函数。
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);
分析代码:
以
tLinkTableNode * GetLinkTableHead(tLinkTable *pLinkTable);为例,其包含了以下 5 个基本要素:
- 接口目标:如函数名 GetLinkTableHead 清晰明确地表明了接口目标,即从链表中取出链表的头节点
- 接口前置条件:链表必须存在,即 pLinkTable != NULL
- 接口协议规范:数据结构 tLinkTableNode 和 tLinkTable
- 接口后置条件:通过 tLinkTableNode 类型指针作为返回值来作为后置条件,即找到链表的头节点
- 接口质量属性:这里并未特别要求接口质量属性
3.3 增加回调函数
查看 lab5.2/linktable.c 的代码:
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;
}
// 回调函数
int SearchCondition(tLinkTableNode * pLinkTableNode, void * args)
{
char * cmd = (char*) args;
tDataNode * pNode = (tDataNode *)pLinkTableNode;
if(strcmp(pNode->cmd, cmd) == 0)
{
return SUCCESS;
}
return FAILURE;
}
tDataNode* FindCmd(tLinkTable * head, char * cmd)
{
return (tDataNode*)SearchLinkTableNode(head,SearchCondition,(void*)cmd);
}
分析代码:
int SearchCondition(tLinkTableNode * pLinkTableNode, void * args)是回调函数,当匹配到传入的字符串 cmd 时,该回调函数向 call-in 方式的函数 SearchLinkTableNode 发送 SUCCESS。- 当函数 SearchLinkTableNode 收到 SUCCESS 时,返回 cmd 数据结构的结点。
- 函数 FindCmd 调用了 SearchLinkTableNode。
四、可重入函数与线程安全
4.1 基本原理
-
线程(thread):操作系统能够进行运算调度的最小单位。
- 线程被包含在进程之中,是进程中的实际运作单位。
- 一条线程指进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
-
可重入(reentrant):函数可以由多个任务并发使用,且无需担心数据错误。因此可重入函数可以在任意时刻被中断,稍后再继续运行,不会丢失数据。
若一个函数是可重入的,则该函数应当满足下述条件:
- 不能含有静态(全局)非常量数据。
- 不能返回静态(全局)非常量数据的地址。
- 只能处理由调用者提供的数据。
- 不能依赖于单实例模式资源的锁。
- 调用(call)的函数也必需是可重入的。
-
线程安全:若代码所在的进程中有多个线程在同时运行,并且这些线程可能会同时运行这段代码,但每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的。
- 线程安全问题由全局变量和静态变量引起。
- 若每个线程中对全局变量只有读操作,无写操作,那么这个全局变量是线程安全。
- 若多个线程同时执行读写操作,需考虑线程同步,否则就可能影响线程安全。
-
函数可重入性与线程安全之间的关系
可重入的函数不一定是线程安全的,不可重入的函数一定不是线程安全的。
4.2 增加互斥锁
查看 lab7.1/linktable.c 的代码:
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;
}
分析代码,以增加 tLinkTable 为例:
- 临界区域指一块对公共资源进行访问的代码,并非一种机制或是算法。
这里的临界区域是pLinkTable->pTail->pNext = pNode;和pLinkTable->pTail = pNode;。- 当多个线程对 pLinkTable 进行尾插修改时,需要加入互斥锁,保证写互斥。
总结
通过对 Menu 项目源码的阅读和分析,总结了软件设计的方法和原则:
- 软件设计的方法论:迭代重构
- 软件设计的六大原则:
- Modularity 模块化
- Interfaces 接口
- Information hiding 封装
- Incremental development 增量开发
- Abstraction 抽象化
- Generality 通用化
- 设计软件系统时:
- 第一步根据系统级、功能模块和代码级划分模块;
- 第二步为这些模块设计接口,设计接口时应该注意封装;
- 第三步根据模块之间的依赖关系确定增量开发顺序。
- 在模块划分和接口设计时应该注意抽象化和通用化。

浙公网安备 33010602011771号