查看原文
其他

C语言的设计模式

cnblogs 嵌入式云IOT技术圈 2021-01-31

单一职责

单一职责原则:通常的定义是只专注于做一件事和仅有一个引起它变化的原因。对于接口、实现、函数级别往往我们比较容易关注单一职责,大家谈的也比较多,但对于返回值、参数可能不会有太多的人关注。但往往就是这些不符合单一职责原则的设计可能导致一些很难发现的BUG。看看下面这段代码:

pBuf = (byte*)realloc( pBuf, size);
if( pbBuf != NULL )
{
TODO...
}

可能很多人一眼看上去并没有什么问题,先让我们看看这个库函数的定义:

函数简介

  • 原型
extern void *realloc(void *mem_address, unsigned int newsize);

语法:指针名=(数据类型*)realloc(要改变内存大小的指针名,新的大小)。

  • 功能

    先判断当前的指针是否有足够的连续空间,如果有,扩大mem_address指向的地址,并且将mem_address返回,如果空间不够,先按照newsize指定的大小分配空间,将原有数据从头到尾拷贝到新分配的内存区域,而后释放原来mem_address所指内存区域,同时返回新分配的内存区域的首地址。即重新分配存储器块的地址。

  • 返回值

    如果重新分配成功则返回指向被分配内存的指针,否则返回空指针NULL。正常情况下pBuf是新空间的地址没有任何问题,但我们考虑下如果分配失败了呢,pBuf会被赋值成NULL,pBuf原指向的地址空间就没有指针指向了,造成了内存泄露。这种问题往往很难定位。熟悉realloc机制的人可能对这个问题很不屑,认为高手不会犯这些错误。但我们可以想下有没有办法设计一个好的接口让菜鸟也写出不会出错的代码呢。假设这个库函数的接口是这样的呢:

函数简介


  • 原型 

        extern flag realloc(void **ppMem_address, unsigned int newsize);

  • 语法

    返回值 =(数据类型*)realloc(要改变内存大小的指针名,新的大小)。

  • 返回值

    如果重新分配成功则返回指True(ppMem_address保存新分配空间地址),否则返回False(ppMem_address保存老空间地址)。

相信任何一个使用这个接口的人都会写出下面的代码:

if( True == realloc( &pBuf, size))
{
TODO...
}
else
{
    TODO...
}

为什么有人会犯pBuf = (byte*)realloc( pBuf, size);这种错误?因为他只关注了realloc返回值是一个地址,没有关注该返回值还有错误识别的功能,换句话来说这个库函数的返回值不具备单一职责,导致了可能的错误使用。如果使用改进后的接口,因为返回值只有一个判断分配成功与否的功能,相信没有人还会用错。

我们再仔细看看我们新的接口,总觉得似乎有什么地方还是不对,看到void **ppMem_address可能要想一下明白,这个参数既是入参又是出参,它承担了原始地址的输入和新地址的输出,这不又违反了单一职责吗?好吧我们再改进一下:

函数简介

  • 原型 
    extern flag realloc(void *pIn_Mem_address,void **ppOut_Mem_address, unsigned int newsize);
  • 语法 返回值 =(数据类型*)realloc(要改变内存大小的指针名,新的内存指针名,新的大小)。
  • 返回值 如果重新分配成功则返回指True,否则返回False。

现在这个接口就算一个初次看到的人也应该大概知道什么意思,相信也不会写出什么带BUG的代码,因为函数的参数、返回值都具有单一的功能,通过返回值来判断分配成功与否,通过出参来获取地址。一切看起来都很清晰。

在C库中还有很多类似的函数,如果当初的设计人员能多考虑单一职责,也许现在的系统中就会少了很多隐藏的BUG,接口永远是给别人使用的,一定要把使用者当成傻瓜,也许才能设计出好的接口。

面向对象机制的实现

为什么要用C来模拟面向对象的机制,在实际的工作中我们往往在感慨一些面向对象的经典设计模式由于C语言的限制无法使用,其实通过简单的模拟面向对象的行为,在C语言中也可以使用这些模式。

1:类的构建

类描述了所创建的对象共同的属性和方法。我们在一个源文件中通过把数据和操作进行适当的组织来完成类的模拟。

/*类的数据*/
typedef struct SQUARE_S SQUARE_T;
struct SQUARE_S
{
void (*draw)(void*);
int sideLen;
};
/*类的方法*/
static void draw(void* pObj)
{
SQUARE_T* pSqr = (SQUARE_T*)pObj;
printf("Draw Square len is %d\n",pSqr->sideLen);
}

如上所示,一个正方形的类我们用一个结构体SQUARE_T来表示正方形的属性,draw是其中的一个方法。

2:类的封装性

类的封装一般要求对细节的隐藏并且提供指定的方法供调用者使用,在SQUARE这个类中,sideLen是图形的细节,只需要提供一个draw接口给调用者。因此在提供给外部调用的接口头文件中构建如下的接口。

