2014年3月9日 星期日

談談應用程式介面的風格 - Flat C APIs (完)

來講講今天的主題吧,應用程式介面的風格,也就是一般常說的 API Style,相信有寫過比較
大型軟體的讀者們一定都會有呼叫 API 的經驗,例如讀者想要開發 OpenGL 的程式,那得要
先安裝 OpenGL SDK,然後才開始呼叫 OpenGL 的 API 來設計相關的繪圖程式,所以這邊要
先釐清一下許多人常常搞混的兩個名詞 SDK 跟 API,簡單的來說,SDK 是一組軟體工具包
一般來說具有平台相依性,會需要安裝 SDK 就表示讀者的程式可能用到了一些特殊的 API
可能是一條而已,也可能大量使用,不安裝該 SDK 的話,程式就無法建構,通常 SDK 裡面
都包含了一大堆 Header File、Library File 還有 Binary File,講得更廣應該還要包含完整的範例
、教學手冊、文件,有些巨型的程式庫還會有工具集 (Utilities),一個很好的例子就是 DirectX
當然學習 DirectX 並不是一件容易的事情,另外一個經典的例子就是 FMOD



用最簡單的方式理解一個基本的 SDK 可以用圖一表示:


圖一. 一套 SDK 於 Windows 系統下常見的組成結構
Binary Files 在 Linux 系統下為 .so
Library Files 在 Linux 系統下為 .a

一般常常講的 APIs 就是一條一條宣告於 Header Files 裡面的函式,也有可能是類別,DLL 裡面
則實現了那些 APIs 的程式碼,至於 LIB 檔就是給開發者做隱式連結使用這些在 DLL 函式內
的符號檔,裡面會有 APIs 的符號名稱與每一條 API 的進入點位址,當然 SDK 不見得會用這
種方式呈現,例如 採用 Static Library 型式的 SDK 只需要提供宣告 APIs 的 Header Files 與
Library Files 即可使用,因為程式庫連結的時候,直接把程式碼合併至執行檔中。另外
還有一種形式的 SDK 只需要 Header Files 就可以運作,SDK 內的整套 API 直接就以原始碼
的形式呈現在 Header Files 中,特色是 能夠在原始碼階段直接與開發者的程式整合在一起
而且沒有任何黑箱,程式庫有問題,開發者可以馬上進行修改,這種形式的程式庫很罕見
商業上在使用的 API 一般都是公司自己的機密,只給用,不給 API 的原始碼。以這種
型式設計 SDK 的例子有 Win32++,所有的類別宣告與實作,通通塞入 Header Files 內。

所以整理一下,可以推演出至少有三種型式的 SDK

  1. 標準型 SDK:以動態連結的方式提供,需要 Header Library Binary
  2. 靜態型 SDK:以靜態連結的方式提供,只需 Header Library
  3. 原始型 SDK:以原始碼呈現方式提供,只有 Header
所以說 SDK 本身的類型決定開發者怎麼使用在 SDK 內的 API 函式群或是說類別群,那麼
API 本身則是讓開發者能夠存取程式庫內的某些狀態與函式來達到開發者所要求的功能,所
以這邊就會出現一個問題,"應該以怎樣的型式來要求這些開發者想要的功能",這個就是
所謂的 API Style,軟體工業經過長期的發展與驗證,人類從建構軟體的過程中確認了主要有
四種型式的 API Style 被用於有系統的建構一套軟體框架,讀者要講說是設計 API 也可以。

  1. Flat C APIs
  2. Object-Oriented C++ APIs
  3. Template-Based APIs
  4. Data-Driven APIs

其中 Flat C APIs 這種 Style 相當重要,就連另外三種類型的 Style 在建構的過程中,往往最底
層還是由 Flat C APIs 來支援,所以另外三種衍生型式的在本文不討論,只討論最重要的第一
類,因為第一類型式的 API Style 即使沒有另外三類一樣可以獨立存活與使用,另外三類就
不行了,它們可以視對第一類 API Style 更加系統化與抽象化的進階技術。

