http://www.jeffhung.net/blog/articles/jeffhung/522/
Writing libraries with Generic-Text-Mapping
最近在設計某 library 的 API,所以就想順便來講講怎麼設計符合 generic-text-mapping 的 library。簡單講,generic-text-mapping 是一種利用 preprocessor 的技巧,用一個 define symbol 決定 application 程式裡用的 character,是指char
亦或是 wchar_t
。若是後者,就是我們常在說的 unicode 程式。個人覺得 generic-text-mapping 實在是 Microsoft 當年偷懶後的結果。在那個年代,這個技術確實是讓眾多既存程式快速升級成 unicode 程式的好方法,但在 unicode 支援已經普遍成熟的現在,卻反而形成了脫褲子放屁的尷尬[1]。
言歸正傳。尷尬歸尷尬,畢竟我們寫的 library,還是不免需要在 Windows 上執行,讓其他 windows developer 使用,因此入境隨俗,從「惡」如流,還是必須的。如何呼叫使用了 generic-text-mapping 技術的 library,已經有太多文章闡述了[2],因此以下簡述如何寫作 generic-text-mapping 的 library 供他人使用。
同時提供兩個版本,最後才用 macro 選擇
以下的範例程式,採用並擴充 Microsoft 的 convention,後綴字(postfix)為
W
時,表示是 wchar_t
的版本;後綴字為 A
時,表示是 ANSI[3],即 char
的版本;後綴字為 G
時,表示是 Generic 的版本,使用 C++ template 技術寫就;後綴字為 T
時,表示是 explicit generic-text-mapping 的版本,正常來說,generic-text-mapping 的版本無後綴字,但有些時候,我們會需要明確指明其為 generic-text-mapping 的 function[4]。要寫 generic-text-mapping 的 function,我們必須要認清的一點就是,使用我們 library 的程式 (客戶程式,client code),會在 compile-time 的時候,利用我們提供的 macro,自動選擇其中一個版本。因為我們無法確定,client code 會使用哪一個版本,所以我們必須要同時提供
char
與 wchar_t
兩個版本。這個遲至 client code 的 compile-time 才發揮作用的 macro,存在於提供給 client code 使用的 header file 裡,寫法如下:
int StrToIntA(const char* str);int StrToIntW(const wchar_t* str);#ifdef _UNICODE# define StrToInt(str) StrToIntW(str)#else# define StrToInt(str) StrToIntA(str)#endif這個 macro 很簡單,就是依據
_UNICODE
存在與否,對應到 W
或 A
的版本。後面的文章,筆者就不再重複。只維護一份程式邏輯
通常來說,我們應該盡可能地讓程式的邏輯,只在一個地方出現,否則,同樣的程式碼有兩份,便會有維護上的麻煩,需得時時確認兩個版本的程式邏輯,是一樣的。
因此,比較好的寫法,是以其中一個版本為主要版本,另外一個版本則即時將參數,轉碼後呼叫主要版本。例如:
bool PathExistA(const char* path){ struct stat sb; if (stat(path, &sb) == 0) { return true; } else { switch (errno) { case ENOENT: return false; // no such entry, path not exists case EACCES: return true; // path exists, though we have no right to access it default: assert(false); // die for other errors, for demo only } } assert(false); // NOT_REACHED return false;} bool PathExistW(const wchar_t* path){ char mbs_path[MAXPATHLEN + 1]; memset(mbs_path, 0, sizeof(mbs_path)); wcstombs(mbs_path, path, sizeof(mbs_path) - 1); return PathExistA(mbs_path);}依靠將參數轉碼以便「只維護一份程式邏輯」,有個問題就是,這些「細碎繁多」的轉碼動作,在有些系統上,會造成明顯的執行時間成本。如果速度是很重要的考量的話,您應該小心地調整您的程式的配置方式,盡可能地減少轉碼動作。
另外,不同 OS 所提供的系統 API 也有所不同,好比說 WinNT 是 unicode 核心,若呼叫上例的
PathExistW()
, 反而等於是我們自行轉碼後,OS 又會再轉碼回 unicode,多做了無謂的轉碼動作,反而降低了效能。這其實是個兩難的問題,不同的 OS 之系統 API 皆不一樣,WinNT 是 unicode 核心,Win 95/98 是 MBCS 核心,而大部分的 Linux,若 encoding 有影響的話,據我所知,很多都是以 UTF-8 為主。因此,究竟要以哪一種版本為主,端視您的 library 的使用情形而定。但小心函式行為的正確性
除了效率的問題之外,函式行為的正確性,也應該仔細思量。這點尤其發生在 byte string 與 character string 的差異上。前者是以 byte 為單位所串成的字串,後者則是以 character 為單位所串成的字串。兩者之間,關鍵的差異在於 character 的定義。舉例來說,假使我們用的是 UTF-8,UTF-8 字元的長度,從 1 個 byte 到 6 個 bytes 不等,並非固定是 1 個 byte,因此,
strlen(utf8_string)
所抓出來的字串長度,實際上的意義為,「碰到 null byte 前的 byte 數」,而非「碰到 null utf-8 character 前的 character 數。」所以,若我們要寫計算字串長度的函式,就必須先確定,我們要算的是 character 數,還是 byte 數。如果是要計算 byte 數,而通常來說,一串 byte 我們不會設計成用 null byte 結尾,所以一般我們要算的是 character 數。如此一來,我們應該要把 multi-byte string 先轉碼成 wide-character string,再來使用
wcslen()
計算 character 數,而不能直接使用 strlen()
計算[5],否則會造成函式行為的錯誤。程式寫法如下:size_t StrLengthW(const wchar_t* str){ return wcslen(str);} size_t StrLengthA(const char* str){ wchar_t wcs[4096]; // assume big enough, for demo only memset(wcs, 0, sizeof(wcs)); mbstowcs(wcs, str, ((sizeof(wcs) / sizeof(wcs[0])) - 1)); return StrLengthW(wcs);}在此例中,我們一改
PathExistW()
以 PathExistA()
為主的作法,改成以 StrLengthW()
為主,為的就是函式行為的正確性。在決定以何者為主,何者為輔時,函式行為的正確性應優先考量,次之才是效率問題。盡可能地使用系統 API
不過,也有可能,我們的 API 所要提供的功能,有直接的系統 API 對應,且該系統 API 有支援 generic-text-mapping。此時,我們應該盡可能地呼叫系統 API,而捨棄自行轉換(碼)的方式,因為通常這樣執行效率較快。例如:
int StrToIntW(const wchar_t* str){ return _wtoi(str);} int StrToIntA(const char* str){ return atoi(str);}由於這樣的函式,通常內容極其簡單,如上例都只有一行而已,故「只維護一份程式邏輯」這句話,我們可以忽略之。
使用 C++ template 自動產生版本
或者,我們寫的是 C++,可以用 template,只寫一份程式邏輯,而讓 compiler 來幫我們自動產生
char
與 wchar_t
的兩個版本。例如:/** * Find the position of the first occurrence of character @p c in string @p s. * * @param[in] s string to search for @p c * @param[in] c character to search for * @return Returns pointer to the position of the first occurrence of character * @p c in string @p s. If character @p c is not found in string @p s, * returns @c 0. */template <typename CharT>CharT* StrFindCharG(CharT* s, CharT c) // the postfix G stands for Generic.{ while (*s) { if (*s == c) { return s; } ++s; } return 0; // not found.} wchar_t* StrFindCharW(wchar_t* s, wchar_t c){ return StrFindCharG<wchar_t>(s, c);} char* StrFindCharA(char* s, char c){ return StrFindCharG<char>(s, c);}使用 C++ template 確實是比較好的方式,既不會有前述的效率問題,程式邏輯也還是只有一份,沒有 duplicated code 的維護問題。請大家多多投靠 C++ 吧,不要再過茹毛飲血的原始生活了。
使用 C++ template 還有一個好處就是,我們可以利用 specialization、partial specialization 甚至是 policy-based-design 等等技術,僅將會影響效率的部份,予以分開撰寫,而在主要函式裡,維持原本完整的程式邏輯[6]。如此一來,即可兼顧程式的維護性,也可以取得最佳的執行效能,而且這些利用 C++ template 技術所作的 optimization,還可以 reuse 供其他程式使用。
不過,在這裡必須強調的是,通常我們不會讓 template code 露出 DLL 外,因為這樣就失去了使用 DLL 的意義。因此,
StrFindCharW()
和 StrFindCharA()
還是有存在的必要,以確保 compiler 有確實產生兩個不同版本的 StrFindCharG()
。別忘了 template 產生的是不同的實體,小心 side-effect
但是,正如前面所述,compiler 依據 template 所產生的,是兩份不同的函式實體,因此,我們必須要小心 side-effect 的問題。什麼意思呢?舉例來說,假設我們有個函式,是用來取得 accumulated access count,通常我們會這麼實做:
typedef unsigned int count_t; count_t GetTagAccessCount(const char* tag){ // lock omitted, for demo only. static map<string, count_t> tags_count = 0; // use map.insert() to ensure the count exist pair<map<string, count_t>::iterator, bool> r = tags_count.insert(make_pair(string(tag), 0)); ++(r.first->second); return (r.first->second);}但如果配上 generic-text-mapping 的話,
char
要代換成 template parameter CharT
,string
要代換成使用 CharT
為參數的 basic_string<CharT>
,程式就會變成這個樣子:typedef unsigned int count_t; template <typename CharT>count_t GetTagAccessCountG(const CharT* tag){ // lock omitted, for demo only. static map<basic_string<CharT>, count_t> tags_count = 0; // use map.insert() to ensure the count exist pair<map<basic_string<CharT>, count_t>::iterator, bool> r = tags_count.insert(make_pair(basic_string<CharT>(tag), 0)); ++(r.first->second); return (r.first->second);} count_t GetTagAccessCountW(const wchar_t* tag){ return GetTagAccessCountG<wchar_t>(tag);} count_t GetTagAccessCountA(const char* tag){ return GetTagAccessCountG<char>(tag);}此時,
GetTagAccessCountW()
與 GetTagAccessCountA()
分別 instantiated 各自的 GetTagAccessCountG<>()
,template function 裡面的 static 變數 tags_count
便會有兩份。因為 client code 會利用 GetTagAccessCount()
這個 macro 選擇出適用的版本,而我們又無法確保所有的 client code 都是使用 char
或 wchar_t
版本[7],因此他們所使用的 tags_count
是不同的,造成函式的行為錯誤。碰到這種情況,我們也許必須退而求其次,放棄 template 法,改用「以一個版本為主,另一個版本即時轉碼後呼叫主要版本」的方法來實做。或者,將可能造成 side-effect 的程式片段,以另一個共用的 class 包裝起來之後,讓 template 使用。以我來說,會選擇使用第二種方法,C++ 強大的彈性,不好好利用,實在是太可惜了。
除了程式邏輯只要保留一份就好以外,最好文件也只有一份,但是...
筆者慣用的文件產生工具是 doxygen,doxygen 是一個強大的文件產生工具,讓我們得以使用如 javadoc 的方法,產生 C、C++、Java、PHP、Python、... 等語言的 API 文件。然而,使用 generic-text-mapping 技法時,doxygen 產生的文件,其長相卻不太理想。
理想上,最好是如 MSDN 的作法一般,將
StrLength()
、StrLengthA()
與 StrLengthW()
甚至是 StrLengthT()
與 StrLengthG()
等函式,集結於同一個頁面描述,並以 StrLength()
為名;至少,要做到把其他版本濾除掉,只做 StrLength()
即可。可是,因為 doxygen 實在是太聰明了,能夠看穿 StrLength()
實際上不是 function,而是 macro,所以我們根本無法把 StrLength()
的文件,作成像 function 一樣,並歸類在 function 類。我們可以使用
@copydoc
[8],讓 StrLengthA()
使用 StrLengthW()
的那一份文件源碼。但是,使 StrLength()
為主,並以 function 的方式呈現,筆者就真的找不到解法了。因此,目前筆者所能想出來的,最好的 doxygen 文件寫法如下:/** * Count the length of given string @p str, in number of characters. * * @param[in] str string to count length * @return Returns the length, number of characters, of string @p str. */size_t StrLengthA(const char* str); /** * @copydoc StrLengthA * @note This is the @c wchar_t version of StrLengthA(). */size_t StrLengthW(const wchar_t* str); /** * @see Expand to StrLengthA() or StrLengthW() according to current * generic-text-mapping configuration. * @hideinitializer */#if (defined(UNICODE) || defined(_UNICODE))# define StrLength StrLengthW#else# define StrLength StrLengthA#endif由於我們根本不需要讓
StrLength()
如 macro 般呈現,且 doxygen 會做 C/C++ pre-processing,根據設定,會在文件上顯示 StrLength
會被展開成為 StrLengthW
或是 StrLengthA
,因此使用 @hideinitializer
把 macro 的右邊隱藏起來。總之,上面的寫法,是筆者目前可以想到的最好寫法,如果有人有更好的解法,筆者很想知道。要不然,看到這樣的文件,實在是覺得太醜了啊。
總結
本文介紹了在撰寫具備 generic-text-mapping 的 library 時,可以使用的手法,應該要注意的事項。為了「只維護一份程式邏輯」,我們可以有許多種的作法,如「以一個版本為主,另一個版本即時轉碼後呼叫主要版 本」,或是使用 C++ 的 template 語法。然而,我們也必須要注意到函式行為正確性的問題、程式的效率,以及可能因 side-effect 而存在的潛藏危機。最後,敬祝大家 happy coding,程式只寫一套最好。
- 既然都要用 generic-text-mapping 了,就表示知道了 i18n 的重要;而既然知道 i18n 很重要了,幹嘛還留個洞,留下使用 single-byte-character 的機會,而且還是預設值。 ↩
- 如 The Code Project 的這篇《Unicode, MBCS and Generic text mappings》,有非常詳細的講解。 ↩
- 這個名字有其歷史包袱在,嘆。請參見《轉換 BIG5、GB2312、UTF-8、Unicode 與 wchar_t》這篇裡,「轉換工具:Windows API」這一段的說明。 ↩
- 例如會依據目前的
TCHAR
選擇是否要呼叫mbstowcs()
的TcsToWcs()
這類的函式。這類函式,筆者將另行撰文說明。 ↩ - 實際上,C99 有定義
mbslen()
可用,不過我沒有測過mbslen()
與先轉碼再wcslen()
的速度何者較快就是了。 ↩ - 相關技巧有機會的話,筆者再另行撰文說明之。 ↩
- 想像一下不同參數編譯出來的 DLL,都來呼叫
GetTagAccessCount()
的情況。 ↩ - 筆者還是習慣使用如 javadoc 一般以
@
開頭的文件指令,而不是 Doxygen/Qt 用的\
。這純粹是個人美感作祟的緣故。 ↩
沒有留言:
張貼留言