NSString 與 Unicode

當你在處理文本時,如果你不是在寫一些非常古老的代碼(legacy code),那麼你一定要使用 Unicode。幸運的是,蘋果和 NeXT 一直致力於推動 Unicode 標準的建立,而 NeXT 在 1994 年推出的 Foundation Kit 則是所有編程語言中最先基於 Unicode 的標準庫之一。但是,即使 NSString 完全支持 Unicode,還替你幹了大部分的重活兒,處理各種語言、各種書寫系統的文本仍然是一個非常複雜的事情。作爲一個程序員,有些事情你應該知道。

這篇文章裏,我會先向你簡單地講一下 Unicode 這個標準,然後解釋 NSString 是怎麼處理它的,再討論一下你可能會遇到的一些常見問題。

歷史

計算機沒法直接處理文本,它只和數字打交道。爲了在計算機裏用數字表示文本,我們指定了一個從字符到數字的映射。這個映射就叫做編碼(encoding)。

最有名的一個字符編碼是 ASCII。ASCII 碼是 7 位的,它將英文字母,數字 0-9 以及一些標點符號和控制字符映射爲 0-127 這些整型。隨後,人們創造了許多不同的 8 位編碼來處理英語以外的其他語言。它們大多都是基於 ASCII 編碼的,並且使用了 ASCII 沒有使用的第 8 位來編入其它字母、符號甚至是整個字母表(比如西裏爾字母和希臘字母)。

當然,這些編碼系統相互之間並不兼容,並且,由於 8 位的空間對於歐洲的文字來說都不夠,更不用說全世界的書寫系統了,因此這種不兼容是肯定會出現的了。這對於當時基於文本的操作系統來說是很麻煩的,因爲那時操作系統只能同時使用一種編碼(也叫做內碼錶,code page)。如果你在一臺機器上寫了一段文字,然後在另一臺使用了不同的內碼錶的機器上打開,那麼在 128-255 這個範圍內的字符就會顯示錯誤。

諸如中文、日文和韓文的東亞文字又讓情況更加複雜。這些書寫系統裏包含的字符實在是太多了,以至於 8 位的數字所能提供的 256 個位置遠遠不夠。結果呢,人們開發了更加通用的編碼(通常是 16 位的)。當你開始糾結於如何處理一個字節裝不下的值時,如何把它存儲到內存或者硬盤裏就變得十分關鍵了。這時,就必須再進行第二次映射,以此來確定字節的順序。而且,最好使用可變長度的編碼而不是固定長度的。請注意,第二次映射其實是另一種形式的“編碼”。我們把這兩個映射都叫做“編碼”很容易造成誤解。這個在下面 UTF-8 和 UTF-16 的部分裏會再作討論。

現代操作系統都已經不再侷限於只能同時使用一種內碼錶了,因此只要每個文檔都清楚地標明自己使用的是哪種編碼,處理幾十甚至上百種編碼系統儘管很討厭,也完全是有可能的。真正不可能的是在一個文檔裏混合使用多種編碼系統,因此撰寫多語言的文檔也不可能了,而正是這一點終結了在 Unicode 編碼出現之前,多種編碼混戰的局面。

1987 年,來自幾個大的科技公司(其中包括蘋果和 NeXT)的工程師們開始合作致力於開發一種能在全世界所有書寫系統中通用的字符編碼系統,於 1991 年 10 月發佈的 1.0.0 版本的 Unicode 標準就是這一努力的成果。

Unicode 概要

基本介紹
簡單地來說,Unicode 標準爲世界上幾乎所有的1書寫系統裏所使用的每一個字符或符號定義了一個唯一的數字。這個數字叫做碼點(code points),以 U+xxxx 這樣的格式寫成,格式裏的 xxxx 代表四到六個十六進制的數。比如,U+0041(十進制是 65)這個碼點代表拉丁字母表(和 ASCII 一致)裏的字母 A;U+1F61B 代表名爲“伸出舌頭的臉”的 emoji,也就是 ��(順便說一下,字符的名字也是 Unicode 標準的一部分。)。你可以用官方的 Code Charts 或者 OS X 裏自帶的字符顯示程序(快捷鍵是 Control + Option + 空格鍵)來查碼點。

