在Menu小程序中学习软件工程方法
前言
???在编写代码的过程中充分运用软件工程的思想是一个优秀程序员不可或缺的技能。一个优秀的程序应该具有良好的可靠性、容错性、易用性、易读性、可扩充性、可理解性和可维护性等等性能。为了达到这些要求,我们应该学会如何让自己编写的程序更加符合软件工程的思想,通过学习代码编写的规范和各种面向对象思想的实现方式,我们就能在今后的职业生涯中一帆风顺。
???学习完孟宁老师的课程后,了解到了许多软件工程的设计思想,需要注意的事项。老师通过一个menu小程序生动形象的介绍了代码的风格规范、模块化设计思想(高内聚,低耦合)、可重用接口的实现、使用回调函数和注意线程安全等问题。
????本篇文章将会分析孟宁老师的menu小程序来学习软件工程设计中的思想,从工程中汲取知识和习惯将会是提升自己最快的方法。在上完孟宁老师的高级软件工程的课程后,我对软件工程思想的了解更近了一步。下面将会对每一个部分进行详细的介绍。
参考文献:https://gitee.com/mengning997/se/blob/master/README.md#代码中的软件工程
一.VSCode中C/C++编译环境配置
本项目的编译运行环境为VSCdoe,首先我们应该配置C/C++的编译环境
1.安装C/C++ extension
2.编辑配置文件tasks.json和launch.json来配置代码的编译命令和Debug环境:
//tasks.json的文件内容
{
"version": "2.0.0",
"tasks": [
{
"type": "cppbuild",
"label": "C/C++: clang build active file",
"command": "/usr/bin/clang",
"args": [
"-g",
"${file}",
"-o",
"${fileDirname}/${fileBasenameNoExtension}"
],
"options": {
"cwd": "/usr/bin"
},
"problemMatcher": [
"$gcc"
],
"group": {
"kind": "build",
"isDefault": true
},
"detail": "compiler: /usr/bin/clang"
}
]
}
//launch.json的文件内容
{
"version": "0.2.0",
"configurations": [
{
"name": "(lldb) Launch",
"type": "cppdbg",
"request": "launch",
"program": "enter program name, for example ${workspaceFolder}/a.out",
"args": [],
"stopAtEntry": false,
"cwd": "${workspaceFolder}",
"environment": [],
"externalConsole": false,
"MIMode": "lldb"
}
]
}
3.编译运行,测试是否配置成功:
二.代码风格规范
1.代码风格的原则:
?????代码的风格规范可以总结为:简约而不简单。具体来说有简明、易读、无二义性这三个特性。我们把代码的风格分成三重境界:一是规范整洁。遵守常规语言规范,合理使用空格、空行、缩进、注释等;二是逻辑清晰。没有代码冗余、重复,让人清晰明了的命名规则。做到逻辑清晰不仅要求程序员的编程能力,更重要的是提高设计能力,选用合适的设计模式、软件架构风格可以有效改善代码的逻辑结构,会让代码简洁清晰;三是优雅。优雅的代码是设计的艺术,是编码的艺术,是编程的最高追求。
?????我们拿lab3.1版本中的menu.c程序来说明具体含,如果我们代码编写为这个形式:
int main(){
while(1){
char cmd[CMD_MAX_LEN];
printf("Input a cmd number > ");
scanf("%s", cmd);
tDataNode *p = head;
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;}
if(p == NULL)
printf("This is a wrong cmd!\n ");
}
}
?????虽然这个程序是能够正常运行的,但是可读性可以说是非常的差劲,代码之间的层次和逻辑关系模糊,不论是对于自己还是他人想要对这个程序进行修改或者扩充都会十分的吃力,这样的代码可以说是失败的,但是我们如果修改成下面的形式,将会大大的提高程序的易读性和可理解性:
int main()
{
/* cmd line begins */
while(1)
{
char cmd[CMD_MAX_LEN];
printf("Input a cmd number > ");
scanf("%s", cmd);
tDataNode *p = head;
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;
}
if(p == NULL)
{
printf("This is a wrong cmd!\n ");
}
}
}
2.程序块头部的注释:
文件头注释和程序段注释应该遵守规范:
- 注释和版权信息:注释也要使用英文,不要使用中文或特殊字符,要保持源代码是ASCII字符格式文件;
- 不要解释程序是如何工作的,要解释程序做什么,为什么这么做,以及特别需要注意的地方;
- 每个源文件头部应该有版权、作者、版本、描述等相关信息
3.具体代码规范总结:
代码编写规范:
- 缩进:4个空格;
- 行宽:< 100个字符;
- 代码行内要适当多留空格,如“=”、“+=” “>=”、“<=”、“+”、“*”、“%”、“&&”、“||”、“<<”,“^”等二元操作符的前后应当加空格。对于表达式比较长的for语句和if语句,为了紧凑起见可以适当地去掉一些空格,如for (i=0; i<10; i++)和if ((a<=b) && (c<=d));
- 在一个函数体内,逻揖上密切相关的语句之间不加空行,逻辑上不相关的代码块之间要适当留有空行以示区隔;
- 在复杂的表达式中要用括号来清楚的表示逻辑优先级;
- 花括号:所有 ‘{’ 和 ‘}’ 应独占一行且成对对齐;
- 不要把多条语句和多个变量的定义放在同一行;
命名规范:
- 类名、函数名、变量名等的命名一定要与程序里的含义保持一致,以便于阅读理解;
- 类型的成员变量通常用m_或者_来做前缀以示区别;
- 一般变量名、对象名等使用LowerCamel风格,即第一个单词首字母小写,之后的单词都首字母大写,第一个单词一般都表示变量类型,比如int型变量iCounter;
- 类型、类、函数名等一般都用Pascal风格,即所有单词首字母大写;
- 类型、类、变量一般用名词或者组合名词,如Member
- 函数名一般使用动词或者动宾短语,如get/set,RenderPage;
三.模块化设计
?????模块化设计是指在对一定范围内的不同功能或相同功能不同性能、不同规格的产品进行功能分析的基础上,划分并设计出一系列功能模块,通过模块的选择和组合可以构成不同的产品,以满足市场的不同需求的设计方法。
?????一个软件系统可按功能不同划分成若干功能模块。软件系统的层次结构正是模块化的具体体现。把一个大而复杂的软件系统划分成易于理解的比较单纯的模块结构,这些模块可以被组装起来以满足整个问题的需求。
?????模块化软件设计的方法如果应用的比较好,最终每一个软件模块都将只有一个单一的功能目标,并相对独立于其他软件模块,使得每一个软件模块都容易理解容易开发。从而整个软件系统也更容易定位软件缺陷bug,因为每一个软件缺陷bug都局限在很少的一两个软件模块内。而且整个系统的变更和维护也更容易,因为一个软件模块内的变更只影响很少的几个软件模块。因此,软件设计中的模块化程度便成为了软件设计有多好的一个重要指标,一般我们使用耦合度(Coupling)和内聚度(Cohesion)来衡量软件模块化的程度。
?????对比menu小程序的lab3.2版本和lab3.3版本,我们发程序中添加了linklist.c文件和linklist.h文件。进行了模块化设计之后我们往往将设计的模块与实现的源代码文件有个映射对应关系,因此我们需要将数据结构和它的操作独立放到单独的源代码文件中,这时就需要设计合适的接口,以便于模块之间互相调用。
?内聚度:内聚度是指一个软件模块内部各种元素之间互相依赖的紧密程度。理想的内聚是功能内聚,也就是一个软件模块只做一件事,只完成一个主要功能点或者一个软件特性(Feather)。
?耦合度:耦合度是指软件模块之间的依赖程度,一般可以分为紧密耦合(Tightly Coupled)、松散耦合(Loosely Coupled)和无耦合(Uncoupled)。一般在软件设计中我们追求松散耦合。
良好模块设计标准:
- 模块可分解性:可将系统按问题/子问题分解的原则分解成系统的模块层次结构。
- 模块可组装性:可利用已有的设计构件组装成新系统,不必一切从头开始。
- 模块可理解性:一个模块可不参考其他模块而被理解。
- 模块连续性:对软件需求的一些微小变更只导致对某个模块的修改而整个系统不用大动。
- 模块保护:将模块内出现异常情况的影响范围限制在模块内部。
- 我们在模块化程序设计中应该追求高内聚、低耦合。
- 遵守KISS(Keep It Simple & Stupid)原则
- 一行代码只做一件事
- 一个块代码只做一件事
- 一个函数只做一件事
- 一个软件模块只做一件事
四.可重用软件设计
1.可重用软件简介
?????在软件开发中,由于不同的环境和功能要求,我们可以通过对以往成熟软件系统的局部修改和重组,保持整体稳定性,以适应新要求。这样的软件称为可重(chong)用软件。据统计,现今,开发一个新的应用系统,40%~60%的代码是重复以前类似系统的成分,重复比例有时甚至更高。因此,软件重用能节约软件开发成本,真正有效地提高软件生产效率。
2.接口的设计
?????接口对于实现软件可重用有着重要的意义。接口就是互相联系的双方共同遵守的一种协议规范,在我们软件系统内部一般的接口方式是通过定义一组API函数来约定软件模块之间的沟通方式。换句话说,接口具体定义了软件模块对系统的其他部分提供了怎样的服务,以及系统的其他部分如何访问所提供的服务。在面向过程的编程中,接口一般定义了数据结构及操作这些数据结构的函数;而在面向对象的编程中,接口是对象对外开放(public)的一组属性和方法的集合。函数或方法具体包括名称、参数和返回值等。
接口的五个要素:
- 接口的目的;
- 接口使用前所需要满足的条件,一般称为前置条件或假定条件;
- 使用接口的双方遵守的协议规范;
- 接口使用之后的效果,一般称为后置条件;
- 接口所隐含的质量属性。
3.menu程序举例
为了更好的理解软件的可重用性设计,我们可以查看lab4中的linktable.h接口文件:
/********************************************************************/
/* Copyright (C) SSE-USTC, 2012-2013 */
/* */
/* FILE NAME : linktabe.h */
/* PRINCIPAL AUTHOR : Mengning */
/* SUBSYSTEM NAME : LinkTable */
/* MODULE NAME : LinkTable */
/* LANGUAGE : C */
/* TARGET ENVIRONMENT : ANY */
/* DATE OF FIRST RELEASE : 2012/12/30 */
/* DESCRIPTION : interface of Link Table */
/********************************************************************/
/*
* Revision log:
*
* Created by Mengning,2012/12/30
*
*/
#ifndef _LINK_TABLE_H_
#define _LINK_TABLE_H_
#include <pthread.h>
#define SUCCESS 0
#define FAILURE (-1)
/*
* LinkTable Node Type
*/
typedef struct LinkTableNode
{
struct LinkTableNode * pNext;
}tLinkTableNode;
/*
* LinkTable Type
*/
typedef struct LinkTable
{
tLinkTableNode *pHead;
tLinkTableNode *pTail;
int SumOfNode;
pthread_mutex_t mutex;
}tLinkTable;
/*
* Create a LinkTable
*/
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);
/*
* get LinkTableHead
*/
tLinkTableNode * GetLinkTableHead(tLinkTable *pLinkTable);
/*
* get next LinkTableNode
*/
tLinkTableNode * GetNextLinkTableNode(tLinkTable *pLinkTable,tLinkTableNode * pNode);
#endif /* _LINK_TABLE_H_ */
?????通过启用linktable.h接口文件,就实现了软件的可重用设计,为了进一步实现软件的可重用设计,我们添加一个callback方式的接口使Linktable的查询接口更加通用。给Linktable增加Callback方式的接口,需要两个函数接口,一个是call-in方式函数,如SearchLinkTableNode函数,其中有一个函数作为参数,这个作为参数的函数就是callback函数,如代码中Conditon函数。
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;
}
Condition函数:
int SearchCondition(tLinkTableNode * pLinkTableNode, void * args)
{
char * cmd = (char*) args;
tDataNode * pNode = (tDataNode *)pLinkTableNode;
if(strcmp(pNode->cmd, cmd) == 0)
{
return SUCCESS;
}
return FAILURE;
}
4.接口和耦合度之间的关系:
????对于软件模块之间的耦合度,前文中提到,耦合度是指软件模块之间的依赖程度,一般可以分为紧密耦合(Tightly Coupled)、松散耦合(Loosely Coupled)和无耦合(Uncoupled)。一般在软件设计中我们追求松散耦合。更细致地对耦合度进一步划分的话,耦合度依次递增可以分为无耦合、数据耦合、标记耦合、控制耦合、公共耦合和内容耦合。这些耦合度划分的依据就是接口的定义方式,我们接下来重点分析一下公共耦合、数据耦合和标记耦合。
公共耦合:
- 当软件模块之间共享数据区或变量名的软件模块之间即是公共耦合,显然两个软件模块之间的接口定义不是通过显式的调用方式,而是隐式的共享了共享了数据区或变量名。
数据耦合
- 在软件模块之间仅通过显式的调用传递基本数据类型即为数据耦合。
标记耦合
- 在软件模块之间仅通过显式的调用传递复杂的数据结构(结构化数据)即为标记耦合,这时数据的结构成为调用双方软件模块隐含的规格约定,因此耦合度要比数据耦合高。但相比公共耦合没有经过显式的调用传递数据的方式耦合度要低。
五.可重入函数和线程安全
1.基本概念
?????线程(thread)是操作系统能够进行运算调度的最小单位。它包含在进程之中,是进程中的实际运作单位。一个线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。一般默认一个进程中只包含一个线程。操作系统中的线程概念也被延伸到CPU硬件上,多线程CPU就是在一个CPU上支持同时运行多个指令流,而多核CPU就是在一块芯片上集成了多个CPU核,比如4核8线程CPU芯片就是在集成了4个CPU核,每个CPU核上支持2个线程。有了多核多线程CPU,操作系统就可以让不同进程运行在不同的CPU核的不同线程上,从而大大减少进程调度进程切换的资源消耗。传统上操作系统工作在单核单线程CPU上是通过分时共享CPU来模拟出多个指令执行流,从而实现多进程和多线程的。
????可重入(reentrant)函数可以由多于一个任务并发使用,而不必担心数据错误。相反,不可重入(non-reentrant)函数不能由超过一个任务所共享,除非能确保函数的互斥(或者使用信号量,或者在代码的关键部分禁用中断)。可重入函数可以在任意时刻被中断,稍后再继续运行,不会丢失数据。可重入函数要么使用局部变量,要么在使用全局变量时保护自己的数据。可重入函数的基本要求:
- 不为连续的调用持有静态数据;
- 不返回指向静态数据的指针;
- 所有数据都由函数的调用者提供;
- 使用局部变量,或者通过制作全局数据的局部变量拷贝来保护全局数据;
- 使用静态数据或全局变量时做周密的并行时序分析,通过临界区互斥避免临界区冲突;
- 绝不调用任何不可重入函数。。
?????线程安全:如果你的代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。线程安全问题都是由全局变量及静态变量引起的。若每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说,这个全局变量是线程安全的;若有多个线程同时执行读写操作,一般都需要考虑线程同步,否则就可能影响线程安全。
2.函数的可重入性与线程安全之间的关系
?????可重入的函数不一定是线程安全的,可能是线程安全的也可能不是线程安全的;可重入的函数在多个线程中并发使用时是线程安全的,但不同的可重入函数(共享全局变量及静态变量)在多个线程中并发使用时会有线程安全问题;不可重入的函数一定不是线程安全的。
3.Menu程序中的线程安全
?????在linktable.c文件中添加pthread.h头文件,并且修改LinkTable结构体,添加 pthread_mutex_t mutex。之后就可以利用mutex变量实现进程安全:
struct LinkTable
{
tLinkTableNode *pHead;
tLinkTableNode *pTail;
int SumOfNode;
pthread_mutex_t mutex;
};
?之后修改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;
}
这样就实现了线程安全。
总结
????最后感谢孟宁老师带我了解软件工程方法,学习到了很多代码编写的习惯,以及项目结构搭建过程中需要注意的点,包括代码的风格规范、模块化设计思想(高内聚,低耦合)、可重用接口的实现、使用回调函数和注意线程安全等等。
参考资料: