前言

(未定稿,近期仍可能頻繁修改,歡迎提出各種建議,不論是技術方面或是文章描述方面)

在即將邁入 2013 年的今日、Unicode 已推出 6.2 版,Unicode 好像已經屬於沒什麼好介紹的基本常識了吧?其實,這篇文章某種程度上是為了「訂正」而寫的。如果我沒記錯的話,嗯,本文的第一個版本是在七年前寫的。說來挺不好意思的是,之前的版本在技術上從來就沒有完全正確過,雖然都不是很嚴重的錯誤。還好早期版本所發表的地方都相對短命,應該沒有誤導到太多人。

本文的目標很單純,只希望以後讀者遇到與文字編碼有關的選擇時,能夠清楚地知道自己在做什麼。因此我會從比較偏向實用的角度整理相關知識,而非專注於編碼的技術細節,畢竟大部分的人都沒有必要挽起袖子親手處理底層格式。

在 Unicode 普及之前

要了解現實中遇到的各種鳥事,還是得回顧歷史緣由。但歷史遺留下來的東西很多,本文無法花太大的篇幅介紹,只能大略介紹一般人可能會面對的現存字元編碼方式。

文字編碼中影響最深遠的里程碑非 ASCII 莫屬,雖然只是美國自己的國家標準,只因計算機軟硬體基礎建設都出自於美國,所以後來出現的編碼多多少少得考慮一下 ASCII,否則就是自找麻煩(和 ASCII 過不去編碼方式也確實存在,所以 C/C++ 必須提供 digraphs、trigraphs 之類的替代寫法)。

標準的 ASCII 字集可以塞到 7 bit 中,對於一般英語系國家來說夠用了。但是歐洲有很多語言需要在字母上畫撇畫點,東歐和希臘甚至使用完全不同的字母系統,所以後來發展出一些 8 bit 的編碼方式,前 127 碼與 ASCII 相容,但 128 ~ 255 則定義成不同語言的字集,例如 ISO-8859-n 系列。對了,以下講到 byte 都是設定為 8-bit。

中文編碼比起歐美字母文字複雜許多,其中一個很大的問題是中文字集沒有明確的邊界。一般人常用的字不超過五六千、康熙字典收錄四萬七千餘字、各地方言和命理師還可以翻出康熙字典之外的字、發現新化學元素又需要造新字..... 因此也有一派觀點認為,中文採用列舉式字集在大方向上根本錯誤,不過這不是主流意見,現實中使用的字集都還是窮舉。決定字集之後,如何排字序、編字碼又是另一門學問。

正體中文早期在缺乏主導力量的情況下曾呈現「萬碼奔騰」的狀態,各系統內部以自己的「內碼」儲存資料,必須另外公訂「交換碼」以便在不同系統之間交換資訊,台灣的標準交換碼為 CNS 11643(其實還有一個 CCCII,不過生不逢時且遭政治干預,所以使用者多半是圖書館和國外機構)。

在因緣際會之下,Big5 變成了業界公認的內碼,一般情況已經沒有必要刻意使用交換碼了。但 Big5 收錄字集不足一直是個很大的問題,甚至連重要機關首長的名字都必須尷尬地表示成「方方土」、「火宣」。又因 Big5 在很長的一段時間裡只是業界公認,並沒有專責維護機構,當 Big5 字集不敷使用的時候,大抵是廠商或政府機關自行擴充,彼此並不相容。也因 Big5 的侷限性,有些政府單位仍然在使用非 Big5 的內碼,例如戶政和健保使用 EUC-TW,只是一般人不太有機會碰到。

大多數字元編碼在發展的時候,都沒有考慮到與其他編碼相容的問題。隨著時代演進,許多軟體終究要面對不同的文字編碼。一般作業系統的解決之道就是提供不同的內碼表,到底一個碼要代表哪一個字符,取決於該環境採用哪一個內碼表。

