第七章——字符串(不定长度字符)

256 阅读9分钟

本文系阅读阅读原章节后总结概括得出。由于需要我进行一定的概括提炼,如有不当之处欢迎读者斧正。如果你对内容有任何疑问,欢迎共同交流讨论。

不定长度字符

一开始,字符串编码这件事很简单。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的含义。如果每个字节长度不定,则需要从头开始遍历。