字符集編碼(三):Unicode

林子er發表於2022-02-28

前面《字符集編碼(上):Unicode 之前》我們講了在二十世紀九十年代 Unicode 出現之前各廠商和標準化組織為了應對不同語言文字的編碼需求而設計了各種互不相容的字符集編碼標準,這使得軟硬體開發商在處理多語言環境時相當棘手。為了解決字符集編碼各自為政的亂象,一些利益相關公司開始湊到一起試圖設計一種新型的、可囊括全世界所有字元的統一編碼標準。


開端

1987 年,蘋果(Apple)和施樂(Xerox)兩家公司的三個技術人員湊到一起,著手研究開發通用字符集的可行性。在 1987 年末到 1988 年初的幾個月裡,他們進行了一系列的調查統計,主要弄清這麼幾個事情:

  • 該字符集大約需要包含多少個字元?
  • 目前世界上使用雙位元組編碼的字元數量?
  • 應該採用固定寬度(定長編碼)還是混合寬度(變長編碼)的編碼方式?
  • 中、日、韓的表意文字可以統一嗎(即同樣的字元同時出現在中日韓三種語言中,是否可以只編碼一次)?

在設計統一碼的時候,世界上已經存在大量的雙位元組編碼標準(如 GB 2312、Shift JIS、Big5 等東亞編碼標準),而且當時施樂也設計了一套雙位元組的多語言編碼標準,所以最初設計統一碼時,大家是傾向於使用雙位元組的,而雙位元組最多能表示的字元數量是 2^16 = 65536 個,所以接下來的重點就是驗證全世界的字元總數是否大於這個數。

工作小組的驗證結果是肯定的(雖然在我們看來有點出乎意料,因為光漢字就不止這個數目了)。當時工作組的原則是只考慮現代字元(也就是現代語言文字中在使用的,不考慮古埃及、古巴比倫、古漢語等現代語言不再使用的字元),而且傾向於使用字元組合(而不是對複雜字元單獨編碼),比如西班牙語的 ñ 由 n 和 ~ 兩個字元組成,大量的韓語也是組合出來的(其實大家還考慮過通過偏旁部首組合漢字,但發現漢字構造過於複雜,就放棄了)。在這些原則下,工作組統計了當時全世界的報紙等刊物,得出了一個結論:兩個位元組足以囊括全世界有實用意義的字元。

早期工作組的另一個設計傾向是採用定長編碼形式

決定採用雙位元組編碼後,面臨兩個選擇:一是採用變長編碼形式(類似 GB 2312),對於 ASCII 字元使用一個位元組,其他字元使用兩個位元組;另一種是採用定長編碼形式,不管是不是 ASCII 字元統一使用兩個位元組。

工作組主要從儲存和處理效率的角度驗證兩種方案的優劣,其驗證結論是雙位元組定長編碼所帶來的文字尺寸增長是可接受的(和其它資訊如文字格式資訊、圖片、視訊等比較起來,字元本身的編碼資訊佔用的空間小的可憐),而定長編碼形式在各方面的處理效率都優於變長編碼形式,所以早期 Unicode 採用了定長編碼形式。

工作組面臨的第三個問題是漢字

東亞國家由於受到中化文化的影響,它們的語言中包含了一些漢字字元(如日語、韓語),這些(不同語言)中的相同字元(漢字)到底是算作同一個字元呢還是不同字元呢?

在上一篇討論字元編碼模型的文章中我們說過,字符集編碼(特別是 Unicode)是對抽象字元編碼,而不是對字形或者字意編碼。按照這個原則,這些漢字應該算同一個字元,雖然它們在含義上不同(可能在字形上也存在些許差異)。

所以工作組決定將中日韓語言中的漢字部分合並叫中日韓統一表意文字(CJK,中日韓三種語言的首字母)——所以你在 Unicode 區塊表中是搜不到諸如 “Han”、“Chinese” 的,漢語是在 CJK 和 CJK 擴充套件塊中。

