徹底解密 C++ 寬字元

發表於2016-12-14

1、從 char 到 wchar_t

“這個問題比你想象中複雜”

從字元到整數

char 是一種整數型別,這句話的含義是,char所能表示的字元在C/C++中都是整數型別。好,接下來,很多文章就會舉出一個典型例子,比如,’a’的數值就是0x61。這種說法對嗎?如果你細心的讀過K&R和BS對於C和C++描述的原著,你就會馬上反駁道,0x61只是’a’的ASCII值,並沒有任何規定C/C++的char值必須對應ASCII。C/C++甚至沒有規定char佔幾位,只是規定了sizeof(char)等於1。
當然,目前大部分情況下,char是8位的,並且,在ASCII範圍內的值,與ASCII對應。

本地化策略集(locale)

“將 ‘a’翻譯成0x61的整數值”,“將ASCII範圍內的編碼與char的整數值對應起來”,類似這樣的規定,是特定系統和特定編譯器制定的,C/C++ 中有個特定的名詞來描述這種規定的集合:本地化策略集(locale。也有翻譯成“現場”)。而翻譯——也就是程式碼轉換(codecvt)只是這個集合中的一個,C++中定義為策略(facet。也有翻譯為“刻面”)

C/C++ 的編譯策略

“本地化策略集”是個很好的概念,可惜在字元和字串這個層面上,C/C++並不使用(C++的locale通常只是影響流(stream)),C/C++使用更直接簡單的策略:硬編碼。

簡單的說,字元(串)在程式檔案(可執行檔案,非原始檔)中的表示,與在程式執行中在記憶體中的表示一致。考慮兩種情況:

A、char c = 0x61;
B、char c = ‘a’;

情況A下,編譯器可以直接認識作為整數的c,但是在情況B下,編譯器必須將’a’翻譯成整數。編譯器的策略也很簡單,就是直接讀取字元(串)在原始檔中的編碼數值。比如:

這段字串在GB2312(Windows 936),也就是我們的windows預設中文系統原始檔中的編碼為:

在UTF-8,也就是Linux預設系統原始檔中的編碼為:

一般情況下,編譯器會忠實於原始檔的編碼為s賦值,例外的情況比如VC會自作聰明的把大部分其他型別編碼的字串轉換成GB2312(除了像UTF-8 without signature這樣的倖存者)。

程式在執行的時候,s也就保持是這樣的編碼,不會再做其他的轉換。

寬字元 wchar_t

正如char沒有規定大小,wchar_t同樣沒有標準限定,標準只是要求一個wchar_t可以表示任何系統所能認識的字元,在win32 中,wchar_t為16位;Linux中是32位。wchar_t同樣沒有規定編碼,因為Unicode的概念我們後面才解釋,所以這裡只是提一下,在 win32中,wchar_t的編碼是UCS-2BE;而Linux中是UTF-32BE(等價於UCS-4BE),不過簡單的說,在16位以內,一個字元的這3種編碼值是一樣的。因此:

的編碼分別為:

大寫的L是告訴編譯器:這是寬字串。所以,這時候是需要編譯器根據locale來進行翻譯的。

比如,在Windows環境中,編譯器的翻譯策略是GB2312到UCS-2BE;Linux環境中的策略是UTF-8到UTF-32BE。

這時候就要求原始檔的編碼與編譯器的本地化策略集中程式碼翻譯的策略一致,例如VC只能讀取GB2312的原始碼(這裡還是例外,VC太自作聰明瞭 ,會將很多其他程式碼在編譯時自動轉換成GB2312),而gcc只能讀取UTF-8的原始碼(這裡就有個尷尬,MinGW執行win32下,所以只有 GB2312系統才認;而MinGW卻用gcc編寫,所以自己只認UTF-8,所以結果就是,MinGW的寬字元被廢掉了)。
寬字元(串)由編譯器翻譯,還是被硬編碼程式序檔案中。

2、Unicode 和 UTF

Unicode 和 UCS

Unicode 和UCS是兩個獨立的組織分別制定的一套編碼標準,但是因為歷史的原因,這兩套標準是完全一樣的。Unicode這個詞用得比較多的原因可能是因為比較容易記住,如果沒有特別的宣告,在本文所提及的Unicode和UCS就是一個意思。Unicode的目標是建立一套可以包含人類所有語言文字元號你想得到想不到的各種東西的編碼,其編碼容量甚至預留了火星語以及銀河系以外語言的空間——開個玩笑,反正簡單的說,Unicode編碼集足夠的大,如果用計算機單位來表示,其數量比3個位元組大一些,不到4個位元組。