編者注 原文中的這個快捷鍵已經過時了,在最新的 OS X Mavericks 系統中,字符顯示程序的快捷鍵應該是 Control + Cmd + 空格鍵。
The Character Viewer in OS X showing a table of Emoji and Unicode character information

就像上文提到的其它編碼一樣,Unicode 以抽象的方式代表一個字符,而不規定這個字符應該如何呈現(render)。如此一來,Unicode 對中文、日文和韓文(CJK)裏使用的漢字(也就是所謂的統一漢字)都使用完全相同的碼點(這一決定頗具爭議),儘管在這些書寫系統裏,每個漢字都發展出了獨特的字形(glyph)變體。

最初,Unicode 編碼是被設計爲 16 位的,提供了 65,536 個字符的空間。當時人們認爲這已經大到足夠編碼世界上現代文本里所有的文字和字符了。不再使用的和罕見的字符本應在需要時被編入 Private Use Areas,這是 65,536 個字符的空間裏一個指定的區域,各個機構可以用它來定義自己的字符映射(這有可能導致衝突)。蘋果在這個區域裏編入了一些自定義符號和控制字符(文檔在這裏),雖然大多數已經不再使用了,但是蘋果的 logo 是個著名的例外:,它的碼點是 U+F8FF。(你可能看到的是另一個不同的字符,這取決於你閱讀本文的平臺)。

後來,考慮到要編碼歷史上的文字以及一些很少使用的日本漢字和中國漢字2,Unicode 編碼擴展到了 21 位(從 U+0000 到 U+10FFFF)。這一點很重要:不管接下來的 NSString 的內容是什麼樣的,Unicode 不是 16 位的編碼!它是 21 位的。這 21 位提供了 1,114,112 個碼點,其中,只有大概 10% 正在使用,所以還有相當大的擴充空間。

編碼空間被分成 17 個平面(plane),每個平面有 65,536 個字符。0 號平面叫做「基本多文種平面」(Basic Multilingual Plane, BMP),涵蓋了幾乎所有你能遇到的字符,除了 emoji。其它平面叫做補充平面,大多是空的。

Unicode 的一些特性
最好將 Unicode 看做是已有的各編碼系統(它們大多是 8 位的)的統一,而不是一個通用的編碼。考慮到要兼容一些古老的編碼系統,這個標準包含了一些需要注意的地方,你需要了解它們才能在你的代碼里正確地處理 Unicode 字符串。

組合字符序列

爲了和已有的標準兼容,某些字符可以表示成以下兩種形式之一:一個單一的碼點,或者兩個以上連續的碼點組成的序列。例如,有重音符號的字母 é 可以直接表示成 U+00E9(「有尖音符號的小寫拉丁字母 e」),或者也可以表示成由 U+0065(「小寫拉丁字母 e」)再加 U+0301(「尖音符號」)組成的分解形式。這兩個形式都是組合字符序列的變體。組合字符序列不僅僅出現在西方文字裏;例如,在諺文**(朝鮮、韓國的文字)中,가 這個字可以表示成一個碼點(U+AC00),或者是 ᄀ + ᅡ (U+1100,U+1161)這個序列。

在 Unicode 的語境下,兩種形式並不相等(因爲兩種形式包含不同的碼點),但是符合「標準等價」(canonically equivalent),也就是說,它們有着相同的外觀和意義。

重複的字符

許多看上去一樣的字符都在不同的碼點編碼了多次,以此來代表不同的含義。例如,拉丁字母 A(U+0041)就與西裏爾字母 A(U+0410)完全同形,但事實上,它們是不同的。把它們編入不同的碼點不僅簡化了與老的編碼系統的轉換,而且能讓 Unicode 的文本保留字符的含義。

但也有極少數真正的重複,這些完全相同的字符在不同的碼點上定義了多次。例如,Unicode 聯盟就列舉出了字母 Å(「上面帶個圓圈的大寫拉丁字母 A」,U+00C5)和字符 Å(「埃米」(長度單位)符號,U+212B)。考慮到「埃米」符號其實就是被定義成這個瑞典大寫字母的,因此這兩個字符是完全相同的。在 Unicode 裏,它們也符合標準等價但不相等。

