Unicode的前世此生

以前忽然發現本身對字符編碼仍是隻知其一;不知其二,基本上只是據說過各類編碼的名字,對它們之間的特色和區別仍是不甚瞭解。因此這段時間查閱了許多資料,對字符編碼也大概有了一些總體的瞭解,寫下這篇文章做爲總結。javascript

在Unicode以前

爲了在計算機的中儲存人類能夠閱讀的文本,必須按照必定的規範將字符映射爲計算機能夠儲存的數值,在計算機發展的早期漸漸造成了統一的標準,在1967年ASCII編碼首次做爲規範標準發佈。這是一套用來表示現代英文的編碼約定,全稱爲美國信息交換標準代碼。ASCII編碼很是簡單,只定義了128個字符,每一個字符經過惟一的編號來表示,每一個字符佔用一個字節(8bit)的空間,由於只有128個字符(2的7次方),因此每一個字符的第一位始終爲0。前端

一個ASCII字符只有8位,最多隻能表示256個字符,對於英文來講足夠了,可是對於像中文這樣的語言而言是遠遠不足的。因此在ASCII之上作了一些擴展,用兩個字節來表示一個字符,這就是1981年發佈的GB2312編碼,爲了與ASCII做區分,GB2312中每一個字節的最高位都是1。這一套編碼中包含了6000多個經常使用的簡體漢字,基本知足平常使用的需求。可是不支持繁體漢字和一些生僻字,因此在後來又在GB2312上進行了擴展,這就是以後的GBK編碼,全稱爲漢字內碼擴展規範java

事實上在那個年代還有不少不一樣的漢字編碼百花齊放,並且不止是中文,世界上其餘各類語言都在指定本身的標準,不一樣編碼之間沒法相互兼容,這爲互聯網的推廣帶來了很大的麻煩,統一字符編碼勢在必行。objective-c

Unicode

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

編碼方式

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

UTF-32:

這是最簡單的一種編碼方式,定長編碼。使用4個字節做爲一個編碼單元,也就是說每個編碼點都用4個字節來表示。app

定長編碼的一個好處就是每一個字符的作佔用的空間都是相同的,因此當咱們想要獲取第n個位置的字符時,直接在首字符的地址加上一個固定的偏移量就能夠了,也就是說能夠在O(1)的時間複雜度索引字符串的任意位置,這也是咱們常說的隨機索引。可是這樣作的缺點也十分明顯,每一個字符佔用32個bit,確定會形成大量的空間浪費,出於這個緣由UTF-32編碼用得並很少。dom

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個字節來表示。ui

爲了能正確表示輔助平面中的編碼點,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 {...} // 0x6一、0xCC、0x8C
for var c in s.utf16 {...} // 0x006一、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/

相關文章
相關標籤/搜索