老齐教室

Python字符串的前世今生

Python字符串的前世今生

1991年,Guido van Rossum发布了Python编程语言的第一个版本,自此,世界迎来了巨变。互联网的发展,要求支持不同的自然语言,这促使了Unicode的发展。Unicode定义了一个通用字符集,它可以表示任何书面语言、各种非字母数字和符号,甚至包括表情符😀。Python在设计之处并没有考虑到Unicode,但它在后来的发展中支持了Unicode,主要的变化发生在Python 3中,这个版本开始将原来的unicode类型改为str类型。在Unicode时代,Python字符串已被证明是处理文本的一种便捷方法。

在本文中,我们就来研究Python字符串是如何演化并能处理各类文本的,特别是窥视其幕后的运作方式。

网页上的字符

据我所知,你一定是在浏览器上阅读本文,那是在微信公众号上,也是使用了微信内置的浏览器。我是在一个编辑器中,将本文的所有符号按照一定的序列输入,为了最终能够使你的浏览器和我的编辑器能够呈现相同的字符序列,它们二者必须能表示相同的字符集。但是,我们两个所用的工具还是有差别的。为此,就要将每个字符映射到某个字节组成的单元序列,这种映射称为字符编码。我们所用的工具都要能够将文本字符编码为字节,还能够从字节中解码。这样才能实现文本内容的呈现和存储。

现在,你的浏览器和我的编辑器都选择支持Unicode字符集,因为它能够表示目前所知的各种书面语言(有点夸张吗?姑且如此认为)中的符号以及其他各类符号。在编辑器这部分,我采用了UTF-8编码,这是网络上用的最广的一种编码方案,通常会在HTML网页中声明:

1
Content-Type: text/html; charset=utf-8

当你用自己的浏览器打开这个网页时,就能自动检测到编码方案。

1
2
3
4
5
6
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<!-- ... -->
</html>

不用担心浏览器不晓得这个编码,因为这已经成为了HTML的标准。

Unicode的发展历程

在Unicode之前,大多数计算机系统使用ASCII字符编码。ASCII足以处理英文文本——供128个字符,但仅此而已。为了支持更多的语言,后来对ASCII进行了扩展,扩展到了256个字符,并用一个字节来编码每个字符。例如,ISO 8859定义了如下编码:

  • 针对德语、法语、葡萄牙语、意大利语等西欧语言的 ISO 8859-1

  • 针对波兰语、克罗地亚语、捷克语、斯洛伐克语等中欧语言的 ISO 8859-2

  • 针对俄语、塞尔维亚语、乌克兰语等语言的 ISO 8859-5

  • 针对阿拉伯语的 ISO 8859-6

  • 针对希腊语的 ISO 8859-7.

适用于多种语言的软件必须具备处理不同的字符编码的能力,否则就会出现所谓的乱码,例如用KOI-8对俄文单词“кракозбббры”进行编码,然后使用ISO 8859-1解码,你将看到“ËÒÁËÏÚÒÙ”。

随着网络的发展,不同语言的人之间交流更加密切,解决编码问题已经迫在眉睫。于是,20世纪80年代末出现了两个相互独立的方案:一个是[ISO10646](https://en.wikipedia.org/wiki/Universal\u Coded\u Character\u Set),另一个是Unicode,后者是由一群软件公司组织的项目。这两个项目有共同的目标:用一个涵盖所有正在广泛使用的语言的通用编码替换数百个相互冲突的字符编码。项目的发展使人们很快意识到,用两个不同的通用字符集无法实现这一目标,因此在1991年,ISO 10646和Unicode合并,统一为Unicode字符集。这就是现在被广泛采用的字符集。

Unicode基础

Unicode以书面语言中最小意义单元定义字符,这意味着像变音符号这样的单位被认为是独立的字符。我们可以将多个Unicode字符组合在一起,以生成一个独立字符,这种组合称为字形群集。例如,字符串“á”是一个由两个字符组成的字组:拉丁字母“a”和锐音符“´”。Unicode也将一些字形群集编码为单独的字符,但这样做只是为了与传统编码兼容。由于字符的组合,Unicode可以生成各种各样的字形群集,例如”ä́” ,同时让字符集保持相对简单。

Unicode字符并不牵扯到字符渲染时的字形,字形是字体设计师的事情,虽然字符和字形之间可能有比较复杂的关系。

Unicode不直接将字符映射到字节,而是分两步映射:

  1. 编码字符集将字符映射到代码点。

  2. 以一种字符编码形式,例如UTF-8,将代码点映射到代码单元序列,其中每个代码单元是一个或多个字节的序列。

Unicode编码字符集是我们通常所说的Unicode。这和 ISO 10646定义的UCS是一样的。“编码”这个词的意思是它实际上不是一个集合,而是一个映射。此映射为字符集中的每个字符指定一个代码点。代码点是 [0, 1114111]范围内的整数,用Unicode十六进制表示法写成U+0000..U+10FFFF,称为代码空间。当前的Unicode 13.0将代码点分配给143,859个字符。

从技术上讲,编码字符集是一个条目集合。每个条目定义一个字符,并通过指定三条信息为其规定一个代码点:

  • 代码点值

  • 字符名称

  • 一个有代表性的字形

例如,字母“b”的条目: (U+0062, LATIN SMALL LETTER B, b)。

该标准还规定了各种字符属性,例如字符是字母、数字还是其他符号,是从左向右还是从右向左书写,是大写字母、小写字母还是根本没有大小写之分。所有这些信息都包含在Unicode字符数据库.。我们可以使用Python标准库中的模块 [unicodedata`](https://docs.python.org/3/library/unicodedata.html#module-unicodedata) 查询这个数据库。

如果用编码字符集对一些文本进行编码,得到的是一系列代码点。这样的序列称为Unicode字符串。这是进行文本处理的适当抽象级别。然而,计算机对代码点一无所知,因此必须将代码点编码为字节。Unicode定义了三种字符编码形式:UTF-8、UTF-16和UTF-32。每种方法都能对整个代码空间进行编码,但各有优缺点。

UTF-32是最直接的编码形式。每个代码点由32位的代码单元表示。例如,代码点U+01F193编码为“0x0001F193”。UTF-32的主要优点除了简单之外,还在于它是一种固定宽度的编码形式,即每个代码点对应于固定数量的代码单元(在本例中是1个代码单元)。这允许我们进行快速的代码点索引:可以在恒定的时间内访问UTF-32编码字符串的第n个代码点。

最初,Unicode只定义了一种编码形式,用16位的代码单位表示每个代码点。使用这种编码形式可以对整个代码空间进行编码,因为代码空间较小,由2^16 = 65,536个代码点组成。随着时间的推移,Unicode用户意识到65,536 个代码点不足以覆盖所有的书面语言,并将代码空间扩展到1,114,112 个代码点。问题是,构成U+010000..U+10FFFF范围的新代码点不能用16位代码单元表示。Unicode通过使用一对16位代码单元(称为代理项对)对每个新代码点进行编码,解决了这个问题。保留了两个未分配的代码点范围,仅在代理项对中使用:U+D800..U+DBFF用于代理项对的较高部分,U+DC00..U+DFFF用于代理项对的较低部分。每个范围由1024个码点组成,因此它们可以用来对1024 × 1024 = 1,048,576个码点进行编码。这种编码形式使用一个16位代码单元对U+0000..U+FFFF范围内的代码点进行编码,并使用两个16位代码单元对U+010000..U+10FFFF范围内的代码点进行编码,因此被称为UTF-16。其原始版本是ISO 10646标准的一部分,称为UCS-2。UTF-16和UCS-2之间的唯一区别是UCS-2不支持代理项对,只能对U+0000..U+FFFF范围内的代码点进行编码,称为基本多语言平面(BMP)。ISO 10646标准还定义了UCS-4编码形式,它实际上与UTF-32相同。

UTF-32和UTF-16广泛用于在程序中表示Unicode字符串。然而,它们不太适合于文本存储和传输。第一个问题是空间效率低下。当使用UTF-32编码形式对主要由ASCII字符组成的文本进行编码时尤其如此。第二个问题是,一个代码单元中的字节可以按little-endian或big-endian排列,因此UTF-32和UTF-16各有两种风格。被称为字节顺序标记(BOM)的特殊代码点通常被添加到文本的开头,以指定字节顺序。而字节顺序标记(BOM)的正确处理增加了复杂性。UTF-8编码形式没有这些问题。它用一个、两个、三个或四个字节的序列来表示每个代码点。第一个字节的前导位表示序列的长度。其他字节的格式总是“0b10xxxxxx”,以便与第一个字节区分开来。下表显示了每种长度的序列的外观以及它们所编码的代码点范围:

Range Byte 1 Byte 2 Byte 3 Byte 4
U+0000..U+007F 0b0xxxxxxx
U+0080..U+07FF 0b110xxxxx 0b10xxxxxx
U+0800..U+FFFF 0b1110xxxx 0b10xxxxxx 0b10xxxxxx
U+010000..U+10FFFF 0b11110xxx 0b10xxxxxx 0b10xxxxxx 0b10xxxxxx

为了对一个代码点进行编码,我们从上表中选择一个合适的模板,并用代码点的二进制表示来替换其中的xs 。最佳的模板是能够对代码点进行编码的最短模板。代码点的二进制表示法向右对齐,前导的xs替换为0s

请注意,UTF-8仅使用一个字节表示所有ASCII字符,因此任何ASCII编码的文本也是UTF-8编码的文本。这一特性是UTF-8被采用并成为网络上最主要的编码方式的原因之一。

以上对Unicode做了简要介绍,如果要了解更多信息,请阅读阅读《Unicode标准》的前几章。

Python字符串简史

现在的Python字符串与Python刚发布时有很大差别了,在历史上,Python语言的字符串有过多次重大变化,为了更好地理解字符串的含义,下面就快速回顾一下这个变革历史。

在Python最初的版本中,就有一个名为str的内置类型表示字符串,但它跟我们现在所使用的Python3中的str类型有所不同。早期的Python字符串本质上式“字节串”,也就是字节序列,与Python3中的bytes对象类似,这与现在Python3中的Unicode字符串有很大差别。

字节序列本身不包含编码信息,例如,下面显示的s,就是一个字节串(这是在Pytyhon 2.7中),而我们所使用的终端是UTF-8编码的,如果用print()函数打印这个字节串,即用UTF-8对其进行编码,那么就能看到实际上的Unicode字符。

1
2
3
4
$ python2.7
>>> s = '\xe2\x9c\x85'
>>> print(s)

既然本质上是“字节串”,却被称为“字符串”,原因何在?这可能是习惯,也可能跟Python为“字节串”对象提供了“字符串方法”有关,例如str.split()str.upper()等。本来str.upper()方法应该对一个字节序列执行某种操作,按照这个思路,如果真的是获取一个字节,并将其转换为大写,其实这是没有什么意义的,因为字节没有大小写之分。如果我们假设字节序列是某种编码中的文本,那么它就有意义了。Python一般会为为当前作用域设置一个编码方案,通常是ASCII码。但是我们可以更改设置,这样,调用该字符串方法就可以开始处理非ASCII编码的文本,例如:

1
2
3
4
5
6
7
8
9
10
11
$ python2.7
>>> s = '\xef\xe8\xf2\xee\xed' # Russian 'питон' in the encoding windows-1251
>>> '\xef\xe8\xf2\xee\xed'.upper() # does nothing since characters are non-ascii
'\xef\xe8\xf2\xee\xed'
>>> import locale
>>> locale.setlocale(locale.LC_ALL , 'ru_RU.CP1251')
'ru_RU.CP1251'
>>> '\xef\xe8\xf2\xee\xed'.upper() # converts to uppercase
'\xcf\xc8\xd2\xce\xcd'
>>> print('\xef\xe8\xf2\xee\xed'.upper().decode('windows-1251')) # let's print it
ПИТОН

以上过程的实现要依赖C标准库,它适用于8位固定宽度编码,不适用于UTF-8或任何其他Unicode编码。简而言之,早期的Python还没有Unicode字符串。

后来,引入了unicode类型——注意,这是一种对象类型,这个发生在Python2之前,当时PEP还不存在,不要误认为是Python3的事情。unicode的实例对象就是真正的Unicode字符串,即代码点序列(也可以称为Unicode字符序列),这与我们今天所用的字符串就很像了。

1
2
3
4
5
6
7
8
$ python2.7
>>> s = u'питон' # note unicode literal
>>> s # each element is a code point
u'\u043f\u0438\u0442\u043e\u043d'
>>> s[1] # can index code points
u'\u0438'
>>> print(s.upper()) # string methods work
ПИТОН

此时的Python,使用UCS-2编码在内部表示Unicode字符串,UCS-2能够对当时分配的所有代码点进行编码,但是,由于Unicode在BMP(Basic Multilingual Plane,基本多文种平面,UCS编码中的一个术语)之外分配了第一个代码点,致使UCS-2不再能够对所有的代码点进行编码,于是Python从UCS-2切换到UTF-16。这引起了另一个问题。由于UTF-16是一种可变宽度编码,因此获取字符串的第n个代码点需要扫描全体字符串,直到找到该代码点。尽管如此,Python中索引的方式还没有改变,如果使用Unicode对象,然后进行索引操作,就会产生下面的结果:

1
2
3
4
5
6
7
$ python2.7
>>> u'hello'[4] # indexing is still supported and works fast
u'o'
>>> len(u'😀') # but length of a character outside BMP is 2
2
>>> u'😀'[1] # and indexing returns code units, not code points
u'\ude00'

PEP 261试图恢复真正的Unicode字符串,它引入了一个启用了UCS-4编码的编译时间选项,但是UCS-4不能完全取代UTF-16,因为它的空间效率低下,所以两者必须共存。

In the meantime, Python developers focused their attention on another source of confusion: the coexistence of byte strings and Unicode strings. There were several problems with this. For example, it was possible to mix two types:

对于Python开发者而言,类似下面的问题,常常让大家困惑:

1
2
>>> "I'm str" + u" and I'm unicode"
u"I'm str and I'm unicode"

字节串和Unicode字符串貌似能够“共存”,然而:

1
2
3
4
>>> "I'm str \x80" + u" and I'm unicode"
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'ascii' codec can't decode byte 0x80 in position 8: ordinal not in range(128)

所以,还得继续进化。

著名的Python3 .0将unicode类型重命名为str类型,并将旧的str类型替换为bytes类型,对此有专门的官方声明概述了这一变化的原因:

与2.x情形的最大区别是,在Python3.0中任何混合文本和数据的操作都会引发TypeError,而如果在python2.x中混合Unicode和8位字符串,8位字符串恰好只包含7位(ASCII)字节,也可以行得通,但是如果它包含非ASCII值,你会看到 UnicodeDecodeError。这些年来,这种因“类型”而异的情况让无数人郁闷。

随着Python3.3的发布,Python字符串成为了我们今天所知道的Python字符串,是真正的Unicode字符串。

现在的Python字符串

CPython使用三种数据结构来表示字符串:PyASCIIObjectPyCompactUnicodeObjectPyUnicodeObject。第二种是第一种的延伸,第三种是第二种的延伸:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
typedef struct {
PyObject_HEAD
Py_ssize_t length;
Py_hash_t hash;
struct {
unsigned int interned:2;
unsigned int kind:2;
unsigned int compact:1;
unsigned int ascii:1;
unsigned int ready:1;
} state;
wchar_t *wstr;
} PyASCIIObject;

typedef struct {
PyASCIIObject _base;
Py_ssize_t utf8_length;
char *utf8;
Py_ssize_t wstr_length;
} PyCompactUnicodeObject;

typedef struct {
PyCompactUnicodeObject _base;
union {
void *any;
Py_UCS1 *latin1;
Py_UCS2 *ucs2;
Py_UCS4 *ucs4;
} data;
} PyUnicodeObject;

我们为什么需要这么多种数据结构?回想一下,CPython提供了Python/C API ,允许编写C扩展。特别是,它提供了一组处理字符串的函数。其中许多函数公开了字符串的内部表示形式,因此PEP393在不破坏C扩展的情况下无法摆脱旧的表示形式。当前字符串的表示法比它实际需要的更加复杂,原因之一就是CPython继续提供旧的API。例如,它提供了PyUnicode_AsUnicode()函数,该函数返回字符串的 `Py_UNICODE*表示形式。

首先让我们看看CPython如何表示使用新API创建的字符串,这些被称为“规范”字符串。它们包括在编写Python代码时创建的所有字符串。PyASCIIObject用于表示仅限ASCII的字符串。保存字符串的缓冲区不是结构的一部分,而是紧跟其后。分配过程如下:

1
obj = (PyObject *) PyObject_MALLOC(struct_size + (size + 1) * char_size);

PyCompactUnicodeObject用于表示所有其他Unicode字符串,缓冲区在此结构之后以相同的方式分配,只有struct_size 不同,char_size 可以是1, 24

PyASCIIObjectPyCompactUnicodeObject都存在的原因为了进行优化。如果字符串是仅限ASCII的字符串,那么CPython可以简单地返回存储在缓冲区中的数据。否则,CPython必须转化为UTF-8编码,PyCompactUnicodeObject 的 UTF-8字段用于存储缓存的UTF-8编码结果,但这些东西并不总是在缓存中。

旧的API,在Python 3中会支持一段时间,在Python 3.12中,就要将它删除了。

如今,Python默认使用UTF-8编码,为了实现此编码,CPython需要选择一个合适的数据结构和编码来表示字符串(ASCII、UCS-1、UCS-2或UCS-4),它必须解码所有的代码点。一种解决方案是读取输入两次:第一次是确定输入中最大的代码点,第二次是将输入从UTF-8编码转换为所选的内部编码。对CPython而言,并没有选用这种方案,而是首先创建一个PyASCIIObject实例来表示字符串。如果在读取输入时遇到非ASCII字符,将创建PyCompactUnicodeObject的实例,选择能够表示该字符的下一个最紧凑的编码,并将已解码的前缀转换为新编码。这样,它只读取一次输入,但最多可以更改三次内部表示。该算法在Objects/unicodeobject.c中的unicode_decode_utf8()函数中实现。

关于Python字符串还有很多要说,如 str.find()str.join()等字符串方法的实现,就可以用一个专题来讨论。这里的重点是关于CPython中实现字符串的方法,所以,其他内容暂不涉及。

其他语言中的字符串

处理文本内容,是每种编程语言都必须要面对的问题,因此也都有字符串,下面列举几种常见编程语言对字符串的处理方法。

C语言

字符串数据类型的最基本形式是字节数组。 Python2字符串就是这种方法的一个例子,它来自C,其中字符串表示为char数组。C标准库提供了一组函数,比如:toupper()isspace(),它们接受字节,并在当前区域设置指定的编码中将它们视为字符。这允许编码中的每个字符使用一个字节。为了支持其他编码,C90标准中引入了wchar_t类型。与char不同,wchar_t可以保证足够大,可以表示由任何支持的作用域设置所指定的任何编码中的所有字符。例如,如果某个作用域设置指定了UTF-8编码,那么wchar_t必须足够大,以表示所有的Unicode代码点。wchar_t的问题是它依赖于平台,其宽度可以小到8位。C11标准解决了这个问题,并引入了char16_tchar32_t类型。这些类型可用于以独立于平台的方式分别表示UTF-16和UTF-32的代码单元。Unicode标准的第5章更详细地讨论了C语言中的Unicode数据类型。

Go语言

在Go中,字符串是只读的字节切片,即一个字节数组以及数组中的字节数。字符串可以包含任意字节,就像C中的“char”数组一样,索引到字符串中会返回一个字节。不过,Go提供了不错的Unicode支持。首先,Go总是以UTF-8编码,这意味着字符串文字是有效的UTF-8序列。其次,用for循环遍历字符串会产生Unicode代码点。有一个单独的类型来表示代码点:rune类型。第三,标准库提供了使用Unicode的函数。例如,我们可以使用unicode/utf8 包提供的函数ValidString()来检查给定的字符串是否是有效的UTF-8序列。

Rust

Rust提供了几种字符串类型。主字符串类型,称为str,用于表示UTF-8编码的文本。字符串是字节切片,不是对任意字节都可容纳,而是只能容纳有效的UTF-8序列。如果从无效的UTF-8序列的字节序列中创建字符串,将导致错误。不支持按整数索引字符串。

迭代是访问代码点的方法。不过,可以按范围索引到字符串中,如&string[0..4]。此操作返回由指定范围内的字节组成的子字符串。如果子字符串不是有效的UTF-8序列,程序将崩溃。通过首先将字符串转换为字节片,总是可以访问字符串的各个字节。

Swift

对于Unicode支持,Swift采取了最激进的方法。Swift中的字符串是Unicode字形集群的序列,也就是人们所理解的字符序列。count属性返回字形集群的数目:

1
2
3
4
5
6
7
let str = "\u{65}\u{301}"
print(str)
print(str.count)

// Output:
// é
// 1

字符串的迭代会产生字形集群:

1
2
3
4
5
6
7
let str = "Cluster:\u{1112}\u{1161}\u{11AB} "
for c in str {
print(c, terminator:" ")
}

// Output:
// C l u s t e r : 한

Swift语言不支持按整数索引到字符串中。不过,有一个API允许通过索引访问:

1
2
3
4
5
6
let str = "Swift";
let c = str[str.index(str.startIndex, offsetBy: 3)]
print(c)

// Output:
// f

结论

在现代编程语境中,“string”一词的意思是Unicode数据。程序员应该知道Unicode的工作原理,语言设计者应该提供正确的抽象概念来应对它。Python字符串是Unicode代码点的序列。灵活的字符串表示法允许在固定时间内索引到字符串中,同时试图让字符串保持相对紧凑。这种方法对于Python似乎很有效,因为访问字符串的元素很容易,而且在大多数情况下,程序员甚至不考虑这些元素应该是字符还是字形集群。

参考文献

[1]. 本文源自:https://tenthousandmeters.com/blog/python-behind-the-scenes-9-how-python-strings-work/,但翻译过程中,有删减。如果有意看原文,请根据上述链接查阅。

使用支付宝打赏
使用微信打赏

若你觉得我的文章对你有帮助,欢迎点击上方按钮对我打赏

关注微信公众号,读文章、听课程,提升技能