2.6 C语言入职例程三:函数指针和程序框架入门

2.6.1 勿在浮沙筑高台

前文介绍过,很多企业的培训体系是这样的:

  1. 新人入职后,师傅会给一堆资料让看,然后新人硬着头皮看一些;
  2. 哪天师傅不忙了,惦记起这个新人,然后交给其一个产品,让其折腾;
  3. 可惜真实产品一般都涉及多个学科,面对一大堆疑问,新人会感觉腾云驾雾般难以前行;
  4. 一段时间后部分新人迈过了入职时的绝望悬崖,有了自己的积累,开始慢慢的深入接触产品,但因各种文档资料奇缺,只能一边学习一边调整;
  5. 数年后,新人成为了老手,同时新的产品体系也诞生了;
  6. 然后重复以上死循环。

如果没有刻意的训练和设计,大多数产品都是以这样的模式做出来的。这类产品的代码,会呈现出强烈的堆砌感,所有的软件功能是简单粗暴堆砌在一起的。没有严格的分层,各模块之间耦合繁杂,修改一个功能需要涉及到很多代码模块,通用的功能很难移植扩展……,最终的结局就是很多企业面对的困局:老人脱不了身,新人上不了手。

代码模块不应该是简单的堆砌在一起,优秀的产品是需要优秀的架构设计的。什么是优秀的架构设计,工控产品种类繁多,差异很大,很难有统一的标准。但如果产品代码大部分是C语言构成的,应该会用到相当量的函数指针。换句话说,如果我们的产品中几乎没用到函数指针,就表明我们还有很大的进步空间。

◇◇◇

不仅传统的堆砌代码,新人难以入手,即使是基于优秀架构设计的产品,新人如不掌握方法,也会难以入手。

我上大二时,一个快参加工作的同学找我玩,告诉我只要学好了VC6.0,以后找工作就可以随便挑了。那是1999年的事情了,实际上即使今天,很多企业软件依然在使用VC6,哪怕面临N多版本兼容问题,可想而知当初VC6.0的重要性。

一听到这个消息,我当然两眼冒光了。然后就去图书馆找了一堆书籍,照书画猫,一路step后,一个漂亮的程序就诞生了。惊喜之余,困惑紧随其后,生成的代码看不懂啊,这是C++语言吗?一大堆宏啥玩意儿,main函数在哪儿,新增加模块在哪儿写代码,……。

一边是赚钱的希望,一边是实际的困惑,进退维谷。记得那个时候,很长一段时间,我都非常困惑。幸运的是,在我一筹莫展的时候,一本书救了我,几乎是柳暗花明的感觉,豁然开朗。

该书叫《MFC深入浅出》,台湾侯捷先生写的,后来这本书被大家称为MFC四大天王之一。这本书一开始先从一个最简单的windows程序讲起,然后用模仿的手段帮大家理解MFC中的各种宏定义和背后技术,最后才是一个简单的MFC示例程序。

记得我当时将这本书通读完后,再去看默认生成的代码,再也没有那种晦涩难懂的感觉,所有的东西都是如此的亲切舒服。后来再学习MFC编程时,几乎是一马平川了。

为何会出现这种情况呢,我们首先来了解一下基于MFC编程的默认学习曲线:
在这里插入图片描述

以MFC开发程序,一开始很快速,因为开发工具为你产生了一个骨干程序,各种界面一应俱全,但是MFC的学习曲线十分陡峭,程序员从默认架构出发,到有能力修改程序代码以符合真实产品的需要,是一段不易攀登的峭壁。

增加《MFC深入浅出》一书的学习后,学习曲线如下:
在这里插入图片描述

从windows程序的入口WinMain函数开始,然后是窗口类别,然后是构建窗口,然后取得消息,然后分发消息,然后决定如何处理消息。

该书通过一系列模仿手段,让我们理解了MFC类库是如何将这些基础动作整合起来的,虽然初看走了N多弯路,但学习曲线却是台阶式的,条例分明的,整个学习曲线会缓和很多。

