轉載自ObjeC中國
歷史
計算機沒法直接處理文本,它只和數字打交道。為了在計算機里用數字表示文本,我們指定了一個從字符到數字的映射。這個映射就叫做編碼(encoding)。
ASCII碼是 7 位的,它將英文字母,數字 0-9 以及一些標點符號和控制字符映射為 0-127 這些整型。隨后,人們創造了許多不同的 8 位編碼來處理英語以外的其他語言。它們大多都是基于 ASCII 編碼的,并且使用了 ASCII 沒有使用的第 8 位來編入其它字母、符號甚至是整個字母表(比如西里爾字母和希臘字母)。
由于 8 位的空間對于歐洲的文字來說都不夠,更不用說全世界的書寫系統了,因此這種不兼容是肯定會出現的了。這對于當時基于文本的操作系統來說是很麻煩的,因為那時操作系統只能同時使用一種編碼(也叫做內碼表)
Unicode 概要
簡單地來說,Unicode 標準為世界上幾乎所有的[^1]書寫系統里所使用的每一個字符或符號定義了一個唯一的數字。
這個數字叫做碼點,以 U+xxxx 這樣的格式寫成,格式里的 xxxx 代表四到六個十六進制的數。
原本的unicode被設計為16位,提供了 65,536 個字符的空間。后來,考慮到要編碼歷史上的文字以及一些很少使用的日本漢字和中國漢字[^2],Unicode 編碼擴展到了 21 位(從 U+0000 到 U+10FFFF),提供了 1,114,112 個碼點。只有大概 10% 正在使用,所以還有相當大的擴充空間。編碼空間被分成 17 個平面,每個平面有 65,536 個字符。0 號平面叫做基本多文種平面,涵蓋了幾乎所有你能遇到的字符,除了 emoji。其它平面叫做補充平面,大多是空的。
組合字符序列
為了和已有的標準兼容,某些字符可以表示成以下兩種形式之一:一個單一的碼點,或者兩個以上連續的碼點組成的序列。例如,有重音符號的字母 é 可以直接表示成 U+00E9(「有尖音符號的小寫拉丁字母 e」),或者也可以表示成由 U+0065(「小寫拉丁字母 e」)再加 U+0301(「尖音符號」)組成的分解形式。這兩個形式都是「組合字符序列」的變體。
在諺文(朝鮮、韓國的文字)中,? 這個字可以表示成一個碼點(U+AC00),或者是 ? + ? (U+1100,U+1161)這個序列。在 Unicode 的語境下,兩種形式并不相等(因為兩種形式包含不同的碼點),但是符合「標準等價」,也就是說,它們有著相同的外觀和意義。
重復的字符
許多看上去一樣的字符都在不同的碼點編碼了多次,以此來代表不同的含義。例如,拉丁字母 A(U+0041)就與西里爾字母 A(U+0410)完全同形,但事實上,它們是不同的。把它們編入不同的碼點不僅簡化了與老的編碼系統的轉換,而且能讓 Unicode 的文本保留字符的含義。
但也有極少數真正的重復,這些完全相同的字符在不同的碼點上定義了多次。例如,Unicode 聯盟就列舉出了字母 ?(「上面帶個圓圈的大寫拉丁字母 A」,U+00C5)和字符 ?(「埃米」(長度單位)符號,U+212B),這兩個字符是完全相同的。在 Unicode 里,它們也符合標準等價但不相等,在 Unicode 標準里叫做「相容等價」
字形變體
有些字體會為一個字符提供多個「字形變體」。Unicode 提供了一個叫做「變體序列」的機制,它允許用戶選擇其中一個變體。這和組合字符序列的工作機制完全一樣:一個基準字符加上 256 個變體選擇符(VS1-VS256,U+FE00 到 U+FE0F,還有 U+E0100 到 U+E01EF)中的一個。
Unicode 標準對「標準化變體序列」和「象形文字變體序列」(是由第三方提交給 Unicode 聯盟的,一旦注冊,它可以被任何人使用)做出了區分。技術上來講,兩者并無區別。
emoji 的樣式就是一個標準化變體序列的例子。許多 emoji 和一些「正常」的字符都有兩種風格:一種是彩色的「emoji 風格」,另一種是黑白的,更像是符號的「文本風格」。例如,「有雨滴的傘」這個字符(U+2614)可能是這樣:?? (U+2614 U+FE0F) ,也可能是這樣的: ?? (U+2614 U+FE0E)。
Unicode 轉換格式
從上文可以看到,字符和碼點之間的映射只完成了一半工作,還需要定義另一種編碼來確定碼點在內存和硬盤中要如何表示。Unicode 標準為此定義了幾種映射,叫做「Unicode 轉換格式」(簡稱UTF)
UTF-32
最清楚明了的一個 UTF 就是 UTF-32:它在每個碼點上使用整 32 位。32 大于 21,因此每一個 UTF-32 值都可以直接表示對應的碼點。盡管簡單,UTF-32卻幾乎從來不在實際中使用,因為每個字符占用 4 字節太浪費空間了。
UTF-16 以及「代理對」(Surrogate Pairs)的概念
它是根據有 16 位固定長度的碼元(code units)定義的。UTF-16 本身是一種長度可變的編碼?;径辔姆N平面(BMP)中的每一個碼點都直接與一個碼元相映射。其它平面里很少使用的碼點都是用兩個 16 位的碼元來編碼的,這兩個合起來表示一個碼點的碼元就叫做代理對(surrogate pair)。
為了避免用 UTF-16 編碼的字符串里的字節序列產生歧義,以及能使檢測代理對更容易,Unicode 標準限制了 U+D800 到 U+DFFF 范圍內的碼點用于 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
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,最需要記住的是:NSString 代表的是用 UTF-16 編碼的文本,長度、索引和范圍都基于 UTF-16 的碼元。除非你知道字符串的內容,或者你提前有所防范,不然 NSString 類里的方法都是基于上述概念的,無法給你提供可靠的信息。每當文檔提及「字符」(character)或者 unichar 時,它其實都說的是碼元。
常見的陷阱
默認情況下,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特性。
1.基本多文種平面外的字符:記住,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(碼元是16位的,而一個emoji表情是由兩個碼元組成的,也就是4個字節,因此可以將字符串轉化為UTF-32,再除以4個字節):
NSUInteger realLength = [s lengthOfBytesUsingEncoding:NSUTF32StringEncoding] / 4;
NSLog(@"The real length of %@ is %lu", s, realLength);
// => The real length of ?? is 1
2.組合字符序列:如果字母 é 是以分解形式(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
不巧的是,并不是所有情況都能這樣做,因為只有最常見的組合字符序列有合成形式——其它基礎字符與標記的組合即便是經過正規化后,也會保持原樣。如果你想知道字符串真正的字符個數,你只能遍歷字符串自己數。后面循環那一節會繼續討論有關細節。
3.變體序列:它們和分解形式的組合字符序列的工作方式一樣,因此變體選擇符也算作單獨的字符。
隨機訪問
用characterAtIndex:方法以索引方式直接訪問 unichar會有同樣的問題。字符串可能會包含組合字符序列、代理對或變體序列。蘋果把這些都叫做合成字符序列(composed character sequence),這些術語就變得容易混淆。注意不要把合成字符序列(蘋果的術語)和組合字符序列(Unicode 術語)搞混。后者是前者的子集??梢杂?code>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";
// ? ligature NSComparisonResult result = [s localizedCompare:t];
NSLog(@"%@ is %@ to %@", s, result == NSOrderedSame ? @"equal" : @"not equal", t);
// => ff is equal to ?
如果你只想用 compare:
比較而不考慮等價關系,compare:options
這個方法變體可以讓你指定 NSLiteralSearch
作為參數,這能讓比較更快。
從文件或網絡讀取文本
總地來說,只有當你知道文本所用的編碼時文本數據才是有用的。當從服務器下載文本數據時,通常你都知道或者可以從 HTTP 的頭文件中得知編碼類型。之后,再用 -[NSString initWithData:encoding:]
這個方法創建字符串對象就很簡單了。
雖然文本文件本身并不包含編碼信息,但 NSString常??梢酝ㄟ^查看擴展文件屬性(extended file attributes)或者通過規律進行試探性的猜測的方法(比如,一個有效的 UTF-8 文件里就不會出現某些特定的二進制序列)來確定文件的編碼??梢允褂?code>-[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 級別的工具,或者不是自然語言的文本來說,這些方法可能不太合適。
把文本寫入文件
我已經提到過,純文本文件,和文件格式或者網絡協議應該選擇 UTF-8 編碼,除非有特別的需要只能用其它的編碼。要向文件中寫入文本,使用 writeToURL:atomically:encoding:error:
這個方法。
這個方法會在 UTF-16 或 UTF-32 編碼的文件上自動加上字節順序標記。它還會把文件的編碼存儲在名為 com.apple.TextEncoding的擴展文件屬性里。鑒于initWithContentsOf…: usedEncoding:error:
方法知道有這個屬性,當你從文件里載入文本時,使用標準的 NSString
方法就能讓確保使用正確的編碼更加容易。
擴展閱讀
- 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 位?,F在,最長的 UTF-8 字節序列是 4 字節的。