第七章——字串(不定長度字元)

bestswifter發表於2017-12-27

本文系閱讀閱讀原章節後總結概括得出。由於需要我進行一定的概括提煉,如有不當之處歡迎讀者斧正。如果你對內容有任何疑問,歡迎共同交流討論。

不定長度字元

一開始,字串編碼這件事很簡單。ASCII碼是一組從0到127的整數,因為128 = 2 ^ 7,因此如果把它存在八個位元組中,還能多餘一位。所以字串中的每一個字元可以隨機檢索[1]

但是對於非英語國家的人來說,他們需要的符號遠不是128個ASCII碼能表示的(比如漢字)。ISO/IEC 8859標準利用了空餘的第八個位,擴充了很多符號,但依然不夠。當我們把這八個bit位全部用上,但還是有些符號無法表示的時候,我們可以選擇繼續增加bit位數,比如用16個位來儲存字元,或者可以讓每個字元佔用的bit位是可變的。Unicode最初使用了2位元組的固定長度,這意味著它可以儲存2 ^ 16 = 65536個字元,不過目前看來依然不夠,但如果增加到4位元組,在通常情況下效率又太低。

在進一步學習之前,有必要理清楚Unicode編碼中的幾個概念:

  • 字元:字元是抽象的最小文字單位,它沒有固定的形狀(比如A都是字元),字元沒有值。

  • 字符集;字符集是字元的集合。比如所有漢字構成漢字字符集,還有英文字符集、日語字符集等等。

  • 編碼字符集:這是一種特殊的字符集。它為每個字元分配一個惟一的數字。Unicode標準的核心是Unicode編碼字符集,比如字元A會分配一個數字0041,Unicode中的數字總是使用16進位制。

  • 程式碼點:英文是Code Point,它表示可用於編碼字符集的數字。程式碼點U+0041對應的字元是A。編碼字符集會定義程式碼點的取值範圍,但是在這個範圍內,並非每個數字(程式碼點)都有對應的字元。

  • 編碼方式:編碼方式表示了從一個程式碼點到一個或多個程式碼單元對映方式。常見的編碼方式有UTF-32、UTF-16、UTF-8。

  • 程式碼單元:程式碼單元是每一種編碼方式下的最基本單元。UTF-32表示程式碼單元是32位,因為16進位制的00000041恰好也是32位,所以UTF-32編碼方式非常簡單:一個程式碼點對映到一個程式碼單元,且兩者值相同。UTF-16下,一個程式碼單元是16位,但這不表示00000041一定對映成00000041。UTF-16編碼方式有自己的對映規則,UTF-8也是同理。

以字母A為例,A是英文字符集中的一個字元,它的程式碼點是00000041,在UTF-32編碼規則下的程式碼單元是00000041,UTF-16下的程式碼單元是0041,UTF-8下的程式碼單元是41

?是一個字元,它的程式碼點是U+10400,UTF-32下的程式碼單元是00010400,UTF-16下的程式碼單元是D801DC00,UTF-8下的程式碼單元有四個:F0909080

目前Unicode使用了可變寬度格式,這體現在兩個方面:

  1. 程式碼點對映到的程式碼單元數量可變。在之前的例子中可以發現一個程式碼點在UTF-8下可以對映成1~4個程式碼單元。
  2. 組成字元的程式碼點數量可變。可能存在多個程式碼點組合成一個字元的情況,這一點我們待會兒會看到具體的例子。

Unicode標量是另外一些程式碼單元,它們可以當做程式碼點來用(除了UFT-16的代理對以外)。在Swift中,標量用字串字面量"\u{xxxx}"表示,這裡的xxxx是一個16進位制的數字。

之前我們說過,組成字元的程式碼點數量可變。也就是說使用者在螢幕上看到的一個字元,可能是由多個程式碼點組成的。大多數處理字串的程式碼一定程度上都沒有注意到Unicode可變寬度的特性,這可能會導致一些bug。Swift在字串時,花費了巨大的努力,儘可能正確的使用了Unicode。至少會在有錯誤時讓開發者知道。這也付出了一定的代價,String型別並不是一個集合,而是提供了多種不同的視角來觀察字串,你可以把字串當做字元(Character)的集合,也可以當做UTF-8或UTF-16編碼下的程式碼單元的集合,或是Unicode標量的集合。Character和另外幾個檢視的區別在於,它可以把若干個程式碼點組合成一個“字形叢集(Grapheme Cluster)

出了UTF-16以外的所有檢視都無法通過下標隨機訪問,不同的檢視在處理大量文字處理時有快有慢,在本章我們會探索其背後的原因。我們還會了解一些處理文字和提高效能的技術。

字形叢集和規範等價

為了展示Swift和NSString處理Unicode字元的區別,我們來分析一下列印字元é的方法。作為一個單個字元,它的Unicode程式碼點是U+00E9。但它也可以表示為字母e後面加一個́(程式碼點U+0301)。無論選擇那種表示方法,最終顯示的結果都是é,對於使用者來說不僅字串相同,長度也相同,都是1。這就是Unicode中“規範等價(Canonically equivalent)”。