Windows 的內碼表稱為 code page,每個內碼表都有個魔術數字代號。大部分內碼表字元都是固定單 byte 寬,例如西歐字母用的 1252。至於亞洲文字的內碼表,微軟使用了一種 Multi-byte character set(MBCS)內碼表。如果一個 byte 值小於 128 則視同 ASCII;若為 128 以上則根據某些規則決定是否要和下一個 byte 合在一起當成一個字。總之就是某些字元使用單 byte,某些字元使用多 byte。微軟支援了若干種 MBCS 內碼表,例如正體中文使用 950,和 Big5 相容;簡體中文使用 936,和 GBK 相容;日文為 932。

MBCS 每個字長度並不統一,對程式設計造成了一些困擾。例如無法直接從 byte 數得知一個字串中有多少字,也不能從任意位置隨機存取字串,只能靠剖析字串來完成這類工作。由於文字處理的基本單位仍然是 byte,有些程式設計師沒注意到 MBCS 組合的規則,往往會把雙 byte 字的一部分看成控制字元或逸出字元;舉例來說,假如有人的名字叫做「許功蓋」,那麼很多程式將無法正常的顯示出他的名字。

基於一些我不太清楚的歷史因素,這類以 byte 為單位、依賴本地內碼表的編碼方式,被某些視窗軟體開發者稱為「ANSI」,儘管這不是 ANSI 規定的。

Unicode

內碼表或許在單一語言的環境下運作良好,但只要在不同語言之間轉換資料許多問題就會浮現。除非知道一段文字使用哪一個內碼表,否則無法保證能正確顯示出來。相信大家應該都有這樣的經驗:開啟了一份怪里怪氣的網頁、電子郵件,然後瘋狂的從十幾種內碼表當中猜測到底該用哪一個。

同一個字碼在不同的內碼表往往代表著不同的字符,但因一次只能使用一個內碼表,所以很難在同一份文件裡顯示多國語言。之前有出現過一些權宜措施,例如某些 Big5 擴充版本利用造字區編入日文假名,雖然表面上看起來是日文,但是和日本人真正使用的內碼完全不同,只能在自己圈子內流通。

終極的解決之道就是建立一個涵蓋所有字符的編碼方式,著手進行這個工作的有 ISO 和 Unicode 兩個組織,最後雙方決定互相合作。

這件浩大的工程始於列舉所需的字集,然後賦予每個字元一個數值碼。光是列舉字集就不是一件容易的事,前面已經提過中文字在列舉上的難處,如果再考慮兩岸三地加日韓使用的漢字,彼此間看起來很像但又有一點點不同,到底要不要視為同一個字符呢?同樣的事情在拉丁語系也有,只不過他們需要吵的字符和我們根本不是同一個數量級。經過一番敲敲打打,最後大家得出了 ISO 10646 通用字符集(Universal Character Set, UCS),而 Unicode 自 2.0 版起,編碼方式完全與 ISO 10646 相對應。

原始的 UCS 設計了 31-bit 的編碼空間,相當於可編 21 億個字碼,但目前 Unicode 只計畫使用 0x10FFFF 以內的範圍,大約為 111.4 萬個字碼。其中每 65,536 個字碼被劃分為一個「平面(Plane)」,總共分為 17 組平面。實際上只有少數平面真正被使用,已編的字碼約在十萬之譜。在 Unicode 中,每個數值碼被稱為「Unicode point」,通常被記作U+<16進制數字>

平面範圍用途
第0平面 U+0000 ~ U+FFFF 基本多語言平面(Basic Multilingual Plane, BMP)
第1平面 U+10000 ~ U+1FFFF 多語言補充平面(Supplementary Multilingual Plane, SMP)
第2平面 U+20000 ~ U+2FFFF 表意文字補充平面
第3平面 U+30000 ~ U+3FFFF 表意文字第三平面
其餘為未使用或保留做為特殊用途