虽然目前学习MFC框架的人已经很少了,我也不推荐大家去学习使用,但这一段学习经历对我的职场生涯影响颇大,在我内心中牢牢烙下了一句话:“勿在浮沙筑高台”。

◇◇◇

同MFC框架类似,多年的迭代之后,我们的产品也呈现出框架思维。新人初接触这一大团东东时,也容易迷糊。怎么才能帮助大家迈过这个坎呢?

谈及架构设计,不可能回避面向对象设计思想,我们的产品中大量的使用各种面向对象设计思想。那么,是否可以从该方面入手呢?

我自己特别喜欢“四人帮”写的《设计模式》那本书,这本书很便宜,很薄,全是干货。有段时间,我强烈给大家推荐这本书,甚至花费很大精力,将常用的设计模式整理出来,供团队内部集中讲解交流,但发现效果寥寥。大家都感觉很好,但一碰到真实代码就无从下手。

后来反思这段打脸经历,我终于理解了知识是需要分级的,和入门者谈设计模式会被认为装叉,如同在贫民窟中谈论品味生活找骂一般。

什么样的架构设计思想适合起步呢?我深入挖掘我们的产品中用到的各种程序技巧,并结合自己的多年新人培训经验,我挖掘出了两个闪光点:抽象和注册机制。本小节谈及的程序框架入门,就是指注册机制,抽象的概念会在第五章接口和模块化中深入展开。

先反复的、使劲的、刻意的锤炼这两个基本技能,在大家的大脑中深深的烙下回沟,让其成为大家的下意识行为,后面的一切好像就可信手拈来了。

理解注册机制需要先理解函数指针,下一小节,我们先补足函数指针这个短板。

2.6.2 函数指针

我一般一个月统计一次家庭消费,拿出计算器,噼里啪啦两分钟,搞定。

大家有没有意识到,很久以来,计算器都是以这种模式工作的,如老祖宗的算盘,还有各种机械计算器。我们输入数据,计算器完成计算并告诉结果,我们记录结果,然后在输入,在计算,在记录结果……这里,计算器仅会计算,而整个流程实际上是由我们控制的。

这种模式一直持续了很久很久,直到有一天,大概是在1946年的某月某天,一个美籍匈牙利数学家,名字叫冯·诺依曼,突然头脑风暴,意识到计算器不仅能计算,还能存储处理流程。从此刻开始,数据和程序(也即数据处理流程)被等同了,程序也可以被当做数据处理了,新的篇章开始了。

从那一刻起,计算器成了计算机,世界开始天翻地覆。当然,为了伺候计算机,不知坑苦了多少苦逼的程序猿。

◇◇◇

依据冯·诺依曼结构,程序是可存储的,而指针的定义是指向存储结构的,因此,指针也可以指向程序。为了便于定位,一般让其指向某个函数的起始位置,习惯性将其称之为函数指针。我们终于迎来了C语言中非常关键的概念,如同打开潘多拉魔盒,会给我们带来许多意想不到的惊喜,但也带来了诸多困惑。

先通过一个简单的例子来描述函数指针的基础语法:

/* 定义一个函数 */
int aa(int a)
{
	return 1;
}

/* 定义一个函数指针,原型要一致 */
int (*pfn)(int n);

/* 赋值操作 */
pfn = aa;

/* 调用操作 */
int a = pfn(1);

上面的几段示例代码片段描述了函数指针的常规语法,不知大家是否有如下疑惑:

  1. 赋值操作没有地址取址符(&)啊,好诡异;
  2. 函数指针表达式好诡异啊,看的人头晕,纯粹在折腾人吗;
  3. 通过函数指针方式调用函数,好似有点脱了库放屁的嫌疑。

◇◇◇

第一条,函数指针赋值为何操作没有取址符(&)。实际上如果你感觉不爽的话,完全可以加一个,总之“pfn = aa;”和“pfn = &aa;”的效果是一模一样的。这也证明了函数本身就是指针了,因此函数aa的调用也可以写成如下的样子:
(*aa)(1);

