编码的艺术

本文参考孟宁老师的授课和课件,基于C语言,先介绍编译调试环境的配置过程,然后通过一个menu项目的具体代码理解编码过程中的问题,体会代码中的软件工程思想,感觉编程之美,感受编码的艺术。

1. C/C++编译调试环境配置

本文使用VS Code + MinGW-w64 作为C/C++的编码环境,VS Code的安装就不再赘述,MinGW-w64可通过链接 https://sourceforge.net/projects/mingw-w64/files/ 下载安装,安装选项如下:

安装完成后,将安装目录下的 /bin 目录加入环境变量,添加完后打开终端,输入 指令gcc -v ,看到版本号则安装成功。

接下来,进入VS Code,在扩展商店搜索C++安装C/C++扩展,

安装完成后,打开项目文件夹SE_CODE,选择一个C文件,按F5运行,会提示选择环境,依次点击C++(FDB/LLDB)gcc.exe-生成和调试活动文件,然后目录下会生成一个 .vscode 目录,里面有两个配置文件:launch.json(调试配置文件)和tasks.json(构建任务配置文件),分别进行如下配置:

launch.json:

tasks.json:

其中gdb.exe和gcc.exe的路径应设置刚才MinGW安装路径下的对应程序路径。

配置完成后,运行测试文件hello.c,看到输出hello world!,即环境配置成功。

2. 代码中的软件工程

2.1 模块化软件设计

模块化(Modularity)是在软件系统设计时保持系统内各部分相对独立,以便每一个部分可以被独立地进行设计和开发。

2.1.1 基本概念

模块化设计的基本思想是自顶向下、逐步分解、分而治之,即将一个较大的程序按照功能分割成一些小模块,各模块相对独立、功能单一、结构清晰、接口简单。模块化设计有很多优点,比如:

  • 控制了程序设计的复杂性,易于理解
  • 提高了代码的重用性
  • 易于调试、维护和功能扩充
  • 有利于团队开发

因此模块化程度是软件设计是否足够优秀的一个重要指标,一般使用 耦合度(Coupling)内聚度(Cohesion)来衡量软件模块化的程度。

  • 耦合度:是指软件模块之间的依赖程度,一般可以分为 紧密耦合、松散耦合 和 无耦合。
  • 内聚度:是指一个软件模块内部各种元素之间互相依赖的紧密程度。理想的状态是功能内聚,也就是一个软件模块只做一件事,只完成一个主要功能点或者一个软件特性。

2.1.2 实例分析

我们可以看到menu项目的代码结构如下,其中linktable.h用来存放数据结构和它的操作,其他的业务逻辑层代码都在menu.c中,在menu.c中只需要导入 linktable.h 头文件,就可以用其中的数据结构快速构建具体的业务逻辑,而不需要从头定义底层代码。将数据结构和对数据结构的操作与业务逻辑分离开,这里就体现了模块化的思想。

2.2 可重用接口

2.2.1 接口定义及规格

模块接口是是互相联系的双方共同遵守的一种协议规范,在软件系统内部一般的接口方式是通过定义一组API函数来约定软件模块之间的沟通方式。接口具体定义了软件模块对系统的其他部分提供了怎样的服务,以及系统的其他部分如何访问所提供的服务。

接口规格是软件系统的开发者正确使用一个软件模块需要知道的所有信息,那么这个软件模块的接口规格定义就必须清晰明确地说明正确使用本软件模块的信息。一般来说,接口规格包含五个基本要素:

  • 接口的目的;
  • 接口使用前所需要满足的条件,一般称为前置条件或假定条件;
  • 使用接口的双方遵守的协议规范;
  • 接口使用之后的效果,一般称为后置条件;
  • 接口所隐含的质量属性。

2.2.2 可重用接口定义方法

在软件设计时,为了避免重复造轮子,需要尽可能地使用可重用接口,让接口能处有更加丰富地应用场景。可重用接口定义地基本方法有:

  • 参数化上下文
  • 移除前置条件
  • 简化后置条件

2.2.3 实例分析

在menu项目中,我们可以通过Linktable模块的接口来展示接口的可重用性。在linktable.h中,SearchLinkTableNode函数的Condition参数是一个函数指针,利用callback函数参数为其他模块提供了一个更加通用的搜索链表的接口。

/*
 * Search a LinkTableNode from LinkTable
 * int Conditon(tLinkTableNode * pNode,void * args);
 */
tLinkTableNode * SearchLinkTableNode(tLinkTable *pLinkTable, int Conditon(tLinkTableNode * pNode, void * args), void * args);