現實流通的文字大都落在第 0 平面,也就是 U+0000 ~ U+FFFF 之間,這個範圍剛好可以塞到 2 byte 內。這個平面被稱為「基本多語言平面」,其他的平面則稱為「輔助平面」。

顧名思義,基本多語言平面劃分為多種不同的語言區段,其中 ASCII 順理成章佔據了前 127 個碼。至於東亞國家共用的漢字,也就是所謂的「中日韓越統一表意符號(CJKV Unified Ideographs)」,主要使用 U+4E00 ~ U+9FF;另外 U+3400 ~ U+4DFF 之間做為 CJKV 擴展 A 區,兩區加起來可用的 Unicode point 有 27,648 個,目前兩區都沒有編滿,自 Unicode 推出以來又陸續增補數次。此外還有一些未共用的方塊字,則另外安排。

至於一些極罕用或是還在整理中的漢字,則被安排到第 2 平面的 CJKV 擴展 B、C、D 區。第 1 平面主要放一些現在很少用的拼音文字,包括的古埃及象形文、亞蘭文等等,甚至還有麻將牌。請注意一件事,這些平面的字碼已經超出 16 bit 的表達範圍。

以上所述只是「數值碼」和「符號」如何對應,至於字碼如何儲存、傳輸之類的問題則另外以 Unicode transformation formats(UTF) 規定。在可預見的未來,Unicode/UCS 所有的數值碼都不超過 0x10FFFF,所以最簡單的做法就是直接用一個 32 bit 整數來存放一個字碼,剛好一個蘿蔔一個坑,這個方法稱為 UTF-32 或者 UCS-4。由於一般人不常使用 U+FFFF 外的字,UTF-32 顯得大而不當。

UTF-16 和 UCS-2

現實流通的文字大都在 0 ~ FFFF 之間,每個字碼剛好可以塞入 2 byte 中。例如英文字母「A」的字碼為 U+0041,那就用 2 byte 存放 0x0041;中文的「象」字碼是 U+8C61,那就用 2 byte 存放 0x8C61,這個做法稱為 UCS-2。很多開發環境都提供 2-byte 寬度的字元型別,以便於處理 UCS-2。

但考慮輔助平面後,有些字碼勢必超出 2 byte 的表達範圍。最省事的解決方法就是假裝這些字碼不存在,假裝每個字都恰恰好為 2 byte。事實上很多軟體都是這樣做的,他們會說自己只支援 UCS-2,如果有人想使用罕用漢字、麻將牌、古埃及象形文、亞蘭文..... 那就自己看著辦。

正規的解決方案是 UTF-16,這是一種以 16-bit 為單位的 Unicode 儲存、傳輸格式。對於基本多語言平面內的字,UTF-16 直接使用一個單位(2 byte)儲存 UCS-2 字碼,和前面提到的做法完全相同。超過這個範圍的字碼則會拆解成「代理對(surrogate pair)」,用兩個單位(4 byte)儲存。舉例來說,英文字母、希臘字母、一般漢字等等,用 UTF-16 編碼都是 2 byte;但是如果是 CJKV Ext-B 區的漢字,用 UTF-16 就會編成 4 byte。

技術細節這裡就不多說明,只強調兩件事:(1)UTF-16 是可變長度編碼;(2)基本多語言平面中 U+D800 ~ U+DFFF 被保留給代理對使用,因此不對應到任何字符

UTF-8

對於一輩子只寫程式給美國人的軟體從業人員來說,他們大概從來不會碰到 256 以上的字碼,甚至連 128 以上的字碼都很少用,使用超過 1 byte 來儲存一個字碼顯然是一種浪費。所以有人就發明了一種以 8 bit 為單位的 Unicode 儲存、傳輸格式,稱為 UTF-8。對字碼在 127 以下的字元仍然用 1 個 byte 來存放,於是對習於使用 ASCII 的人來說完全不會感覺到任何差別,統一之前製作的文件都不必轉碼,碼照跑、舞照跳。

