Unicode的前世今生

L-Zephyr發表於2019-02-20

之前突然發現自己對字元編碼還是一知半解,基本上只是聽說過各種編碼的名字,對它們之間的特點和區別還是不甚瞭解。所以這段時間查閱了許多資料,對字元編碼也大概有了一些整體的瞭解,寫下這篇文章作為總結。

在Unicode之前

為了在計算機的中儲存人類可以閱讀的文字,必須按照一定的規範將字元對映為計算機可以儲存的數值,在計算機發展的早期漸漸形成了統一的標準,在1967年ASCII編碼首次作為規範標準釋出。這是一套用來表示現代英文的編碼約定,全稱為美國資訊交換標準程式碼。ASCII編碼非常簡單,只定義了128個字元,每個字元通過唯一的編號來表示,每個字元佔用一個位元組(8bit)的空間,因為只有128個字元(2的7次方),所以每個字元的第一位始終為0。

一個ASCII字元只有8位,最多隻能表示256個字元,對於英文來說足夠了,但是對於像中文這樣的語言而言是遠遠不足的。所以在ASCII之上做了一些擴充套件,用兩個位元組來表示一個字元,這就是1981年釋出的GB2312編碼,為了與ASCII作區分,GB2312中每個位元組的最高位都是1。這一套編碼中包含了6000多個常用的簡體漢字,基本滿足日常使用的需求。但是不支援繁體漢字和一些生僻字,所以在後來又在GB2312上進行了擴充套件,這就是之後的GBK編碼,全稱為漢字內碼擴充套件規範

事實上在那個年代還有很多不同的漢字編碼百花齊放,而且不止是中文,世界上其他各種語言都在指定自己的標準,不同編碼之間無法相互相容,這為網際網路的推廣帶來了很大的麻煩,統一字元編碼勢在必行。

Unicode

Unicode是國際標準化組織制定的一套字元編碼方案,致力於統一世界上所有語言字元的編碼。Unicode為每個字元分配了一個固定的數值,稱為編碼點(Code Point),所有的編碼點組成的集合稱為編碼空間(Code Space)。目前Unicode的編碼空間共包含0x10FFFF(十進位制的1114111)個編碼點,被劃分為17個平面,每個平面包含0xFFFF個字元。從1991年釋出的第一個版本開始,每一年都會有新的字元被編入Unicode中,目前所定義的字符集只用了不到五分之一的編碼空間。

編碼方式

Unicode制定了一套字符集編碼的標準,而在實際中如何去表示一個編碼點呢,有幾種不同編碼方案:UTF-8、UTF-16和UTF-32,這幾種方案各有特點。

UTF-32:

這是最簡單的一種編碼方式,定長編碼。使用4個位元組作為一個編碼單元,也就是說每一個編碼點都用4個位元組來表示。

定長編碼的一個好處就是每個字元的做佔用的空間都是相同的,所以當我們想要獲取第n個位置的字元時,直接在首字元的地址加上一個固定的偏移量就可以了,也就是說可以在O(1)的時間複雜度索引字串的任意位置,這也是我們常說的隨機索引。但是這樣做的缺點也十分明顯,每個字元佔用32個bit,肯定會造成大量的空間浪費,出於這個原因UTF-32編碼用得並不多。

UTF-16:

在介紹UTF-16之前,先講講UCS-2編碼。在早期的Unicode標準中,只定義了不到65535(0xFFFF,2的16次方)個編碼點,所有的字元都可以用兩個位元組的UTF-16編碼來表示,所以在那個時候UTF-16還是一個定長編碼,UCS-2就等同於UTF-16。然而設計師還是錯誤的估算了編碼點的範圍,16位的範圍並不足以囊括世界上的所有文字,所以Unicode需要擴大最初的範圍。在新的標準中編碼空間被擴充套件到了0x10FFFF的大小,分成17塊65535大小的板塊,第一個板塊包含了最初UCS-2中定義的65535個編碼點,被稱為基本多文種平面(BMP),餘下新增的16個板塊稱為輔助平面。所以在今天來說,UTF-16可以看成UCS-2的父集。

隨著標準的擴充,UTF-16也必須擴充套件以支援更多的編碼點。在如今的UTF-16編碼中使用了2個位元組作為一個編碼單元,一個編碼點需要2個或4個位元組來表示。