接下来我们在linktable.c中看下SearchLinkTableNode函数的具体实现,可以看到,这个函数没有用到任何业务逻辑层的数据,只是负责遍历链表,具体判断是否找到指定条件的节点交由Condition回调函数来负责。所以当其他模块中想按一定条件遍历链表时,只需要构建特定的Condition函数就能方便地来调用这个搜索函数,而无需对遍历链表的操作重复造轮子。

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

在menu.c中,我们可以看到这个可重用接口的具体使用。menu.c中先定义了一个SearchCondition函数,用来定义查找链表的条件,然后FindCmd函数调用了SearchLinkTableNode函数接口,而且将SearchCondition函数作为参数传入这个接口。

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

/* find a cmd in the linklist and return the datanode pointer */
tDataNode* FindCmd(tLinkTable * head, char * cmd)
{
    return  (tDataNode*)SearchLinkTableNode(head,SearchCondition,(void*)cmd);
}

除此之外,为了让menu作为一个子系统可以用在不同项目中,还为menu子系统设计了可重用的接口。在menu.h中定义了两个可重用的接口,一个是通过给出命令的名称、描述和命令的实现函数定义一个命令,另一个是启动menu引擎。

/* add cmd to menu */
int MenuConfig(char * cmd, char * desc, int (*handler)());

/* Menu Engine Execute */
int ExecuteMenu();

在test.c中对这两个接口进行了调用。

int main(int argc,char* argv[])
{

    MenuConfig("version","XXX V1.0(Menu program v1.0 inside)",NULL);
    MenuConfig("quit","Quit from XXX",Quit);
    
    ExecuteMenu();
}

2.3 线程安全

2.3.1 线程

线程(thread)是操作系统能够进行运算调度的最小单位。它包含在进程之中,是进程中的实际运作单位,每一个线程都有一个独自拥有的函数调用堆栈空间,其中函数参数和局部变量都存储在函数调用堆栈空间中,因此函数参数和局部变量也是线程独自拥有的。除了函数调用堆栈空间,同一个进程的多个线程是共享其他进程资源的,如全局变量。

2.3.2 可重入与不可重入函数

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

2.3.3 线程安全

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

了解完上面的相关概念后,我们可以思考:函数的可重入性一线程安全之间是什么关系呢?

可重入的函数不一定是线程安全的,可能是线程安全的也可能不是线程安全的;可重入的函数在多个线程中并发使用时是线程安全的,但不同的可重入函数(共享全局变量及静态变量)在多个线程中并发使用时会有线程安全问题;

而不可重入的函数一定不是线程安全的。

2.3.4 实例分析

在menu项目中,linktable.h文件通过给链表的增删操作加了一个互斥锁实现线程安全。

首先在结构体LinkTable中定义可互斥锁pthread_mutex_t,

struct LinkTable
{
    tLinkTableNode *pHead;
    tLinkTableNode *pTail;
    int			SumOfNode;
    pthread_mutex_t mutex;  
};

然后在 CreateLinkTable函数中使用 pthread_mutex_init对互斥锁进行初始化,

tLinkTable * CreateLinkTable()
{
    tLinkTable * pLinkTable = (tLinkTable *)malloc(sizeof(tLinkTable));
    if(pLinkTable == NULL)
    {
        return NULL;
    }
    pLinkTable->pHead = NULL;
    pLinkTable->pTail = NULL;
    pLinkTable->SumOfNode = 0;
    pthread_mutex_init(&(pLinkTable->mutex), NULL);
    return pLinkTable;
}

在DeleteLinkTable函数、AddLinkTableNode函数、DelLinkTableNode函数中对链表的操作都分别进行加锁和解锁。以DeleteLinkTable函数为例,使用pthread_mutex_lock和pthread_mutex_unlock在删除链表结点前后分别进行加锁和解锁,使用pthread_mutex_destroy销毁互斥锁。

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

通过加锁和解锁操作确保在同一时间最多只有一个线程访问添加或删除结点的代码,可以避免数据错误,即使用互斥锁实现了线程安全。

3. 总结

理论帮助实践,知识概念让我们对模块化设计、可重用接口、线程安全等等有初步的了解,编码实践能加深对这些知识点的理解。模块化设计降低了系统的复杂性,也易于调试维护;可重用接口让代码更加容易复用,避免了重复造轮子;线程安全保障了多线程下的数据安全。

程序员最终还是要在项目中去感受软件工程的思想。感谢孟宁老师通过menu项目对这些知识点进行深入浅出地讲解,让我们能进一步体会到编码不仅仅是编码。只有用科学的软件工程思维,结合合适的方法,才能写出诗一样的代码,这就是编码的艺术。

参考资料:

  1. https://gitee.com/mengning997/se/blob/master/README.md
posted @ 2020-11-08 14:45  regnover  阅读(279)  评论(0编辑  收藏  举报