Unicode和UTF

因為Unicode包含的內容太多,其編碼在計算機中的表示方法就成為了一個有必要研究的問題。傳統編碼,比如標準的7位ASCII,在計算機中的表示方法就是佔一個位元組的後7位,這似乎是不需要解釋就符合大家習慣的表示方法。但是當今Unicode的總數達到32位(計算機的最小單位是位元組,所以大於3位元組,就只能至少用4位元組表示),對於大部分常用字元,比如Unicode編碼只佔一個位元組大小的英語字母,佔兩個位元組大小漢字,都用4個位元組來儲存太奢侈了。另外,如果都用4位元組直接表示,就不可避免的出現為0的位元組。而我們知道,在C語言中,0x00的位元組就是’\0’,表示的是一個字串(char字串,非wchar_t)的結束,換句話說,C風格的char字串無法表示Unicode。

因為類似的種種問題,為Unicode在計算機中的編碼方法出現了,這就是UTF;所對應的,為UCS編碼實現的方式也有自己的說法。一般來說,UTF-x,x表示這套編碼一個單位至少佔用x位,因為Unicode最長達到32位,所以 UTF-x通常是變長的——除了UTF-32;而UCS-y表示一個單位就佔用y個位元組,所以能表示當今Unicode的UCS-y只有UCS-4,但是因為歷史的原因,當Unicode還沒那麼龐大的時候,2個位元組足夠表示,所以有UCS-2,現在看來,UCS-2所能表示的Unicode只是當今 Unicode的一個子集。

也就是說,如果某種編碼,能根據一定的規則演算法,得到Unicode編碼,那麼這種編碼方式就可以稱之為UTF。

UTF-8 和 Windows GB2312

UTF- 8是一套“聰明”的編碼,可能用1,2,3,4個位元組表示。通過UTF-8的演算法,每一個位元組表示的資訊都很明確:這是不是某個Unicode編碼的第一個位元組;如果是第一個位元組,這是一個幾位Unicode編碼。這種“聰明”被稱為UTF-8的自我同步,也是UTF-8成為網路傳輸標準編碼的原因。

另外,UTF-8也不會出現0位元組,所以可以表示為char字串,所以可以成為系統的編碼。Linux系統預設使用UTF-8編碼。

Windows GB2312一般自稱為GB2312,其實真正的名字應該是Windows Codepage 936,這也是一種變長的編碼:1個位元組表示傳統的ASCII部分;漢字部分是兩個位元組的GBK(國標擴(展),拼音聲母)。Codepage 936也可以表示為char字串,是中文Windows系統的預設編碼。

我們在第1節中看到的

在Windows中的編碼就是Codepage 936;在Linux中的編碼就是UTF-8。

需要注意的是,Codepage 936不像UTF,跟Unicode沒有換算的關係,所以只能通過“內碼表”技術查表對應。

UTF-16 和 UCS-2

UTF- 16用2個位元組或者4個位元組表示。在2個位元組大小的時候,跟UCS-2是一樣的。UTF-16不像UTF-8,沒有自我同步機制,所以,編碼大位在前還是小位在前,就成了見仁見智的問題。我們在第1節中,“中”的UCS-2BE(因為是兩個位元組,所以也就是UTF-16BE)編碼是0x4E2D,這裡的 BE就是大位在後的意思(也就是小位在前了),對應的,如果是UCS-2LE,編碼就成了0x2D4E。

Windows中的wchar_t就是採用UCS-2BE編碼。需要指出的是,C++標準中對wchar_t的要求是要能表示所有系統能識別的字元。Windows自稱支援Unicode,但是其wchar_t卻不能表示所有的Unicode,由此違背了C++標準。

UTF-32 和 UCS-4

UTF- 32在目前階段等價於UCS-4,都用定長的4個位元組表示。UTF-32同樣存在BE和LE的問題。Linux的wchar_t編碼就是UTF- 32BE。在16位以內的時候,UTF-32BE的後兩位(前兩位是0x00 0x00)等價於UTF-16BE也就等價於UCS-2BE

BOM

