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 级别的工具,或者不是自然语言的文本来说,这些方法可能不太合适。
把文本写入文件
我已经提到过,纯文本文件,和文件格式或者网络协议应该选择 UTF-8 编码,除非有特别的需要只能用其它的编码。要向文件中写入文本,使用 writeToURL:atomically:encoding:error:
这个方法。
这个方法会在 UTF-16 或 UTF-32 编码的文件上自动加上字节顺序标记。它还会把文件的编码存储在名为 com.apple.TextEncoding
的扩展文件属性里。鉴于 initWithContentsOf…: usedEncoding:error:
方法知道有这个属性,当你从文件里载入文本时,使用标准的 NSString
方法就能让确保使用正确的编码更加容易。
网友评论