至於 127 以上的字碼則會編成 2 ~ 4 byte 不等,這裡同樣不討論技術細節。從前的 Big5 一個字用 2 byte,轉成 UTF-8 就要 3 個 byte,CJKV Ext-B 區的漢字甚至要 4 byte。泰文則從 1 byte 漲價到 3 byte,意即以前的泰文文件轉成 UTF-8 體積會暴增為原來的三倍。

以上是最常見到的幾種 UTF,其他像是 UTF-7、UTF-EBCDIC,這裡就不多介紹了。使用上述幾種 UTF 儲存不同字碼範圍所需要的 byte 數如下表所示:

 UTF-8UTF-16UTF-32
BMP 0000 ~ 007F 1 2 4
0080 ~ 07FF 2
0800 ~ FFFF 3
010000 ~ 10FFFF 4 4

猜猜我是誰

看到這裡應該可以體會純文字其實並不簡單,即使知道一串 byte 或一個檔案是所謂的純文字,如果不知道編碼方式也是枉然。它有可能是眾多 UTF 中的一種,也可能是某些人所謂的「ANSI」,亦即以 byte 為單位、平台相依的字串格式。還有更糟糕的事,2 或 4-byte 的寬字元都有 byte oder 的問題,所以 UTF-16、UTF32 各自有 big/little endian 兩種表示法。

在 Unicode 當中保留了一個特別的字碼 U+FEFF 做為識別編碼方式與位元順序的標記,稱為 Byte-order mark(BOM)。這個標記通常添加在檔案或者網路串流的開頭,應用程式可以輕易從這個標記判別接下來的內容該如何解讀。

  • UTF-8 只有一種 byte oder,所以沒有必要加 BOM,但是很多程式為了便於識別編碼方式還是會加。U+FEFF 以 UTF-8 編碼為「EF BB BF」。
  • UTF-16 BE: FE FF
  • UTF-16 LE: FF FE
  • UTF-32 BE: 00 00 FE FF
  • UTF-32 LE: FF FE 00 00

Unicode 之前的編碼方式大都沒有這樣的設計,而且採用 Unicode 的應用程式也不一定會在輸出加上 BOM,這時候就只能瘋狂猜猜看了。現在的瀏覽器猜編碼的功力都算不錯,大部分的時候都會猜對。一般來說,UTF-8「浪費」了很多 bit 在格式上,隨便一串 byte 要碰巧成為合法的 UTF-8 字串的機率並不高,因此優先測試 UTF-8 是明智的選擇。

等價性與正規化

有些人類概念上相同的文字,在 Unicode 當中卻可能存在多種編碼序列,不利於機器檢索與排序。例如 ü (U+00FC)可以單獨編成一個字碼,也可以編成 u(U+0075)及 ¨(U+00A8) 兩個字碼的組合。如果只天真地靠字碼進行搜尋或排序,結果可能和一般人預期的不同。

為了解決問題,Unicode 制訂了幾種正規化方法,使得概念相同的字符可以被轉換成獨一無二的表示法。目前共有四種正規化形式:NFC、NFD、NFKC、NFKD,這裡不多說明。讀者只要記得幾件事:有些在人類概念上相同的文字,用了不同的正規形式後會產生不同的編碼序列。另一點值得注意的是,組合字的存在意味著一個 Unicode point 並不總是對應人類心目中的一個字符,就算是使用 UTF-32 仍然得面臨一個字符使用多個儲存單位的可能性。

面對現實

前面說了這麼多只是要傳達一件事,Unicode 只是一個編碼方案而不是具體的儲存格式,現實中的 Unicode 會以各種 UTF 和正規形式存在。Windows 和 Java 在一開始選擇了 16-bit 寬字元,因此(不得不)踏上了 UTF-16 之路;Linux 社群大致上選擇了 UTF-8;網路世界幾乎是 UTF-8 的天下。