為了能正確表示輔助平面中的編碼點,UTF-16對編碼點的字首做了一些約束,引入了一個稱為代理編碼點(surrogate)的概念。也就是在Unicode的編碼空間中劃分出了一塊保留區域,落在在這個區域中的編碼點就是代理編碼點,這塊區域包含從字首110110到字首110111的所有編碼點,也就是從11011000000000001101111111111111的範圍,十六進位制為0xD8000xDFFF。這個區域中的編碼點只能成對出現在UTF-16編碼中,出現在UTF-32和UTF-8中都是非法的。

UTF-16在編碼的時候遵循以下規則:

位元組數 UTF-16二進位制表示 編碼點 編碼範圍
2 xxxxxxxxyyyyyyyy xxxxxxxxxxxxxxxx 0 ~ 0xFFFF
4 110110xxxxxxxxxx + 110111yyyyyyyyyy xxxxxxxxxxyyyyyyyyyy + 0x10000 0x10000 ~ 0x10FFFF

當編碼點在0到0xFFFF的範圍內時,這兩個位元組中的所有bit都可用來表示編碼點;而當編碼點大於0xFFFF,就必須要使用兩個代理編碼點了,分別取前後兩個位元組中低位的10個bit,這樣就有了20bit的編碼空間,最大能表示0x100000的值,再加上0xFFFF,正好就是0x10FFFF,Unicode中定義的最大編碼空間。

UTF-8:

UTF-8使用單個位元組作為編碼單元,這是一種變長編碼,根據需要使用1個到4個位元組來表示一個編碼點。在這種編碼模式中,一個位元組可能是表示一個單位元組的字元,也可能是多位元組字元中的一部分,在解析的時候必須要能夠區分出來。所以在UTF-8中每個位元組最高的幾個bit不用來儲存編碼值,而是用來表示該位元組在其所表示的字元中的位置:

位元組數 UTF-8二進位制表示 編碼點 編碼範圍
1 0xxxxxxx xxxxxxx (7bit) 0 ~ 0x7F
2 110xxxxx + 10yyyyyy yyyyyzzzzzz (11bit) 0x80 ~ 0x7FF
3 1110xxxx + 10yyyyyy + 10zzzzzz xxxxyyyyyyzzzzzz (16bit) 0x800 ~ 0xD7FF + 0xE000 ~ 0xFFFF
4 11110xxx + 10yyyyyy + 10zzzzzz + 10wwwwww xxxyyyyyyzzzzzzwwwwww (21bit) 0x10000 ~ 0x10FFFF

3個位元組的情況下有兩個編碼範圍,這是因為上一節中提到的代理編碼點不能表示任何字元

簡單來說UTF-8的編碼規則只有兩條:

  1. 單位元組字元的最高位為0,後7位為該字元的編碼值。
  2. n個位元組的符號(n > 1),第一個位元組的最高n位都為1,n + 1位為0,剩餘的位元組的最高位都為10。

可以看到,單位元組的UTF-8編碼最高位作為標誌位始終為0,在上面提到的ASCII編碼中最高位沒有用上也始終為0。也就是說前128個字元的編碼方式與ASCII是完全相同的,這樣一來UTF-8就能夠完全相容ASCII,用ASCII編碼的檔案無需任何轉換就可以直接被UTF-8所識別。

對空間的高效利用,以及對ASCII相容性,使得UTF-8成為了最主流的編碼方式。

位元組序

說到位元組序的問題必須先談一談大端和小端,在計算機的世界中多位元組的資料會按照其位元組順序被儲存,而位元組之間的排列方式有兩種:大端模式(Big-Endian)和小端模式(Big-Endian):

  • 大端模式:低位位元組排放在記憶體中的高位地址,高位位元組排放在記憶體中的低位地址。
  • 小端模式:低位位元組排放在記憶體中的低位地址,高位位元組排放在記憶體中的高位地址。

比如說有一個short型別的資料0x3A80,需要佔用2個位元組的空間,其中高位位元組為3A,低位位元組為80

使用大端模式儲存時記憶體的排列方式如下,記憶體中的高地址方向存放的是低位位元組80

大端模式

使用小端模式儲存時記憶體中的排列方式如下,記憶體中高地址方向存放的是高位位元組3A

小端模式