我們在Swift中舉一個具體的例子,這兩個字串的顯示效果完全相同:

let single = "Pok\u{00E9}mon"
let double = "Pok\u{0065}\u{0301}mon"

print(single, double)
// 輸出結果是“Pokémon Pokémon”
複製程式碼

還可以證明一下他們的字串變數時相等的,字元數量也相等:

print(single == double)    // 輸出結果:true
print(single.characters.count == double.characters.count)    // 輸出結果:true
複製程式碼

不過,如果切換成UTF-16檢視,就可以看出兩者的區別了:

print(single.utf16.count)	// 輸出結果為7
print(double.utf16.count)	// 輸出結果為8
複製程式碼

如果使用NSString,不僅字元數量不同,字串本身也不相同:

let nssingle = NSString(characters: [0x0065, 0x0031], length: 2)
let nsdouble = NSString(characters: [0x00E9], length: 1)

print(nssingle == nsdouble)		//輸出結果是:false
print(nssingle.isEqualToString(nsdouble as String))     //輸出結果是:false
複製程式碼

其中等號運算子比較的是兩個NSObject型別的物件,它的定義是:

func ==(lhs: NSObject, rhs: NSObject) -> Bool {
return lhs.isEqual(rhs)
}
複製程式碼

這是因為在NSString的比較方法中,只考慮字面量是否相等,不會考慮多個字元的組合結果是否是“規範等價”的。如果你真的想進行規範比較,那麼需要使用NSStringcompare方法。啥,你不知道這個方法?不好意思,那你就等著以後的iOS開發和資料庫開發中不停地報錯吧。

直接比較程式碼單元的優點在於速度非常快,比用characters快很多。比如:

print(single.utf16.elementsEqual(double.utf16))     //輸出結果是:false
複製程式碼

不僅僅是兩個字元可以拼接組合成一個,更多的字元也可以拼接。比如約魯巴語中有一個字元:ọ̀,它中間是字母o,上面是一個類似於漢語中第四聲調的字元:"`",下面則是一個點:"."。它有四種表示方法:

  1. 字母o和其中一個符號拼接後的符號,和另一個符號拼接。這有兩種方法
  2. 三個字元分別拼接,其中o位於開頭,後面兩個字元的順序可以對調。這又是兩種方法。

我們用程式碼表示:

// U+6F是字母o,U+300是第四聲,U+323是"."
let chars: [Character] = [
"\u{1ECD}\u{300}",  // U+1ECD是U+6F和U+323的拼接結果,等價於:(o + .) + 第四聲
"\u{F2}\u{323}",  // U+F2是U+6F和U+300的拼接結果,等價於:(o + 第四聲) + .
"\u{6F}\u{323}\u{300}",  // 等價於:o + . + 第四聲
"\u{6F}\u{300}\u{323}",  // 等價於:o + 第四聲 + .
]

for char in chars {
print(char)
}

/** 列印結果:

ọ̀
ọ̀
ọ̀
ọ̀

*/
複製程式碼

事實上,這種聲調符是可以無限新增的,不過長度依然是1:

let many = "\u{1ECD}\u{300}\u{300}\u{300}\u{300}"
print(many.characters.count)   // 輸出結果:1
print(many.utf8.count)	// 輸出結果:11,U+1ECD在UTF-8下由3個程式碼單元組成,U+300由2個組成,11 = 3 + 2 * 4
print(many)

/* 字串輸出結果:

ọ̀̀̀̀

*/
複製程式碼

Emoji

Emoji表情不是很重要,但是很好玩。搞懂下面這個問題有助於幫助我們理解Unicode標量的拼接:

let emoji1 = "????????????"
let emoji2 = "???"

print(emoji1.characters.count)
print(emoji2.characters.count)
複製程式碼

如果你認為列印結果分別是6和3,那麼你就上當了。答案是1和3。回想一下之前ọ̀這個字元,他有四種組成方法,但是細心的讀者可能會問,為什麼"\u{300}\u{6F}\u{323}"這種寫法(也就是第四聲+o+.)不行?

這是因為在Unicode中,有些字元稱為基字元(base)。只有這種字元是可以向後擴充的,我們之前所說的字形叢集的定義是:“一個基字元,加上後面0或多個字元”。

所以,輸出結果是1而不是6的原因在於,Unicode規範中國旗是一個基字元,6個國旗拼接在一起會被認為是一個字形叢集,也就是依然是一個字元。而?並不是基字元,所以可以被正確識別為3個字元。

譯者注

[1]:考慮字串hello,只要知道字元o是第5個字元,因為每個字元的長度固定,都是8個bit位,所以立刻可以到第(5 - 1) * 8 = 32個bit位去查詢字元o。這就是原文中random access的含義。如果每個位元組長度不定,則需要從頭開始遍歷。

相關文章