2010年11月11日 星期四

Writing libraries with Generic-Text-Mapping

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 會使用哪一個版本,所以我們必須要同時提供 charwchar_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 存在與否,對應到 WA 的版本。後面的文章,筆者就不再重複。
只維護一份程式邏輯
通常來說,我們應該盡可能地讓程式的邏輯,只在一個地方出現,否則,同樣的程式碼有兩份,便會有維護上的麻煩,需得時時確認兩個版本的程式邏輯,是一樣的。
因此,比較好的寫法,是以其中一個版本為主要版本,另外一個版本則即時將參數,轉碼後呼叫主要版本。例如:
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 來幫我們自動產生 charwchar_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 CharTstring 要代換成使用 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 都是使用 charwchar_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,程式只寫一套最好。

  1. 既然都要用 generic-text-mapping 了,就表示知道了 i18n 的重要;而既然知道 i18n 很重要了,幹嘛還留個洞,留下使用 single-byte-character 的機會,而且還是預設值。
  2. The Code Project 的這篇《Unicode, MBCS and Generic text mappings》,有非常詳細的講解。
  3. 這個名字有其歷史包袱在,嘆。請參見《轉換 BIG5、GB2312、UTF-8、Unicode 與 wchar_t》這篇裡,「轉換工具:Windows API」這一段的說明。
  4. 例如會依據目前的 TCHAR 選擇是否要呼叫 mbstowcs()TcsToWcs() 這類的函式。這類函式,筆者將另行撰文說明。
  5. 實際上,C99 有定義 mbslen() 可用,不過我沒有測過 mbslen() 與先轉碼再 wcslen() 的速度何者較快就是了。
  6. 相關技巧有機會的話,筆者再另行撰文說明之。
  7. 想像一下不同參數編譯出來的 DLL,都來呼叫 GetTagAccessCount() 的情況。
  8. 筆者還是習慣使用如 javadoc 一般以 @ 開頭的文件指令,而不是 Doxygen/Qt 用的 \。這純粹是個人美感作祟的緣故。

沒有留言:

張貼留言