楔子

我们之前提到,字符串采用不同的编码,底层的结构体实例的元数据所占用的内存是不一样的。其实本质上是,字符串会根据编码的不同,而选择不同的存储结构。

  • PyASCIIObject:字符串仅包含 ASCII 字符;
  • PyCompactUnicodeObject:字符串包含非 ASCII 字符,但可以紧凑表示;
  • PyUnicodeObject:通用结构,可以表达所有类型的字符串(该结构不做讨论);

需要强调的是,虽然 ASCII 字符占一字节,但只有码点小于 128 的字符才叫 ASCII 字符。

下面我们来分析一下。

unicode 分类

unicode 会根据编码的不同而分为以下几类。

// Include/cpython/unicodeobject.h
enum PyUnicode_Kind {
    // 所有字符的码点均位于 U+0000 ~ U+00FF 
    PyUnicode_1BYTE_KIND = 1,
    // 所有字符的码点均位于 U+0000 ~ U+FFFF 
    // 且至少有一个大于 U+00FF
    PyUnicode_2BYTE_KIND = 2,
    // 所有字符的码点均位于 U+0000 ~ U+10FFFF
    // 且至少有一个大于 U+FFFF
    PyUnicode_4BYTE_KIND = 4
};

而采用不同的编码,每个字符的大小也是不同的。

// Include/unicodeobject.h
typedef uint32_t Py_UCS4;
typedef uint16_t Py_UCS2;
typedef uint8_t Py_UCS1;

Python 有一个内置函数 ord,可以查看字符的码点。

  • 如果码点位于 0 ~ 255,那么使用 Py_UCS1,占 1 字节;
  • 如果码点位于 256 ~ 65535,那么使用 Py_UCS2,占 2 字节;
  • 如果码点大于 65535,那么使用 Py_UCS4,占 4 字节;

通过字符的范围,选择一个最合适的存储单元,从而节省内存。

PyASCIIObject

如果字符串只包含 ASCII 字符,即字符的码点均小于 128,那么底层使用 PyASCIIObject 进行存储。

// Include/cpython/unicodeobject.h
typedef struct {
    // 对象的公共头部
    PyObject_HEAD
    // 字符串的长度,充当了 ob_size
    Py_ssize_t length;
    // 哈希值,初始为 -1   
    Py_hash_t hash;
    struct {
        // 字符串是否开启 intern 机制,后续介绍
        unsigned int interned:2;
        // 类型,标识每个存储单元的大小,可以有以下几种
        // PyUnicode_1BYTE_KIND
        // PyUnicode_2BYTE_KIND
        // PyUnicode_4BYTE_KIND
        unsigned int kind:3;
        // 字符串是否紧凑表示,它是针对内存分配方案而言的
        /* 紧凑的字符串由 PyASCIIObject 和 PyCompactUnicodeObject 表示
           它的特点是对象和文本缓冲区是结合的,只需要一个内存块 */
        /* 非紧凑的字符串由 PyUnicodeObject 表示(不是本文的重点)
           它的特点是对象和文本缓冲区是分离的,需要两个内存块 
           一个负责存储 PyUnicodeObject 对象,另一个负责存储文本缓冲区 */
        unsigned int compact:1;
        // 字符串是否只包含 ASCII 字符,如果是则为 1, 否则为 0
        // 虽然一个字节可表示的范围是 0 ~ 255,但只有 0 ~ 127 之间的才是 ASCII 字符
        unsigned int ascii:1;
        // 对象布局是否已完全初始化,不用关注
        unsigned int ready:1;
        // 注意上面的字段名后面跟着 :数字,这是 C 语言的位域
        // 比如 interned:2 表示使用 unsigned int 的前 2 个位
        // 所以 struct state 结构体总共占 4 字节,因为所有字段共用 4 字节内存
        // 上面总共使用了 8 个位,显然这里的 :24 负责占满 32 个位
        unsigned int :24;
    } state;
    // 缓存宽字符格式(wide character)的字符串,无需关注,在之后的版本会被移除
    wchar_t *wstr;
} PyASCIIObject;

那么问题来了,实际的字符串文本数据存在了什么地方,我们没看到结构体里面有哪个字段负责存储文本啊。答案很简单,字符串文本会直接跟在 PyASCIIObject 结构体实例的后面,也就是紧凑表示。

我们以字符串 "miu" 为例,看一下它的底层结构。

注:为优化内存访问效率,结构体字段会进行内存对齐,所以 state 后面会多出一个 4 字节的空洞。

再来分析一下为什么一个空字符串会占 49 个字节,因为 ob_refcnt、ob_type、length、hash、wstr 都是 8 字节,加起来 40 字节。而 state 是 4 字节,但又留下了 4 字节的空洞,加起来也是 8 字节,所以总共占 40 + 8 = 48 个字节。然后还有一个 '\0',所以还要加上一个 1,总共 49 字节。

而对于 "miu" 这个 unicode 字符串来说,占的总字节数就是 49 + 3 = 52。

>>> import sys
>>> sys.getsizeof("abc")
52

当字符串只包含 ASCII 字符时,由 PyASCIIObject 结构体表示,大小等于 49 + 字符串长度。当然啦,我们也可以认为大小等于 48 + (字符串长度 + 1),这样理解起来更直观一些。

PyCompactUnicodeObject

如果字符串包含了非 ASCII 字符,那么由 PyCompactUnicodeObject 结构体表示,假设字符串中码点最大的字符的码点为 maxchar。

if maxchar < 128:
    struct = PyASCIIObject
    kind = PyUnicode_1BYTE_KIND  # 1
    ascii = 1
elif maxchar < 256:
    struct = PyCompactUnicodeObject
    kind = PyUnicode_1BYTE_KIND  # 1
    ascii = 0
elif maxchar < 65536:
    struct = PyCompactUnicodeObject
    kind = PyUnicode_2BYTE_KIND  # 2
    ascii = 0
else:
    struct = PyCompactUnicodeObject
    kind = PyUnicode_4BYTE_KIND  # 4
    ascii = 0

看一下 PyCompactUnicodeObject 的底层结构。

// Include/cpython/unicodeobject.h
typedef struct {
    PyASCIIObject _base;
    // 字符串的 utf-8 编码长度
    Py_ssize_t utf8_length;
    // 字符串使用 utf-8 编码的结果,这里是缓存起来从而避免重复的编码运算
    char *utf8;
    // 宽字符的数量
    Py_ssize_t wstr_length;
} PyCompactUnicodeObject;

PyCompactUnicodeObject 相当于在 PyASCIIObject 的基础上增加了 3 个字段,那么它实例的大小是多少呢?由于新增的三个字段,每个都是 8 字节,并且字符串文本会紧跟在 PyCompactUnicodeObject 的后面,所以大小一目了然。

PyCompactUnicodeObject 实例的大小等于 48 + 24 + (字符串长度 + 1) * 每个字符的大小,即 72 + (字符串长度 + 1) * 每个字符的大小

因此以后在看到一个字符串时,我们可以很轻松地计算出它的大小。

import sys
# 只包含 ASCII 字符,那么结构体使用 PyASCIIObject
# 这样的字符串所占的内存大小为 48 + (字符串长度 + 1)
only_ascii = "satori"
# 所以结果是 48 + (6 + 1) = 55 字节
print(sys.getsizeof(only_ascii))
"""
55
"""

# 包含非 ASCII 字符,结构体使用 PyCompactUnicodeObject
# 但所有字符的码点均小于 256,因此编码仍使用 Py_UCS1
# 这样的字符串所占的内存大小为 72 + (字符串长度 + 1)
non_ascii_with_ucs1 = "sator¡"
# 所以结果是 72 + (6 + 1) = 79 字节
print(sys.getsizeof(non_ascii_with_ucs1))
"""
79
"""

