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

参考资料见:
源码 https://github.com/mengning/menu
一步步实现源码

我们的目的是实现一个命令行的菜单小程序,最终目标是完成一个通用的命令行的菜单子系统便于在不同项目中重用。通过这样一个小程序,理解软件工程的代码规范,见微知著。


首先修改text.c中的Quit函数

int Quit(int argc, char *argv[])
{
    /* add XXX clean ops */
    exit(0);
    return 0;
}

不然使用quit命令无法退出。

1. 环境搭建

1.1 查看gcc版本

mac系统下,打开命令行,输入

$ gcc -v
Configured with: --prefix=/Applications/Xcode.app/Contents/Developer/usr --with-gxx-include-dir=/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/c++/4.2.1
Apple clang version 12.0.0 (clang-1200.0.32.21)
Target: x86_64-apple-darwin19.6.0
Thread model: posix
InstalledDir: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin

可以看到我们系统默认的是clang,关于gcc和clang的异同,我们可以参考
https://blog.csdn.net/fengbingchun/article/details/79252110
Clang是一个C、C++、Objective-C和Objective-C++编程语言的编译器前端。它采用了底层虚拟机(LLVM)作为其后端。它的目标是提供一个GNU编译器套装(GCC)的替代品。
简单理解,gcc有的宏clang也有,而且clang编译速度快,内存占用小,也方便ide集成。

1.2. 编译和调试环境的配置

其实我们环境应该来说已经完成了,是的,啥都不用干。
Unix和类Unix系统下,我们是可以直接通过命令行编译c/cpp文件的使用命令
gcc -o hello hello.c,生成可执行的hello二进制文件。

我们也可以通过vscode帮我们这样干,但是需要两个文件tasks.jsonlaunch.json。tasks用于在launch前执行任务,launch用于读取执行文件。

参考
https://code.visualstudio.com/docs/cpp/config-clang-mac
https://zhuanlan.zhihu.com/p/92175757

cd 到项目路径,使用命令code .当前工作文件夹中打开VS Code,该文件夹成为工作区。但是并没有生成.vscode文件。


那我们就来手动创建,我的项目下有两个文件,编辑器打开menu.c,再点击左边运行按钮,点击运行和调试,选择 GDB/LLDB
选择第一个



终端打印下面语句,说明配置成功




可以看到文件夹下多了.vscode文件夹和menu.dSYM文件夹

同时还生成了名为memu的二进制文件
这里的menu.c是lab2的内容
输出一下


解释一下tasks.json和launch.json

tasks.json的主要作用就是执行类似 gcc -g main.c -o main 的命令,创建一个tasks.json文件告诉VS代码如何构建(编译)程序。

需要注意的一点是,tasks.json的"label"参数值和launch.json的"preLaunchTask"参数值需要保持一致

launch.json文件,是用来配置VS Code以在按F5调试程序时启动LLDB调试器。

2. 如何编写高质量代码

2.1 注释

如果我们从Github上查看一些大牛的项目,我们可以看到他们的代码中,注释很都很工整清晰,格式统一规范。

总计有几种注释的方法,

  • 最精简的是无注释,理想的状态是即便没有注释,也能通过函数、变量等的命名直接理解代码。
  • 还有就是一句话的简短注释
  • 最后是将函数功能、各参数的含义和输入/输出用途等一一列举,这往往是模块的对外接口,以方便自动生成开发者文档。

给代码写上工整的注释是一个优秀程序员的良好习惯。工整简洁的代码未必就有较高的可读性,在一些业务比较繁琐,参数比较多的函数中,阅读代码的人会在各种参数的用法中纠缠不清,但是如果在参数或者业务操作的代码旁加上工整的注释,可以让既有的代码脉络清晰,也方便自动生成开发者文档


在这里我就推荐一下vscode注释插件koroFileHeader,商店下载安装cmd+ctr+i是头注释,cmd+ctr+t是函数注释,效果如下

/*
 * @Author: your name
 * @Date: 2020-11-04 09:53:04
 * @LastEditTime: 2020-11-04 10:03:47
 * @LastEditors: Please set LastEditors
 * @Description: In User Settings Edit
 * @FilePath: /se_code/menu/menu.c
 */

言归正传,以menu这个程序为例,我们看看孟老师是怎么写出优秀的头部注释的

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

我们可以看到在优秀的头部注释中,最主要的有包名、文件名和描述信息,其次还有copyright和author之类的信息,让人一目了然清晰易懂。
再看看函数注释

/* show all cmd in listlist */
int ShowAllCmd(tLinkTable * head)
{
  ...
}

注释中尽量使用/* xxx */的格式,而不使用//这样的注释风格(阿里巴巴JAVA开发守则不推荐这种注释风格)

2.2 代码风格规范

最重要的一致性规则是命名管理. 命名的风格能让我们在不需要去查找类型声明的条件下快速地了解某个名字代表的含义: 类型, 变量, 函数, 常量, 宏, 等等, 甚至. 我们大脑中的模式匹配引擎非常依赖这些命名规则.

命名规则具有一定随意性, 但相比按个人喜好命名, 一致性更重要, 所以无论你认为它们是否重要, 规则总归是规则.

这里推荐阅读google c++命名规范https://zh-google-styleguide.readthedocs.io/en/latest/google-cpp-styleguide/naming/
和阿里巴巴的java开发手册

总结代码风格规范

  • 缩进:4个空格;
  • 行宽:< 100个字符;
  • 代码行内要适当多留空格,如“=”、“+=” “>=”、“<=”、“+”、“*”、“%”、“&&”、“||”、“<<”,“^”等二元操作符的前后应当加空格。对于表达式比较长的for语句和if语句,为了紧凑起见可以适当地去掉一些空格,如for (i=0; i<10; i++)和if ((a<=b) && (c<=d));
  • 在一个函数体内,逻揖上密切相关的语句之间不加空行,逻辑上不相关的代码块之间要适当留有空行以示区隔;
  • 在复杂的表达式中要用括号来清楚的表示逻辑优先级;
  • 花括号:所有 ‘{’ 和 ‘}’ 应独占一行且成对对齐;这是C++的通常做法,Java中将'{'放在函数声明的同一行末尾
  • 不要把多条语句和多个变量的定义放在同一行;
  • 命名:合适的命名会大大增加代码的可读性;
    • 类名、函数名、变量名等的命名一定要与程序里的含义保持一致,以便于阅读理解;
    • 类型的成员变量通常用m_或者_来做前缀以示区别;
    • 一般变量名、对象名等使用LowerCamel风格,即第一个单词首字母小写,之后的单词都首字母大写,第一个单词一般都表示变量类型,比如int型变量iCounter;
    • 类型、类、函数名等一般都用Pascal风格,即所有单词首字母大写;
    • 类型、类、变量一般用名词或者组合名词,如Member
    • 函数名一般使用动词或者动宾短语,如get/set,RenderPage;

2.3 编写高质量代码基本准则

  1. 通过控制结构简化代码
  2. 通过数据结构简化代码
  3. 一定要有错误处理
    程序的主要功能(80%的工作)大约仅用20%时间,而错误处理(20%的工作)却要80%的时间

参数处理的基本原则:

  • Debug版本中所有的参数都要验证是否正确;Release版本中从外部(用户或别的模块)传递进来的参数要验证正确性。
  • 肯定如何时用断言;可能发生时用错误处理。

3.模块化

软件工程发展到今天,特别注重解耦合的思想。模块化是指将我们整个软件系统按照功能的不同划分成不同的模块,每个模块只有单一的功能目标并相对独立于其他模块,使得开发和维护变得简单,同时模块的分离增加了可重用性。

我们使用耦合度(Coupling)和内聚度(Cohesion)来衡量软件模块化的程度。
一般在软件设计中我们追求松散耦合,理想的内聚是功能内聚,也就是一个软件模块只做一件事,只完成一个主要功能点或者一个软件特性(Feather)

软件设计中的一些基本方法

  • KISS(Keep It Simple & Stupid)原则

一行代码只做一件事
一个块代码只做一件事
一个函数只做一件事
一个软件模块只做一件事

  • 使用本地化外部接口来提高代码的适应能力
    不要和陌生人说话原则
  • 先写伪代码的代码结构更好一些
    using design to frame the code(matching design with implementation)

我们来看看menu程序中是如何实现KISS的


项目路径下,有test、linktable.*和menu.*,linktable和menu是两个不同的模块,linktable是menu中用到的数据结构,linktable定义了管理链表数据结构和对其操作的方法,这个模块看不到调用它的上层结构做了什么,这就做到了linktable和menu业务的切分