由於原始型 SDK 相當罕見,在講 Flat C APIs 設計風格前,在下給出一個簡單的範例
讓讀者可以稍微理解原始型 SDK 是以怎樣的形式提供 API 給開發者,當然比較完整
的應用,可以參考前面提過的 Win32++ 專案,另外一個很有名的原始型 SDK 的專案
就是 Eigen,是一個龐大的線性代數程式庫,該程式庫使用 C++ 構成,而且採用第三種
API Style,也就是說它是一種 C++ Template Library,但是該程式庫龐大,在下也只有用過
矩陣向量乘法運算的功能,主要是用於控制系統,裡面的 Sample 很多,要完全編譯
時間很長,建議只針對有需求的 Sample 編譯測試,回到原來所述,首先給出這個範例
的檔案結構,如圖二所示。


圖二 . utilities.h 直接提供原始碼形式的 mul2x 函式
給 sub2x.c 與 add2x.c

讀者可以看到,原始碼形式的 API ,函式前面加了一個 Dialect,這個 Dialect 是 __inline
這是一個很重要的 Dialect,它告訴編譯器這條函式將會被 "內列" 於呼叫它的
C 程式檔內,而且該函式只會被編譯器 "內列一次",讀者可能已經昏頭了,因為讀者
看到 sub2x.c 與 add2x.c 不是明明都分別引入了 mul2x 函式嗎?原始碼層面上是這樣沒錯
可是編譯器內部只會記錄一次 _mul2x 符號(_mul2x 是 mul2x 經過編譯後的符號),所以
不少人以為 __inline 主要是把函式類似巨集一樣直接展開放入 caller 內,實際上這個觀念
是完全錯誤,這是其中一種特性沒錯,但是並不是最重要的特性,__inline 這個 Dialect 可以
讓編譯器只記錄一次函式符號,讓我們可以直接用原始碼型式直接提供 API,__inline 要能
夠具有巨集展開的功能還得要選擇編譯器的內嵌展開選項才要效,因此,以為該選項
沒有打開就以為 __inline 沒有任何用途這是錯誤的觀念,"內列一次"才是 __inline 的重點
所在之處,沒打開內嵌展開選項編譯器一樣會使用呼叫函式的形式來使用函式,簡單的講
在 x86 平台上就是組合語言指令 call 與  ret,其他平台像 ARMv7-M 就是用 blbx
要理解上面講的 "內列一次" 觀念很簡單,就是手動編譯程式,看結果如何。

情況一:與圖二同,mul2x 函式有加上 __inline (編譯連結過關)


讀者可以看到程式編譯過關,沒有任何錯誤,這就證明了 __inline 發揮的用途,不過在下
編譯的時候只有下 /c ,讀者有興趣可以產生組合語言看看,讀者將會發現,sub2x 與 add2x
還是以呼叫函式的形式來使用 mul2x,不會有內嵌展開的動作,要產生組語檔可以加 /FA。

情況二:mul2x 去掉 __inline (編譯過關,連結失敗)


這樣就很清楚了吧,可以編譯,但是連結多個 C 語言模組產生的目的檔 (OBJ),將會導致
_mul2x 多重定義,這樣有沒有比較清楚在下講的 "內列一次" 的觀念呢?再次強調
__inline 這個 Dialect 的重點根本不在內嵌展開,這個特性只是其次的功能,"內列一次" 才是
真正用途所在,所以在下覺得設計 C 編譯器的人不知道腦袋在想甚麼,這個 Dialect 很容易
讓人誤會它真正的用途,因為有了 __inline ,就可以直接設計原始碼形式的 API。

情況三:mul2x 改為 static 函式 (編譯過關,連結失敗)


改用 static 也是另外一種設計原始碼形式 API 的方法,不過 static 的話,會使得 mul2x 不會產生外部符號,在主程式 main 裡面呼叫 mul2x 將會失敗,因為連結器找不到 _mul2x 符號
所以圖二的程式得要把主程式 main 內呼叫 mul2x 的函式去掉,就可以連結出程式執行檔了。

