str 对象,统一的 Unicode 字符串

Unicode是什么

计算机存储的基本单位是 八位字节 ,由 8 个比特位组成,简称 字节

由于英文只由 26 个字母加若干符号组成,因此英文字符可以直接用 字节 来保存。其他诸如中日韩等语言,由于字符众多,则不得不用多个字节来编码。

随着计算机技术的传播,非拉丁文字符编码技术蓬勃发展,但存在两个比较大的局限性:

  • 不支持多语言 ,例如中文的编码方案不能表示日文;
  • 没有统一标准 ,例如中文有 GB2312GBK 、 GB18030 等多种编码标准;

由于编码方式不统一,开发人员经常需要在不同编码间来回转化,错误频出。为了彻底解决这些问题, 统一码联盟 提出了 Unicode 标准。

Unicode 对世界上大部分文字系统进行整理、编码,让计算机可以用统一的方式处理文本。Unicode 目前已经收录了超过 13 万个字符,天然地支持多语言。

使用 Unicode ,即可彻底跟编码问题说拜拜!

Python中的Unicode

Python3 之后,str 对象内部改用 Unicode 表示,因而在源码中被称为 Unicode 对象。

这么做好处是显然易见的,程序核心逻辑统一用 Unicode ,只需在输入、输入层进行编码、解码,可最大程度避免各种编码问题:

由于 Unicode 收录字符已经超过 13 万个,每个字符至少需要 4 个字节来保存。这意味着巨大的内存开销,显然是不可接受的。英文字符用 ASCII 表示仅需 1 个字节,而用 Unicode 表示内存开销却增加 4 倍!

Python 作者们肯定不允许这样的事情发生,不信我们先来观察下( getsizeof 获取对象内存大小):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> import sys
# 英文字符还是1字节
>>> sys.getsizeof('ab') - sys.getsizeof('a')
1
# 中文字符需要2字节
>>> sys.getsizeof('中国') - sys.getsizeof('中')
2
# Emoji表情需要4字节
>>> sys.getsizeof('😂😂') - sys.getsizeof('😂')
4
  • 每个 ASCII 英文字符,占用 1 字节;
  • 每个中文字符,占用 2 字节;
  • Emoji 表情,占用 4 字节;

由此可见,Python 内部对 Unicode 进行优化:根据文本内容,选择底层存储单元。至于这种黑科技是怎么实现的,我们只能到源码中寻找答案了。与 str 对象实现相关源码如下:

  • Include/unicodeobject.h
  • Objects/unicodectype.c

在 Include/unicodeobject.h 头文件中,我们发现 str 对象底层存储根据文本字符 Unicode 码位范围分成几类:

  • PyUnicode_1BYTE_KIND ,所有字符码位均在 U+0000U+00FF 之间;
  • PyUnicode_2BYTE_KIND ,所有字符码位均在 U+0000U+FFFF 之间,且至少一个大于 U+00FF
  • PyUnicode_4BYTE_KIND ,所有字符码位均在 U+0000U+10FFFF 之间,且至少一个大于 U+FFFF
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
enum PyUnicode_Kind {
/* String contains only wstr byte characters.  This is only possible
   when the string was created with a legacy API and _PyUnicode_Ready()
   has not been called yet.  */
    PyUnicode_WCHAR_KIND = 0,
/* Return values of the PyUnicode_KIND() macro: */
    PyUnicode_1BYTE_KIND = 1,
    PyUnicode_2BYTE_KIND = 2,
    PyUnicode_4BYTE_KIND = 4
};

如果文本字符码位均在 U+0000U+00FF 之间,单个字符只需 1 字节来表示;而码位在 U+0000U+FFFF 之间的文本,单个字符则需要 2 字节才能表示;以此类推。这样一来,根据文本码位范围,便可为字符选用尽量小的存储单元,以最大限度节约内存。

1
2
3
typedef uint32_t Py_UCS4;
typedef uint16_t Py_UCS2;
typedef uint8_t Py_UCS1;
文本类型 字符存储单元 字符存储单元大小(字节)
PyUnicode_1BYTE_KIND Py_UCS1 1
PyUnicode_2BYTE_KIND Py_UCS2 2
PyUnicode_4BYTE_KIND Py_UCS4 4

Unicode 内部存储结构因文本类型而异,因此类型 kind 必须作为 Unicode 对象公共字段保存。Python 内部定义了若干个 标志位 ,作为 Unicode 公共字段,kind 便是其中之一:

  • interned ,是否为 interned 机制维护, internel 机制在本节后半部分介绍;
  • kind ,类型,用于区分字符底层存储单元大小;
  • compact ,内存分配方式,对象与文本缓冲区是否分离,本文不涉及分离模式;
  • ascii ,文本是否均为纯 ASCII

