Loading

代码中的软件工程——menu项目分析

参考资料:

1. https://gitee.com/mengning997/se/blob/master/README.md#%E4%BB%A3%E7%A0%81%E4%B8%AD%E7%9A%84%E8%BD%AF%E4%BB%B6%E5%B7%A5%E7%A8%8B

2. https://github.com/mengning/menu

3. https://code.visualstudio.com/docs/cpp/config-mingw#_prerequisites

前言.

   经过孟老师的教导,在本次课程中我对代码的编译和成长过程进行了学习,同时也对代码的风格规范、通用的可重用接口设计以及线程安全等有了更深入的了解,借此机会,对孟老师的menu项目源码结合上课所学知识进行分析。

一. 编译和调试环境配置

 1. 安装VSCode并安装C/C++拓展

 VSCode安装,过程就不赘述了,C/C++扩展在VSCode左边的扩展(Ctrl + Shift + X)中安装。安装后的效果如下图所示:

  

2. 安装MinGW

 根据PPT中的网址去选择对应版本的MinGW下载,下载完成后将MinGW\bin文件夹添加至PATH环境变量。打开控制台输入:

g++ --version
gdb --version

 若出现下图所示的情况,即安装成功

3. 配置编译器和调试器

 首先创建一个Hello.c文件以用来配置编译器和调试器。在标题栏选择终端->配置默认生成任务,选择,然后会自动生成task.json文件。在VSCode左边一栏选   择运行,点击运行和调试  ,会出现,点击即可。可以看到控制台下面已经输出Hello World!

 

 至此准备工作完成,可以看到工作文件夹中多出了.vscode文件夹,里面有task.json文件和launch.json两个文件。

二. menu项目源码分析

 1.代码风格与规范

  代码风格的基本原则要求:简明、易读、无二义性。一千个观众眼中有一千个哈姆雷特,每个人都有自己的代码风格,但是我们并不可能一直单枪匹马地战斗,只有规范、易于理解的代码风格才能够减少一些不必要的语法错误以及在团队协作时事半功倍,也利于日后的维护。

  程序块头部注释

  每个源文件的头部应该有版权、作者、版本、描述等相关信息,对于程序的描述应该解释其是做什么的,为什么这么做以及注意事项,而无需介绍其工作原理。在注释中要是用英文以保持源码是ASCII字符格式文件。如下图所示是menu项目中menu.c源文件中的头部注释。

/**************************************************************************************************/
/* 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
 *
 */

  代码风格规范

  1.程序块需要进行缩进:4个空格

  2.代码行内应适当留空格,如运算符的前后应留空格,而对于表达式较长的循环与判断语句可以适当地去掉一些空格以保持紧凑

  3.函数体内,逻辑上密切相关的语句间不加空行,而逻辑上不相关的语句可以适当留空行

  4.花括号要独占一行且对齐

  5.复杂的表达式要用括号来表示优先级

  6.不要将多条语句和变量定义放在同一行

  命名规范

  1.类名、函数名、变量名等的命名一定要与程序里的含义保持一致,以便于阅读理解

  2.类型的成员变量通常用m_或者_来做前缀以示区别

  3.一般变量名、对象名等使用LowerCamel风格,即第一个单词首字母小写,之后的单词都首字母大写,第一个单词一般都表示变量类型,比如int型变量iCounter

  4.类型、类、函数名等一般都用Pascal风格,即所有单词首字母大写

  5.类型、类、变量一般用名词或者组合名词

  6.函数名一般使用动词或者动宾短语

   看如下linktable.c中的GetNextLinkTableNode函数就展示了良好的代码规范:在函数定义上方注释了该函数的作用,函数中的变量名也都采用了LowerCamel风格且其命名都与程序里的含义保持一致易于理解,每一个大括号( '{', '}' )均独占一行,每一个程序块间都进行了缩进。

/*
 * get next LinkTableNode
 */
tLinkTableNode * GetNextLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode)
{
    if(pLinkTable == NULL || pNode == NULL)
    {
        return NULL;
    }
    tLinkTableNode * pTempNode = pLinkTable->pHead;
    while(pTempNode != NULL)
    {    
        if(pTempNode == pNode)
        {
            return pTempNode->pNext;                    
        }
        pTempNode = pTempNode->pNext;
    }
    return NULL;
}

  2.模块化设计

  所谓模块化是在软件系统设计时保持系统内各部分相对地独立,以使得每一个部分都可以被独立地进行设计和开发。模块化软件设计使得每一个软件模块都专注一单一的功能目标,并相对独立于其他软件模块,使得每一个的软件模块都更易于理解、变更、维护。

  模块化程度常用耦合度与内聚度来区分:

  耦合度:软件模块间的依赖程度,有紧密耦合、松散耦合与无耦合。我们软件设计过程中的目标是松散耦合

  内聚度:一个软件模块内部各种元素间相互依赖的紧密程度

  模块化设计后设计的模块与实现的源代码文件间有个映射对应关系,因此我们需要将数据结构与它的操作独立地放到单独的源代码文件中。我们来看menu项目中的menu.c与linktable.h/linktable.c之间就存在这对应的关系,将业务代码与底层实现进行了分离,menu.c调用linktable.h/linktable.c中提供的接口

  linktable.h/linktable.c只对menu模块提供了底层的数据结构与相对应的操作,其对menu模块是如何操作的它是看不到的。menu模块只知道linktable.h/linktable.c对其提供了相应的操作接口,其并不直到底层数据结构的具体实现。如此一来,一个模块可以只专注单一的模块。

  具体地来看,下面menu.c中的FindCmd的代码和linktable.c中的GetLinktableHead和GetNextLinktableHead函数,FindCmd函数它调用GetLinktableHead和GetNextLinktableHead函数来进行查找命令的操作,但是它并不知道其中的得到链表头结点和依次查找链表是如何操作的。而GetLinktableHead和GetNextLinktableHead函数只是提供了它们的操作,而不知道上层的业务具体是什么。

 1 /* find a cmd in the linklist and return the datanode pointer */
 2 tDataNode* FindCmd(tLinkTable * head, char * cmd)
 3 {
 4     tDataNode * pNode = (tDataNode*)GetLinkTableHead(head);
 5     while(pNode != NULL)
 6     {
 7         if(!strcmp(pNode->cmd, cmd))
 8         {
 9             return  pNode;  
10         }
11         pNode = (tDataNode*)GetNextLinkTableNode(head,(tLinkTableNode *)pNode);
12     }
13     return NULL;
14 }
/*
 * get LinkTableHead
 */
tLinkTableNode * GetLinkTableHead(tLinkTable *pLinkTable)
{
    if(pLinkTable == NULL)
    {
        return NULL;
    }    
    return pLinkTable->pHead;
}

/*
 * get next LinkTableNode
 */
tLinkTableNode * GetNextLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode)
{
    if(pLinkTable == NULL || pNode == NULL)
    {
        return NULL;
    }
    tLinkTableNode * pTempNode = pLinkTable->pHead;
    while(pTempNode != NULL)
    {    
        if(pTempNode == pNode)
        {
            return pTempNode->pNext;                    
        }
        pTempNode = pTempNode->pNext;
    }
    return NULL;
}

 3.可重用接口

  接口就是互相联系的双方共同遵守的一种协议规范,接口具体定义了软件模块对系统的其他部分提供了怎样的服务,以及系统的其他部分如何访问所提供的服务。

  接口的五个基本要素:1.接口的目的 2.接口使用前所需满足的条件,即前置条件或假定条件 3.使用接口的双方遵守的协议规范 4. 接口使用时候的效果,即后置条件 5. 接口所隐含的质量属性

  要想使得接口可重用,就需要定义通用的接口,通用接口定义的基本方法就是:参数化上下文、移除前置条件、简化后置条件。

  具体地来看menu项目中linktable.c的SearchLinkTableNode函数以及tLinkTableNode数据结构的定义还有menu.c中SearchCondition函数以及tDataNode数据结构的定义:


typedef struct LinkTableNode
{
    struct LinkTableNode * pNext;
}tLinkTableNode;

/*
* Search a LinkTableNode from LinkTable * int Conditon(tLinkTableNode * pNode); */ 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; }
typedef struct DataNode
{
    tLinkTableNode * pNext;
    char*   cmd;
    char*   desc;
    int     (*handler)(int argc, char *argv[]);
} tDataNode;