以上三種情況的介紹應該可以讓讀者更好的認識 __inline 這個 Dialect 的用途,讀者可能會問
假如把 utilities.h 去掉,且直接在 sub2x.c 與 add2x.c 內的實作各自的 __inline mul2x 函式呢?
其實很簡單,就是要同時擁有 static 與 __inline,這就是嵌入式環境常常看到很多小型工具
函式都是 static __inline,用 GCC 的話就是 static inline 沒有 double underscore,為什麼需要
這種宣告,主要原因在於有些特殊功能沒辦法用 C 語言寫,例如 GCC 沒辦法直接使用
ARMv7-M 的 SVC 指令,這時就得要用一個  C 函式內插組語呼叫 SVC 指令,然後包裝函式
前面加上 static inline,當 GCC 内嵌選項有打開時,SVC 指令就直接插在呼叫函式內,而
static 可以只作用於所在 .C 檔內自己的 SVC 指令的 C Wrapper 函式,其他 .C 檔是看不到該
函式的存在。

以上講了一堆,應該對於原始型 SDK 至少會有簡單的觀念,主要就是應用 static 或 __inline
__inline 的函式會輸出符號,而 static 的函式不會輸出符號

認識 Flat C APIs

Flat C APIs 一個很鮮明的特性就是這些 APIs 必須能夠至少被 C 編譯器進行編譯為目標系統
的目的檔,這種 APIs 通常形式很簡單,主要至少包含兩種內容

1. Free Functions,即全域自由函式群
2. Supporting Data Structures and Constants,支援該函式群的相關資料結構與常數群

Remark:注意,由巨集定義的那些輔助功能不算,雖然一般 Flat C APIs Style 形式的 Library
相關的 Header file 都會提供許多巨集來簡化使用該 API 的某些繁瑣動作,但是 Flat C APIs Style
並不含括這些巨集形式的 "仿函式",因為沒有這些仿函式一樣可以使用 APIs 只是比較麻煩
可是用 const、enum、#define 定義的常數,是算做 Flat C APIs Style 的一部分,這點讀者要稍微
留意一下,因為常數群往往代表了某些函式所要操作的動作參數,一個很由名的例子就是
Windows 下的 DeviceIoControl 函式,它可以讓應用程式對驅動程式下達命令,Windows 下
驅動程式 (副檔名 SYS ) 裡面那些服務,應用程式就是依賴這條 API 可以對硬體進行操作
例如:你想要寫一個像 usbview 這種應用軟體,就得用 DeviceIoControl 操作 HCD 相關的
服務,假如沒有 DDK內的 USBIOCTL.H,就無法知道操作 USB HCD 的 IOCTL Code ,這樣
就不知道如何用 DeviceIoControl 取得需要的 USB HCD 服務,所以常數群也是被視為
Flat C APIs Style 的一部分

以上兩點很重要,也就是這一類的 APIs 可以從對應的 Header file 中發現,它們就只是一條
一條的函式,沒有任何的物件或是繼承的觀念在裡面,每一條函式都是完全的獨立,而且
都是 Free (Free 的觀念有點抽像後面會解釋),因而得名 "flat"

純 C 語言的語法表達具有嚴格的限制,其中很重要的限制就是 C 語言天生就是
Global Namespace,這是很重要的觀念,表示 C 語言本身就是一種 Global Free 的語言
前面講的 Free 就是指這件事情,讀者假如沒有這種觀念的話,在對付大型多個 C 原始檔
專案而沒有好好規劃,常常就會發生函式符號多重定義,函式跨檔引用找不到的問題。

天生具有 Global Namespace 特性的重要 C 關鍵字
1. typedefs         ; 自訂型態關鍵字 typedef
2. structs            ; 結構宣告關鍵字 struct
3. function calls ; 函式呼叫關鍵字 operator ( )

