楔子
上一篇文章我们介绍了字符集,它是一系列字符组成的集合,但不同的字符集所能容纳的字符是有限的。于是为了能将全世界的字符统一起来,便诞生了 unicode。
unicode 字符集对世界上出现的所有字符都进行了系统的整理,包括各种 emoji,不管是哪个国家的语言,都可以使用 unicode 字符集。
print(ord("a")) # 97
print(ord("憨")) # 25000
print(ord("て")) # 12390
不管什么文字,都可以用一个 unicode 来表示,它们在字符集中对应一个唯一的码点。所谓码点,就是字符在字符集中的索引,或者说唯一编号。
但是问题来了,unicode 能表示这么多的字符,占用的内存一定不低吧。的确,根据当时的编码,一个 unicode 字符最高会占用到 4 字节,因此对西方人来说就有点苦不堪言了,明明一个字符就够用了,为啥需要那么多。
于是又出现了 utf-8,它是为 unicode 提供的一个新的编码规则,具有可变长的功能。不同种类的字符占用的大小不同,比如英文字符使用一个字节存储,汉字使用 3 个字节存储,Emoji 使用 4 个字节存储。
但 Python 在表示 unicode 字符串时,使用的却不是 utf-8 编码,至于原因我们下面来分析一下。
unicode 的三种编码
从 Python3 开始,字符串使用的是 unicode。而根据编码的不同,unicode 的每个字符最大可以占到 4 字节,从内存的角度来说,这种编码有时会比较昂贵。
为了减少内存消耗并且提高性能,Python 的内部使用了三种编码方式来表示 unicode。
Latin-1 编码:每个字符占 1 字节;
UCS2 编码:每个字符占 2 字节;
UCS4 编码:每个字符占 4 字节;
在 Python 编程中,所有字符串的行为都是一致的,而且大多数时候我们都没有注意到差异。然而在处理大文本的时候,这种差异就会变得异常显著,甚至有些让人出乎意料。
为了看到内部表示的差异,我们看一下字符串所占的内存大小。
>>> sys.getsizeof("a")
42
>>> sys.getsizeof("憨")
60
>>> sys.getsizeof("😂")
64
我们看到都是一个字符,但它们占用的内存却是不一样的。因为 Python 面对不同的字符会采用不同的编码,进而导致大小不同。
但需要注意的是,Python 的每一个字符串都需要额外占用至少 41 个字节,因为要存储一些元数据,比如:公共的头部、哈希、长度、字节长度、编码类型等等。
import sys
# 对于 ASCII 字符,一个占 1 字节,显然此时编码是 Latin-1 编码
print(sys.getsizeof("ab") - sys.getsizeof("a")) # 1
# 对于汉字,日文等等,一个占用 2 字节,此时是 UCS2 编码
print(sys.getsizeof("憨憨") - sys.getsizeof("憨")) # 2
print(sys.getsizeof("です") - sys.getsizeof("で")) # 2
# 像 Emoji,则是一个占 4 字节 ,此时是 UCS4 编码
print(sys.getsizeof("😂😂") - sys.getsizeof("😂")) # 4
而采用不同的编码,那么底层结构体实例的元数据也会占用不同大小的内存。
# 所以一个空字符串占用 41 个字节
# 此时会采用占用内存最小的 Latin-1 编码
print(sys.getsizeof("")) # 41
# 此时使用 UCS2
print(sys.getsizeof("憨") - 2) # 58
# UCS4
print(sys.getsizeof("🍌") - 4) # 60
如果编码是 Latin-1,那么这个结构体实例的元数据会占 41 个字节;编码是 UCS2,占 58 个字节;编码是 UCS4,占 60 个字节。然后字符串所占的字节数就等于:元数据 + 字符个数 * 单个字符所占的字节。
为什么不使用 utf-8 编码
上面提到的三种编码,是 Python 在底层所使用的,但我们知道 unicode 还有一个 utf-8 编码,那 Python 为啥不用呢?