還有更多的字符和序列是更廣意義上的「重複」,在 Unicode 標準裏叫做「相容等價」(compatibility equivalence)。相容的序列代表着相同的字符,但有着不同的外觀和表現。例子包括很多被用作數學和技術符號的希臘字母,還有,儘管已經有了從 U+2160 到 U+2183 這個範圍裏的標準拉丁字母,羅馬數字也被單獨編入 Unicode。其它關於相容等價的典型例子就是連字(ligature):字母 ff(小寫拉丁連字 ff,U+FB00)和 ff 的序列(小寫拉丁字母 f + 小寫拉丁字母 f,U+0066 U+0066)就符合相容等價但不符合標準等價,雖然它們也可能以完全一致的樣子呈現出來,這取決於環境、字體以及文本的渲染系統。

正規形式

從上面可以看出,在 Unicode 裏,字符串的等價性並不是一個簡單的概念。除了一個碼點一個碼點地比較兩個字符串以外,我們還需要另一種方式來鑑定標準等價和相容等價。爲此,Unicode 定義了幾個正規化(normalization)算法。正規化一個字符串的意思是:爲了能使它與另一個正規化了的字符串進行二進制比較(binary-compare),將其轉化成有且僅有的唯一一個表示形式,這個形式由等價字符的序列組成。

Unicode 標準裏包含了四個正規形式,分別是 C、D、KD 和 KC。它們可以放入一個 2*2 的矩陣裏(下面還列舉出了 NSString 提供的對應方法):

Unicode 正規形式(NF) 字符形式
合成形式(é) 分解形式(e + ´)
等價
類別 標準等價
C

precomposed​String​With​Canonical​Mapping

D

decomposed​String​With​Canonical​Mapping

相容等價
KC

precomposed​String​With​Compatibility​Mapping

KD

decomposed​String​With​Compatibility​Mapping

僅僅爲了比較的話,先把字符串正規化成分解形式(D)還是合成形式(C)並不重要。但 C 形式的算法包含兩個步驟:先分解字符再重新組合起來,因此 D 形式要更快一些。如果一個字符序列裏含有多個組合標記,那麼組合標記的順序在分解後會是唯一的。另一方面,Unicode 聯盟推薦 C 形式用於存儲,因爲它能和從舊的編碼系統轉換過來的字符串更好地兼容。

兩種等價對於字符串比較來說都很有用,尤其在排序和查找時。但是,要記住,如果要永久保存一個字符串,一般情況下不應該用相容等價的方式去將它正規化,因爲這樣會改變文本的含義:

不要對任意文本都盲目地使用 KC 或 KD 這兩種正規形式,這樣會清除很多格式上的差異。它們能防止與許多老舊的字符集之間的循環轉化,與此同時,除非有格式標記代替,否則 KC 和 KD 還會清除許多對文本的語義很重要的差異。最好把這些正規形式想成字母大寫與小寫之間的映射:有時在需要辨認很重要的意思時很有用,但也會不恰當地修改文本。
字形變體

有些字體會爲一個字符提供多個字形(glyph)變體。Unicode 提供了一個叫做「變體序列」(variation sequences)的機制,它允許用戶選擇其中一個變體。這和組合字符序列的工作機制完全一樣:一個基準字符加上 256 個變體選擇符(VS1-VS256,U+FE00 到 U+FE0F,還有 U+E0100 到 U+E01EF)中的一個。Unicode 標準對「標準化變體序列」(Standardized Variation Sequences,在 Unicode 標準中定義)和「象形文字變體序列」(Ideographic Variation Sequences,是由第三方提交給 Unicode 聯盟的,一旦註冊,它可以被任何人使用)做出了區分。技術上來講,兩者並無區別。

emoji 的樣式就是一個標準化變體序列的例子。許多 emoji 和一些「正常」的字符都有兩種風格:一種是彩色的「emoji 風格」,另一種是黑白的,更像是符號的「文本風格」。例如,「有雨滴的傘」這個字符(U+2614)可能是這樣:☔️ (U+2614 U+FE0F) ,也可能是這樣的: ☔︎ (U+2614 U+FE0E)。