Objects/unicodectype.c 源文件中的 PyUnicode_New 函数,根据文本字符数 size 以及最大字符 maxchar 初始化 Unicode 对象。该函数根据 maxcharUnicode 对象选择最紧凑的字符存储单元以及底层结构体:

maxchar < 128 maxchar < 256 maxchar < 65536 maxchar < MAX_UNICODE
kind PyUnicode_1BYTE_KIND PyUnicode_1BYTE_KIND PyUnicode_2BYTE_KIND PyUnicode_4BYTE_KIND
ascii 1 0 0 0
字符存储单元大小 1 1 2 4
底层结构体 PyASCIIObject PyCompactUnicodeObject PyCompactUnicodeObject PyCompactUnicodeObject

PyASCIIObject

如果 str 对象保存的文本均为 ASCII ,即 maxchar < 128,则底层由 PyASCIIObject 结构存储:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
/* ASCII-only strings created through PyUnicode_New use the PyASCIIObject
   structure. state.ascii and state.compact are set, and the data
   immediately follow the structure. utf8_length and wstr_length can be found
   in the length field; the utf8 pointer is equal to the data pointer. */
typedef struct {
    PyObject_HEAD
    Py_ssize_t length;          /* Number of code points in the string */
    Py_hash_t hash;             /* Hash value; -1 if not set */
    struct {
        unsigned int interned:2;
        unsigned int kind:3;
        unsigned int compact:1;
        unsigned int ascii:1;
        unsigned int ready:1;
        unsigned int :24;
    } state;
    wchar_t *wstr;              /* wchar_t representation (null-terminated) */
} PyASCIIObject;

PyASCIIObject 结构体也是其他 Unicode 底层存储结构体的基础,所有字段均为 Unicode 公共字段:

  • ob_refcnt ,引用计数;
  • ob_type ,对象类型;
  • length ,文本长度;
  • hash ,文本哈希值;
  • stateUnicode 对象标志位,包括 internelkindasciicompact 等;
  • wstr ,略;

注意到,state 字段后有一个 4 字节的空洞,这是结构体字段 内存对齐 造成的现象。在 64 位机器下,指针大小为 8 字节,为优化内存访问效率,wstr 必须以 8 字节对齐;而 state 字段大小只是 4 字节,便留下 4 字节的空洞。 PyASCIIObject 结构体大小在 64 位机器下为 48 字节,在 32 位机器下为 24 字节。

ASCII 文本则紧接着位于 PyASCIIObject 结构体后面,以字符串对象 ‘abc’ 以及空字符串对象 '' 为例:

注意到,与 bytes 对象一样,Python 也在 ASCII 文本末尾,额外添加一个 \0 字符,以兼容 C 字符串。

如此一来,以 Unicode 表示的 ASCII 文本,额外内存开销仅为 PyASCIIObject 结构体加上末尾的 \0 字节而已。PyASCIIObject 结构体在 64 位机器下,大小为 48 字节。因此,长度为 n 的纯 ASCII 字符串对象,需要消耗 n+48+1 ,即 n+49 字节的内存空间。

1
2
3
4
5
6
>>> sys.getsizeof('')
49
>>> sys.getsizeof('abc')
52
>>> sys.getsizeof('a' * 10000)
10049

PyCompactUnicodeObject

如果文本不全是 ASCIIUnicode 对象底层便由 PyCompactUnicodeObject 结构体保存:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
/* Non-ASCII strings allocated through PyUnicode_New use the
   PyCompactUnicodeObject structure. state.compact is set, and the data
   immediately follow the structure. */
typedef struct {
    PyASCIIObject _base;
    Py_ssize_t utf8_length;     /* Number of bytes in utf8, excluding the
                                 * terminating \0. */
    char *utf8;                 /* UTF-8 representation (null-terminated) */
    Py_ssize_t wstr_length;     /* Number of code points in wstr, possible
                                 * surrogates count as two code points. */
} PyCompactUnicodeObject;

PyCompactUnicodeObject 在 PyASCIIObject 基础上,增加 3 个字段:

  • utf8_length ,文本 UTF8 编码长度;
  • utf8 ,文本 UTF8 编码形式,缓存以避免重复编码运算;
  • wstr_length ,略;

由于 ASCII 本身兼容 UTF8 ,无须保存 UTF8 编码形式,这也是 ASCII 文本底层由 PyASCIIObject 保存的原因。在 64 位机器,PyCompactUnicodeObject 结构体大小为 72 字节;在 32 位机器则是 36 字节。

PyUnicode_1BYTE_KIND