用白話講就是 C 編譯器預設會把它們的符號匯出,C 編譯器假設其他 Object file 可能需要來自
另外一個 Object file 內的符號,這樣使得連結器將所有 Object file 連結在起時,可以找到,來
字不同 Object file 內匯出的符號,所以前面解釋原始碼形式的 SDK 的觀念引入就可以知道
假如你想改變預設行為,反而需要特別用 static 關鍵字向 C 編譯器說 "不要匯出符號",需要有
這樣的機制是因為 C 語言沒有 C++ 的 namespace 關鍵字,所以 Flat C APIs Style 中,通常
該 APIs 的 C 原始內檔私用的函式都會加上 static,用來隱藏該私用的函式的符號名稱,這種
行為有時候也稱做 internal linkage,讀者用 CL 產生組合語言檔就會發現,static 函式
不會有 PUBLIC 這個 MASM 關鍵字,所以在下認為學習程式語言了解組合語言是必要功夫
許多觀念從組合語言觀點來說相當簡單,其實並不神秘,即便沒有要拿組語當作主要工作
的程式語言,還是得要有基本的了解,例如沒有組語的功夫,基本上讀者就不可能去
看懂 VisualBoyAdvanceMMX 指令作快速圖形兩倍放大的 Routine 與 模擬 ARM7TDMI
指令的 C 程式,頂多只能看懂跟視窗程式有關的部分,VisualBoyAdvance 是完整模擬 GBA
的 Emulator,原始碼有極高的研究價值,該程式混合了兩類的設計風格,模擬 ARM7TDMI
與 GBA 硬體周邊是 Flat C APIs Style,而具有 GUI 介面的主程式採用 MFC 與 COM 介面
技術,採用 Object-Oriented C++ APIs,例如該程式用 DirectSound 模擬 GBA 音效播放。
所以像這類完整複雜的應用軟體,除了用到多種 APIs,程式本身其實自己也在建構 APIs
用以供應最終 GUI 主程式使用,所以 Flat C APIs Style 作為基礎程式庫的 APIs Style 具有
相當重要的地位,基本上都是與軟體核心功能有關的實作。

前面講說沒有 namespace 關鍵字容易引起 C 函式名稱衝突,那怎麼辦呢?
Flat C APIs Style 鮮明的特性在這裡就引出來了,該 Style 採用 Common Prefix 加在所有
有要公開的 Global Free Function 與 Data Structures 用來避免名稱衝突,英文稱 Name Collision
這樣在使用多種 C 程式庫才不會引起名稱衝突,但其實以在下的經驗常常還是碰到名稱
甚至版本衝突的問題,因為畢竟各種功能的 C 程式庫實在多到數不清,會撞牆在所難免。

那採用 Flat C APIs Style 到底有甚麼優點?

1. 具有最好的 Binary Compatibility
2. 容易被加入已經存在的 Project 內使用 
3. 容易替換可共享的程式庫 (DLL for win / SO for linux) 而不用重新編譯整個專案原始碼
4. 容易實現重要的基礎 API 作為工具提供者,像 VisualBoyAdvance 就是極佳的範例

這些好的特性隱含,假如讀者所建構的 API 一開始就完全採用 Object-Oriented C++ 作設計
最好還是得要提供一組用 C 介面提供的版本給許多只堅持使用純 C 設計的專案,這樣
才不會喪失廣大的純 C 語言的使用這族群,要知道,在 2012/10,C 語言的地位依然在
第一名的寶座,也就是 25 年王者地位屹立不搖,所以讀者假如是物件導向的愛用者,最好
還是也提供 C 介面版本 (術語叫 C Wrapper),讓程式庫能有更多的人能夠使用,不過依在下
的經驗,看到的情況大部分都是用物件導向技術去封裝 Flat C APIs,因為 Flat C APIs Style 通
常作為系統最基礎的龐大函式群,供應系統各種服務,是系統運轉的源頭,例如 C++ 常用
記憶體配置運算子 operator new ,其實搞到最後還是會去呼叫 Windows 的 Heap APIs,在下
的經驗是另外三類 APIs Style 比較像是 Flat C APIs 的糖衣

動手寫寫一支簡單的 flat C API

先看看下面範例原始碼,這種形式的 API 就是很標準的 Flat C APIs Style


圖三. 簡單的 Flat C APIs Style 範例:操作 Stack

Flat C APIs Style 有一個很鮮明的特性 Common Prefix,在圖二的範例裡面就是

STACK_