Unicode 轉換格式
從上文可以看到,字符和碼點之間的映射只完成了一半工作,還需要定義另一種編碼來確定碼點在內存和硬盤中要如何表示。Unicode 標準爲此定義了幾種映射,叫做「Unicode 轉換格式」(Unicode Transformation Formats,簡稱 UTF)。日常工作中,人們就直接把它們叫做「編碼」—— 因爲按照定義,如果是用 UTF 編碼的,那麼就要使用 Unicode,所以也就沒必要明確區分這兩個步驟了。

UTF-32
最清楚明瞭的一個 UTF 就是 UTF-32:它在每個碼點上使用整 32 位。32 大於 21,因此每一個 UTF-32 值都可以直接表示對應的碼點。儘管簡單,UTF-32卻幾乎從來不在實際中使用,因爲每個字符佔用 4 字節太浪費空間了。

UTF-16 以及「代理對」(Surrogate Pairs)的概念
UTF-16 要常見得多,而且在下文我們會看到,它與我們討論 NSString 對 Unicode 的實現息息相關。它是根據有 16 位固定長度的碼元(code units)定義的。UTF-16 本身是一種長度可變的編碼。基本多文種平面(BMP)中的每一個碼點都直接與一個碼元相映射。鑑於 BMP 幾乎囊括了所有常見字符,UTF-16 一般只需要 UTF-32 一半的空間。其它平面裏很少使用的碼點都是用兩個 16 位的碼元來編碼的,這兩個合起來表示一個碼點的碼元就叫做代理對(surrogate pair)。

爲了避免用 UTF-16 編碼的字符串裏的字節序列產生歧義,以及能使檢測代理對更容易,Unicode 標準限制了 U+D800 到 U+DFFF 範圍內的碼點用於 UTF-16,這個範圍內的碼點值不能分配給任何字符。當程序在一個 UTF-16 編碼的字符串裏發現在這個範圍內的序列時,就能立刻知道這是某個代理對的一部分。實際的編碼算法很簡單,維基百科上 UTF-16 的文章裏有更多介紹。UTF-16 的這種設計也是爲什麼碼點最長也只有奇怪的 21 位的原因。UTF-16 下,U+10FFFF 是能編碼的最高值。

和所有多字節長度的編碼系統一樣,UTF-16(以及 UTF-32)還得解決字節順序的問題。在內存裏存儲字符串時,大多數實現方式自然都採用自己運行平臺的 CPU 的字節序(endianness);而在硬盤裏存儲或者通過網絡傳輸字符串時,UTF-16 允許在字符串的開頭插入一個「字節順序標記」(Byte Order Mask,簡稱 BOM)。字節順序標記是一個值爲 U+FEFF 的碼元,通過檢查文件的頭兩個字節,解碼器就可以識別出其字節順序。字節順序標記不是必須的,Unicode 標準把高字節順序(big-endian byte order)定爲默認情況。UTF-16 需要指明字節順序,這也是爲什麼 UTF-16 在文件格式和網絡傳輸方面不受歡迎的一個原因,不過微軟和蘋果都在自己的操作系統內部使用它。

UTF-8

由於 Unicode 的前 256 個碼點(U+0000 到 U+00FF)和常見的 ISO-8859-1(Latin 1) 編碼完全一致,UTF-16 還是在常用的英文和西歐文本上浪費了大量的空間:每個 16 位的碼點的高 8 位的值都會是 03。也許更重要的是,UTF-16 對一些老舊的代碼造成了挑戰,這些代碼常常會假定文本是用 ASCII 編碼的。Ken Thompson(他在 Unix 社區很有名) 和 Rob Pike 開發了 UTF-8 來彌補這些不足4。它的設計很出色,請務必閱讀 Rob Pike 對 UTF-8 創造過程的講述。

UTF-8 使用一到四個5字節來編碼一個碼點。從 0 到 127 的這些碼點直接映射成 1 個字節(對於只包含這個範圍字符的文本來說,這一點使得 UTF-8 和 ASCII 完全相同)。接下來的 1,920 個碼點映射成 2 個字節,在 BMP 裏所有剩下的碼點需要 3 個字節。Unicode 的其他平面裏的碼點則需要 4 個字節。UTF-8 是基於 8 位的碼元的,因此它並不需要關心字節順序(不過仍有一些程序會在 UTF-8 文件里加上多餘的 BOM)。