Unicode 將中日韓漢字合併這點是有爭議的。因為 Unicode 是針對抽象字元而非字形編碼,而同一個漢字在不同語言中寫法(字形)可能不同(比如中國漢字“帶”在日語中寫作“帯”,以及同一個字的古今寫法可能也不同),對於一些寫法不同的漢字 Unicode 也認為是同一個字元。所以有些人認為這種合併會讓人覺得語言本身失去了獨立性。

最初的調研成果總結起來就是:

  1. 使用雙位元組定長編碼;
  2. 中日韓漢字合併編碼;

這些研究成果於 1988 年 8 月以草案的形式釋出(後稱為 Unicode 88),在該草案中正式使用 “Unicode” 一詞,中文翻譯為“統一碼“。

後來,越來越多的公司加入到這個工作組中,包括 Sun、微軟、NeXT 等。這些公司於 1991 年 1月決定在加州成立一個正式的聯盟,就叫Unicode 聯盟(Unicode Consortium),並於同年 10 月釋出了 Unicode 的第一版(Unicode 1.0.0。注意 88 年的那個叫草案,只是公佈了最初研究成果,很多工作還沒完成呢)。該版僅包含 24 種語言文字共 7163 個字元。

注意第一版 Unicode 標準中並不包含 CJK 字元——此時 CJK 部分的工作尚未完成。CJK 部分是在第二年(1992 年 6 月)的 Unicode 1.0.1 中加入的(此版本包含 20902 個漢字)。

如今(2022 年 2 月)最新版 Unicode 已經到了 14.0.0(於 2021 年 9 月釋出),支援 159 種文字,共包含 144,697 個字元(包括控制字元、文字元號、表情符號等)。


另一個兄弟

Unicode 聯盟並不孤單,當這幫人在設計 Unicode 標準時,另一個組織也在幹同樣的事情。

ISO 和 IEC 兩家組織在 1984 年就成立了一個聯合工作組來設計一套新的統一字符集——也就是後來的 UCS(Universal Character Set),於 1989 年公佈了 UCS 草案(Unicode 工作小組在 1988 年釋出了 Unicode 草案)。

這兩個工作組起初誰也不知道對方的存在,直到雙方公佈了各自的設計草案並被傳閱後,大家才逐漸意識到兩者乾的是同樣的事情,這會導致世界上存在兩套統一編碼字符集標準,對雙方以及對世人都沒有好處。

於是這兩個工作組於 1991 年開始謀求合併方案。

所謂合併,並不是簡單地一個工作組完全拋棄自己的東西去擁抱另一個工作組的標準。如果你花費大量人力物力搞了個東西,然後發現別人也搞了個幾乎一樣的,你願意完全放棄自己的這套?另外雖然兩個組織都奔著相同的目標(統一字符集),但在設計原則和一些細節上還是有很多不同的,更麻煩的是,當大家坐下來談合併事項時,Unicode 1.0 已經發布了,UCS 還處於草案階段,但也離釋出不遠了。出於各方面的原因,合併的前提是仍然保持兩個標準都獨立存在和發展,在此基礎上保持兩個標準的相容和同步(或者其中一個是另一個的子集,或者兩個在編碼字符集 CCS 層面完全一致)。

和 Unicode 使用 16 位編碼空間不同,UCS 一開始就選擇使用 31 位編碼空間,也就是說,UCS 最多可以容納 2^31 約 21 億個字元。最開始,Unicode 打算作為 UCS 的真子集,即 Unicode 中的每個字元都存在於 UCS 中,而且兩者的碼點相同,但 UCS 中的字元(編碼超過 64K 的)則不一定存在於 Unicode 中。

經過多次的爭論、投票,最終雙方都作出了一些妥協,在字符集層面達成了一致,即兩個標準中相同字元的編碼(碼點)必須是一樣的,Unicode 針對 1.0 版本做了一些調整(如調整一些字元的編碼,調整某些語言文字的區塊),最終 ISO/IEC 在 1993 年釋出了 UCS 的第一個版本 ISO/IEC 10646-1:1993,Unicode 聯盟也在同一年釋出了相容版本 Unicode 1.1。

當然,合併工作是分步進行的,合併工作成果也是分版本釋出的,1993 年的版本釋出只是雙方初步的(也是最重要的)合併成果釋出。


Unicode 概覽