理解了这个概念,碰到嵌入式产品中,需要C语言和汇编混合编程时,就会少一些困惑。

实际上同函数类似的还有数组,数组也是指针,因此下面的语法是成立的;

int sz[] = {1,2,3,4};
int *p = sz;

在这种写法,我们团队(包括我)好多小伙伴总有不踏实的感觉,大家习惯性写成:

int *p = &sz[0];

你能体谅这种为了寻求概念清晰而采取的啰嗦策略吗!

◇◇◇

第二条,大家都感觉函数指针表达式好诡异,人之常情啊!因为同其他类型变量定义长的不一样,尤其是我们的函数指针还经常用于数组和参数的情况。大家如果感觉还不晕,我继续加把火,体味体味如下代码片段吧(我保证这些都是常用代码片段)。

/* 函数指针赋值 */
int (*pfn)(int n) = aa;

/* 函数指针数组赋值 */
int (*pfn[])(int n) = {aa, bb, cc};

/* 函数指针作为函数参数传递 */
void fun(int (*pfn)(int n)){}

有没有头晕的感觉,还记得我在例程二指针混合运算中提到的表达式(p[])()吗?这就是函数指针数组定义。因为[]的优先级高于,因此p首先是一个数组,然后数组中的每一项是一个指针,是什么指针呢,外部括号表明是一个函数指针,后面的括号表明这个函数无参数,默认返回。综述,p就是一个函数指针数组。

真实产品代码需要多人审核,是不允许出现类似代码的。如何破,破解大法就是typedef,转化后代码示例如下:

/* 函数指针定义 */
typedef int (*PFN)(int n);
PFN pfn;

/* 函数指针赋值 */
pfn = aa;

/* 函数指针数组赋值 */
pfn[] = {aa, bb, cc};

/* 函数指针作为函数参数传递 */
void fun(PFN pfn){}

是否清爽了好多,因此,在我们的产品代码中有一条编程规范:函数指针必须通过typedef方式使用。

◇◇◇

函数指针本质上依然是一个变量,既然是变量,就应该支持各种运算符的,支持哪些呢?

在例程二指针一节中,我们深入探讨过指针变量支持的运算符,有&,*,==,!=,<,>,+,-等,并提及并非所有类型的指针变量都支持所有类型的运算符。指针运算符的这种特点给大家带来了很多困惑,碰到这类问题,我们习惯采用整理汇总的方式进行规范。以前已经整理过了,增加函数指针概念后我们迭代一次,如下:

  1. 任何指针(常用于函数指针)和0进行相等或不等的比较都是有意义的;
  2. 指针(不含函数指针)可以和整数进行加减操作,如p+n,需要注意的是,是按照指向对象大小进行加减的;
  3. 同一数组(不含函数指针数组)中的指针可以进行比较操作,用于判断前后关系;
  4. 同一数组(不含函数指针数组)中指针相减,尤其是减去头部指针可判断当前元素位置,常使用的技巧。

◇◇◇

第三条,通过函数指针方式调用函数,好似有点脱了库放屁的嫌疑,这是因为这段代码很简单,该处仅需要直接调用,而不需要间接调用。

如果我们要使用一个功能,是直接调用呢,还是间接调用呢,很多人肯定会回答直接调用。但如果关联思考一个问题,如果我们现在想吃苹果了,是直接找果农买苹果呢,还是到超市去买。

在计算机行业内有一句经典名言:任何问题都可以通过增加一层抽象层来解决,而函数指针经常就是用来实现抽象的,它让程序的世界变得精彩纷呈。

为了让新人理解函数指针的这个特点,例程三出现了:给学生信息管理系统中,每个学生需要增加一个自我介绍功能,为了珍惜每个学生的表现欲,形式不限,可以是一段话,可以是一个动画,甚至可以是一个游戏。

因为各个学生的表现方式不同,我们没有统一的数据结构来存储“自我介绍”,不妨将其下放给每个学生,如何下放呢?就需要借助函数指针来实现。