menu是菜单的功能的实现,同样的,menu也不知道linktable具体是怎么操作链表的,但是能通过linktable提供的功能并将他们组装起来实现自己的功能。这就做到了一个软件模块只做一件事。

menu程序的入口是test,我们在test中调用menu的方法实现对应的操作,也就是说menu模块给我们提供了他的一系列功能,但是怎么使用是否使用是由test决定的,test并不清楚这些功能的具体实现,只要在我需要的时候调用并能得到想要的结果就行了。

再通过一个函数说明如何实现一行代码只做一件事,一个块代码只做一件事,一个函数只做一件事

/* find a cmd in the linklist and return the datanode pointer */
tDataNode* FindCmd(tLinkTable * head, char * cmd)
{
    tDataNode * pNode = (tDataNode*)GetLinkTableHead(head);
    while(pNode != NULL)
    {
        if(!strcmp(pNode->cmd, cmd))
        {
            return  pNode;  
        }
        pNode = (tDataNode*)GetNextLinkTableNode(head,(tLinkTableNode *)pNode);
    }
    return NULL;
}

我们可以看到我们 一行代码不会有定义两个变量的情况,做到了第一点,同时也可以看到,显然这里一个{ }只执行它该干的事情,这个函数只实现了找cmd的功能,目标明确,功能单一。所以这段代码也做到了第二点第三点。

但是系统模块间总会多少有些依赖。当一个模块变化时,其它模块可能也需要随之而改变。模块化设计的目标就是最小化模块间的依赖

为了管理依赖,我们可以把模块看成两部分:接口和实现

4. 可重用接口

实现模块化的方法就是接口化设计

接口只描述模块做什么,但不会包含怎么做。完成接口所做出的承诺的代码被称为实现。通过将模块的接口和实现分离,我们可以对系统的其它部分隐藏实现的复杂度。模块的使用者只需要理解接口提供的抽象。在设计类和其它模块时,最重要的问题是让它们深,它们要对常见用例有足够简单的接口,但同时依然提供强大的功能。这就最大化地隐藏了复杂度。

接口规格包含五个基本要素:

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

linktable.h就是linktable模块的接口,对应的linktable.c是接口的实现,接口的设计应足够的通用,不是和单一的项目紧密耦合的,而是在不同的项目都可以重复使用。
来看linktable.h

... ...
/*
 * Delete a LinkTable
 */
int DeleteLinkTable(tLinkTable *pLinkTable);
/*
 * Add a LinkTableNode to LinkTable
 */
int AddLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);
... ...

这里定义接口,只暴露必要的信息,对外隐藏不必要的信息,一个接口的设计就应该足够的“干净”。

给接口增加一个callback方法

lab5.1中linktable.h定义了这样一个接口SearchLinkTableNode,显然这个函数式找到链表中的一个节点,而这个函数的参数是一个函数condition(返回值为int),这个函数参数是tLinkTableNode * pNode,这就是callback方法。

给Linktable增加Callback方式的接口,需要两个函数接口,一个是call-in方式函数,如SearchLinkTableNode函数,其中有一个函数作为参数,这个作为参数的函数就是callback函数,如代码中Conditon函数

/*
 * Search a LinkTableNode from LinkTable
 * int Conditon(tLinkTableNode * pNode);
 */
tLinkTableNode * SearchLinkTableNode(tLinkTable *pLinkTable, int Conditon(tLinkTableNode * pNode))
{
    if(pLinkTable == NULL || Conditon == NULL)
    {
        return NULL;
    }
    tLinkTableNode * pNode = pLinkTable->pHead;
    while(pNode != pLinkTable->pTail)
    {    
        if(Conditon(pNode) == SUCCESS)
        {
            return pNode;                   
        }
        pNode = pNode->pNext;
    }
    return NULL;
}

condition的实现,这个函数将传进来的参数与全局变量cmd比较,相同则返回SUCCESS,实现底层linktable模块和menu模块的通信

/* condition实现 */
int SearchCondition(tLinkTableNode * pLinkTableNode)
{
    tDataNode * pNode = (tDataNode *)pLinkTableNode;
    if(strcmp(pNode->cmd, cmd) == 0)
    {
        return  SUCCESS;  
    }
    return FAILURE;        
}

虽然这里callback方法使得接口更加通用。