# 字符的码点达到了 256,但小于 65536,因此编码使用 Py_UCS2
# 这样的字符串所占的内存大小为 72 + (字符串长度 + 1) * 2
# 注意:因为编码使用 Py_UCS2,那么 \0 也要占两个字节
non_ascii_with_ucs2 = "憨pi"
# 所以结果是 72 + (3 + 1) * 2 = 80
print(sys.getsizeof(non_ascii_with_ucs2))
"""
80
"""

# 字符的码点达到了 65536,因此编码使用 Py_UCS4
# 这样的字符串所占的内存大小为 72 + (字符串长度 + 1) * 4
# 因为编码使用 Py_UCS4,那么 \0 也要占 4 个字节
non_ascii_with_ucs4 = "🍌君"
# 所以结果是 72 + (2 + 1) * 4 = 84
print(sys.getsizeof(non_ascii_with_ucs4))
"""
84
"""

所以随着编码的不同,一个 Python 字符串的元数据(包含 '\0')会占据不同的大小,假设字符串中码点最大的字符的码点为 maxchar。

  • 如果 maxchar < 128,那么采用 Latin-1 编码,结构体为 PyASCIIObject,元数据的大小为 48 + 1 = 49。
  • 如果 128 <= maxchar < 256,那么采用 Latin-1 编码,结构体为 PyCompactUnicodeObject,元数据的大小为 72 + 1 = 73。
  • 如果 256 <= maxchar < 65536,那么采用 USC2 编码,结构体为 PyCompactUnicodeObject,元数据的大小为 72 + 2 = 74。
  • 如果 maxchar >= 65536,那么采用 USC4 编码,结构体为 PyCompactUnicodeObject,元数据的大小为 72 + 4 = 76。

所以我们之前说根据编码的不同,字符串的额外部分可能占据 49、74、76字节,这个结论其实不够准确,还漏掉了一个 73。因为 128 <= maxchar < 256 的字符串虽然不是 ASCII 字符串,但它仍然使用 Latin-1 编码,所以 '\0' 占的是 1 字节,而不是 2 字节和 4 字节。

下面通过画图来描述一下这几个字符串的底层结构,由于 ASCII 字符串已经说过了,这里就不再赘述了。

non_ascii_with_ucs1 = "sator¡"

注意结尾的是字符 ¡,不是 i。

每个字符占一字节,所以大小是 72 + 7= 79 字节,然后 kind 为 PyUnicode_1BYTE_KIND。

import sys

print(sys.getsizeof("sator¡"))  # 79

只有所有的字符的码点都小于 128,才叫 ASCII 字符串。而 ¡ 的码点是 161,所以 "sator¡" 不是 ASCII 字符串,但它的每个字符仍然只需一个字节存储。

non_ascii_with_ucs2 = "憨pi"

每个字符占两字节,所以大小是 72 + 4 * 2 = 80 字节,然后 kind 为 PyUnicode_2BYTE_KIND。

>>> import sys
>>> print(sys.getsizeof("憨pi"))
80

non_ascii_with_ucs4 = "🍌君"

每个字符占四字节,所以大小是 72 + 3 * 4 = 84 字节,然后 kind 为 PyUnicode_4BYTE_KIND。

>>> import sys
>>> print(sys.getsizeof("🍌君"))
84

以上我们就讨论了不同编码的字符串的底层结构,以及内存计算方式。

字符串的内存申请

我们再来看一下解释器是怎么为字符串申请内存的,这个过程会调用 PyUnicode_New 函数。

该函数接收一个 size 参数和 maxchar 参数,负责申请容纳 size 个字符的 unicode 对象。而 maxchar 表示所有字符的码点中最大的那一个,对象的每个字符占多大空间,则基于 maxchar 进行判断。

// Objects/unicodeobject.c