UTF-8 最大的好處在於使用 8-bit 作為儲存單位,同時對 ASCII 保持良很好的相容性。這表示以標準 ASCII 製作的文件都會自動成為合法的 UTF-8 檔案;既存的 API 都可以繼續用 char* 當介面,只要在內部修改解釋的方式即可。

雖然 UTF-8 表面上會讓其他語言的儲存空間膨脹,但事實上各種檔案格式、傳輸協定都有相當比例的資料量是耗在格式標記上,而這些格式標記多由 ASCII 構成。所以就現實應用來說,UTF-8 整體耗用的空間甚至可能比 UTF-16 還少。如果讀者感興趣的話,可以隨便 unzip 一個 MS Office 2007 或者 LibreOffice 文件,你會得到一群以 UTF-8 編碼的 XML 檔案,觀察一下其中 XML 標籤和實質內容所佔的比例。

UTF-8 的每個字碼可能佔用 1 ~ 4 byte 不等的長度,對程式設計是一件很麻煩的事。和前述的 MBCS 一樣,諸如「計算字數」或者「存取第 n 個字」之類的工作都沒辦法簡單完成。現有的 C/C++ 字串程式庫大都假設一個儲存單位對應一個字碼,更是與此格格不入。

以現在的後見之明看來,採用 UTF-16 似乎也沒有好到哪裡去。早期 16-bit 寬字元確實具有一個儲存單位對應一個字碼的便利性,然而隨著 Unicode 輔助平面啟用、UTF-16 標準化之後,這項優勢漸漸落空了。現在 UTF-16 的優勢勉強可以這麼說:如果你假裝每個字都固定佔 16-bit 寬,那麼有非常高的機率不會遇到意外,我相信很多軟體終其生命周期都不會碰到(很不巧的是,正體中文使用者大概是這個世界上最容易遇到意外的一群人)。

為了實作 UTF-16 而引入的 16-bit 寬字元與既存的程式碼幾乎都不相容,因此所有與文字相關的基礎建設都必須生出一套寬字元版,特別是 C/C++ 這類程式語言(Java 當時是全新設計,所以沒差)。微軟花了十幾年的時間,仍然很難讓所有人都使用寬字元,而且所有 C/C++ 書籍還是從 char 和 std::string 談起。微軟必須同時提供 A 版和 W 版兩套 API,搭配 TCHAR 之類的玩意作為過渡。

我的 OS 都用了 Unicode,為什麼程式還有問題?

支援 Unicode 牽涉到許多層面,與 OS 最息息相關的大概是:(1)API;(2)顯示字形;(3)檔案系統;(4)輸入法等議題。OS 層級的支援只是基礎,要讓使用者有良好的體驗,從各級程式庫直至最上層的應用程式,中間每一個環節都必須要正確處理 Unicode,只要一個環節出錯就會造成讓人抓狂的現象。

以檔案系統為例,NTFS 只接受 UTF-16 編碼的檔名;Linux 的檔案系統對編碼方式沒有太多限制,使用者通常會選 UTF-8;雖然 Mac OS X 也是用 UTF-8 檔名,但卻是以 NFD 做為正規形式,和採用 NFC 的 Linux 與 Windows 不同。跨平台檔案分享軟體通常要歷經一段磨合期,才有辦法把每一個環節搞對,這就是為什麼使用 smb、ftp 跨平台分享檔案的人多少都遇過一些鳥事。

事情還沒完,舊的編碼方式還會持續存在一段時間,所以很多系統都會提供向前相容的行為。像是 Linux 使用者可以改變 locale 設定而使用 Big5 檔名;雖然 NTFS 全面使用 UTF-16,但如果有人在 Windows 上用了 A 版 API 或者 fopen 之類的傳統 C 函數,系統還是會表現出接受傳統內碼表的樣子。向前相容使得依賴舊編碼的應用程式得以繼續運作,然而這些應用程式的行為往往會讓期待 Unicode 的使用者感到困惑。這不是作業系統的錯,只能說向前相容做得太好。