更通用是指底层不用关心condition是如何实现的,只要上层去判断,觉得这两个节点相同就可以了,底层只负责找到你想要的节点。

但是这会有一些问题,问题的关键在于全局变量cmd的,这是最好的实现吗?足够的解耦合吗?还有更好的实现吗?

首先来看看接口与耦合度的关系


上面的方法就是一种公共耦合,我们希望更松散的耦合,即数据耦合,该怎么做?
关键在于显式地调用传递基本数据类型
给condition函数增加一个参数*args

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

修改SearchLinkTableNode函数,增加一个参数*args

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

让我们看看这两个函数是怎么工作的,SearchLinkTableNode新增的args实际上就是用户的输入cmd,被强转为void*类型,condition的args其实就是同一个args。之所以要转成void类型,就是为了更通用,是因为我们不想让底层的linktable模块知道上层用的数据类型,甚至是任何类型都可以,不影响我找节点的功能。

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

5. 线程安全

race condition

最后是线程安全的问题,多线程的应用会面临一个race问题,是指多个进程或者线程并发访问和操作同一数据且执行结果与访问发生的特定顺序有关的现象。换句话说,就是线程或进程之间访问数据的先后顺序决定了数据修改的结果,这种现象在多线程编程中是经常见到的。

函数调用堆栈

栈是每个线程独有的,保存其运行状态和局部自动变量的。栈在线程开始的时候初始化,每个线程的栈互相独立,因此,栈是线程安全的。操作系统在切换线程的时候会自动切换栈。栈空间不需要在高级语言里面显式的分配和释放。

线程不安全

对于线程间共享的内存区域,如果进程中的A线程操作了数据,切换到B线程执行,修改了同样的数据,回到A线程时,数据就不是A线程切换时候的样子,这样一来,数据就被污染了,我们就说这块数据在多线程环境下是不安全的,即线程不安全的。

当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。

可重入函数和不可重入函数

可重入(reentrant)函数可以由多于一个任务并发使用,而不必担心数据错误。相反,不可重入(non-reentrant)函数不能由超过一个任务所共享,除非能确保函数的互斥(或者使用信号量,或者在代码的关键部分禁用中断)。

可重入函数要求:

不为连续的调用持有静态数据;
不返回指向静态数据的指针;
所有数据都由函数的调用者提供;
使用局部变量,或者通过制作全局数据的局部变量拷贝来保护全局数据;
使用静态数据或全局变量时做周密的并行时序分析,通过临界区互斥避免临界区冲突;
绝不调用任何不可重入函数

分析代码中的线程安全问题

lab7.2给LinkTable 加了一把锁 pthread_mutex_t,这个锁的粒度是多大?是对这个LinkTable加的锁,当加锁时,对这个LinkTable的增删改操作都将阻塞

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

};

一般来讲读操作是不需要加锁的,对临界区变量的改变操作就需要加锁保证对该变量的顺序执行。我们来逐一分析

/*
 * Create a LinkTable
 * 创建LinkTable时候开辟了新的空间,这个操作不需要加锁,
 * 但这里调用了pthread_mutex_init(&(pLinkTable->mutex), NULL);
 * 初始化了该链表的锁
 */
tLinkTable * CreateLinkTable();
/*
 * Delete a LinkTable
 * 删除操作整个链表,加锁
 */
int DeleteLinkTable(tLinkTable *pLinkTable);
/*
 * Add a LinkTableNode to LinkTable
 * 增加节点操作,加锁
 */
int AddLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);
/*
 * Delete a LinkTableNode from LinkTable
 * 删除链表节点,加锁
 */
int DelLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);
/*
 * Search a LinkTableNode from LinkTable
 * int Conditon(tLinkTableNode * pNode,void * args);
 * 查找操作某个节点,不加锁
 */
tLinkTableNode * SearchLinkTableNode(tLinkTable *pLinkTable, int Conditon(tLinkTableNode * pNode, void * args), void * args);
/*
 * get LinkTableHead
 * 拿到头结点,查询操作,不加锁
 */
tLinkTableNode * GetLinkTableHead(tLinkTable *pLinkTable);
/*
 * get next LinkTableNode
 * 找到下一个节点,查询操作,不加锁
 */
tLinkTableNode * GetNextLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);

你可能感兴趣的:(代码中的软件工程——menu程序分析)