再回到Unicode中,由於UTF-16使用了兩個位元組作為一個編碼單元,在解析的時候每次需要讀取兩個位元組,所以位元組序就變得尤為重要。例如漢字的編碼點為0x5440,如果以錯誤的位元組序來讀取的話,則會將其識別為0x4054,這樣一來就變成了漢字

為了保證字串始終能以正確的位元組序來讀取,標準建議UTF-16檔案在起始的位置加上0xFEFF,稱為位元組順序標記(BOM)。因為在讀取檔案是按照低地址到高地址的順序,所以如果讀取到0xFEFF則說明該檔案是採用大端模式來儲存的;如果讀取到0xFFFE則說明檔案是採用小端模式來儲存的。

如果使用的是UTF-8編碼則不需要關心這個問題,因為UTF-8的編碼單元只有一個位元組,每次只需要讀取一個位元組即可,所以不存在位元組順序的問題。

組合字元

Unicode的複雜性不僅體現在其編碼方式上,在Unicode中有一些字元存在多種不同的表示方式。這是什麼意思呢?有一些文字會帶有音調符號,比如一個帶有音標的符號ǎ,它可以直接通過編碼點0x01CE來表示,也可以使用一個a(編碼點為0x0061)和一個̌(編碼點為0x030C)組合起來表示,雖然說編碼看起來不一樣,但是這兩種寫法在語義上和視覺上都是相同的。這樣就引入了一個新的概念,我們稱ǎ字元和ǎ組成的序列是標準等價的。

這樣麻煩就來了,當用兩種寫法來表示同一個字元的時候,計算機根據位元組比較會認為它們是不同的。為了能正確判斷字串之間的等價性,Unicode規定了一套標準的正規化演算法(有四種正規化的形式,就不再展開介紹了),也就是將所有標準等價的字元轉換成統一的表示形式:

let c1 = '\u{01CE}'; // ǎ
let c2 = '\u{0061}\u{030C}'; // ǎ

c1.normalize(); // 01CE
c1.normalize(); // 01CE
複製程式碼

在上面的這一段JavaScript程式碼中,ǎ的兩種寫法在經過正規化之後都被轉換成了相同編碼01CE,這樣一來就能正確的進行相等性比較了。

到了Emoji這邊情況就變得更加複雜了,很多Emoji表情是用多個Unicode碼點來表示的,比如說❤️是由一個心型字元 ❤(0x2764)和一個樣式控制符號(0xFE0F)組合而成。此外Emoji還支援使用零寬度連線符(ZWJ,碼點為0x200D)將多個Emoji字元組合新的字元。也就是將0x200D字元放在兩個Emoji字元的中間,這兩個Emoji會被連線起來組成新的Emoji字元。比如說?和?可以組合成?‍?(\u{1f469}\u{200d}\u{1f466}),像?‍?‍?‍?這種Emoji更是由7個Unicode字元組合成的複雜字元。

從上面的這些例子中可以看出,在Unicode中語義上的單個字元實際上可能是由許多個字元組合而成的,為了更好的描述這種場景,Unicode中引入了一個稱為字位簇(grapheme cluster)的概念。字位簇用來表示一個語義上的字元,不論是單個字元還是包含多個字元序列的組合字元,都視為一個字位簇。

實際應用

在瞭解了Unicode的各種特性之後再來看看不同語言中對於字元編碼的處理吧,下面對比了一下個人平常使用的語言中字元編碼的異同:

JavaScript

在JavaScript剛剛釋出的那個年代,還是UCS-2的天下,所以JavaScript內部字串的編碼方式採用了UTF-16,準確的說是UTF-16的子集UCS-2。

這一歷史問題為今天的JavaScript帶來了一些困擾,因為所有的字元在JavaScript中都被視為兩個位元組的編碼,如果字串中包含輔助平面的編碼點時,JavaScript會將其視為2個2位元組的字元來處理。這個問題影響了JavaScript中的字元處理函式:

let c = '?'; // 0x20017
c.length; // 2

c.charCodeAt(0).toString(16); // 0xD840
c.charCodeAt(1).toString(16); // 0xDC17
複製程式碼