直到不久之前,Windows 版的 PHP 仍然無法直接處理 Unicode 檔名,這個問題在其他採用 UTF-8 的平台並不存在。即使我沒有研究過 PHP 直譯器,大概也猜得到原因:PHP 內部採用以窄字元基礎的字串模型,並使用窄字元版的 C 函數 及 Win API,這些函數會使用傳統內碼表(而非 Unicode)來解釋 PHP 餵進來的字串。幸好 PHP 6.0 之後可望改善對 Windows 的支援。

另一個惡名昭彰的例子是 Windows 自己的「傳送到 > 壓縮資料夾」,這個功能一直到 Win7 都還是不支援 Unicode,原因大概離不開上述理由。據說兇手是微軟使用的第三方程式庫,原因是 ZIP 很晚才正式將 Unicode 納入規格,微軟用的程式庫是在此之前取得。

還有很多問題其實不是 OS 或應用程式造成的,而是使用者不懂得相關設定,我相信讀到這裡的讀者應該都有足夠的觀念進行大多數的決定了。最後提供一些建議:

  • 我知道很多東西不是說改就能改,但如果可以的話,盡量用 Unicode 儲存資料。我自己寫的文章都是存成 UTF-8。

  • 請對 wchar_t 小心謹慎:

    • 在 Windows 上 wchar_t 是 2 byte,但在 Linux 上通常是 4 byte,如果你找到 1 byte 的實作也不要太驚訝,因為標準沒有規定!C++11 之後提供了 char16_t 和 char32_t 等寬度明確的字元型態。
    • Linux 的 wchar_t 確實可以存放一個 Unicode 字碼,這點在 Windows 上並不總是成立。
  • 你最常使用的純文字文件是什麼?對我來說是程式原始碼。我發現儲存程式原始碼是一個相當 tricky 的問題。首先,UTF-16 極不適合用來存原始碼,所以完全不考慮。所謂的「ANSI」會被如何解釋,完全取決於本地內碼表,具有潛在的危險性。UTF-8 看起來似乎是最佳選項,問題是:BOM!有的工具總是指望 UTF-8 要加 BOM,另一些工具則相反。這裡提供一個不要 BOM 的理由:Unix-like 環境會根據純文字文件開頭的 #! 標記來選擇該文件的解釋器,這是個很方便的功能,我手邊的 bash、python、gnuplot 等等腳本常會這樣寫,BOM 會破壞這個機制。不幸的是許多 Windows 平台的編輯器並沒考慮到這一層,即使在記事本或 Visual Studio 選擇了 UTF-8 存檔,它們不讓使用者選擇就擅自加 BOM。

如果只在單一環境下開發程式,或許這些問題並不重要。不過對於可能會在不同平台編輯、編譯的原始碼,或許最佳的策略就是嚴格拒絕 ASCII 之外的字集,連註解和 string literal 都不應使用非 ASCII 文字。


延伸閱讀(待補)

Unicode FAQ:http://www.unicode.org/faq/

UTF 和 BOM 的 FAQ:http://www.unicode.org/faq/utf_bom.html

Wikipedia 中各種 UTF 的比較:http://en.wikipedia.org/wiki/Comparison_of_Unicode_encodings

Unicode 各平面使用情形:http://zh.wikipedia.org/wiki/Unicode%E5%AD%97%E7%AC%A6%E5%B9%B3%E9%9D%A2%E6%98%A0%E5%B0%84

The Joel on Software--《每個軟體開發者都絕對一定要會的Unicode及字元集必備知識(沒有藉口!)》: http://local.joelonsoftware.com/wiki/The_Joel_on_Software_Translation_Project:%E8%90%AC%E5%9C%8B%E7%A2%BC

arrow
arrow
    全站熱搜

    novus 發表在 痞客邦 留言(7) 人氣()