经过这样的改造,我们的学生信息结构如下:

typedef void (*SelfIntro)(void);
struct student
{
    int num;           /* 学号 */
    float score;    	 /* 成绩 */
    SelfIntro pfnSelf;      /* 自我介绍 */
    struct student *next;  /* 指向下一结点 */
};

每个学生可以有个性的自我介绍,示例如下:

void student1()
{
    printf("我是小马儿,一个渴望良知与灵魂的嵌入式软件工程师。");
}

void student2()
{
   /* 准备开始播放一段动画 */
   play(...);
}

void student3()
{
   /* 准备开始唱歌 */
   music(...);
}
……

然后,在构建学生信息时,需要将这个各具特性的函数传递进去,构建学生信息的函数原型如下:

void createStudent(int num, float score, SelfIntro pfnSelf);

构建过程如下:

createStudent(1, 35, student1);
createStudent(2, 96, student2);
createStudent(3, 89, student3);
……

此时,奇妙的事情发生了,虽然每个学生的个人介绍千差万别,但学生信息管理系统可以以统一的方式组织,如让所有考试及格的学生依次做一个自我介绍,程序示意如下:

void fun(void)
{
    student* p;
    for (p = mgr.next; p != NULL; p = p->next)
    {
       if (p->score >= 60.0f)
           p->pfnSelf();
    }
}

如果将上述这段代码当做框架代码,而每个学生的自我介绍是基于框架的特定应用,是否能嗅到一丝框架程序设计的味道。

2.6.3 注册机制

在我们团队负责的产品中,有一种简单的程序技巧使用频率颇高,很多的程序框架都是基于该技巧演化出来的。团队内部为了便于交流,起了一个很直观的名字:注册机制。在培训过程中,我发现,新人只要熟练掌握了该技巧,再去接触整个架构设计,就会顺利许多。

为了让大家更好理解,我们用一个例子来描述这个概念。现在的大家估计都是手机控,吃大餐前要先拍张照片刷刷朋友圈啥的,我们就拿相机这个软件开刀吧。

朋友圈晒图片主要有两种典型操作模式:

  1. 打开相机,咔嚓一声,然后打开微信,选择发送图片,打开图库软件,从已拍好的照片中选择一幅图片并发送;
  2. 打开相机软件,咔嚓一声,然后点击发送,并选择发送微信朋友圈。

(注:目前微信功能已经越发强大了,本身已集成了拍照和图库模块,该处我们侧重于理解微信和拍照这两个软件模块之间的交互逻辑。)

该处,请大家思考一下,这两种操作模式那种好呢。然后,我们会惊奇的发现,这个“好”面对用户和工程师时可能是不一样的。

对用户来说,肯定是第二种操作模式好了,因为操作步骤少,逻辑清晰,而且不需要面对从一大堆图片中找出一幅图片的痛苦。

但是,将逻辑切换到工程师角度,会有截然不同的观点。相机是一个基本软件模块,提供拍照和看图片的功能;而微信是一个高级软件模块,需要使用图片。软件设计希望高内聚低耦合,相机模块又是很多高级模块都用到的底层模块。因此,好的设计理念是先写好相机模块,并提供接口,然后由微信模块调用。

因此,工程师更倾向于第一种操作逻辑。

进入移动互联网时代,用户体验越发重要,因此,市场人员发话了,领导发威了,受伤的总是郁闷的工程师。必须增加第二种操作逻辑,如何修改呢?让我们先来描述一下最朴素的修改策略吧,伪码如下:

用户发送功能(void)
{
	if (微信已经安装)
	 {
		 if (用户选择发送给某个朋友)
		  	微信.发送个人(朋友,图片);
		 else if (用户选择发送朋友圈)
		 	微信.发送朋友圈(图片);
	 }
}

呵呵,终于搞定了,可以实现第二种操作逻辑了,皆大欢喜,至于相机软件和微信软件是否被迫紧密的耦合在一起,就顾不得那么多了。