為了說明一個檔案採用的是什麼編碼,在檔案最開始的部分,可以有BOM,比如0xFE 0xFF表示UTF-16BE,0xFF 0xFE 0x00 0x00表示UTF-32LE。UTF-8原本是不需要BOM的,因為其自我同步的特性,但是為了明確說明這是UTF-8(而不是讓文字編輯器去猜),也可以加上UTF-8的BOM:0xEF 0xBB 0xBF

以上內容都講述得很概略,詳細資訊請查閱維基百科相關內容。

3、利用C執行時庫函式轉換

std::locale

通過前面兩節的知識,我們知道了在C/C++中,字元(串)和寬字元(串)之間的轉換不是簡單的,固定的數學關係,寬窄轉換依賴於本地化策略集(locale)。換句話說,一個程式在執行之前並不知道系統的本地化策略集是什麼,程式只有在執行之後才通過locale獲得當時的本地化策略集。

C有自己的locale函式,我們這裡直接介紹C++的locale類。

先討論locale的建構函式:

這個建構函式是獲得當前程式的locale,用法如下:

或者(這是構造物件的兩種表示方式,後同)

另外一個建構函式是:

這個建構函式以name的名字建立新的locale。重要的locale物件有:

std::locale sys_loc(“”); //獲得當前系統環境的locale
std::locale C_loc(“C”); 或者 std::locale C_loc = std::locale::classic(); //獲得C定義locale
std::locale old_loc = std::locale::global(new_loc); //將new_loc設定為當前全域性locale,並將原來的locale返回給old_loc

除了這些,其它的name具體名字依賴於C++編譯器和作業系統,比如Linux下gcc中文系統的locale名字為”zh_CN.UTF-8″,中文Windows可以用”chs”(更加完整的名字可以用name()函式檢視)。

mbstowcs() 和 wcstombs()

這兩個C執行時庫函式依賴於全域性locale進行轉換,所以,使用前必須先設定全域性locale。

std::locale已經包含在<iostream>中了,再加上我們需要用到的C++字串,所以包含<string>。

我們先看窄到寬的轉換函式:

我們將全域性locale設定為系統locale,並儲存原來的全域性locale在old_loc中。

在制定轉換空間快取大小的時候,考慮如下:char是用1個或多個物件,也就是1個或者多個位元組來表示各種符號:比如,GB2312用1個位元組表示數字和字母,2個位元組表示漢字;UTF-8用一個位元組表示數字和字母,3個位元組表示漢字,4個位元組表示一些很少用到的符號,比如音樂中G大調符號等。wchar_t是用1個物件(2位元組或者4位元組)來表示各種符號。因此,表示同樣的字串,寬字串的大小(也就是wchar_t物件的數量)總是小於或者等於窄字串大小(char物件數量)的。+1 是為了在最後預留一個值為0的物件,以便讓C風格的char或者wchar_t字串自動截斷——這當然是寬串大小等於窄串大小的時候才會用上的,大部分時候,字串早在前面某個轉換完畢的位置就被0值物件所截斷了。
最後我們將全域性locale設定回原來的old_loc。

窄串到寬串的轉換函式:

這裡考慮轉換空間快取大小的策略正好相反,在最極端的情況下,所有的wchar_t都需要4個char來表示,所以最大的可能就是4倍加1。

這兩個函式在VC和gcc中都能正常執行(MinGW因為前面說到的原因不支援寬字元的正常使用),在VC中會給出不安全的警告,這是告訴給那些弄不清寬窄轉換實質的人的警告,對於瞭解到目前這些知識的你我來說,這就是囉嗦了。

4、利用 codecvt 和 use_facet 轉換

locale 和 facet

C++ 的locale框架比C更完備。C++除了一個籠統本地策略集locale,還可以為locale指定具體的策略facet,甚至可以用自己定義的 facet去改造一個現有的locale產生一個新的locale。如果有一個facet類NewFacet需要新增到某個old_loc中形成新 new_loc,需要另外一個建構函式,通常的做法是:

標準庫裡的標準facet都具有自己特有的功能,訪問一個locale物件中特定的facet需要使用模板函式use_facet:
template <class Facet> const Facet& use_factet(const locale&);

換一種說法,use_facet把一個facet類例項化成了物件,由此就可以使用這個facet物件的成員函式。

codecvt

