0%

[python源码分析] 6.python3中的字符串str/Unicode

Unicode是什么#

计算机存储的基本单位是 八位字节 ,由 8 个比特位组成,简称 字节 。由于英文只由 26 个字母加若干符号组成,因此英文字符可以直接用 字节 来保存。其他诸如 中日韩等语言,由于字符众多,则不得不用多个字节来编码。 随着计算机技术的传播,非拉丁文字符编码技术蓬勃发展,但存在两个比较大的局限性:

  • 不支持多语言 ,例如中文的编码方案不能表示日文;
  • 没有统一标准 ,例如中文有 GB2312 ,GBK 、 GB18030 等多种编码标准; 由于编码方式不统一,开发人员经常需要在不同编码间来回转化,错误频出。为了彻底解决这些问题, 统一码联盟 提出了 Unicode 标准。Unicode 对世界上大部分文字系统进行整理、编码,让计算机可以用统一的方式处理文本。Unicode 目前已经收录了超过 13 万个字符,天然地支持多语言。

Python中的字符串Unicode#

version 特点
python2.7 str等同于char指针, unicode默认用assic编码
python3+ str等同于unicode, 使用PyASCIIObject或PyCompactUnicodeObject实现

在 Python 中处理文本数据是使用 str 对象,也称为 字符串。Python 在 3 之后,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

由此可见,Python 内部对 Unicode 进行优化:根据文本内容,选择 底层存储单元。与 str 对象实现相关源码如下:

1
2
Include/unicodeobject.h
Objects/unicodectype.c
在 Include/unicodeobject.h 头文件中,我们发现 str 对象底层存储根据文本字符 Unicode 码位范围分成几类:
1
2
3
4
5
6
7
8
9
10
11
12
13
PyUnicode_1BYTE_KIND ,所有字符码位均在 U+0000 到 U+00FF 之间;
PyUnicode_2BYTE_KIND ,所有字符码位均在 U+0000 到 U+FFFF 之间,且至少一个大于 U+00FF;
PyUnicode_4BYTE_KIND ,所有字符码位均在 U+0000 到 U+10FFFF 之间,且至少一个大于 U+FFFF;
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
};
这样一来,根据 文本码位范围,便可为字符选用 尽量小的 存储单元,以最大限度节约内存。
1
2
3
typedef uint32_t Py_UCS4;
typedef uint16_t Py_UCS2;
typedef uint8_t Py_UCS1;

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

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

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

  |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<128maxchar<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; /*Unicode 对象标志位,包括 internel 、 kind 、 ascii 、 compact 等*/
wchar_t *wstr; /* wchar_t representation (null-terminated) */
} PyASCIIObject;

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

state 字段后有一个 4 字节的空洞?#

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

与 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('abcdef')
55
>>> sys.getsizeof('1' * 10000)
10049

PyCompactUnicodeObject#

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

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

在 64 位机器,PyCompactUnicodeObject 结构体大小为 72 字节;在 32 位机器则是 36 字节。

PyCompactUnicodeObject结构如下图:

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

空字符串#

interned机制#

如果 str 对象 interned 标识位为 1 ,Python 虚拟机将为其开启 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 中取,避免重复创建。

1
static PyObject *interned = NULL;

sample 1 : 如果删除字符串并初始化一个新的相同字符串,则它们的ID相同,即首次创建时,它会存储在Interned词典中

1
2
3
4
5
6
7
8
9
>>> id(s)
4314134768
>>> del s
>>> s = "aaa"
>>> id(s)
4314134768
>>> y = "aaa"
>>> id(y)
4314134768

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

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

kind#

在PyASCIIObject中总共有四个kind域的值,这意味着字符是如何在内部存储在unicode对象中的。

  • PyUnicode_WCHAR_KIND

我还没有找到一种方法来定义python层中 用PyUnicode_WCHAR_KIND表示的unicode对象,它可能在 c / c ++中使用

PyUnicode_1BYTE_KIND#

-- 8位/字符 -- ascii标志设置? --- ascii标志设置为true:U + 0000-U + 007F --- ascii标志设置为false:U + 0080-U + 00FF中至少一个字符 utf8_length字段仍存储以null终止的c样式字符串,除了interned字段为0之外,只有特定范围内的字符才会存储在 Interned字典中。