上面程式碼中漢字"?"的Unicode編碼點是0x20017,大小超過了0xFFFF,位於輔助平面中,所以在UTF-16中需要4個位元組,編碼為0xD840DC17。呼叫length的輸出是2,說明JavaScript將其識別成了兩個字元。charCodeAt是一個用來列印指定位置字元編碼值的方法,將結果轉換成16進位制後可以看到分別輸出了兩個編碼單元的值d840dc17。想必前端的同學一定對這些多位元組字元處理上的坑深惡痛絕。

不過好訊息是ES6以來這些坑也在陸續填上了:新增的codePointAt方法能正確識別4位元組的UTF-16字元、新的Unicode字元表示方法\u{20017}、新增for…of迴圈也能正確的遍歷4位元組字元...

let c = '?';
Array.from(c).length; // 1
c.codePointAt(0) // 20017
複製程式碼

Objective-C

OC中對字串的處理與JavaScript類似,內部的字串編碼同樣採用了UCS-2,上面的那個例子在OC中會獲得同樣的結果:

NSString *s = @"?"; // 0x20017
s.length; // 2
[s characterAtIndex:0]; // 0xD840
[s characterAtIndex:1]; // 0xDC17
複製程式碼

想要獲得正確的字元數可以先將字串轉換成定長的UTF-32編碼,然後再除以4:

[@"?" lengthOfBytesUsingEncoding:NSUTF32StringEncoding] / 4; // 1
複製程式碼

這樣子可以正確的識別出Unicode碼點的個數,然而對於組合字元還是無能為力。

這個問題同樣會影響到比較字串時常用的isEqualToString方法:

NSString *s1 = @"a\u030C"; // ǎ
NSString *s2 = @"\u01CE"; // ǎ

[s1 isEqualToString:s2]; // NO
複製程式碼

若要對字串進行標準等價比較,必須使用compare方法,或者先使用precomposedStringWithCanonicalMapping方法將字串正規化:

[s1 compare:s2] == NSOrderedSame; // YES
[s1 precomposedStringWithCanonicalMapping]; 
複製程式碼

Swift

String

Swift在字串編碼上做了很多事情,Swift用String型別來表示字串,不同的是在遍歷字串的時候有很多種選擇,可以按照字元來遍歷,也可以按照UTF-8或UTF-16編碼來遍歷:

let s = "\u{0061}\u{030C}" // ǎ

for var c in s {...} // ǎ
for var c in s.utf8 {...} // 0x61、0xCC、0x8C
for var c in s.utf16 {...} // 0x0061、0x030C
複製程式碼

在上面的程式碼中s是直接以Unicode標量來初始化的,而s.utf8會將其轉換成UTF-8的編碼方式,隨後遍歷每一個編碼單元,UTF-16也與之類似。字串物件中utf8和utf16這兩個屬性的型別分別是String.UTF8ViewString.UTF16View,它們都是一個集合型別,實現了BidirectionalCollection協議,之所以沒實現RandomAccessCollection是因為UTF-8和UTF-16都是變長編碼,沒辦法做到隨機索引。

String型別過載了==符號,而且在比較的時候會自動將字串正規化後再進行比較:

let s1 = "\u{0061}\u{030C}" // ǎ
let s2 = "\u{01CE}" // ǎ

s1 == s2 // true
複製程式碼

在這一點上

Character

一個字串是多個字元組成的序列,Swift中表示單個字元的型別是Character。Character表示的是一個Unicode的字位簇,也就是說一個Character中可以包含多個Unicode編碼點:

let s = "?‍?‍?‍?abc"
s.first // ?‍?‍?‍?
複製程式碼

可以看到像上面這種帶組合字元的情況在Character中能夠被正確的處理,s.first獲取到的第一個字元是?‍?‍?‍?(而不是?)。

Character中提供了unicodeScalars屬性用來訪問字位簇中的每一個Unicode編碼點,每個編碼點通過Unicode.Scalar型別來表示:

let c = "?‍?‍?‍?"

c.unicodeScalars.count // 7
c.unicodeScalars.first?.value // 0x1F468 (Unicode編碼點)
c.unicodeScalars.first?.utf16 // 0xD83D、0xDC68
複製程式碼

參考資料

http://blog.csdn.net/zhuxipan1990/article/details/51602299

http://blog.jobbole.com/111261/

https://zh.wikipedia.org/wiki/UTF-16

https://zh.wikipedia.org/wiki/UTF-8

https://objccn.io/issue-9-1/

相關文章