API 裡面還有一個很鮮明的特性就是通常第一個參數就是該函式要操作的介面,其實就是
一種資料結構,Flat C APIs Style 用以資料結構為基礎的介面來操作 API,一種資料結構
或多種資料結構常常會被作為操作介面,被整個 API 群使用,另外一個特性就是函式
的建構會有很明顯建立介面破壞介面的函式,讀者不應該直接去宣告會配置 STACK 結構
而是交給 API 去做,由 API 統一去管理自己所用的介面,因此,這類 API 的使用過程往往
程式碼就是夾在 建立介面的函式破壞介面的函式之間,當然圖三只是簡單的例子,對於
要了解 Flat C APIs Style 的觀念也就足夠,類似的觀念還有 Open 與 Close 這種成對的函式
以 Windows API 為例,CreateFile 時常與 CloseHandle 成對出現,不過讀者可以看到一點
像 Windows API 有些基礎函式可以看到就沒有 Common Prefix,而且也不見得名稱成對
否則應該稱為 CloseFile,這是因為 Windows API 很複雜,往往 API 的名稱是比較偏向
功能面的意義,因為 CloseHandle 可以關閉的東西可不是只有 CreateFile 回傳的 File Handle
還有許多系統核心 API 回傳的 Handle,像核心同步物件的 Handle 都要用 CloseHandle 關閉
 Common Prefix 並不是絕對需要,這是要看情況的經驗問題,一般要寫過大量的程式
與閱讀大量的英文文件去摸索,多研究各種程式專案內的 C file 與 Header file 內的原始碼
在加上我們並非以英語為母語,設計 Flat C APIs Style 的 API 讀者常常會遇到的問題就是
命名上的詞窮,腦袋中那少的可憐的程式英文辭彙,寫幾個函式就不知道怎麼繼續
往下寫,這一點是我們寫程式比較吃虧的地方,但是多閱讀大型程式專案的原始碼,這是
可以慢慢培養起來的能力,不過幾乎所有 Third-party Library 都有 Common Prefix,最後就給
讀者們一個比較進階的範例,FMOD API 的使用,這是很有名的商用 Audio 程式庫,它
成功的封裝系統原生音效 API,提供一個統一的高階應用介面,開發者不用去面對系統
原生音效的架構,例如 Linux 下的 ALSA (進階 Linux Sound 架構 ) 或是 Windows 下的 XAudio
它獲得了很多有名的大公司採用,成功案例讀者可以自己去開發 FMOD 的公司網站,在下
自己現在也用這個來避開難搞的 DirectSound,用它播放聲音很方便,當然它的功能不僅僅
於此,這個範例只是簡單的循環播放聲音,讓讀者看看 FMOD 的 Flat C APIs Style 長的樣子
請勿詢問任何 FMOD 使用的問題,本文不講解音效相關的 API 使用技術,用它當例子是因為
FMOD 是少數很正式的實作了上面四種類型的 APIs Style 的大型程式庫。

用 FMOD 實作簡單循環播放聲音

/*
 play_sound.cpp
 
 該程式簡單的示範了著名的聲音框架 FMOD APIs 的使用
 程式會不斷的重複播放 drumloop.wav,編譯程式的時候
 得特別注意原始檔副檔名為 .cpp,因為該程式庫還有提
 供用 C++ 類別方裝版,用 .c 為副檔名編譯該程式引入
 fmod.h 時會產生錯誤,這點要特別注意。
 
 但是程式主要示範 Flat C APIs
*/
 
#include <windows.h>
#include <tchar.h>
#include <strsafe.h>
#include "fmod.h"
 
#pragma comment(lib, "fmod_vc.lib")
 
