其他分享
首页 > 其他分享> > Menu项目中的软件工程

Menu项目中的软件工程

作者:互联网

项目中的软件工程

本博客是在孟宁老师的软件工程课程的指导下,针对项目案例https://github.com/mengning/menu进行阅读和分析,参考资料见:https://gitee.com/mengning997/se/blob/master/README.md#代码中的软件工程

本项目是由C语言完成,我们使用Ubuntu和gcc编译器进行项目环境的搭建。
首先我们先下载gcc编译器,输入如下命令:

$ apt install gcc
$ gcc -v

结果如下:
gcc

然后在VSCode中安装C++插件,如图:

项目结构如图所示:

然后点击页面左上角的图标:

项目环境搭建完成,接下来开始分析项目中的代码。

模块化

现如今的软件项目越来越复杂,一个程序员很难独自完成,这意味着需要一个项目小组来协同工作,同时也要求小组中的每个成员完成自己负责的任务。然而在实际工程中,我们自己的任务需要用到其他小组成员的代码,或者我们写的代码也要提供给其他小组成员进行调用,这就需要我们把自己的代码模块化,将接口与实现分离,控制代码与业务代码分离,真正实现高内聚,低耦合。

接口与实现分离

简单来说,在C语言中,模块即是一个.c文件和.h 文件的结合,头文件就是对于该模块接口的声明。这揭示了模块化的实现方法和本质。我们以menu项目中的linktable为例:

/* linktable.h */
// 声明并定义链表结点
typedef struct LinkTableNode {
    struct LinkTableNode * pNext;
}tLinkTableNode;
// 声明链表结构体
typedef struct LinkTable tLinkTable;
// 函数声明如下:
tLinkTable * CreateLinkTable();
int DeleteLinkTable(tLinkTable *pLinkTable);
int AddLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);
int DelLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);
tLinkTableNode * SearchLinkTableNode(tLinkTable *pLinkTable, int Conditon(tLinkTableNode * pNode, void * args), void * args);
tLinkTableNode * GetLinkTableHead(tLinkTable *pLinkTable);
tLinkTableNode * GetNextLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);

根据上述的头文件,我们声明了链表和链表节点以及相应的函数方法,而具体的实现则是在linktable.c文件中。该模块整体向外部提供了一个链表的整体功能,外部文件只需要在头部引入该模块的头文件即可调用链表的相关功能。

但是,这里我们发现一个问题,那就是头文件里声明并且实现了链表节点,但却只是声明了链表,而没有实现具体的链表结构,这是为何呢?

这里就涉及到模块化的编程思维。我们知道链表是一个数据结构,链表中的节点需要有一个指针指向下一个节点,这就是链表的基本定义。然而,在实际项目中,我们可以采取任意方式实现链表,只要链表的节点有一个指针,并指向下一个节点,我们即可声明我们实现了链表。所以我们在头文件中声明链表节点的同时,定义了节点的内部结构,即一个节点指针。至于链表结构,就比较复杂了,根据我们需求的不同而不同,比如我们可以定义一个双向链表或循环链表,可以在其内部定义两个节点指针指向头尾节点,还可以定义一个整型变量表示链表的长度,当然还有更多,所以我们把链表的实现放在了.c文件中。

以上我们介绍了接口与实现分离的编程范式。

控制逻辑与业务逻辑分离

但是,这时想必大家又产生了疑问,那就是如果我们实现的是一个双向链表,那么对应的链表结点应该有两个指针,分别指向前后节点。然而在头文件中,我们已经写死了节点的结构,是一个单向节点,那岂不是产生了冲突吗?

这是一个好问题,针对此问题,我们开始介绍控制逻辑与业务逻辑分离的编程范式。

根据上述头文件,我们实现了最低标准的链表结构,所谓的最低标准,就是不能再简化了,如果再简化,那就不是链表了。所以说,链表的最低标准就是我们实现的单向无环链表,至于其他功能更复杂的链表结构,可以根据我们实现的链表节点为基础进行扩展,或者我们也可以不进行拓展,直接重新实现一个也是可以的。但无论是拓展还是重新实现,不同类型的链表都是数据结构,是项目中的控制逻辑部分,而我们真正关心的是项目中的业务逻辑部分,也只有业务部分才需要对底层的数据结构进行定制与拓展。由于业务逻辑的不同,对底层数据结构的要求也不尽相同,如果我们为了业务逻辑而修改了底层数据结构,这就导致了数据结构与业务代码的高耦合,违反了模块化的编程范式。所以我们实现的数据结构只是实现了最基本的功能,而针对不同业务的拓展由业务逻辑自己负责实现。

还有,我们虽然实现的是单向链表,但在具体的工程实践中,比如Java语言中,底层的链表结构都是一个双向链表,因为这既可以表示单向,也可以实现队列等相关结构。由于我们的menu项目是出于学习角度考虑,各方面进行了简化,所以大可不必纠结于单向链表这个点上。重要的是,无论是单向还是双向链表,它们都是控制逻辑,轻易不能更改,即使更改,也是对其具体实现进行优化,以提高对链表的增删改查等相关操作的效率。我们真正要关心的是业务逻辑,下面给出menu项目的业务代码的部分细节:

/* menu.c */
typedef struct DataNode
{
    tLinkTableNode * pNext;
    char*   cmd;
    char*   desc;
    int     (*handler)(int argc, char *argv[]);
} tDataNode;

