Unicode的前世今生

之前突然發(fā)現(xiàn)自己對字符編碼還是一知半解毕贼,基本上只是聽說過各種編碼的名字温赔,對它們之間的特點和區(qū)別還是不甚了解。所以這段時間查閱了許多資料鬼癣,對字符編碼也大概有了一些整體的了解陶贼,寫下這篇文章作為總結。

在Unicode之前

為了在計算機的中儲存人類可以閱讀的文本待秃,必須按照一定的規(guī)范將字符映射為計算機可以儲存的數(shù)值拜秧,在計算機發(fā)展的早期漸漸形成了統(tǒng)一的標準,在1967年ASCII編碼首次作為規(guī)范標準發(fā)布章郁。這是一套用來表示現(xiàn)代英文的編碼約定枉氮,全稱為美國信息交換標準代碼。ASCII編碼非常簡單暖庄,只定義了128個字符聊替,每個字符通過唯一的編號來表示,每個字符占用一個字節(jié)(8bit)的空間培廓,因為只有128個字符(2的7次方)惹悄,所以每個字符的第一位始終為0。

一個ASCII字符只有8位肩钠,最多只能表示256個字符泣港,對于英文來說足夠了象缀,但是對于像中文這樣的語言而言是遠遠不足的。所以在ASCII之上做了一些擴展爷速,用兩個字節(jié)來表示一個字符央星,這就是1981年發(fā)布的GB2312編碼,為了與ASCII作區(qū)分惫东,GB2312中每個字節(jié)的最高位都是1莉给。這一套編碼中包含了6000多個常用的簡體漢字,基本滿足日常使用的需求廉沮。但是不支持繁體漢字和一些生僻字颓遏,所以在后來又在GB2312上進行了擴展,這就是之后的GBK編碼滞时,全稱為漢字內碼擴展規(guī)范叁幢。

事實上在那個年代還有很多不同的漢字編碼百花齊放,而且不止是中文坪稽,世界上其他各種語言都在指定自己的標準曼玩,不同編碼之間無法相互兼容,這為互聯(lián)網的推廣帶來了很大的麻煩窒百,統(tǒng)一字符編碼勢在必行黍判。

Unicode

Unicode是國際標準化組織制定的一套字符編碼方案,致力于統(tǒng)一世界上所有語言字符的編碼篙梢。Unicode為每個字符分配了一個固定的數(shù)值顷帖,稱為編碼點(Code Point),所有的編碼點組成的集合稱為編碼空間(Code Space)渤滞。目前Unicode的編碼空間共包含0x10FFFF(十進制的1114111)個編碼點贬墩,被劃分為17個平面,每個平面包含0xFFFF個字符妄呕。從1991年發(fā)布的第一個版本開始陶舞,每一年都會有新的字符被編入Unicode中,目前所定義的字符集只用了不到五分之一的編碼空間趴腋。

編碼方式

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

UTF-32:

這是最簡單的一種編碼方式蠢护,定長編碼。使用4個字節(jié)作為一個編碼單元养涮,也就是說每一個編碼點都用4個字節(jié)來表示葵硕。

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

UTF-16:

在介紹UTF-16之前寒瓦,先講講UCS-2編碼。在早期的Unicode標準中坪仇,只定義了不到65535(0xFFFF杂腰,2的16次方)個編碼點,所有的字符都可以用兩個字節(jié)的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個字節(jié)作為一個編碼單元珠移,一個編碼點需要2個或4個字節(jié)來表示弓乙。

為了能正確表示輔助平面中的編碼點,UTF-16對編碼點的前綴做了一些約束钧惧,引入了一個稱為代理編碼點(surrogate)的概念暇韧。也就是在Unicode的編碼空間中劃分出了一塊保留區(qū)域,落在在這個區(qū)域中的編碼點就是代理編碼點浓瞪,這塊區(qū)域包含從前綴110110到前綴110111的所有編碼點懈玻,也就是從11011000000000001101111111111111的范圍,十六進制為0xD8000xDFFF乾颁。這個區(qū)域中的編碼點只能成對出現(xiàn)在UTF-16編碼中涂乌,出現(xiàn)在UTF-32和UTF-8中都是非法的艺栈。

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

字節(jié)數(shù) UTF-16二進制表示 編碼點 編碼范圍
2 xxxxxxxxyyyyyyyy xxxxxxxxxxxxxxxx 0 ~ 0xFFFF
4 110110xxxxxxxxxx + 110111yyyyyyyyyy xxxxxxxxxxyyyyyyyyyy + 0x10000 0x10000 ~ 0x10FFFF

當編碼點在0到0xFFFF的范圍內時,這兩個字節(jié)中的所有bit都可用來表示編碼點湾盒;而當編碼點大于0xFFFF湿右,就必須要使用兩個代理編碼點了,分別取前后兩個字節(jié)中低位的10個bit罚勾,這樣就有了20bit的編碼空間毅人,最大能表示0x100000的值,再加上0xFFFF荧库,正好就是0x10FFFF堰塌,Unicode中定義的最大編碼空間。

UTF-8:

UTF-8使用單個字節(jié)作為編碼單元分衫,這是一種變長編碼场刑,根據(jù)需要使用1個到4個字節(jié)來表示一個編碼點。在這種編碼模式中蚪战,一個字節(jié)可能是表示一個單字節(jié)的字符牵现,也可能是多字節(jié)字符中的一部分,在解析的時候必須要能夠區(qū)分出來邀桑。所以在UTF-8中每個字節(jié)最高的幾個bit不用來儲存編碼值瞎疼,而是用來表示該字節(jié)在其所表示的字符中的位置:

字節(jié)數(shù) 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個字節(jié)的情況下有兩個編碼范圍,這是因為上一節(jié)中提到的代理編碼點不能表示任何字符

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

  1. 單字節(jié)字符的最高位為0壁畸,后7位為該字符的編碼值贼急。
  2. n個字節(jié)的符號(n > 1),第一個字節(jié)的最高n位都為1捏萍,n + 1位為0太抓,剩余的字節(jié)的最高位都為10。

可以看到令杈,單字節(jié)的UTF-8編碼最高位作為標志位始終為0走敌,在上面提到的ASCII編碼中最高位沒有用上也始終為0。也就是說前128個字符的編碼方式與ASCII是完全相同的逗噩,這樣一來UTF-8就能夠完全兼容ASCII掉丽,用ASCII編碼的文件無需任何轉換就可以直接被UTF-8所識別。

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

字節(jié)序

說到字節(jié)序的問題必須先談一談大端和小端片迅,在計算機的世界中多字節(jié)的數(shù)據(jù)會按照其字節(jié)順序被儲存残邀,而字節(jié)之間的排列方式有兩種:大端模式(Big-Endian)和小端模式(Big-Endian):

  • 大端模式:低位字節(jié)排放在內存中的高位地址,高位字節(jié)排放在內存中的低位地址柑蛇。
  • 小端模式:低位字節(jié)排放在內存中的低位地址芥挣,高位字節(jié)排放在內存中的高位地址。

比如說有一個short類型的數(shù)據(jù)0x3A80耻台,需要占用2個字節(jié)的空間空免,其中高位字節(jié)為3A,低位字節(jié)為80盆耽。

使用大端模式儲存時內存的排列方式如下蹋砚,內存中的高地址方向存放的是低位字節(jié)80

大端模式

使用小端模式存儲時內存中的排列方式如下,內存中高地址方向存放的是高位字節(jié)3A

小端模式

再回到Unicode中摄杂,由于UTF-16使用了兩個字節(jié)作為一個編碼單元坝咐,在解析的時候每次需要讀取兩個字節(jié),所以字節(jié)序就變得尤為重要析恢。例如漢字的編碼點為0x5440墨坚,如果以錯誤的字節(jié)序來讀取的話,則會將其識別為0x4054映挂,這樣一來就變成了漢字?泽篮。

為了保證字符串始終能以正確的字節(jié)序來讀取,標準建議UTF-16文件在起始的位置加上0xFEFF柑船,稱為字節(jié)順序標記(BOM)帽撑。因為在讀取文件是按照低地址到高地址的順序,所以如果讀取到0xFEFF則說明該文件是采用大端模式來儲存的鞍时;如果讀取到0xFFFE則說明文件是采用小端模式來存儲的亏拉。

如果使用的是UTF-8編碼則不需要關心這個問題,因為UTF-8的編碼單元只有一個字節(jié)逆巍,每次只需要讀取一個字節(jié)即可及塘,所以不存在字節(jié)順序的問題。

組合字符

Unicode的復雜性不僅體現(xiàn)在其編碼方式上蒸苇,在Unicode中有一些字符存在多種不同的表示方式磷蛹。這是什么意思呢?有一些文字會帶有音調符號溪烤,比如一個帶有音標的符號ǎ味咳,它可以直接通過編碼點0x01CE來表示,也可以使用一個a(編碼點為0x0061)和一個?(編碼點為0x030C)組合起來表示檬嘀,雖然說編碼看起來不一樣槽驶,但是這兩種寫法在語義上和視覺上都是相同的。這樣就引入了一個新的概念鸳兽,我們稱ǎ字符和a掂铐、?組成的序列是標準等價的。

這樣麻煩就來了,當用兩種寫法來表示同一個字符的時候全陨,計算機根據(jù)字節(jié)比較會認為它們是不同的爆班。為了能正確判斷字符串之間的等價性,Unicode規(guī)定了一套標準的正規(guī)化算法(有四種正規(guī)化的形式辱姨,就不再展開介紹了)柿菩,也就是將所有標準等價的字符轉換成統(tǒng)一的表示形式:

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

c1.normalize(); // 01CE
c1.normalize(); // 01CE

在上面的這一段JavaScript代碼中,ǎ的兩種寫法在經過正規(guī)化之后都被轉換成了相同編碼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剛剛發(fā)布的那個年代登夫,還是UCS-2的天下,所以JavaScript內部字符串的編碼方式采用了UTF-16允趟,準確的說是UTF-16的子集UCS-2恼策。