如果 128 <= maxchar < 256Unicode 对象底层便由 PyCompactUnicodeObject 结构体保存,字符存储单元为 Py_UCS1 ,大小为 1 字节。以 Python® 为例,字符 ® 码位为 U+00AE ,满足该条件,内部结构如下:

字符存储单元还是 1 字节,跟 ASCII 文本一样。 因此,Python® 对象需要占用 80 字节的内存空间:

$$72+1\times7+1=72+8=80$$

1
2
>>> sys.getsizeof('Python®')
80

PyUnicode_2BYTE_KIND

如果 256 <= maxchar < 65536Unicode 对象底层同样由 PyCompactUnicodeObject 结构体保存,但字符存储单元为 Py_UCS2 ,大小为 2 字节。以 AC米兰 为例,常用汉字码位在 U+0100U+FFFF 之间,满足该条件,内部结构如下:

由于现在字符存储单元为 2 字节,故而 str 对象 AC米兰 需要占用 82 字节的内存空间:

$$72+2\times4+2=72+10=82$$

1
2
>>> sys.getsizeof('AC米兰')
82

我们看到,当文本包含中文后,英文字母也只能用 2 字节的存储单元来保存了。

你可能会提出疑问,为什么不采用变长存储单元呢?例如,字母 1 字节,汉字 2 字节?这是因为采用变长存储单元后,就无法在 O(1) 时间内取出文本第 n 个字符了——你只能从头遍历直到遇到第 n 个字符。

PyUnicode_4BYTE_KIND

如果 65536 <= maxchar < 4294967296 ,便只能用 4 字节存储单元 Py_UCS4 了。以 AC米兰😂 为例:

1
2
>>> sys.getsizeof('AC米兰😂')
96

这样一来,给一段英文文本加上表情,内存暴增 4 倍,也就不奇怪了:

1
2
3
4
5
6
>>> text = 'a' * 1000
>>> sys.getsizeof(text)
1049
>>> text += '😂'
>>> sys.getsizeof(text)
4080

interned机制

如果 str 对象 interned 标识位为 1Python 虚拟机将为其开启 interned 机制。那么,什么是 interned 机制?

先考虑以下场景,如果程序中有大量 User 对象,有什么可优化的地方?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> class User:
...
...     def __init__(self, name, age):
...         self.name = name
...         self.age = age
...
>>>
>>> user = User(name='tom', age=20)
>>> user.__dict__
{'name': 'tom', 'age': 20}

由于对象的属性由 dict 保存,这意味着每个 User 对象都需要保存 str 对象 name 。换句话讲,1 亿个 User 对象需要重复保存 1 亿个同样的 str 对象,这将浪费多少内存!

由于 str 是不可变对象,因此 Python 内部将有潜在重复可能的字符串都做成 单例模式 ,这就是 interned 机制。Python 具体做法是在内部维护一个全局 dict 对象,所有开启 interned 机制 str 对象均保存在这里;后续需要用到相关对象的地方,则优先到全局 dict 中取,避免重复创建。

举个例子,虽然 str 对象 ‘abc’ 由不同的运算产生,但背后却是同一个对象:

1
2
3
4
>>> a = 'abc'
>>> b = 'ab' + 'c'
>>> id(a), id(b), a is b
(4424345224, 4424345224, True)

洞悉 Python 虚拟机运行机制,探索高效程序设计之道!

FAQ

  1. 对于 strbytes 对象之间的关系还是有点疑惑,是不是 str 转化成 bytes 后,utf8 字段指向 bytes 对象呢?

str 对象的思想是以定长字节来保存字符文本,这样查找效率高。bytes 对象本质上是字节序列,至于字节序列保存的内容是什么,它并不关心。

网络通信和序列化存储,都需要将数据转化成字节序列。字符转化成字节序列,有很多种方案,比如 utf8 ,它是字节变长的。

str 对象跟 bytes 对象本质上没有必然联系,只不过可以互相转换。根据编码方案,比如 utf8 互相转化。

utf8 是一种很常见的编码方案,因此 str 对象做了缓存。这样一来,多次调用 encode 进行编码时,就不用重复执行编码逻辑。这样用的内存多了,但是第一次以后的编码逻辑省去了,典型的空间换时间。

utf8 应该不是直接指向 bytes 对象,而是指向一个 char 数组。跟 bytes 对象里面保存的 char 数组一样,但可能不是同一个。

到底如何才能提升我的 Python 开发水平,向更高一级的岗位迈进呢? 如果你有这些问题或者疑惑,请订阅我们的专栏 Python源码深度剖析 ,阅读更多章节:

【Python源码剖析】系列文章首发于公众号【小菜学编程】,敬请关注: