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交流,需備註“異維”二字。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章