這一歷史問題為今天的JavaScript帶來了一些困擾,因為所有的字符在JavaScript中都被視為兩個字節(jié)的編碼潮剪,如果字符串中包含輔助平面的編碼點時涣楷,JavaScript會將其視為2個2字節(jié)的字符來處理。這個問題影響了JavaScript中的字符處理函數(shù):

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

c.charCodeAt(0).toString(16); // 0xD840
c.charCodeAt(1).toString(16); // 0xDC17

上面代碼中漢字"??"的Unicode編碼點是0x20017抗碰,大小超過了0xFFFF狮斗,位于輔助平面中,所以在UTF-16中需要4個字節(jié)弧蝇,編碼為0xD840DC17碳褒。調用length的輸出是2折砸,說明JavaScript將其識別成了兩個字符。charCodeAt是一個用來打印指定位置字符編碼值的方法沙峻,將結果轉換成16進制后可以看到分別輸出了兩個編碼單元的值d840dc17睦授。想必前端的同學一定對這些多字節(jié)字符處理上的坑深惡痛絕。

不過好消息是ES6以來這些坑也在陸續(xù)填上了:新增的codePointAt方法能正確識別4字節(jié)的UTF-16字符专酗、新的Unicode字符表示方法\u{20017}睹逃、新增for…of循環(huán)也能正確的遍歷4字節(jié)字符...

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

想要獲得正確的字符數(shù)可以先將字符串轉換成定長的UTF-32編碼,然后再除以4:

[@"??" lengthOfBytesUsingEncoding:NSUTF32StringEncoding] / 4; // 1

這樣子可以正確的識別出Unicode碼點的個數(shù)疗隶,然而對于組合字符還是無能為力佑笋。

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

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

[s1 isEqualToString:s2]; // NO

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

[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愁茁,它們都是一個集合類型蚕钦,實現(xiàn)了BidirectionalCollection協(xié)議,之所以沒實現(xiàn)RandomAccessCollection是因為UTF-8和UTF-16都是變長編碼鹅很,沒辦法做到隨機索引嘶居。

String類型重載了==符號,而且在比較的時候會自動將字符串正規(guī)化后再進行比較:

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/

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末迹蛤,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌盗飒,老刑警劉巖嚷量,帶你破解...
    沈念sama閱讀 206,214評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異逆趣,居然都是意外死亡蝶溶,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,307評論 2 382
  • 文/潘曉璐 我一進店門宣渗,熙熙樓的掌柜王于貴愁眉苦臉地迎上來抖所,“玉大人,你說我怎么就攤上這事痕囱√镌” “怎么了?”我有些...
    開封第一講書人閱讀 152,543評論 0 341
  • 文/不壞的土叔 我叫張陵鞍恢,是天一觀的道長傻粘。 經常有香客問我,道長帮掉,這世上最難降的妖魔是什么弦悉? 我笑而不...
    開封第一講書人閱讀 55,221評論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮蟆炊,結果婚禮上稽莉,老公的妹妹穿的比我還像新娘。我一直安慰自己盅称,他們只是感情好厘肮,可當我...
    茶點故事閱讀 64,224評論 5 371
  • 文/花漫 我一把揭開白布验懊。 她就那樣靜靜地躺著本股,像睡著了一般屑墨。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上疾层,一...
    開封第一講書人閱讀 49,007評論 1 284
  • 那天将饺,我揣著相機與錄音,去河邊找鬼痛黎。 笑死予弧,一個胖子當著我的面吹牛,可吹牛的內容都是我干的湖饱。 我是一名探鬼主播掖蛤,決...
    沈念sama閱讀 38,313評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼井厌!你這毒婦竟也來了蚓庭?” 一聲冷哼從身側響起致讥,我...
    開封第一講書人閱讀 36,956評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎器赞,沒想到半個月后垢袱,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經...
    沈念sama閱讀 43,441評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡港柜,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 35,925評論 2 323
  • 正文 我和宋清朗相戀三年请契,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片夏醉。...
    茶點故事閱讀 38,018評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡爽锥,死狀恐怖,靈堂內的尸體忽然破棺而出授舟,到底是詐尸還是另有隱情救恨,我是刑警寧澤,帶...
    沈念sama閱讀 33,685評論 4 322
  • 正文 年R本政府宣布释树,位于F島的核電站,受9級特大地震影響擎淤,放射性物質發(fā)生泄漏奢啥。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,234評論 3 307
  • 文/蒙蒙 一嘴拢、第九天 我趴在偏房一處隱蔽的房頂上張望桩盲。 院中可真熱鬧,春花似錦席吴、人聲如沸赌结。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,240評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽柬姚。三九已至,卻和暖如春庄涡,著一層夾襖步出監(jiān)牢的瞬間量承,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,464評論 1 261
  • 我被黑心中介騙來泰國打工穴店, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留撕捍,地道東北人。 一個月前我還...
    沈念sama閱讀 45,467評論 2 352
  • 正文 我出身青樓泣洞,卻偏偏與公主長得像忧风,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子球凰,可洞房花燭夜當晚...
    茶點故事閱讀 42,762評論 2 345

推薦閱讀更多精彩內容