網上有一種提問:“Unicode 和 UTF-8 是什麼關係?”

很多回答說:“Unicode 是字元編碼,UTF-8 是編碼實現。”

這種回答並不準確。Unicode 是字元編碼不錯,但一般人理解的字元編碼僅僅是指給字元編號(字元編碼模型中的第二層),這給人感覺好像 Unicode 和 UTF-8 是兩個平行的、獨立的東西。實際情況是 Unicode 標準囊括了字元編碼模型的所有層次,從抽象字符集的定義到計算機編碼方案,UTF-8 屬於 Unicode 標準中編碼實現部分。

Unicode 設計之初只打算對人類正在使用的字元進行編碼,而不考慮曾經使用但現在已經廢棄的古文字,因而起初覺得雙位元組已經足夠。不過很快大家就發現該思路走不通,因為一些文字元號雖然在日常生活中用不到,但在特殊領域會用到(比如歷史、考古、語言學等),如果 Unicode 不包含這些字元,則這些特殊領域就必須設計另外的字符集標準,這有違 Unicode 初衷。於是很快 Unicode 聯盟就決定拓寬 Unicode 編碼空間,從 16 位拓寬到 21 位,共可表示一百多萬的字元(其中有些空間屬於保留空間或私人空間,不可分配碼點)。

Unicode 不對非人類語言文字編碼,比如騰格瓦語(Tengwar,《魔戒》作者托爾金創造的精靈語文字)、克林貢語(Klingons,《星際迷航》中克林貢人使用的語言)。

一個抽象字元可能有多種形狀(字形),但由於都具有相同的含義,在 Unicode 中被視為同一個字元,只會分配一個碼點,比如漢字有楷、行、草、隸等寫法,這些屬於同一個字元的不同字形。需要注意,這裡所說的“字形”是指不同的書寫方式導致的字形差異,而非結構性差異,比如繁體中文和簡體中文,從字源來說,簡體中文是對繁體中文的簡化寫法,屬於同意不同字,但這兩者的差距是體現在結構上而非書寫形式上,所以要視為不同的字元。

一個字形到底是由一個字元構成的,還是由多個字元組合成的,是一件見仁見智的事情。比如字元 é,你可以認為它是一個獨立的字元,也可以認為是由拉丁字母 e 和音調字元 ́ 構成。傳統編碼傾向於作為獨立字元看待,而 Unicode 傾向於作為組合字元——Unicode 傾向於使用簡單字元組合複雜字元

另外,抽象字元不一定就存在視覺化的字形,比如控制字元。

在 Unicode 字符集中,每個抽象字元都有唯一的名稱,使用大寫 ASCII 字元表示,比如拉丁字母 a 在 Unicode 中的名稱是“LATIN SMALL LETTER A”。可在 http://www.unicode.org/Public/UNIDATA/NamesList.txt 檢視所有字元的名稱。

Unicode 使用整型數值對這些抽象字元編碼,在書寫上,用數值的十六進位制表示,且至少是 4 位,少於 4 位的使用前導 0 填充,比如 61 要寫成 0061。另外要在數值前面加上 U+ 表示是 Unicode 碼點,因而拉丁字母 a 的 Unicode 碼點寫作 U+0061。

數值編碼(碼點)可能的範圍叫編碼空間(codespace)。起初 Unicode 的編碼空間是 U+0000 ~ U+FFFF,大家很快發現 64K 的編碼空間根本不夠用,所以後來將編碼空間擴大到了 U+0000 ~ U+10FFFF,可容納一百多萬的字元。

這一百多萬的編碼空間被劃分成 17 個平面(planes,17 個大小相同的區域,編號 0 ~ 16),每個平面可容納 2^16 即 65,536 個碼點。每個平面的作用不一樣:

image-20220225153242675

其中最重要的平面是基本平面 Plane 0,也叫 BMP,全世界日常使用的字元都在該平面中(早期 Unicode 就是以該平面作為整個編碼空間);Plane 2 和 Plane 3 是給漢字擴充套件用的;最後兩個平面是私有編碼空間(PUA),不會分配字元碼點,專門給軟體自定義用的。

