寫給程式設計師的 Unicode 入門介紹

逆旅發表於2017-05-23

寫給程式設計師的 Unicode 入門介紹

程式設計師世界對這個名字發自內心的恐懼和敬畏。我們都知道在我們的軟體中應該 “支援 Unicode”(無論是什麼意思——對所有的字串使用 wchar_t,是嗎?)。但 Unicode 很深奧,它有千頁的 Unicode 標準,還有幾十頁的補充附錄、報告和註解,簡直太嚇人了。即使 Unicode 誕生 30 多年後,程式設計師們還覺得它很神祕。

幾個月前,我開始對 Unicode 著迷,決定花些時間仔細瞭解一番。在本文,我來從程式設計師的視角對其做介紹。

我主要關注字符集,與字串處理和 Unicode 文字相關的東西。因此,這裡我不會過細地聊字型、文字佈局、形狀、渲染,或本地化,那些是另外的議題,超出了我的能力(知識)範圍。

多樣性和內在複雜性

當你開始學習 Unicode,有一件事情很明顯,就是它和你熟悉的字符集(比如 ASCII)相比,Unicode 複雜性要高了一大截。這不僅僅是指 Unicode 包含了很多的字元,雖然這是一個方面。Unicode 還有很多內部結構,特性和特殊情況,使其不只是人們所認為的純粹的“ 字符集”。本文後續會介紹一些相關內容。

當面對所有的複雜性時,尤其是作為工程師,很難不問自己,“為什麼我們需要這麼多?真的有必要嗎?可以簡化嗎?”

然而,Unicode 的目標是準確地表示全世界的書寫系統(writing systems)。Unicode 協會的目標是“讓全世界的人們不論什麼語言都可以使用電腦”,所以你可想見,書面語言的多樣性是巨大的!迄今為止,Unicode 支援135 種不同的書寫系統,包含約 1100 種語言,但目前還有超過 100 種書寫系統沒有支援,包括現代的和已成為歷史的,Unicode 協會還在努力將其加進來。

鑑於分支的多樣性,要表示它們必然是一個複雜的專案。Unicode 接受了它的多樣,接受了任務(包含所有人類的書寫系統)中的內在複雜性,它沒有在名字簡化上做太多取捨,但是它對需要完善任務的地方的規則,做了異常處理。

此外,Unicode 承諾不僅支援單一語言的文字,還支援多種語言共存於一個文字中——引進了更多的複雜性。

大多數程式語言都有處理底層文字操作的的庫,但是作為程式設計師,你仍然需要知道一些 Unicode 特性 ,知道何時怎樣去應用它。要了解這些東西可能得花些時間動動腦筋,但別灰心——想想有數以億計的人,如果你的軟體支援他們的語言,那他們也可以使用你的軟體噠。所以,擁抱複雜吧!

Unicode 編碼空間

我們先從幾個大的方向入手。Unicode 的基本元素 —— 它的 “字元”,雖然這種叫法不是太貼切——被稱作編碼點(Code Point)。編碼點通過數字來區分,通常寫成 16 進位制的形式再加字首“U+”,例如 U+0041 表示拉丁字母 “A”U+03B8 表示 希臘字母 “θ”。每個編碼點都有一個簡稱,還有一些其他屬性,Unicode 字元資料庫 對此有詳細說明。

所有編碼點組成的集合被稱作編碼空間(Code Space)。Unicode 編碼空間包含 1,114,112 個編碼點。然而,其中只有128,237 個編碼點 —— 編碼空間的 12% 被賦值,目前。還有很多空間用來增長!Unicode 還保留了另外 137,468 字元 作為 “自用” 空間,這些字元沒有標準的含義,可以被個人應用所使用。

空間分配

為了對編碼空間的佈局有個瞭解,把它視覺化會比較直觀。下面是整個編碼空間的佈局,一個畫素代表一個編碼點。使用小方塊來表示以保證視覺的一致性;每個小方塊是 16×16 = 256 個編碼點,每個大方塊是一個面有 65536 個 編碼點。總共加起來有 17 個皮膚。

