這個第四篇原本要寫的是另一個主題,不過由於最近有一件應該和編碼稍微有關的事上了新聞,所以就從那個新聞開始來談一個 Unicode 的一個不少人還是會忽略的部份。
「開元路𩵚魠魚羹」
大約在這篇文章發表的半個月前,特斯拉的導航功能出了一個只有台灣才會碰到的 bug:當要導航導向「開元路𩵚魠魚羹」的時候導航系統會當機。新聞方面各位搜尋一下這個名字就能找得到不少文章,但這個 bug 的成因由於特斯拉官方沒有正式發表,所以大家都只能猜:有猜測是這幾個字的發音會跟命令導航系統重開機的指令很接近,也有猜測是說這其實是因為導航系統無法正確處理「𩵚」這個字造成的。
我對電腦語音辨識不熟,所以不太能判斷這個「和重開機指令接近」是不是個可能原因,但後一個猜測「系統不能處理『𩵚』字」卻是在處理 Unicode 編碼時很有可能撞上的問題。
究意這是什麼樣的問題?
Surrogate Pair (代理對)
Unicode 有一個被稱做 Surrogate Pair (代理對) 的設計,用來在 UTF-16 當中表示 code point 由 U+10000 開始的字。詳細來說,Unicode 在前 65,536 的範圍裡保留了兩大塊的區域,一塊由 0xD800~0xDBFF 稱做 High Surrogate (高位代理),另一塊由 0xDC00~0xDFFF 稱做 Low Surrogate (低位代理);這兩個區域各有 1,024 個組合,兩區各取一個一共有 1,048,576 個組合,和 U+10000 開始的字一一對應。(會叫做代理對就是來自這樣的設計:用兩個「字」來代理表示一個後面碼位的字)
以文章開頭提到的「𩵚」字為例,它的 Unicode 編碼為 U+29D5A,要將其轉換為 UTF-16 的處理如下:
- 先減去 0x10000 計算是延伸區域的第幾個字:0x29D5A – 0x10000 = 0x19D5A
- 將它視為一個 20-bit 數字,拆成高低位兩個 10 bit 數字:0x19D5A = 0x67 × 1024 + 0x15A
- 將高位加上 0xD800,低位加上 0xDC00,就是最後的編碼了:0xD867 0xDD5A
從 UTF-16 解碼只要照著倒過來的順序即可。
這個設計其實是個歷史遺跡:Unicode 最早最早的設計是一個 16-bit 編碼,因為在 1988 年提出 Unicode 的人認為:
Unicode aims in the first instance at the characters published in modern text (e.g. in the union of all newspapers and magazines printed in the world in 1988), whose number is undoubtedly far below 214 = 16,384.
— Joe Becker, Unicode 88, as quoted by Wikipedia
「undoubtedly far below 214」這句話沒多久就被漢字打臉了:四年之後的 1992 年,第一波整理進入 Unicode 的中日韓統一表意文字就有 20,902 個字之多。也就是說,一開始訂成 16 位元的編碼當中,光常用的漢字 (繁中、簡中、日文漢字、韓文漢字) 就用掉這空間的快三分之一了,之後擴充加字很快就會不敷使用。因此在又四年之後的 1996 年,Unicode 2.0 制定時,劃定了這兩塊代理對的空間,並把 Unicode 的字元總空間擴充到這些代理對能表示的範圍。
這樣延伸之後,Unicode 的範圍擴大到了 1,114,112 個字,這麼大的範圍需要稍微切分才好管理,因此也是在這個版本裡,Unicode 提出了字碼平面的分區,把整個範圍劃為 17 個字碼平面,每個平面有 65,536 個字;這些平面由它們字碼的高位數值進行編號,編為第 0 平面到第 16 平面。寫文當下最新的 Unicode 標準是 13.0,這當中對於各平面的規劃如下:
- 第 0 平面:U+0000~U+FFFF,基本多文種平面 (Basic Multilingual Plane,簡稱 BMP)。這是最一開始劃定的 65,536 個字,雖然現在已經 99% 滿了但仍然時不時有些新字塞在這個地方。1
- 第 1 平面:U+10000~U+1FFFF,多文種補充平面 (Supplementary Multilingual Plane,簡稱 SMP)。這一區補充了一些比較老舊的文字,以及圖形文字 (如顏文字) 等。
- 第 2 平面:U+20000~U+2FFFF,表意文字補充平面 (Supplementary Ideographic Plane,簡稱 SIP)。這一區收錄古中文字、罕用中文字、地區漢字等。「𩵚」就是收錄在這裡,屬於 Extension B 這一塊補充漢字區域。
- 第 3 平面:U+30000~U+3FFFF,Tertiary Ideographic Plane (簡稱 TIP)。原本預計是用來收錄甲骨文、小篆等字,但因為第 2 平面已經幾乎滿了,因此也是在 13.0 版中在第 3 平面劃了一塊 (Extension G) 用來放更多的補充漢字。
- 第 4 ~ 13 平面:沒有規劃。
- 第 14 平面:特別用途補充平面 (Supplementary Special-purpose Plane,簡稱 SSP)。目前只有開頭 U+E0000~U+E007F 和 U+E0100~U+U+E01EF 有定義數百個控制字元。
- 第 15~16 平面:U+F0000~U+10FFFF 共 131,072 個字2是私人使用區,就是根據用途不同各自自行定義用途的區域。某種意義上來說可以說這一塊就是造字區,但這用途可以不限於造字。值得一提的是,在 BMP 當中其實已經有一塊私人使用區了,在 U+E000~U+F8FF 一共 6,400 個字,許多 DBCS 編碼的造字區多半都會先對應到這一塊來;這兩個平面則是更大一塊的這樣的區域而已。
由於代理對的設計是後來追加的,而且用了兩個 16-bit 的數字來表示,因此對於使用 UTF-16 編碼的處理就造成了跟 DBCS 有一點類似的問題了:有些字需要一個 16-bit 數字表示,有些字卻需要兩個。好在因為代理對的兩個範圍彼此和其他的字完全不重疊,因此任意跳到一個字串的中間是可以馬上知道指向的是一個 16-bit 數的單獨字、代理對的高位代理還是低位代理,不需要像 DBCS 編碼一樣要掃過整個字串才能決定,因此也就不會出現第二篇提到的被吃掉一半的亂碼錯誤。
但是,一個字可能會使用兩個以上的單位 (16-bit 數字) 來表示仍然會對一些系統的設計上產生問題。如果系統設計不良,使得原本只計劃放一個 16-bit 數字的空間拿去放了這樣的一個字,就有可能因為 buffer overflow 造成當機;或者如果一個原本為了存取某個和文字有關,大小為 65,536 的 (不管什麼) 表格,所以只預期最大到 65,535 的數字放入了一個超過它範圍的數字,那也會因為陣列存取超界而當機。特斯拉的導航是不是這樣當機的我沒有資料,不過出問題的「𩵚」字所在的 Extension B 區域早在二十年前的 Unicode 3.1 就已經制定,很難想像現在的系統會在這種地方發生當機問題--除非這系統真的只支援 65,536 個字……3
CESU-8
提到代理對,可以順帶來提一個和這相關的 Unicode 編碼方式:CESU-8,全名 Compatibility Encoding Scheme for UTF-16: 8-Bit。這是一個 UTF-8 編碼的變種,其差異在於 BMP 之外的字元要先表示成代理對,然後把兩半個別使用 UTF-8 編碼。因此這樣的字元在正確的 UTF-8 中應該是使用四個位元組編碼,但在 CESU-8 中會表示為六個位元組。
這其實是一個過渡性編碼:名字裡的 C 代表 Compatibility,「相容性」,也就是給那些在代理對出來之前就已經實作 UTF-8 的系統一個過渡時期以正確地轉換到正確支援代理對來的。最好的例子就是資料庫編碼:Oracle 的資料庫的文字編碼中標示為「UTF8」編碼的其實是 CESU-8,這就是為了那些已經使用早期的 UTF-8 編碼的資料庫而留下來的;要在這裡使用正確的 UTF-8 要使用「AL32UTF8」。Unicode 官方有在技術報告當中提到過這種編碼的存在並給了 CESU-8 這個名字,但明確表示官方不預期也不建議使用這種編碼進行資訊交換。
不過就算是這樣,在一些內部使用 UTF-16 做為文字儲存方式的系統來說,如果沒有注意到代理對的存在的話,就會做出表面上是 UTF-8,實際上卻是 CESU-8 的編碼出來了。我是還沒有看過哪個比較近期的系統會不小心做出這種編碼出來啦4,但這個眉角和許多圍繞代理對的問題都再次說明了一個處理 Unicode 的重要觀念--周思博甚至把這個觀念開宗明義地寫在幾乎是最一開始的地方:
在Unicode裡一個字母是對映到一個叫code point的東西(還只是一個理論上的概念)。要如何在記憶體或是磁碟上表示code point就完全是另一回事。
我這篇文章談的代理對的問題--或者更一般地,BMP 平面以外的字元的處理問題--其根本原因就是錯誤地假設一個 Unicode code point 可以用 16 bit 數字來表示,使得在不知情的狀況下使用了 UTF-16 做為內部編碼而造成的。這種問題其實有一個很懶人的解法:使用 UTF-32 (或稱 UCS-4) 做內部編碼,每一個儲存單元是 4 byte,足夠存下一個 code point 的所有範圍;要輸出成其他形式時再行轉換。這有一個很明顯的壞處:因為 Unicode 只有到 U+10FFFF,只用到了 32 bit 當中的 21 個,至少有三分之一的位元是浪費掉了的。那是否要花這個空間換取處理上的方便就是各自的取捨了。5
話說回來,我這裡其實故意沒有提到另一個「字」的問題:由於那個問題多半是在顏文字上出現,所以會錯誤地認為這是和 BMP 平面外字元有關的問題,但事實上是源自於對 Unicode 的又一個誤解而來的。這個問題的範圍比較大,所以容我在這裡冨樫一下,之後有機會再來整理吧。
- 例如 13.0 新增的字中,在 Bopomofo Extended 區塊 (U+31A0~U+31BF) 新增了五個注音符號,其中 U+31BB 是一個小字ㄍ,用來標台語音的。 ↩︎
- 嚴格上來說有四個字不算在內,因為 Unicode 定義 U+xxFFFE 和 U+xxFFFF 為「非字」;不過這裡暫且先不深究這個。 ↩︎
- (2024/9/3 新增) 三年後的新發現:原來 Unreal 引擎到 2024 年的現在還在堅持 UCS-2……(不過看看 Unreal,那一頁的開頭就是周思博文章的連結) 聽說 Windows 好像準備要換了的樣子,不過現在還沒看到相關文章就是。 ↩︎
- 一個使用 CESU-8 的著名地方是 Java,它儲存在 .class 當中的字串就是 (修改過的) CESU-8,在 Java 的官方文件裡叫做「Modified UTF-8」;同樣由 Java 編譯產生的 dex 格式 (Android 的執行檔格式) 裡的字串也因此繼承了 Modified UTF-8 的表示方式。但 Java 並不近期;它會發展成這樣也是有其歷史原因的。 ↩︎
- 我先前在參與 CPPGM (C++ 編譯器大師計畫) 時用的就是這個方式:程式內部使用 UCS-4 表示字串,外部則是讀寫 UTF-8,只在讀跟寫的介面上進行轉換;這讓我可以不需要花心思在基本的內部字串處理上。 ↩︎