這些平面又進一步劃分成(block),每個塊放一組特定的字元,如 0000~007F 放基本拉丁字母(ASCII 字母),0590~05FF 放希伯來文,4E00~9FFF 放中日韓統一表意文字(我們最常用的漢字就是在這個塊)

# Unicode 字元塊(部分)

0000—007F 基本拉丁字母
0080—00FF 拉丁文補充1
0100—017F 拉丁文擴充套件A
0180—024F 拉丁文擴充套件B
...
0370—03FF 希臘字母及科普特字母
0400—04FF 西裡爾字母
0500—052F 西裡爾字母補充
0530—058F 亞美尼亞字母
0590—05FF 希伯來文
0600—06FF 阿拉伯文
...
0E00—0E7F 泰文
0E80—0EFF 寮國文
0F00—0FFF 藏文
1000—109F 緬甸文
...
2200—22FF 數學運算子
...
2E80—2EFF 中日韓部首補充
2F00—2FDF 康熙部首
2FF0—2FFF 表意文字描述符
...
4DC0—4DFF 易經六十四卦符號
4E00—9FFF 中日韓統一表意文字
A000—A48F 彝文音節
A490—A4CF 彝文字根
...

一種語言文字可能分散在多個塊中,如漢字就存在很多擴充套件塊,不過最常用的漢字都是在 4E00~9FFF 中。

image-20220225161242070

其中一個擴充套件塊中的漢字,都是我們沒見過的,平時根本用不到


Unicode 的三種表示形式

Unicode 設計之初是採用雙位元組定長編碼的,其碼點和計算機層面表示形式(編碼模式中的第三層 CEF)是一致的。比如漢字的“漢”的 Unicode 碼點是 U+6C49,其計算機編碼表示就是 6C49——這就是 UTF-16 的早期樣子。這種編碼方式的優點是高效,不需要檢查標誌位。

後來大家發現 16 位編碼空間根本不夠用,於是將編碼空間擴充到 21 位。由於原始的 UTF-16 編碼形式無法表示大於 FFFF 的碼點,於是對 UTF-16 也進行了擴充,使其既能用 1 個碼元表達 BMP 中的字元,也能用 2 個碼元表示補充平面的字元——這就是現代版本的 UTF-16。

在 Unicode 認為自己的 16 位編碼空間太小的同時,ISO/IEC 也覺得 UCS 的 31 位編碼空間太多了,實際中根本沒有幾十億字元。所以最終 Unicode 聯盟和 ISO/IEC 工作組達成一致:兩者使用統一的編碼空間 0000 ~ 10FFFF(即 UCS 保證永遠不分配大於 10FFFF 的字元碼點),而且雙方在字元編碼上保持同步,即一方標準中增加了字元,也要通知另一方同步。

使用雙位元組定長編碼還存在另一個——可能是更要命的——問題:它無法在編碼形式層面相容 ASCII 碼。雖然 Unicode 在碼點層面(第二層)相容 ASCII(U+0000~U+007E 的碼點分配和 ASCII 一致),但由於在計算機編碼層面,Unicode 使用兩個位元組,而 ASCII 使用一個位元組,這導致採用 Unicode 編碼標準的軟體無法正確處理現有的 ASCII 編碼檔案。

Unicode 1991 年才釋出,ASCII 在 1968 年就釋出了,這二十多年間產生了大量的 ASCII 檔案和使用 ASCII 標準的軟體,Unicode 置這些現存檔案和軟體不顧的後果就是新興的 Unicode 標準很難被全世界(特別是計算機重度使用區歐美)廣泛接受。Unicode 要想快速普及,就必須完全相容 ASCII,因而 Unicode 聯盟很快推出了 8 bit 碼元編碼方案:UTF-8。UTF-8 和改進後的 UTF-16 一樣是變長編碼方式,ASCII 字元采用單位元組編碼(最高位是 0),其它字元可能採用 2~4 位元組,比如常用漢字用 3 位元組(一些不常用漢字會用到 4 位元組)。

關於為何現在 UTF-8 成為 Unicode 標準中最廣泛使用的編碼方式,網上大多數的回答都是說因為 UTF-8 在編碼拉丁字母時更節約空間,所以歐美公司傾向於使用 UTF-8。