显然,在menu.c文件中,我们拓展了链表节点的结构,针对菜单项目的业务逻辑,添加了cmddeschandler函数指针这三个属性。

以上,就是我对模块化编程思想的感悟,主要注意两点,那就是接口与实现分离,控制逻辑与业务逻辑分离。

可重用接口

现在,让我们一起来讨论模块间的接口设计。

所谓接口设计,就是要设计一个好的接口,那什么才算是好的接口呢,我认为除了接口声明的简洁明了外,就是接口是否可以重复利用。在编程实践中,我们往往把多次重复利用的代码封装成一个函数,这样可以使代码层次清晰。然而,这样写的代码离可重用还是有相当的距离,因为每次调用函数都是执行相同的代码逻辑,如果业务逻辑发生了变动,相应的函数就可能会失效。真正的可重用,就是拿来代码直接就用,涉及到修改代码的统统都不是真正的可重用。

那么我们应该如何实现呢,我们可以参考Unix的设计哲学:

Write programs that do one thing and do it well.

写出来的代码只做一件事,并且把这件事做好。

Write programs to work together.

写出来的代码相互之间要一起协作。

我们应该把函数的实现尽量原子化,即每个函数只完成最小功能,然后函数之间相互调用。比如在linktable文件中的增加节点,删除节点等基本链表操作。如下:

int AddLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode); // 增加节点
int DelLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode); // 删除节点
tLinkTableNode * SearchLinkTableNode(tLinkTable *pLinkTable, int Conditon(tLinkTableNode * pNode, void * args), void * args); // 查询指定节点
tLinkTableNode * GetLinkTableHead(tLinkTable *pLinkTable); // 获取链表头节点

上面的每一个函数都只实现了链表的单一功能,如AddLinkTableNode函数,该函数有两个参数,一个是等待操作的链表,一个是要加入链表的节点。由于该函数的参数只和链表结构有关,所以无论业务逻辑如何变化,该函数都可以重复使用,不受业务逻辑的影响。

接下来,有一个非常经典的函数,这个函数的实现充分展现了可重用的精髓,它就是SearchLinkTableNode函数,下面我们重点分析该函数,体会可重用接口的魅力。

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

看完以上代码,我们发现,该函数的参数除了要操作的链表外,还传入了一个函数ConditionCondition函数代码实现如下:

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

在menu项目中,Condition函数将传入的参数与链表节点中包含的参数进行匹配,成功返回SUCCESS,失败返回FAILURE。现在SearchLinkTableNode函数的逻辑是,将链表中的节点依次与我们传入的第三个参数进行匹配,匹配成功就返回相应的节点,至于匹配的具体逻辑是什么,由传入的第二个参数,即Condition函数负责,而我们的SearchLinkTableNode函数只需要调用Condition函数即可,根本不需要考虑具体实现,这充分体现了模块化的编程思想,即模块与模块之间的相互调用。SearchLinkTableNode函数是链表中的函数,属于控制逻辑,而Condition函数属于业务逻辑,两者通过一个接口发生了神奇的关联,Condition的改动根本不会影响到SearchLinkTableNode,拿来代码直接用,真正实现可重用。

线程安全

在听孟宁老师的软件工程课时,老师对于可重入函数以及线程安全的问题,结合PPT进行了总结:

对可重入函数的基本要求:

函数的可重入性与线程安全之间的关系:

下面对linktable文件中的函数进行线程安全分析。

tLinkTable * CreateLinkTable();
int DeleteLinkTable(tLinkTable *pLinkTable);
int AddLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);
tLinkTableNode * SearchLinkTableNode(tLinkTable *pLinkTable, int Conditon(tLinkTableNode * pNode, void * args), void * args);
tLinkTableNode * GetLinkTableHead(tLinkTable *pLinkTable);
tLinkTableNode * GetNextLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);

如上的函数均是可重入函数,例如链表初始化函数主要是对内存的分配,不同线程调用该函数会分配不同内存,彼此之间毫无影响,因此是线程安全的,是可重入函数。然而,我们还需要注意,对于有关读操作的函数,他们彼此之间是可重入的,也是线程安全的,但读操作与写操作进行并发操作,则会导致异常,因此最好在读和写的原子操作上加上读写锁。

int DeleteLinkTable(tLinkTable *pLinkTable) {
    // ...省略
    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);
    }
    // ...省略
}

删除链表函数的锁操作有潜在的问题,如果多个线程对一个表进行删除操作,会导致重复删除。如果两个线程A和B同时开始遍历链表,并且都有一个指向链表头结点的指针p,A先获得锁,将Head指针往后移动一个,然后锁被释放,p指针指向的原头节点被删除,接着,线程B执行加锁操作,将head指针再向后移动一个,然后释放锁,删除p指向的节点,这里需要注意,线程A和B的p指针指向同一个节点,A删除一次后B又删除了一次,导致重复删除,因此此函数不是可重入函数。

综上,是我对menu项目的总结与分析,由于本人才疏学浅,如果分析的有错误和不合理的地方,还请多加指正。

标签:函数,项目,tLinkTable,Menu,链表,软件工程,pLinkTable,tLinkTableNode,节点
来源: https://www.cnblogs.com/hcds/p/13951134.html