有效率的空間使用(僅就西方語言來講),以及不需要操心字節順序問題使得 UTF-8 成爲存儲和交流 Unicode 文本方面的最佳編碼。它也已經是文件格式、網絡協議以及 Web API 領域裏事實上的標準了。

NSString 和 Unicode

NSString 是完全建立在 Unicode 之上的。但是,這方面蘋果解釋得並不好。這是蘋果的文檔對 CFString 對象的說明(CFString 也包含了 NSString 的底層實現):

從概念上來講,CFString 代表了一個 Unicode 字符組成的數組和一個字符總數的計數。……[Unicode] 標準定義了一個通用、統一的編碼方案,其中每個字符 16 位。
強調是我(原文作者)加的。這完全是錯誤的!我們已經瞭解了 Unicode 是一種 21 位的編碼方案。但是有了這樣的文檔,難怪很多人都認爲它是 16 位的呢。

NSString 的文檔同樣誤導人:

一個字符串對象代表着一個 Unicode 字符組成的數組…… 可以用 length 方法來獲得一個字符串對象所包含的字符數;用 characterAtIndex: 方法取得特定的字符。這兩個簡單的方法爲訪問字符串對象提供了基本的途徑。
這段話初讀起來似乎好一些了,它沒有又扯淡地講 Unicode 字符是 16 位的。但深究後就會發現,characterAtIndex: 這個方法的返回值 unichar 不過是個 16 位的無符號整型罷了。顯然,它不夠用來表示 21 位的 Unicode 字符:

typedef unsigned short unichar;
事實是這樣的,NSString 對象代表的其實是用 UTF-16 編碼的碼元組成的數組。相應地,length 方法的返回值也是字符串包含的碼元個數(而不是字符個數)。NSString 還在開發的時候(它最初是作爲 Foundation Kit 的一部分在 1994 年發佈的),Unicode 還是 16 位的;更廣的範圍和 UTF-16 的代理字符機制則是於 1996 年隨着 Unicode 2.0 引入的。從現在的角度來看,unichar 這個類型和 characterAtIndex: 這個方法的命名都很糟糕,因爲它們使程序員對於 Unicode 字符(碼點)和 UTF-16 碼元兩個概念困惑的情況更加嚴重。如果像 codeUnitAtIndex: 這樣來命名則要好得多。

關於 NSString,最需要記住的是:NSString 代表的是用 UTF-16 編碼的文本,長度、索引和範圍都基於 UTF-16 的碼元。除非你知道字符串的內容,或者你提前有所防範,不然 NSString 類裏的方法都是基於上述概念的,無法給你提供可靠的信息。每當文檔提及「字符」(character)或者 unichar 時,它其實都說的是碼元。事實上,在 String Programming Guide 裏之後一個章節中,文檔的表述是正確的,但繼續錯誤地使用「字符」(character)這個詞。強烈建議你閱讀 Characters and Grapheme Clusters 這一章,裏面很好地解釋了真實的情況。

請注意,儘管在概念上 NSString 是基於 UTF-16 的,但這並不意味着這個類總是能與 UTF-16 編碼的數據很好地工作。它不保證內部的實現(你可以子類化 NSString 來寫你自己的實現)。事實上,在保證快速的(時間複雜度 O(1) 級別)與 UTF-16 碼元轉換的同時,CFString 儘可能有效率地利用內存,這取決於字符串的內容。你可以閱讀 CFString 的源代碼來自己驗證。

常見的陷阱
瞭解了 NSString 和 Unicode,你現在應該能辨別出哪些操作對字符串有潛在的危險。我們來看看這些操作,以及如何避免出現問題。但首先,我們得知道怎麼用任意的 Unicode 字符序列創建字符串。

默認情況下,Clang 會把源文件看作以 UTF-8 編碼的。只要你確保 Xcode 以 UTF-8 編碼保存文件,你就可以直接用字符顯示程序插入任意字符。如果你更喜歡用碼點,最大到 U+FFFF 這個範圍內的碼點你可以以 @”\u266A”(♪)的方式輸入,BMP 外其它平面的碼點則以 @”\U0001F340”(��)的方式輸入。有意思的是,C99 不允許標準 C 字符集裏的字符用通用字符名(universal character name)來指定,因此不能這樣寫:

NSString *s = @”\u0041”; // Latin capital letter A
// error: character ‘A’ cannot be specified by a universal character name
我認爲應該避免使用格式化佔位符 %C(使用 unichar 類型)來創建字符串變量,因爲這樣很容易混淆碼元和碼點。但是在輸出 log 信息時 %C 很有用。

長度

-[NSString length] 返回字符串裏 unichar 的個數。我們已經瞭解了三個可能導致這個返回值與實際(可見)字符數不符的 Unicode 特性。

基本多文種平面外的字符:記住,BMP 裏所有的字符在 UTF-16 裏都可以用一個碼元表示。所有其餘的字符都需要兩個碼元(一個代理對)。基本上所有現代使用的字符都在 BMP 裏,因此在實際中很難遇到代理對。然而,幾年前隨着 emoji 被引入 Unicode(在 1 號平面),這種情況已經有所變化。emoji 已經變得十分普遍,你的代碼必須能夠正確處理它們:

NSString *s = @”\U0001F30D”; // earth globe emoji ��
NSLog(@”The length of %@ is %lu”, s, [s length]);
// => The length of �� is 2
可以用一個小花招解決這個問題,直接計算字符串在 UTF-32 編碼下所需要的字節數,再除以 4:

NSUInteger realLength =
[s lengthOfBytesUsingEncoding:NSUTF32StringEncoding] / 4;
NSLog(@”The real length of %@ is %lu”, s, realLength);
// => The real length of �� is 1
組合字符序列:如果字母 é 是以分解形式(e + ´)編碼的,算作兩個碼元:

NSString *s = @”e\u0301”; // e + ´
NSLog(@”The length of %@ is %lu”, s, [s length]);
// => The length of é is 2
這個字符串包含了兩個 Unicode 字符,在這個意義上,返回值 2 是正確的,但顯然正常人都不會這麼去數。可以用 precomposedStringWithCanonicalMapping: 把字符串正規化成 C 形式(合成形式)來得到更好的結果:

NSString *n = [s precomposedStringWithCanonicalMapping];
NSLog(@”The length of %@ is %lu”, n, [n length]);
// => The length of é is 1
不巧的是,並不是所有情況都能這樣做,因爲只有最常見的組合字符序列有合成形式——其它基礎字符與標記的組合即便是經過正規化後,也會保持原樣。如果你想知道字符串真正的字符個數,你只能遍歷字符串自己數。後面循環那一節會繼續討論有關細節。

變體序列:它們和分解形式的組合字符序列的工作方式一樣,因此變體選擇符也算作單獨的字符。

隨機訪問

用 characterAtIndex: 方法以索引方式直接訪問 unichar 會有同樣的問題。字符串可能會包含組合字符序列、代理對或變體序列。蘋果把這些都叫做合成字符序列(composed character sequence),這些術語就變得容易混淆。注意不要把合成字符序列(蘋果的術語)和組合字符序列(Unicode 術語)搞混。後者是前者的子集。可以用 rangeOfComposedCharacterSequenceAtIndex: 來確定特定位置的 unichar 是不是代表單個字符(可能由多個碼點組成)的碼元序列的一部分。每當給另一個方法傳入一個內容未知的字符串的範圍作參數時都應該這樣做,確保 Unicode 字符不會被從中間分開。

循環

使用 rangeOfComposedCharacterSequenceAtIndex: 的時候,可以寫一個代碼套路來正確地循環字符串裏所有的字符,但每次要遍歷一個字符串時都得這樣做太不方便了。幸運的是,NSString 有更好地方式:enumerateSubstringsInRange:options:usingBlock: 方法。這個方法把 Unicode 抽象的地方隱藏了,能讓你輕鬆地循環字符串裏的組合字符串、單詞、行、句子或段落。你甚至可以加上 NSStringEnumerationLocalized 這個選項,這樣可以在確定詞語間和句子間的邊界時把用戶所在的區域考慮進去。要遍歷單個字符,把參數指定爲 NSStringEnumerationByComposedCharacterSequences:

NSString *s = @”The weather on \U0001F30D is \U0001F31E today.”;
// The weather on �� is �� today.
NSRange fullRange = NSMakeRange(0, [s length]);
[s enumerateSubstringsInRange:fullRange
options:NSStringEnumerationByComposedCharacterSequences
usingBlock:^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop)
{
NSLog(@”%@ %@”, substring, NSStringFromRange(substringRange));
}];
這個奇妙的方法表明,蘋果想讓我們把字符串看做子字符串的集合,而不是(蘋果意義上的)字符的集合,因爲

單個 unichar 太小,不足以代表一個真正的 Unicode 字符;
一些(普遍意義上的)字符由多個 Unicode 碼點組成。
請注意,這個方法的加入相對晚一些(在 OS X 10.6 和 iOS 4.0 的時候)。在之前,按字符循環一個字符串要麻煩得多。

比較

除非你手動執行,否則字符串對象不會自己正規化。這意味着直接比較包含組合字符序列的字符串可能會得出錯誤的結果。isEqual: 和 isEqualToString: 這兩個方法都是一個字節一個字節地比較的。如果希望字符串的合成和分解的形式相吻合,得先自己正規化:

NSString *s = @”\u00E9”; // é
NSString *t = @”e\u0301”; // e + ´
BOOL isEqual = [s isEqualToString:t];
NSLog(@”%@ is %@ to %@”, s, isEqual ? @”equal” : @”not equal”, t);
// => é is not equal to é

// Normalizing to form C
NSString *sNorm = [s precomposedStringWithCanonicalMapping];
NSString *tNorm = [t precomposedStringWithCanonicalMapping];
BOOL isEqualNorm = [sNorm isEqualToString:tNorm];
NSLog(@”%@ is %@ to %@”, sNorm, isEqualNorm ? @”equal” : @”not equal”, tNorm);
// => é is equal to é
另一個選擇是使用 compare: 方法(或者它的其它變形方法,比如:localizedCompare:),這個方法返回一個和它相容等價的字符串。對此,蘋果沒有很好地寫入文檔。請注意,你常常還需要作標準等價的比較。compare: 沒法作這個比較。

NSString *s = @”ff”; // ff
NSString *t = @”\uFB00”; // ff ligature
NSComparisonResult result = [s localizedCompare:t];
NSLog(@”%@ is %@ to %@”, s, result == NSOrderedSame ? @”equal” : @”not equal”, t);
// => ff is equal to ff
如果你只想用 compare: 比較而不考慮等價關係,compare:options 這個方法變體可以讓你指定 NSLiteralSearch 作爲參數,這能讓比較更快。

從文件或網絡讀取文本

總地來說,只有當你知道文本所用的編碼時文本數據纔是有用的。當從服務器下載文本數據時,通常你都知道或者可以從 HTTP 的頭文件中得知編碼類型。之後,再用 -[NSString initWithData:encoding:] 這個方法創建字符串對象就很簡單了。

編者注 這一段和下一段的這兩個 NSString 的方法均爲實例方法而非類方法,即應該先 alloc 後再調用,原文這樣寫估計只是爲了簡潔,請讀者知會。
雖然文本文件本身並不包含編碼信息,但 NSString 常常可以通過查看擴展文件屬性(extended file attributes)或者通過規律進行試探性的猜測的方法(比如,一個有效的 UTF-8 文件裏就不會出現某些特定的二進制序列)來確定文件的編碼。可以使用 -[NSString initWithContentsOfURL:encoding:error:] 這個方法,來從編碼已知的文件裏讀取文本。要讀取編碼未知的文件,蘋果提出了以下原則:

如果你不得不猜測文件的編碼(注意,沒有明確信息,就只有猜測):

試試這兩個方法:stringWithContentsOfFile:usedEncoding:error: 或者 initWithContentsOfFile:usedEncoding:error: (或者這兩個方法參數爲 URL 的等價方法)。
這些方法會嘗試猜測資源的編碼,如果猜測成功,會以引用的形式帶回所用的編碼。
如果 1 失敗了,試着用 UTF-8 讀取資源。
如果 2 失敗了,試試合適的老的編碼。
這裏「合適的」取決於具體情況。它可以是默認的 C 語言字符串編碼,也可以是 ISO 或者 Windows Latin 1 編碼,亦或者是其它的,取決於你的數據來源。
最終,還可以試試 Application Kit 裏 NSAttributedString 類的載入方法(比如:initWithFileURL:options:documentAttributes:error:)。這些方法會嘗試純文本文件,然後返回使用的編碼。可以用這些方法打開任意的文檔。如果你的程序並不是專業處理文本的程序,這些方法也值得考慮。對於 Foundation 級別的工具,或者不是自然語言的文本來說,這些方法可能不太合適。
編者注 第 4 條中 NSAttributedString 的方法名原文拼寫有誤,本譯文已更正。
把文本寫入文件