int SearchConditon(tLinkTableNode * pLinkTableNode,void * arg)
{
    char * cmd = (char*)arg;
    tDataNode * pNode = (tDataNode *)pLinkTableNode;
    if(strcmp(pNode->cmd, cmd) == 0)
    {
        return  SUCCESS;  
    }
    return FAILURE;           
}

  可以从上述的源码中看出,在链表结点中只定义了一个pNext指针,这就好比将链表定义为了一个"箱子",在这个"箱子"中我们可以存储任何类型的数据结构,可以是基本数据类型,也可以是复杂的结构体定义,在使用过程中只需要做好对应的类型转换就可以很好地进行使用,这就体现了接口的通用性。

  再来看SearchLinkTableNode函数和SearchCondition函数,在SearchLinkTableNode函数中它接收了一个函数指针作为参数以及一个void指针(可以指向任意类型的数据结构),这种方式称为call back,这种方式大大降低了软件模块之间的耦合度和软件的通用性,如果没有使用void指针,那么就需要使用到全局变量来进行SearchCondition的编写,这样使得软件模块间共享数据、变量名,即所谓的公共耦合。而采用传递一个void *参数,则消除了这种全局变量,软件模块间采用显式的调用传递数据结构,此即标记耦合,其耦合度相较于公共耦合有所降低。也大大增加了软件模块的通用性。再看linktable.h中的所有函数定义的其第一个参数均为tLinkTable,它们通过参数来传递上下文信息,直到操作的是哪个链表,而不是隐含地去依赖上下文环境,这就用到了定义通用接口的参数化上下文。SearchLinkTableNode函数的第三个参数接收void指针,它可以指向任意的数据类型,而不是指定的数据类型,这就体现了接口定义中的移除前置条件。

 4. 线程安全

  可重入函数:可以由多于一个任务并发使用,而不必担心数据错误。与其相反的不可重入函数即不能由超过一个任务所共享,除非能确保函数的互斥(或者使用信号量,或者在代码的关键部分禁用中断)。可重入函数可以在任意时刻被中断,稍后再继续运行,不会丢失数据。

  线程安全:如果你的代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。

  可重入函数与线程安全的关系:可重入函数是线程安全的必要条件,即线程安全的函数一定是可重入函数,但可重入函数未必是线程安全的。

  接下来我们对linktable.c中的源码进行具体的分析:我们可以看到GetNextLinkTableNode函数、GetLinkTableHead函数以及SearchLinkTableNode函数都没有用到mutex锁,因为它们都是线程安全的,这三个函数在执行过程中并没有涉及到链表的增加、删除、变更等操作,只涉及到查找因此是可重入函数,是线程安全的。

/*
 * Delete a LinkTable
 */
int DeleteLinkTable(tLinkTable *pLinkTable)
{
    if(pLinkTable == NULL)
    {
        return FAILURE;
    }
    while(pLinkTable->pHead != NULL)
    {
        tLinkTableNode * p = pLinkTable->pHead;
        pthread_mutex_lock(&(pLinkTable->mutex));
        pLinkTable->pHead = pLinkTable->pHead->pNext;
        pLinkTable->SumOfNode -= 1 ;
        pthread_mutex_unlock(&(pLinkTable->mutex));
        free(p);
    }
    pLinkTable->pHead = NULL;
    pLinkTable->pTail = NULL;
    pLinkTable->SumOfNode = 0;
    pthread_mutex_destroy(&(pLinkTable->mutex));
    free(pLinkTable);
    return SUCCESS;        
}

  可以看到上述删除链表的函数中就采用了线程锁,将下述这段代码锁住,防止了多个线程进行pHead = pHead->pNext操作和SumOfNode -= 1操作,导致数据紊乱的结果,比如链表断链,或者有的链表结点

pLinkTable->pHead = pLinkTable->pHead->pNext;
pLinkTable->SumOfNode -= 1 ;

未被释放等。

  再来看源码中增加链表结点的函数的例子:在增加结点时需要用到线程安全锁,假设没有对增加结点的操作进行互斥处理,且此时链表中存在结点,我们对下面代码中的两个操作进行了编号,我们再假设刚好有两个线程要进行添加结点的操作,线程一首先执行①,此时pTail->next指向线程一要添加的pNode,再线程一执行②之前,线程二执行了①,这是pTail->next则指向了线程二要添加的pNode,这时线程二紧接着执行②,结束后线程一执行①,这时我们会发现tLinkTable的pTail指向了线程一的pNode,但是这个pNode并没有被添加到链表中去,而线程②添加的pNode虽然被添加至链表中去,但pTail却没有指向它。因此整个链表添加结点的过程发生了错误。这种线程不安全的问题还会引发其他无法预测的错误,在此只分析一种情况来说明此问题。因此对链表的增加结点操作我们需要对其进行互斥处理,以保证其是线程安全的。

/*
 * Add a LinkTableNode to LinkTable
 */
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;        
}

   通过上述两个例子,我们将线程安全与具体例子结合在一起进行了具体的阐述。

三. 总结

  通过本次课程对源码的分析,让我对软件的模块化设计有了更加深入的了解,以及一个项目是如何从一行代码不断发展、优化的过程,本人受益匪浅

  

 

posted @ 2020-11-10 12:25  DreamD  阅读(144)  评论(0编辑  收藏  举报