在我看來,這可能是原因之一,但並非主因。

節約空間並不是導致 UTF-8 被廣泛使用的主因。想一想當時設計 Unicode 的都是誰,蘋果、施樂、微軟等等,這些都是當時或未來計算機行業的代表,他們肯定是按照自己的實際訴求來設計 Unicode 的,是在做了充分調研、實際測試驗證後,得出雙位元組編碼並不會造成拉丁語系文字空間顯著增長的結論,才決定用雙位元組定長編碼。

真正讓 UTF-8 廣為接受的恰恰就是歷史遺留下來的那些 ASCII 文字和程式(以及作業系統、程式語言)。

這個論點從直覺上可能覺得不可思議——因為人們直覺總是覺得過去無關緊要,現在和未來才是重要的(比如 Unicode 設計之初就不打算考慮古文字,選用雙位元組編碼也是不打算徹底相容 ASCII),然而真正決定未來的往往就是歷史——人類文明如此,專案的成敗亦是如此。

我們必須正視兩個事實:1. Unicode 比 ASCII 晚了二十多年;2. 其他傳統編碼基本上都相容 ASCII。這兩點導致在 Unicode 釋出的時候,世界上必然存在大量的 ASCII 文字和使用或相容 ASCII 的軟體。

於是 Unicode 出來後,各大軟硬體廠商面臨幾個選擇:

  • 完全擁抱 Unicode,這將造成新舊不相容(比如文書處理軟體 2.0 無法處理 1.0 生成的檔案),這很可能導致新產品賣不出去;
  • 完全不用 Unicode,以前該怎麼苦逼繼續怎麼苦逼;
  • 開發轉換工具,為新老文字和工具做雙向轉換(但這種方式無疑是彆扭的);

如果是你,你會怎麼選呢?有可能是新產品用 Unicode,老產品繼續用老編碼標準,也有可能直接不用 Unicode——因為你還要考慮公司產品之間的相容性問題。

所以當各大公司發現 UTF-8 能完美解決相容性問題,自然都跑去用 UTF-8 了。大家都用,那 UTF-8 自然就被推廣開了,而且人家都用,你不用,你的產品在跟別家互操作時就會出現相容性問題,進而就會被市場淘汰——所以你也必須用。

我們用上帝視角設想,假如 1968 年的那個標準不是單位元組編碼的 ASCII 而是雙位元組編碼的 Unicode(雖然從歷史環境來說不太可能),那麼很可能今天就壓根沒有 UTF-8 編碼。UTF-8 編碼也僅僅是在 ASCII 字元區域節約空間,在拉丁擴充套件區域(也就是歐洲拉丁字母)和 UTF-16 一樣佔兩個位元組,而在常用漢字區域則比 UTF-16 多使用一個位元組。全世界最常用的字元都是在基本平面 BMP 中,UTF-16 在該平面恆定使用兩個位元組編碼,其效率近似於定長編碼方式,而 UTF-8 則使用 1~3 個位元組編碼,是真正的變長編碼——文書處理軟體可以針對 UTF-16 做定長假定優化,對 UTF-8 則不行(也即是說,文書處理軟體可以假定 UTF-16 文字都是雙位元組編碼,當真的遇到四位元組時再做特殊處理,但不能對 UTF-8 這麼做)。

所以,UTF-8 之所以會勝出,不是因為 UTF-8 在技術上比 UTF-16 有多大優勢(雖然 UTF-8 設計得很巧妙),而是因為 UTF-8 在那時解決了各大公司的痛點——更準確地說,UTF-8 是為了解決大家的痛點才出現的。

參考資料:UTF-8 history; Early Years of Unicode;

另外,UCS 一開始就是支援 32 位碼元的(人家從一開始就是 31 位編碼空間),為了和 UCS 保持一致,Unicode 也支援 32 位碼元:UTF-32。

UTF-X 中的 X 表示碼元位數。

Unicode 在 2.0(1996 年) 中正式引入了 UTF-8 和改進後的 UTF-16,在 3.1(2001 年) 版本中引入了 UTF-32。

我們在下一篇將詳細介紹三種編碼方式的實現細節,此處僅做概要介紹。