codecvt就是一個標準facet。在C++的設計框架裡,這是一個通用的程式碼轉換模板——也就是說,並不是僅僅為寬窄轉換制定的。

I表示內部編碼,E表示外部編碼,State是不同轉換方式的標識,如果定義如下型別:

那麼CodecvtFacet就是一個標準的寬窄轉換facet,其中mbstate_t是標準寬窄轉換的State。

內部編碼和外部編碼

我們考慮第1節中提到的C++編譯器讀取原始檔時候的情形,當讀到L”中文abc”的時候,外部編碼,也就是原始檔的編碼,是GB2312或者UTF-8的 char,而編譯器必須將其翻譯為UCS-2BE或者UTF-32BE的wchar_t,這也就是程式的內部編碼。如果不是寬字串,內外編碼都是 char,也就不需要轉換了。類似的,當C++讀寫檔案的時候 ,就會可能需要到內外編碼轉換。事實上,codecvt就正是被檔案流快取basic_filebuf所使用的。理解這一點很重要,原因會在下一小節看到。

CodecvtFacet 的 in() 和 out()

因為在CodecvtFacet中,內部編碼設定為wchar_t,外部編碼設定為char,轉換模式是標準寬窄轉換mbstate_t,所以,類方法in()就是從char標準轉換到wchar_t,out()就是從 wchar_t標準轉換到char。這就成了我們正需要的內外轉換函式。

其中,s是非const引用,儲存著轉換位移狀態資訊。這裡需要重點強調的是,因為轉換的實際工作交給了執行時庫,也就是說,轉換可能不是在程式的主程式中完成的,而轉換工作依賴於查詢s的值,因此,如果s在轉換結束前析構,就可能丟擲執行時異常。所以,最安全的辦法是,將s設定為全域性變數!

const的3個指標分別是待轉換字串的起點,終點,和出現錯誤時候的停點(的下一個位置);另外3個指標是轉換目標字串的起點,終點以及出現錯誤時候的停點(的下一個位置)。

程式碼如下:

最後補充說明一下std::use_facet<CodecvtFacet>(sys_loc).in()和 std::use_facet<CodecvtFacet>(sys_loc).out()。sys_loc是系統的locale,這個 locale中就包含著特定的codecvt facet,我們已經typedef為了CodecvtFacet。用use_facet對CodecvtFacet進行了例項化,所以可以使用這個 facet的方法in()和out()。

5、利用 fstream 轉換

C++的流和本地化策略集

BS在設計C++流的時候希望其具備智慧化,並且是可擴充套件的智慧化,也就是說,C++的流可以“讀懂”一些內容。比如:

這句程式碼中,std::cout是能判斷出123是int而”ok”是const char[3]。利用流的智慧,甚至可以做一些基礎型別的轉換,比如從int到string,string到int:

儘管如此,C++並不滿足,C++甚至希望流能“明白”時間,貨幣的表示法。而時間和貨幣的表示方法在世界範圍內是不同的,所以,每一個流都有自己的 locale在影響其行為,C++中叫做啟用(imbue,也有翻譯成浸染)。而我們知道,每一個locale都有多個facet,這些facet並非總是被use_facet使用的。決定使用哪些facet的,是流的快取basic_streambuf及其派生類basic_stringbuf和 basic_filebuf。我們要用到的facet是codecvt,這個facet只被basic_filebuf使用——這就是為什麼只能用 fstream來實現寬窄轉換,而無法使用sstream來實現的原因。

在窄到寬的轉化中,我們先使用預設的本地化策略集(locale)將s通過窄檔案流ofs傳入檔案,這是char到char的傳遞,沒有任何轉換;然後我們開啟寬檔案流wifs,並用系統的本地化策略集(locale)去啟用(imbue)之,流在讀回寬串wstr的時候,就是char到wchar_t的轉換,並且因為啟用了sys_loc,所以實現標準窄到寬的轉換。

在寬到窄的轉化中,我們先開啟的是寬檔案流wofs,並且用系統的本地化策略集 sys_loc啟用(imbue)之,這時候,因為要寫的檔案cvt_buf是一個外部編碼,所以執行了從wchar_t到char的標準轉換。讀回來的檔案流從char到char,不做任何轉換。

6、國際化策略

硬編碼的硬傷

我們現在知道,C/C++的寬窄轉換是依賴系統的locale的,並且在執行時完成。考慮這樣一種情況,我們在簡體中文Windows下編譯如下語句:

根據我們之前的討論,編譯器將按照Windows Codepage936(GB2312)對這個字串進行編碼。如果我們在程式中執行寬窄轉換函式,將s轉換為寬字串ws,如果這個程式執行在簡體中文環境下是沒問題的,將執行從GB2312到UCS-2BE的轉換;但是,如果在其他語言環境下,比如是繁體中文BIG5,程式將根據系統的locale執行從BIG5到UCS-2BE的轉換,這顯然就出現了錯誤。

補救

有沒有補救這個問題的辦法呢?一個解決方案就是執行不依賴locale的寬窄轉換。實際上,這就已經不是寬窄轉換之間的問題了,而是編碼之間轉換的問題了。我們可以用GNU的libiconv實現任意編碼間的轉換,對於以上的具體情況,指明是從GB2312到UCS-2BE就不會出錯。(請參考本人前面的章節:win32下的libiconv),但這顯然是一個笨拙的策略:我們在簡體中文Windows下必須使用GB2312到UCS-2BE版本的寬窄轉換函式;到了BIG5環境下,就必須重新寫從BIG5到UCS-2BE的寬窄轉換函式。

Windows 的策略

Windows的策略是淘汰了窄字串,乾脆只用寬字串。所有的硬編碼全部加上特定巨集,比如TEXT(),如果程式是所謂Unicode編譯,在編譯時就翻譯為UCS2-BE——Windows自稱為Unicode程式設計,其本質是使用了UCS-2BE的16位寬字串。

Linux 的策略

Linux下根本就不存在這個問題!因為各種語言的Linux都使用UTF-8的編碼,所以,無論系統locale如何變化,窄到寬轉換的規則一直是UTF-8到UTF32-BE 。

跨平臺策略

因為在16位的範圍內,UTF32-BE的前16位為0,後16位與UCS2-BE是一樣的,所以,即使wchar_t的sizeof()不一樣,在一般情況下,跨平臺使用寬字元(串)也應該是相容的。但是依然存在潛在的問題,就是那些4位元組的UTF32編碼。

gettext 策略

以上都是將ASCII及以外的編碼硬編碼在程式中的辦法。GNU的gettext提供了另外一種選擇:在程式中只硬編碼ASCII,多語言支援由gettext函式庫在執行時載入。(對gettext的介紹請參考本人前面的章節:Win32下的GetText)。 gettext的多語言翻譯檔案不在程式中,而是單獨的提出來放在特定的位置。gettext明確的知道這些翻譯檔案的編碼,所以可以準確的告訴給系統翻譯的正確資訊,而系統將這些資訊以當前的系統locale編碼成窄字串反饋給程式。例如,在簡體中文Windows中,gettext的po檔案也可以以UTF-8儲存,gettext將po檔案翻譯成mo檔案,確保mo檔案在任何系統和語言環境下都能夠正確翻譯。在執行是傳給win32程式的窄串符合當前locale,是GB2312。gettext讓國際化的翻譯更加的方便,缺點是目前我沒找到支援寬字串的版本(據說是有ugettext()支援寬字串),所以要使用gettext只能使用窄字串。但是gettext可以轉換到寬字串,而且不會出現寬窄轉換的問題,因為gettext是執行時根據locale翻譯的。例如:

其中”Chinese a b c”在po中的翻譯是”中文abc”

使用依賴locale的執行時寬窄轉換函式:

執行時呼叫該po檔案對應的mo檔案,在簡體中文環境下就以GB2312傳給程式,在繁體中文中就以BIG5傳給程式,這樣s2ws()總能夠正常換算編碼。

更多

在本文的最後,我想回到C++的stream問題上。用fstream轉換如此的簡單,sstream卻不支援。改造一個支援codecvt的string stream需要改造basic_stringbuf。basic_stringbuf和basic_filebuf都派生自 basic_streambuf,所不同的是basic_filebuf在構造和open()的時候呼叫了codecvt,只需要在 basic_stringbuf中新增這個功能就可以了。說起來容易,實際上是需要重新改造一個STL模板,儘管這些模板原始碼都是在標準庫標頭檔案中現成的,但是我還是水平有限,沒有去深究了。另外一個思路是構建一個基於記憶體對映的虛擬檔案,這個框架在boost的iostreams庫中,有興趣的朋友可以深入的研究。
(完)

相關文章