好运不长,其他应用发现微信这个操作方式好,纷纷效仿,因此,QQ来了,微博来了,甚至便签都跑来凑热闹了,此时的代码已被修改的满目伤痕,伪码示意如下:

相机发送(void)
{
	if (微信已安装)
	{
		if (用户选择发送给某个朋友)
			微信.发送个人(朋友,图片);
		else if (用户选择发送朋友圈)
			微信.发送朋友圈(图片);
	}
	
	if (QQ已安装)
	{
		if (用户选择发送给某个朋友)
			QQ.发送个人(朋友,图片);
		else if (收藏)
			QQ.收藏(图片);
	}

	if (微博已安装)
	    微博.发送(图片);
	if (便签已安装)
	    便签.发送(图片);
	 ……
}

拿起我的手机,发现有好多发送选项:微信、朋友圈、QQ、微博、小米快传、微信收藏、短信、蓝牙、便签、二维码、邮件、beam、MetaMoJiNote、有道云笔记、拍立淘、发送到电脑、美图秀秀-美化图片、美图休息-人像美容、支付宝、面对面快传、QQ收藏……

俗话说千里之堤,溃于蚁穴,此时上面这段代码已经成了灭绝老太的裹脚布——又臭又长。高内聚低耦合的设计理念早就被扔到垃圾桶里面了,各模块之间互相调用,如同乱麻一样胶合在一起,……

◇◇◇

上面这段代码的困局,非常适合通过注册机制来破解:相机模块仅额外提供注册机制,各高级应用模块初始化时注册接口,而相机模块发送时仅调用接口即可。伪码示例如下:

相机注册(高级应用接口)
{
    保存并管理所有高级应用接口,包含名称、图标、接口函数等;
}

相机发送(void)
{
    以列表方式展现所有的高级应用接口,可以通过名称或图标方式展示;
    获取用户选择了哪个接口;
    调用相应的高级应用接口;
}

这个世界瞬间清净了,不管使用图片的高级应用有多少,而相机模块代码不在需要修改了,且保留了高内聚低耦合的设计理念,所有的软件模块之间都解耦了。

◇◇◇

为了实现注册机制,需要用函数指针保存各种接口函数,并以间接方式调用。相机及注册机制代码示意如下:

/* 注册函数定义 */
typedef void (*FunRegister)(unsigned char* szMenuName);
#define MAX_REGISTER_COUNT 16	/* 最大允许的注册个数, 一般位于系统配置文件内 */
struct TRegister
{
	LPCTSTR szMenuName;	/* 菜单名字 */
	FunRegister pfn;		/* 注册函数 */
}register[MAX_REGISTER_COUNT];
int nIndex;	/* 索引兼注册个数 */

/* 初始化 */
void init(void)
{
	nIndex = 0;
	for (i = 0; i < MAX_REGISTER_COUNT; i++)
	{
		register[i].szMenuName = NULL;
		register[i].pfn = NULL;
	}
}

/* 注册过程 */
int registerProc(LPCTSRT lpszMenuName, FunRegister pfn)
{
	if (nIndex >= MAX_REGISTER_COUNT)
		return 0;
	register[nIndex].szName = lpszMenuName;
	register[nIndex].pfn = pfn;
	nIndex++;
}

/* 选择一副图片后,点击发送,弹出菜单列表 */
void clickMsg(void)
{
	int i;
	for (i = 0; i < nIndex; i++)
		popMenu(register[i].szMenuName);
}

/* 用户选择某一项菜单,执行消息函数 */
void menuMsg(int i, unsigned char* pImage)
{
	register[i].pfn(pImage);
}

/* 应用层构建注册函数并注册的过程 */
void user1(unsigned char* pImage)
{
	……
}
register("user1Menu", user1);

◇◇◇

此时,大家是否已经深刻的理解了注册机制呢?多年的带人经历,我发现即使一个简单的技能,都需要经历一定数量的刻意训练,才能将其内化成自己的下意识行为。

在审核新人代码时,我经常刻意挖掘出一些适合用注册机制优化的地方,以帮助新人锻炼。该处举一个在真实产品中的真实例子,便于大家举三反一,加深理解。

