代码中的软件工程

前言

本文基于孟宁老师的上课内容、参考 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 个问题:

  1. 输入字符串 cmd 不仅与函数 strcmp 存在耦合,还与 if-else 语句中的代码存在耦合。
  2. 函数 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}
};

分析代码:

  1. lab2 的输入字符串 cmd 被重新定义成了链表形式的数据结构 DataNode。
  2. 数据结构 DataNode 包含输入、注释、操作函数以及下一输入。
  3. 每个 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);
}

分析代码:

  1. lab2 的 if-else 语句中的代码在上述代码中被封装成了两个独立的函数。
  2. 数据结构和相应操作与具体的菜单业务处理进行分离处理,虽然还是在同一个源代码文件中,但是已经在逻辑上做了切分,可以认为有了初步的模块化。

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;
}

对比两段代码:

  1. 匹配 cmd 的过程被封装为独立函数。
  2. 模块化设计之后,我们往往将设计的模块与实现的源代码文件有个映射对应关系,因此我们需要将数据结构和它的操作独立放到单独的源代码文件中,这时就需要设计合适的接口,以便于模块之间互相调用,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 个基本要素:

  1. 接口目标:如函数名 GetLinkTableHead 清晰明确地表明了接口目标,即从链表中取出链表的头节点
  2. 接口前置条件:链表必须存在,即 pLinkTable != NULL
  3. 接口协议规范:数据结构 tLinkTableNode 和 tLinkTable
  4. 接口后置条件:通过 tLinkTableNode 类型指针作为返回值来作为后置条件,即找到链表的头节点
  5. 接口质量属性:这里并未特别要求接口质量属性

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);
}

分析代码:

  1. int SearchCondition(tLinkTableNode * pLinkTableNode, void * args) 是回调函数,当匹配到传入的字符串 cmd 时,该回调函数向 call-in 方式的函数 SearchLinkTableNode 发送 SUCCESS。
  2. 当函数 SearchLinkTableNode 收到 SUCCESS 时,返回 cmd 数据结构的结点。
  3. 函数 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 为例:

  1. 临界区域指一块对公共资源进行访问的代码,并非一种机制或是算法。
    这里的临界区域是 pLinkTable->pTail->pNext = pNode;pLinkTable->pTail = pNode;
  2. 当多个线程对 pLinkTable 进行尾插修改时,需要加入互斥锁,保证写互斥。

总结

通过对 Menu 项目源码的阅读和分析,总结了软件设计的方法和原则:

  • 软件设计的方法论:迭代重构
  • 软件设计的六大原则:
    • Modularity 模块化
    • Interfaces 接口
    • Information hiding 封装
    • Incremental development 增量开发
    • Abstraction 抽象化
    • Generality 通用化
  • 设计软件系统时:
    • 第一步根据系统级、功能模块和代码级划分模块;
    • 第二步为这些模块设计接口,设计接口时应该注意封装;
    • 第三步根据模块之间的依赖关系确定增量开发顺序。
    • 在模块划分和接口设计时应该注意抽象化和通用化。
posted @ 2020-11-09 16:25  理实交融  阅读(231)  评论(0)    收藏  举报