妥協

Unicode 有兩個設計原則:

  1. 抽象字元原則:面向抽象字元而不是字形或字意編碼;
  2. 動態組合原則:使用簡單的字元組合複雜字元,而不是為複雜字元單獨編碼;

為了讓 Unicode 能夠被廣泛地接受,Unicode 聯盟在設計之初做了一項重要決定:Unicode 必須完全相容現有的所有字符集編碼標準。這個相容是“雙程”的:任何現有字符集中的任何一個字元,可以轉換成 Unicode 字符集中的字元,並且從 Unicode 中的這個字元再轉換回去後還是原來那個字元,這個規則稱為 round-trip rule。

這個規則讓 Unicode 在實現上做了很多妥協。

我們在上篇文章中舉過拉丁字母 K 的例子。在一些傳統的編碼標準中,拉丁字母 K 和熱力學單位 K(開爾文)被當做兩個不同的字元,

為了實現 round-trip 規則,Unicode 中也必須編碼兩個 K(分別是 Latin Capital Letter K U+004B 和 KELVIN SIGN U+212A)——否則那個傳統編碼中的兩個 K(在那邊的碼點是不一樣的)轉換成 Unicode 編碼後變成同一個 K 了,再轉回去就不知道對應誰了。

類似的情況在漢語中也有很多。比如 U+2F08 和 U+319F 都是漢字“人”,U+2F17 和 U+3038 都是漢字“十”,U+03A9 和 U+2126 都是 Ω(一個是希臘字母,一個是電阻符號)。

中國的 GB 編碼和日本的 JIS 編碼在相容 ASCII 的同時,又給 ASCII 中的可見字元做了個“全形”編碼(原 ASCII 中的字元被稱為“半形”字元)。所謂全形和半形字元,在字形和字意上都完全相同,只是全形字元佔用寬度(注意不是字形本身的寬度)是半形字元的兩倍(據說是為了中英文混排時的美觀效果),按照 Unicode 的設計原則,這種問題應該交由文字渲染程式去處理,但由於傳統編碼標準中做了獨立編碼,所以 Unicode 中也必須支援,在 Unicode 編碼表中也能看到一系列 Full Width 的拉丁字母。

另外有些傳統編碼標準中對不同書寫體的拉丁字母也做了不同編碼,Unicode 同樣需要相容之。

image-20220225231217484

Unicode 中同一個字元的不同字形或字意被重複編碼。注意源自東亞標準中的 FULL WIDTH

這些重複編碼違背了 Unicode 設計中的抽象字元原則

在傳統編碼中很少有組合字元的說法,所以諸如 Å(瑞典語字元,以及長度單位“埃”)在傳統編碼(如 ISO/IEC 8859)中視作一個獨立字元,但在 Unicode 中視作兩個字元 A(U+0041)和 ̊(U+030A)的組合字元。為了相容傳統編碼,Unicode 在支援組合的同時,還必須將該字形視作單獨的字元分配額外碼點(U+00C5)——Unicode 中稱這種字元為預合成字元(precomposed character)。

Å 不但存在動態組合與預合成的問題,該字元本身由於在一些傳統編碼標準中作為長度單位“埃”和作為拉丁字母 Å 做了不同的編碼,Unicode 中也必須作此重複(U+00C5:LATIN CAPITAL LETTER A WITH RING ABOVE;U+212B:ANGSTROM SIGN)。

Unicode 中存在大量的這種違背動態組合原則的字元。

image-20220226002130115

大部分韓語在 Unicode 中也是可以組合的,所以也存在多種編碼的可能。

Unicode 中視預合成字元和動態組合字元是等效的,也就是說,如果文字中存在兩個 Å,一個是組合的:<U+0041,U+030A>,另一個是預合成的:U+00C5,則軟體應該將其視為一種字元,搜尋的時候兩個都應該能搜出來——不過目前貌似很多軟體並沒有實現這一點。

Unicode 基本概念就介紹到這裡,下一篇我們講講 Unicode 的三種計算機編碼實現:UTF-8、UTF-16 和 UTF-32。



原文連結:字符集編碼(中):Unicode

相關文章