PyObject *
PyUnicode_New(Py_ssize_t size, Py_UCS4 maxchar)
{   
    // 声明相关变量
    PyObject *obj;
    PyCompactUnicodeObject *unicode;
    void *data;
    enum PyUnicode_Kind kind;
    int is_sharing, is_ascii;
    Py_ssize_t char_size;
    Py_ssize_t struct_size;

    // 如果 size 为 0,返回空字符串,注:空字符串是单例的
    if (size == 0 && unicode_empty != NULL) {
        Py_INCREF(unicode_empty);
        return unicode_empty;
    }

    is_ascii = 0;
    is_sharing = 0;
    struct_size = sizeof(PyCompactUnicodeObject);
    // 如果 maxchar 小于 128,说明全部是 ASCII 字符
    // 此时结构体使用 PyASCIIObject 
    if (maxchar < 128) {
        kind = PyUnicode_1BYTE_KIND;  // kind 为 1
        char_size = 1;  // 字符大小为 1
        is_ascii = 1;  // 是 ASCII 字符串
        struct_size = sizeof(PyASCIIObject);   // 结构体大小
    }
    // 否则说明字符串包含非 ASCII 字符,那么 is_ascii 为 0
    // 此时结构体一律使用 PyCompactUnicodeObject
    else if (maxchar < 256) {
        // 如果 maxchar 小于 256,那么 kind 依旧为 1,字符大小为 1
        kind = PyUnicode_1BYTE_KIND;
        char_size = 1;
    }
    else if (maxchar < 65536) {
        // 如果 maxchar 小于 65536,那么 kind 为 2,字符大小为 2
        kind = PyUnicode_2BYTE_KIND;
        char_size = 2;
        if (sizeof(wchar_t) == 2)
            is_sharing = 1;
    }
    else {
        // 否则说明要采用 4 字节存储,此时 kind 为 4,字符大小为 4
        // 注意:maxchar 也是有范围的,不能超过 0x10ffff
        // 不过目前也没有哪个字符的码点能超过这个值
        if (maxchar > MAX_UNICODE) {
            PyErr_SetString(PyExc_SystemError,
                            "invalid maximum character passed to PyUnicode_New");
            return NULL;
        }
        kind = PyUnicode_4BYTE_KIND;
        char_size = 4;
        if (sizeof(wchar_t) == 4)
            is_sharing = 1;
    }

    // size 不能小于 0
    if (size < 0) {
        PyErr_SetString(PyExc_SystemError,
                        "Negative size passed to PyUnicode_New");
        return NULL;
    }
    // (size + 1) * char_size + struct_size 不能超过 PY_SSIZE_T_MAX
    if (size > ((PY_SSIZE_T_MAX - struct_size) / char_size - 1))
        return PyErr_NoMemory();

    // 为 Unicode 对象申请内存,如果返回 NULL 则说明申请失败
    // 申请的内存不仅包含结构体本身,还有 size + 1 个字符,每个字符大小为 char_size
    // 从这里就体现出 "紧凑" 的含义了,因为结构体和字符串文本是结合的
    obj = (PyObject *) PyObject_MALLOC(struct_size + (size + 1) * char_size);
    if (obj == NULL)
        return PyErr_NoMemory();
    // 初始化引用计数和类型
    obj = PyObject_INIT(obj, &PyUnicode_Type);
    if (obj == NULL)
        return NULL;
    // 将 obj 转成 PyCompactUnicodeObject *
    unicode = (PyCompactUnicodeObject *)obj;
    // 注:存储字符串文本的内存(可以理解为一个数组)紧跟在结构体后面
    // 而下面的变量 data 会指向数组的首元素
    // 那么问题来了,这是怎么做到的呢?我们解释一下,顺便回顾一下 C 的指针
    // 假设有一个 int * 类型的指针 a,即 a 指向一个 int
    // 而 C 指针是可以运算的,a + 1 会指向下一个 int
    // 当然更准确的说,a + 1 会向后偏移 sizeof(int) 个字节
    // 同理,如果 a 的类型是 ssize_t *,那么 a + 1 会向后偏移 sizeof(ssize_t) 个字节
    if (is_ascii)
        // 此时我们就知道这里的代码是做什么的了,如果 is_ascii 等于 1
        // 说明结构体是 PyASCIIObject,将泛型指针 obj 转成 PyASCIIObject *
        // 加 1 之后会向后偏移 sizeof(PyASCIIObject) 个字节
        // 正好指向跟在 PyASCIIObject 结构体实例尾部的首个字符
        data = ((PyASCIIObject*)obj) + 1;
    else
        // 否则说明结构体是 PyCompactUnicodeObject
        // 那么 unicode + 1 之后会向后偏移 sizeof(PyCompactUnicodeObject) 个字节
        // 同样指向跟在 PyCompactUnicodeObject 结构体实例尾部的首个字符
        data = unicode + 1;
    
    // 将 length 字段设置为 size
    _PyUnicode_LENGTH(unicode) = size;
    // 将 hash 字段初始化为 -1
    _PyUnicode_HASH(unicode) = -1;
    // 将 state.interned 字段设置为 0
    _PyUnicode_STATE(unicode).interned = 0;
    // 将 state.kind 字段设置为 kind
    _PyUnicode_STATE(unicode).kind = kind;
    // 将 state.compact 字段设置为 1
    _PyUnicode_STATE(unicode).compact = 1;
    // 将 state.ready 字段设置为 1
    _PyUnicode_STATE(unicode).ready = 1;
    // 将 state.ascii 字段设置为 is_ascii
    _PyUnicode_STATE(unicode).ascii = is_ascii;
    
    // 如果 is_ascii 等于 1,即字符串为 ASCII 字符串
    // 将字符数组索引为 size 的元素设置为 '\0'
    if (is_ascii) {
        ((char*)data)[size] = 0;
        _PyUnicode_WSTR(unicode) = NULL;
    }
    // 否则说明结构体是 PyCompactUnicodeObject,还要多初始化两个字段
    // 将 utf8 设置为 NULL,将 utf8_length 设置为 0
    else if (kind == PyUnicode_1BYTE_KIND) {
        ((char*)data)[size] = 0;
        _PyUnicode_WSTR(unicode) = NULL;
        _PyUnicode_WSTR_LENGTH(unicode) = 0;
        unicode->utf8 = NULL;
        unicode->utf8_length = 0;
    }
    // 结构体是 PyCompactUnicodeObject,并且 kind 不等于 PyUnicode_1BYTE_KIND
    else {
        unicode->utf8 = NULL;
        unicode->utf8_length = 0;
        // 如果 kind 等于 PyUnicode_2BYTE_KIND,说明每个字符要占 2 字节
        // 将 data 转成 Py_UCS2 *,此时会用两个字节存储 '\0'
        if (kind == PyUnicode_2BYTE_KIND)
            ((Py_UCS2*)data)[size] = 0;
        // 否则说明 kind 等于 PyUnicode_4BYTE_KIND,每个字符要占 4 字节
        // 将 data 转成 Py_UCS4 *,此时会用四个字节存储 '\0'
        else
            ((Py_UCS4*)data)[size] = 0;
        if (is_sharing) {
            _PyUnicode_WSTR_LENGTH(unicode) = size;
            _PyUnicode_WSTR(unicode) = (wchar_t *)data;
        }
        else {
            _PyUnicode_WSTR_LENGTH(unicode) = 0;
            _PyUnicode_WSTR(unicode) = NULL;
        }
    }
#ifdef Py_DEBUG
    unicode_fill_invalid((PyObject*)unicode, 0);
#endif
    assert(_PyUnicode_CheckConsistency((PyObject*)unicode, 0));
    // 最后返回泛型指针 PyObject *
    return obj;
}

通过字符串的内存申请,我们应该对底层结构有了更深刻的认识,说白了就是采用不同的编码,每个字符占用不同数量的字节。

另外 PyUnicode_New 主要负责申请内存,包括结构体本身以及指定数量的字符,至于具体的字符是什么则不得而知。因此 PyUnicode_New 一般会被其它 API 调用,当调用 PyUnicode_New 申请完内存之后,再对字符数组初始化。

小结

以上就是字符串的底层结构,虽然字符串作为最基础的数据结构被大量使用,但它的底层实现却并不简单。


 

欢迎大家关注我的公众号:古明地觉的编程教室。

如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。