电度计算是很多仪表类产品的基本功能,原理很简单,在固定的时间间隔累加功率即可,当然我们要考虑有功无功,考虑正向负向,因此有了四象限电度。

很简单的一个软件模块,但需求多变,假如用户要求增加分时电度功能,也就是将一天分为多个时段,分别统计尖峰平谷电度值呢?

最朴素的策略就是修改电度模块,但分时判断逻辑也是一个复杂的模块啊,需要参数和维护软件配合,因此我们的电度模块代码开始臃肿了。

霉运一般在你落魄的时候准时而来,不仅分时电度来了,XX电度,YY电度,ZZ电度也来了,作为程序员,我内心经常有想上去狠狠的踹用户几脚的感觉啊!

如果你还记得我啰啰嗦嗦反复提及的注册机制,是否会眼前一亮,这不正是注册机制发威的地方吗。

不管是分时电度,还是YY电度,基本都是在某条件下的电度统计功能,我们可以将电度模块提炼为定间隔累积和在特定条件累积两层,伪码示例如下:

/* 电度累积注册函数 */
typedef void (*FunRegister)(int nEnergy);
#define MAX_REGISTER_COUNT 16	/* 最大允许的注册个数 */
FunRegister register[MAX_REGISTER_COUNT];
int nIndex;	/* 索引兼注册个数 */

/* 初始化 */
void init(void)
{
    nIndex = 0;
    for (i = 0; i < MAX_REGISTER_COUNT; i++)
        register[i] = NULL;
}

/* 注册过程 */
int registerProc(FunRegister pfn)
{
    if (nIndex >= MAX_REGISTER_COUNT)
        return 0;
    register[nIndex++] = pfn;
}

/* 定时间隔电度统计过程 */
void clickMsg(void)
{
    int i, energy;
    energy = ……;	/* 定时间隔电度累加 */
    for (i = 0; i < nIndex; i++)
        register[i](energy);
}

/* 分时电度统计过程 */
void timeEnergy(int nEnergy)
{
    依据用户设定进行分时类型判断;
    switch (当前分时类型)
    {
        case 尖:
            尖电度 += nEnergy;
            break;
        case 峰:
            峰电度 += nEnergy;
            break;
        ……;
    }
}

2.6.4 总结与思考

好的产品代码是有架构设计的,优秀的架构设计可以解耦模块,分离框架程序和应用程序,将修改范围限定在一个比较小的范围内。在嵌入式系统中,如果基于C语言编程,为了实现各种架构理念,必然需要增加各种抽象层,引入各种间接调用机制,此时,一般都需要用到函数指针。

增加函数指针后,指针变量允许的各种操作,汇总如下:

  1. 任何指针(常用于函数指针)和0进行相等或不等的比较都是有意义的;
  2. 指针(不含函数指针)可以和整数进行加减操作,如p+n,需要注意的是,是按照指向对象大小进行加减的;
  3. 同一数组(不含函数指针数组)中的指针可以进行比较操作,用于判断前后关系;
  4. 同一数组(不含函数指针数组)中指针相减,尤其是减去头部指针可判断当前元素位置,常使用的技巧。

一开始面对复杂的架构设计,学习曲线会比较陡。不妨先熟练掌握一些常见的简单框架程序技巧,可起到事半功倍的效用。在我们的产品中,注册机制是一种最基本最简单的程序技巧,熟练掌握,进入架构世界后会容易许多。

注册机制是一种技能,而非知识,要掌握它需要一定量的刻意训练,不然你会有一种朦胧的感觉,明明感觉很简单,就是用不起来。

——————————————

我是小马儿,一个渴望良知与灵魂的嵌入式软件工程师,欢迎您的陪伴与同行,如感兴趣可加个人微信号nzn_xiaomaer交流,需备注“异维”二字。

发布了14 篇原创文章 · 获赞 18 · 访问量 3200

猜你喜欢

转载自blog.csdn.net/zhangmalong/article/details/104122879