int _tmain(int argc,const TCHAR *argv[])
{
 FMOD_SYSTEM  *system;
 FMOD_SOUND   *sound;
 FMOD_CHANNEL *channel = NULL;
 UINT         version;
 
 /* Initialize FMOD */
 FMOD_System_Create(&system);
 
 FMOD_System_GetVersion(system, &version);
 if(version < FMOD_VERSION)
 {
  static TCHAR errMsg[] = TEXT("Error! FMOD version %08x required\n");
  StringCchPrintf(errMsg, _countof(errMsg), FMOD_VERSION);
  exit(0);
 }
 
 FMOD_System_Init(system, 32, FMOD_INIT_NORMAL, NULL);
 
 /* Load and play a sound sample */
 FMOD_System_CreateSound(system, "drumloop.wav", FMOD_SOFTWARE, NULL, &sound);
 FMOD_System_PlaySound(system, sound, NULL, FALSE, &channel);
 
 /* Main loop */
key_loop:
 if(GetAsyncKeyState(VK_ESCAPE) & 0x8000)
  goto key_exit;
 else
 {
  FMOD_System_Update(system);
  Sleep(50);
  goto key_loop;
 }
 
key_exit:
 /* Shut down */
 FMOD_Sound_Release(sound);
 FMOD_System_Close(system);
 FMOD_System_Release(system);
 return 0;
}

各位可以看到 FMOD 用到了 多重 Common Prefix,除了 共有的 FMOD_ Prefix 外
與系統有關的會加上 System_ Prefix 與聲音有關的使用 Sound_ Prefix,在下程式
用很標準的 _tmain 形式來撰寫,使用泛型字元 TCHAR 與使用 Windows 新型標準
的安全字串函式 StringCchPrintf 而非用 printf,且在 if 內對有初始化的字元陣列用 static。

假如想要執行程式,在下已經把壓縮好的專案檔放在下面連結,讀者可以自己試試看
你不需要有 FMOD Library,在下已經將 FMOD Library 與專案放在一起

程式專案 play_sound.7z  http://1drv.ms/NiY6rK

後記補充:

 要記住,當宣告使用指標變數的時候,指標最好跟變數名稱靠在一起,而不是跟 Type 靠在
一起,因為從程式語言的角度來看,指標會關聯到一個變數,也就是說指標的本質更像是
用來修飾該變數讓編譯器認知到所儲存的數值是一個位址,C 語言在這方面有點會讓人
混淆,假如讀者有寫過 Pascal 就可以理解這件事情,讓在下用 Pascal 說明這種情況

Pascal 下的變數宣告          var a,b:integer;
Pascal 下的指標變數宣告  var a,b:^integer;

也就是說 Pascal 的變數宣告可以在 : 後面來一起修飾
所以 a , b 都是指標變數,沒有使用 ^ 的話,全部都是變數。

假如能理解在下舉的例子,就可以輕易了解等同於 C 語言
內的指標變數宣告應該是

int *a,*b; // 合乎 C 語言上的認知 a,b 都是指標變數

而不是

int* a,*b; // 可以編譯,但是不合乎 C 語言上的認知

搞懂這個觀念就不會犯下面常常出現的錯誤認知

int*  a, b; // 以為 a,b 都為指標變數,但是 b 其實只是變數(容易產生錯誤認知)
int*  a,*b; // 常常容易以為 b 是雙指標變數             (容易產生錯誤認知)
int  *a, b; // 這樣寫就可以很好理解 a 是指標變數,b 只是變數

所以各位在寫程式的時候,一定要特別注意語言文法的合理性,一支可以編譯的程式並正確
達成所要的功能那只是 progworker,還得仔細檢查自己的程式是不是常常犯下這些
不合理的語言文法表達錯誤,養成好的表達習慣會有助於別人容易看懂你的程式,怎麼說呢
假如在 C 語言中用第一種表達指標變數的形式,可能接手你的程式人員以前是用 Pascal
那他在閱讀你的程式就會產生極大的誤解。

3 則留言:

  1. 原始型 SDK:以原始碼呈現方式提供,只有 Header
    這種Header中宣告函式也同時定義他的動作
    是這樣嗎?

    回覆刪除
    回覆
    1. 這種程式庫 直接會用 static 的型式實作所有 routine
      程式碼直接就實作 每一條 routine ; static routine 不用宣告 prototype 喔
      其實一個完美的程式庫 應該是要以這種形式呈現 沒有黑箱 沒有專利
      可惜在現實 世界是不太可能 因為每個人總有一些 know-how 不想讓別人
      知道 ; 這個形式的程式庫很罕見 既然夜風有提 那我文章裡面 我在加一個實際的範例來解說好了~~~~~~

      刪除
  2. 網誌管理員已經移除這則留言。

    回覆刪除