我已經提到過,純文本文件,和文件格式或者網絡協議應該選擇 UTF-8 編碼,除非有特別的需要只能用其它的編碼。要向文件中寫入文本,使用 writeToURL:atomically:encoding:error: 這個方法。

這個方法會在 UTF-16 或 UTF-32 編碼的文件上自動加上字節順序標記。它還會把文件的編碼存儲在名爲 com.apple.TextEncoding 的擴展文件屬性裏。鑑於 initWithContentsOf…: usedEncoding:error: 方法知道有這個屬性,當你從文件裏載入文本時,使用標準的 NSString 方法就能讓確保使用正確的編碼更加容易。

結語

文本很複雜。儘管 Unicode 已經極大地改善了軟件處理文本的方式,程序員還是需要了解其中的運作機制以便能正確處理它。今天,幾乎每一個應用都要處理多文種文本。即使你的應用不需要爲中文或阿拉伯文做本地化,只要有任何一處需要用戶進行輸入,你還是得和 Unicode 的整個機制打交道。

你可以用全世界的語言來作完整的字符串處理測試,這意味着你需要測試輸入爲非英語的情況。確保在你的單元測試裏用大量的 emoji 和 非拉丁文字作爲測試用例。如果你不知道怎麼輸入某種文字,維基百科可以幫助你。這裏有各種語言版本的維基百科,選擇某種語言,隨機選取一篇文章,拷貝里面的一些字詞,然後盡情地測試吧。

擴展閱讀

Joel Spolsky: 關於 Unicode 和字符集,每個程序員絕對、必須要了解的一點內容。這篇文章已經有 10 年了,而且不僅限於 Cocoa 編程,但是值得一讀。
Ross Carter 在 2012 年 NSCoference 上做了一次名叫「你也可以講 Unicode」 的精彩演講。演講很有意思,強烈推薦觀看。這篇文章的一部分就是基於 Ross 的演講稿的。NSConference 的 Scotty 人很好,讓 objc.io 的讀者可以觀看這次視頻。謝了!
維基百科上關於 Unicode 的文章很棒。
unicode.org 是 Unicode 聯盟的官網,上面不僅有完整的標準和碼錶索引,還有其它很有意思的信息。擴展部分 FAQ 也很棒。


  1. 最新的 6.3.0 版本的 Unicode 標準支持 100 種文字和 15 種符號集,比如數學符號和麻將牌。在還沒有提供支持的文字中,有 12 種「仍有人使用的文字」以及 31 種「古老的」或者「已經消亡的」文字。
  2. 如今,Unicode 編碼了超過 70,000 個統一的中日韓文字(CJK),單單這些文字就已經遠遠超過了 16 位所提供的空間。
  3. 就連用其它文字寫成的文檔裏也會包含大量這個範圍裏的字符。假設有一個 HTML 文檔,它的內容全部是中文,但這個文檔的字符裏仍將有極大的比例是由 HTML 標記、CSS 樣式、Javascript 代碼、空格、換行符等組成的。
  4. 我(原文作者,下同)在 2012 年的一篇博文裏質疑了讓 UTF-8 兼容 ASCII 的決定是否正確。事實上,我現在知道了,UTF-8 的核心目標之一就是這個兼容性,以明確避免與不支持 Unicode 的文件系統之間的問題。不過我還是覺得太多的向前兼容最後往往會成爲累贅,因爲即使在今天,這個特性仍然會把一些漏洞掩蓋在沒有充分測試的處理 Unicode 的代碼裏。
  5. UTF-8 最初是設計用來編碼最長達 31 位的碼點的,而這需要最多達 6 字節的序列。後來爲了遵守 UTF-16 的約束,將它限制到了 21 位。現在,最長的 UTF-8 字節序列是 4 字節的。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章