typedef struct SHAPE_S SHAPE;
struct SHAPE_S
{
void (*draw)(void*);
};

通过定义不同的数据结构来达到数据隐藏的目的,如下图所示,对外接口中只能看到draw,内部实现中可以看到draw和sideLen。

3:多态的模拟

多态无疑是面向对象语言的很重要的一个机制,很多面向对象的设计模式都是以多态为基础,C语言并不支持多态,导致很多设计模式都无法直接使用。

一个典型的多态例子,通过声明一个SHAPE接口,根据实例化对象类型的不同,pShape在运行时动态的表现不同的行为。

SHAPE* pShape = NULL; //一个形状接口
pShape = (SHAPE*)Ins(SQUARE,2); //实例化为一个正方形
pShape->draw(pShape); //pShape表现为正方形的行为

多态机制的实现依赖函数指针,在每个类的构造函数中把相关接口用具体的函数地址填充,这样在实例化一个对象的时候我们才绑定了其具体的操作,也就是所谓的动态绑定。

/*每个类的构造函数*/
static void* Constructor(void* pObj,va_list* pData)
{
    SQUARE_T* pSquare = (SQUARE_T*)pObj;
    pSquare->draw = draw; //具体行为的填充
    pSquare->sideLen = va_arg(*pData,int);
    return pObj;
}

4:对象的创建

有了类,我们需要实例化为可以运行的对象,实例化主要的工作是分配内存、动态绑定、数据初始化等工作。

void* Ins(const void* pClass,...)
{
CLASS* pCls = NULL;
void* pObj = NULL;
va_list vaList = NULL;
pCls = (CLASS* )pClass;
pObj = malloc(pCls->classSize);
memset(pObj,0,pCls->classSize);
va_start(vaList,pClass);
pObj = pCls->Constructor(pObj,&vaList);
return pObj;
}

接口隔离

定义为客户端不应该依赖它不需用的接口,在C语言中我们可以把头文件看成一个模块的接口,根据接口隔离原则也就是说这个头文件中只能包含外部需要的接口,但在实际的项目中往往头文件都不符合接口隔离原则。

1:内、外部接口的隔离

头文件中通常包含了模块内部接口(内部类型定义、内部接口声明)和外部接口(外部接口声明)

假设moudle模块对外提供一个fun1接口,模块内部实现需要定义一个结构类型,一般的实现如下:

/*moudle.h*/
typedef struct str_s str_t;
struct str_s
{
int a;
int b;
};
void fun1();

/*moudle.c*/
#include "moudle.h"
void fun1()
{
str_t s = {0};
TODO...
}

客户端在使用接口的时候需要包含moudle.h文件,而该接口并不符合接口的隔离,其内部包含了客户并不需要的一些定义。为了解决这个问题我们可以通过定义不同的头文件来隔离接口,moudle.h定义外部的接口,moudle.inc定义内部接口

/*moudle.h*/
void fun1();
/*moudle.inc*/
typedef struct str_s str_t;
struct str_s
{
int a;
int b;
};
/*moudle.c*/
#include "moudle.inc"
void fun1()
{
str_t s = {0};
TODO...
};

moudle.h包含外部模块需要的接口,外部模块包含moudle.h,moudle.inc包含内部模块需要的接口,在模块内部包含moudle.inc。通过查看模块的.inc和.h文件,我们就可以清晰的理解模块对外和对内提供了什么接口。

2:避免万能头文件的使用

在实际项目中我们经常可以看到一些头文件包含了所有模块的接口声明,客户端只需要包含这个头文件就可以使用任何接口了。

/*global.h*/
#inlcude "moudle1.h"
#inlcude "moudle2.h"
#inlcude "moudle3.h"
....
#inlcude "moudlen.h"

可能带来如下问题:

会显著的增加编译时间,如果项目大,可能大部分的编译时间都花在展开头文件(笔者一个项目测试80%左右的时间)。

不利于代码的框架的理解,客户端无法从包含的头文件中清晰的看到依赖什么外部模块。

3:如果没有隔离接口可能会导致一些误操作:

一个数据获取模块提供两个接口分别从网络和本地缓存获取数据,后台管理模块使用网络接口定时获取数据更新缓存,前台模块使用缓存接口快速获取数据显示,由于没有对接口隔离,后期的维护人员可能并不清楚开始的设计,在前台模块中直接使用网络接口来获取数据显示,导致界面延迟严重。如果一开始就把接口分离,给前台模块提供本地缓存接口,给后台模块提供网络接口,就不会导致问题的出现。

往期精彩

嵌入式系统软件架构设计(长篇深度好文)

分享一个非常有用且简单C语言测试框架

分享一个自己量产项目上的集成测试软件MTTEST

使您的软件运行起来: 防止缓冲区溢出(C语言精华帖)

若觉得本次分享的文章对您有帮助,随手点[在看]并转发分享,也是对我的支持。


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存