1
2
3
4
5
6
>>> s1 = "\u007F\u0000"
>>> id(s1)
4472458224
>>> s2 = "\u007F\u0000"
>>> id(s2) # bacause "interned" field is 0, the unicode object will not be shared
4472458608

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

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

1
2
3
s = "\u0088\u0011\u00f1"

由于第一个字符为U + 0088,ascii标志变为0,并且PyUnicode_UTF8(unicode)不再返回utf8_length字段的地址,而是返回 **char * utf8**字段中的值,即0

如果PyUnicode_UTF8(unicode)为零,则这三个字节位于何处?我们没有在PyUnicodeObject中使用数据字段,让我们找出源码进行print(不感兴趣的可以跳过):

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
31
32
33
34
35
36
37
38
39
40
41
42
static PyObject *
unicode_repr(PyObject *unicode)
{
...
/*
// there exists official marco to get the char in exact index,
// I use my own to have a better understanding of how things work internally
Py_ssize_t isize = PyUnicode_GET_LENGTH(unicode);
Py_ssize_t idata = PyUnicode_DATA(unicode);
int ikind = PyUnicode_KIND(unicode);
// no mattner what ikind is, use Py_UCS4(4 bytes) to catch the result
Py_UCS4 ch = PyUnicode_READ(ikind, idata, i);
*/

switch (_PyUnicode_STATE(unicode).kind)
{
case (PyUnicode_1BYTE_KIND):
printf("PyUnicode_1BYTE_KIND, ");
if (PyUnicode_UTF8(unicode) == 0)
{

char *value = &(((PyUnicodeObject *)unicode)->data);
printf("\nPyUnicodeObject->latin1: ");
for (size_t i = 0; i < _PyUnicode_LENGTH(unicode); ++i)
{
printf("%#hhx ", *(value + i));
}

printf("\n");
}
break;
case (PyUnicode_2BYTE_KIND):
printf("PyUnicode_2BYTE_KIND, ");
break;
case (PyUnicode_4BYTE_KIND):
printf("PyUnicode_4BYTE_KIND, ");
break;
default:
printf("unknown kind: %d, ", _PyUnicode_STATE(unicode).kind);
}
...
}
重新编译上面的代码后,我们可以在repr()函数中跟踪latin1字段
1
2
3
>>> repr(s)
PyUnicode_1BYTE_KIND,
PyUnicodeObject->latin1: 0x88 0x11 0xf1

PyUnicode_2BYTE_KIND#

  • 16位/字符
  • 所有字符都在U + 0000-U + FFFF范围内
  • 至少一个字符在U + 0100-U + FFFF范围内

我们可以使用相同的代码来跟踪存储在数据字段中的字节,现在字段名称为ucs2(ucs2或latin1具有不同的名称,但是地址相同,它们位于相同的c并集结构中)

1
2
3
> s = "\u0011\u0111\u1111"
>>> repr(s)
kind: PyUnicode_2BYTE_KIND, PyUnicodeObject->ucs2: 0x11 0x111 0x1111

现在,kind字段是PyUnicode_2BYTE_KIND,需要 2个字节来存储每个字符

PyUnicode_4BYTE_KIND#

  • 32位/字符
  • 至少一个字符在U+10000-U+10FFFF范围内 现在,种类字段变为PyUnicode_4BYTE_KIND
    1
    2
    3
    >>> s = "\u00ff\U0010FFFF\U00100111\U0010FFF1"
    >>> repr(s)
    kind: PyUnicode_4BYTE_KIND, PyUnicodeObject->ucs4: 00xff 0x10ffff 0x100111 0x10fff1

Unicode内存使用情况摘要#

我们现在知道存在 三种存储机制,CPython存储一个Unicode对象将消耗多少字节 取决于字符的最大范围。 unicode对象内的所有字符必须 具有相同的大小,如果CPython使用可变大小的表示形式(例如utf-8),则不可能在O(1)时间内进行索引操作

compact#

如果compact为1,则意味着无论哪种字段,所有字符都存储在 PyUnicodeObject中(上面的示例都将compact设置为1)。否则,数据块将不会直接存储在PyUnicodeObject对象内,该数据块将是 新分配的位置。compact=0和compact=1之间的差异与Redis字符串编码raw和embstr之间的差异相同。

reference#

  1. str
  2. Python 源码深度剖析/10 str 对象,统一的 Unicode 字符串