寫給程式設計師的 Unicode 入門介紹

  • 白色表示未用空間;
  • 藍色表示已用空間;
  • 綠色表示自用區域;
  • 小的紅色區域是代理區(surrogates,後面會講)。

如你所見,被使用的區域分佈有點稀疏,但都集中在前三個面裡。

0 號皮膚也被稱作 “基本多語言皮膚(Basic Multilingual Plane,簡稱 BMP)”。BMP 包含現代文字所需的基本所有字元,包括拉丁文、斯拉夫文、希臘文、漢字(中國),日文、朝鮮文、阿拉伯文、希伯來文、梵文(印度)等等。

(過去,編碼空間只有 BMP 而已—— Unicode 最初設想是 一個 16 Bit 的編碼,只包含 65536 個字元。在 1996 年擴充到現在的規模。然而,絕大多數現代字元屬於 BMP。)

1 號皮膚包含歷史上的文字,比如蘇美爾楔形文字和埃及象形文字,還有 emoji 和其他各種符號。2 號皮膚包含一大塊不常用的和歷史上的漢字字元。剩下的面是空的,除了 14 號皮膚中有一小部分被用作格式化字元;15-16 號皮膚全部保留自用。

寫給程式設計師的 Unicode 入門介紹

書寫系統

讓我們放大前三個皮膚,因為這是最重要的部分:

 

寫給程式設計師的 Unicode 入門介紹

這張圖用顏色表示了 Unicode 中135 種不同的書寫系統。你可以看到漢字(藍色)和朝鮮語(棕色)佔了 BMP 很大一部分(右邊的大方塊)。與之相對,此圖中所有的歐洲,中東,南亞語言加起來剛好佔了 BMP 的第一行。

編碼空間的很多區域都和更早的編碼相容或相同。例如,Unicode 的前 128 個字元就是 ASCII 的拷貝。顯然是對相容性很有好處——很容易無損的從小編碼轉向 Unicode (反過來也一樣,只要沒有使用小編碼之外的字元)。

使用頻率

視覺化編碼空間還有一個有趣的方法,就是看使用頻率的分佈——換句話說,就是每個編碼點在真實世界中使用的頻率。0-2 號面的熱力圖是基於來自維基百科 和 推特(所有語言)的大量文字所得。頻率增長的方向是黑(沒出現)、紅、黃、白。

寫給程式設計師的 Unicode 入門介紹

你可以看到,絕大多書樣本文字都分佈在 BMP 中,有些零散的使用來自1-2 號面。最大的異常是 emoji,它點亮了 1 號面最底下那的幾個小方塊。

編碼

我們知道 Unicode 編碼點,通過它們在編碼空間中的下標來定義,範圍從 U+0000 到 U+10FFFF。但是在記憶體或檔案中編碼點如何用位元組表示呢?

對計算機友好的最省事方式是用 32 位整數來儲存編碼點下標。這樣做是可行,但是每個字元用 4 個位元組有點浪費。當你處理大量文字的時候,使用 32 位整數儲存 Unicode 會佔用大量額外儲存、記憶體、頻寬等。

於是,Unicode 有了幾個緊湊的編碼 。32 位整數編碼被稱作 UTF-32(UTF=”Unicode Transformation Format”),但是很少被用來儲存。頂多作為臨時內部表示出現,用來檢查或操作字串中的編碼點。

最常見的是,你會看到 Unicode 文字被編碼為 UTF-8 或 UTF-16。這些都是可變長度編碼,分別由 8-bit 或 16-bit 為一個單元組成。這些方案中,下標值較小的編碼點佔用的位元組數也少,會節省不少記憶體。這樣做的代價是處理 UTF-8/16 需要以程式設計的方式來處理,會慢一些。

UTF-8

在 UTF-8 中,每個編碼點依據下標值,被儲存為 1 到 4 個位元組。

UTF-8 使用二進位制字首系統,在此係統中每個字元的最高位的幾個位元表明它是否是單個位元組,多位元組序列的開始,或中間位元組;剩餘的位元連線起來表示編碼點的下標。下面的表格展示了UTF-8 是如何編碼的:

UTF-8 (二進位制) 編碼點 (二進位制) 範圍
0xxxxxxx xxxxxxx U+0000–U+007F
110xxxxx 10yyyyyy xxxxxyyyyyy U+0080–U+07FF
1110xxxx 10yyyyyy 10zzzzzz xxxxyyyyyyzzzzzz U+0800–U+FFFF
11110xxx 10yyyyyy 10zzzzzz 10wwwwww xxxyyyyyyzzzzzzwwwwww U+10000–U+10FFFF

UTF-8 有一個方便的屬性,即最開始128 個字元(ASCII字元)被編碼為單個位元組,所有的非 ASCII 字元被編碼為 128-255。這產生了兩個好處。首先,任何已經是 ASCII 編碼的字串和檔案無需轉換就可以被 UTF-8 識別。其次,大量的廣泛使用的程式設計慣例——比如 NULL 結尾,分隔符(n,t,’,’,”)等——在 UTF-8 中也是可用的。ASCII 位元組不會出現在非 ASCII 編碼點中,所以搜尋以 NULL 結尾或分隔符結尾的字串是可以的。

多虧了這個便利,使擴充套件遺留 ASCII 程式和 API 來處理 UTF-8 字元變得簡單。UTF-8 被廣泛運用在 Unix、Linux 和網路世界中,還有許多程式設計師主張 UTF-8 應該作為任何地方的預設編碼

然而,UTF-8 還不能全面替代 ASCII。例如,遍歷字串中的 “字元” 的程式碼需要解碼 UTF-8 並遍歷編碼點(或字位簇(grapheme cluster)——後面會講到),而不是位元組。當你測量字串 “長度” 時,你得考慮是要位元組長度,還是編碼點長度,還是文字渲染的寬度為單位的長度還是其它長度。

UTF-16

你可能遇到的另一個編碼是 UTF-16。它使用 16-bit 字,每個字元被儲存為 1 個或 2 個字。

和 UTF-8 一樣,我們可以用二進位制字首的形式表示 UTF-16 的編碼規則:

UTF-16(二進位制) 編碼點(二進位制) 範圍
xxxxxxxxxxxxxxxx xxxxxxxxxxxxxxxx U+0000–U+FFFF
110110xxxxxxxxxx 110111yyyyyyyyyy xxxxxxxxxxyyyyyyyyyy + 0x10000 U+10000–U+10FFFF

但是,通常人們談到 UTF-16是因為它涉及到了一個在編碼點術語中被稱作“代理(surrogate)”的東西。所有在範圍 U+D800-U+DFFF(或在其他範圍) 中的編碼點,這些和上表中二進位制字首 110110 和 110111 匹配的編碼點——是 UTF-16 中的保留區域,它們自身不表示任何有效的字元。它們僅用於上面 2 個字的編碼模式中,被稱作“代理對(surrogate pair)”,代理編碼點在任何其他情況下都是非法的!它們不能出現在 UTF-8 和 UTF-32 中。

在過去,UTF-16 是1996 年之前的 Unicode 版本的派生物,那時只有 65536 個編碼點。初衷是不應有不同的編碼,Unicode 應該是簡單的16-bit 字符集。後來,編碼空間被擴充用來表示不常用的(仍然重要)的漢字字元,這是 Unicode 設計者之前沒計劃的。代理區在那時被引進,直說了吧,作為拼湊,允許16-bit 編碼訪問新的編碼點。

如今,Javascript 使用 UTF-16 作為其標準的字串表示:如果你問一個字串的長度,或遍歷它等,結果都以 16-bit 的字為單位,同時任何 BMP 之外的編碼點都用代理對錶示。UTF-16 也被微軟 WIN32 API 使用;儘管 Win32 同時支援 8-bit 和 16-bit 字串,但是 8-bit 版本仍然莫名其妙地不支援 UTF-8——只支援使用舊編碼的程式碼,像 ANSI。這使得 UTF-16 成為在 Windows 上獲得 Unicode 支援的唯一方法。

順便說一下,UTF-16 字元可以大端儲存,也可以小端儲存。Unicode 在這個問題上沒有說明,雖然它確實鼓勵一個慣例,即把 U+FEFF 零寬無間斷間隔這個字元放到 UTF-16 檔案開頭作為位元組序標識,來消除位元組序問題。(如果檔案和系統的位元組序不同,BOM(ByteOrderMark) 會被解碼為 U-FFFE,這不是一個有效的編碼點。)

組合標記

目前為止,我們一直在討論編碼點。但是 Unicode 中,字元比單獨的編碼點更復雜!

Unicode 包含一個系統,可以合併多個編碼點,動態組合字元。此係統用各種方式增加靈活性,而不引起編碼點的巨大組合膨脹。

例如,在歐洲語言中,組合標記出現在變音符和字母的使用中。 Unicode 支援各種各樣的變音符號,包括尖音符號的和重音符號、母音變音符號、變音符號等等。所有這些變音符可以被使用在任何字母表的字母中。事實上,多個變音符號可以被使用在一個字母上。

如果 Unicode 試圖為每個字母組合或變音符組合分配一個獨立的編碼點,事情會變得無法控制。相反,動態組合系統可以讓你構造你想要的任何字元,通過以一個基礎編碼點(字母)開始然後附加額外的編碼點,被稱作“組合標識”,來指定變音符。當一個文字渲染器看到字串中有這樣的序列時,它會自動堆疊變音符到基礎字母的上面或下面來造出一個組合字元。

例如,帶重音的字元“Á” 會被表示成由兩個編碼點組成的字串:U+0041 “A” 拉丁大寫字母 a 加上 U+0301 “◌́”組合尖音符號。這個字串自動被渲染成單個字元:“Á”。

如今,Unicode 還包含許多 “預設的” 編碼點,每個表示一個被使用過的組合,例如 U+00C1 “Á” 帶銳音符的拉丁大寫字母A U+1EC7 “ệ” 帶揚抑符和下點的小寫拉丁字母 e。我懷疑這些大多繼承自融入 Unicode 的舊編碼,來保證相容性。實際上,對於歐洲語言中的大多數常見的帶變音符號的字母都有預設,所以文字中動態組合用的不多。

可是,組合標誌系統確實允許任意數量的變音符號被疊加到任何基礎字元上。使用歸謬法的 Zalgo 文字,它通過隨機疊加任意數量的變音符號在每個字母上,讓它溢位行距,產生混亂現象。(如下圖)

寫給程式設計師的 Unicode 入門介紹

Unicode 中出現動態組合字元的其他地區:

  • 阿拉伯文和希伯來文中的母音標記 。這些語言中,單詞通常由母音拼寫。它們有變音符號標記母音(用在字典,語言教學材料,兒童教材,等地方)。這些變音符號用組合標記表示。
  • 希伯來文,帶注音符號: אֶת דַלְתִּי הֵזִיז הֵנִיעַ, קֶטֶב לִשְׁכַּתִּי יָשׁוֹד
    正常文字(不帶注音符號): את דלתי הזיז הניע, קטב לשכתי ישוד
  • 天成體(梵文),這種文字被用在印度北部,梵文和其他南亞語言中,用組合標記標識特定母音的附加到子音字母上。例如,“ह” + “ि” = “हि” (“h” + “i” = “hi”).
  • 表示音節的朝鮮字元,但是它被稱作Jamo ,用來表示音節中的母音和子音。當然也有為朝鮮文預製的編碼點,同時也可以動態組合它們的 jamo。例如,“ᄒ” + “ᅡ” + “ᆫ” = “한” (“h” + “a” + “n” = “han”).

規範等價性

Unicode 中,預設字元和動態組合系統並存。後果就是有多種方法表示同一個字串——不同編碼點序列產生相同使用者可感知的字元。例如,我們之前看到的,表示字元 “Á”,我們可以用一個編碼點 U+00C1 ,也可以用兩個編碼點 U+0041 和U+0301。

另一個歧義來源是一個字元中的多個注音符號。當兩個注音符號作用在同意個基本字元上面時,注音符號的順序很重要,例如,都在上面:”ǡ“ (點然後長音符)和 ”ā̇“ (長音符然後點)是不一樣的。 然而,當音節運用在不同邊時,例如。一個在上邊一個在下邊,編碼點的順序不會影響渲染。此外,一個有多個音節的字元,它可能會由一個預製的編碼點再加其餘的編碼點來表示。

例如,越南字母“ệ” 可以用以下五種方式表示:

  • 完全預設:U+1EC7 “ệ”
  • 部分預設:U+1EB9 “ẹ” + U+0302 “◌̂”
  • 部分預設:U+00EA “ê” + U+0323 “◌̣”
  • 完全分解:U+0065 “e” + U+0323 “◌̣” + U+0302 “◌̂”
  • 完全分解: U+0065 “e” + U+0302 “◌̂” + U+0323 “◌̣”

Unicode 把這樣的字串集合稱作 “規範等價”字元。在搜尋、排序、渲染、文字選擇等操作中,規範等價字元應該被同等對待。這影響到了你如何實現文字的操作。例如,假設你的程式有“查詢”操作,使用者搜尋 “ệ”,理論上應當找到如上所有出現的所有版本的 “ệ”!

形式正規化

要解決如何處理等值字串的問題,Unicode 定義了幾種正規形式:是幾種把字串轉化成規範形式的方法,這樣它們就可以被逐點比較(或按位元組比較)。

“NFD” 正規化方法,完全分解每個字元到基本部件和組合標記,去掉字串中任何預製的編碼點。還會按渲染位置排列每個組合標記,舉個例子,在字母底下的注音符號要比在上邊的靠前。(不會重排有相同渲染位置的注音符號,因為它們的位置關係是可視的,前面提到過。)

“NFC” 正規化方法,反過來,儘可能的把編碼點替換成預製編碼點。如果使用了不常用的注音符號組合,可能不會有任何預製的編碼點,這種情況下 NFC 仍然替換它可以替換的,然後留下組合標誌(和 NFD一樣,還是會按渲染順序重新排序)。

還有一些方法被稱作 NFKD 和NFKC。 這裡的 “K” 指的是相容性分解,它包含了某種程度上“相似”但是視覺上不同的字元。但我不打算講這些。

字位簇

如上所見,Unicode 包含多種情況,使用者認為的一個“字元” 事實上底下可能由多個編碼點組成。Unicode 使用「字位簇」的概念來表示這種情況。一個由一個或多個編碼點組成的字串構成一個 “使用者感知的字元”。

UAX #29 為字位叢定義了精確的規則。它大約是 “一個基本的編碼點接著任意數量的組合標記”,但是真實的定義有點複雜;它包含了朝鮮語字母,和 emoji ZWJ 序列。

字位簇主要被用在文字編輯:它們對游標和文字選擇來說是最明顯的單元。使用字位簇,確保在複製和貼上文字時不會突然丟掉一些符號,同時左右方向鍵也總是以一個可見字元的距離移動,等等。

另一個用到字位簇的地方是,執行字串長度限制——比如在資料庫域中。其實,底層的限制可能是類似 UTF-8 中的位元組長度之類的東西,你不能簡單的通過截斷位元組的方式來限制長度。至少,你得 “捨去” 最近的編碼點;但更好的是,捨去最近的字位簇。除此以外,你可以通過捨棄它的一個注音符號破壞一個字元,中斷一個 jamo 序列或 ZWJ 序列。

更多···

從程式設計師的角度來看,關於 Unicode 還有很多東西可以講!我還沒有深入一些有趣的主題,比如對映、排序、相容性分解和容易混淆的詞,Unicode 正規表示式,和雙向文字。還有個我沒談到的是實現主題——如何有效儲存和查詢分佈稀疏的編碼點資料,或著如何優化 UTF-8 解碼、字串比較和NFC 標準化。也許我會在未來的文章中講到這些。

Unicode 是個令人著迷的複雜系統。在位元組和編碼點之前有多對一的對映,除此之外編碼點和“字元”之間也有(某些情況下多對多)多對一的對映關係。在每個角落都有古怪的特例。沒人聲稱表示全部書寫系統很容易,但很明顯我們不會回到使用不相容編碼來拼湊的艱難歲月了。

打賞支援我翻譯更多好文章,謝謝!

打賞譯者

打賞支援我翻譯更多好文章,謝謝!

寫給程式設計師的 Unicode 入門介紹

相關文章