解密字符串的底层结构,它是怎么实现的?

文摘   2024-07-15 08:30   北京  

楔子



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

  • 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 statically_allocated:1;
        // 注意上面的字段名后面跟着 :数字,这是 C 语言的位域
        // 比如 interned:2 表示使用 unsigned int 的前 2 个位
        // 所以 struct state 结构体总共占 4 字节,因为所有字段共用 4 字节内存
        // 上面总共使用了 8 个位,显然这里的 :24 负责占满 32 个位
        unsigned int :24;
    } state;
} PyASCIIObject;

到这里可能有人好奇了,字符串的文本存在了什么地方,我们没看到结构体里面有哪个字段负责存储文本啊。答案很简单,字符串文本会直接跟在 PyASCIIObject 结构体实例的后面。

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

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

再来分析一下空字符串为什么占 41 个字节,首先 ob_refcnt、ob_type、length、hash 都是 8 字节,总共 32 字节。而 state 虽然占 4 字节,但会多出 4 字节的空洞,所以也是 8 字节。再加上一个 \0,因此总共是 32 + 8 + 1 = 41 字节。

而对于 "rust" 这个 unicode 字符串来说,占的总字节数就是 41 + 4 = 45

>>> import sys
>>> sys.getsizeof("rust")
45

当字符串只包含 ASCII 字符时,由 PyASCIIObject 结构体表示,大小等于 41 + 字符串长度。当然啦,我们也可以认为大小等于 40 + (字符串长度 + 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  # 2
    ascii = 0

看一下 PyCompactUnicodeObject 的底层结构。

// Include/cpython/unicodeobject.h
typedef struct {
    PyASCIIObject _base;
    Py_ssize_t utf8_length;    
    char *utf8;                
} PyCompactUnicodeObject;

我们看到 PyCompactUnicodeObject 在 PyASCIIObject 的基础上增加了 2 个字段。

  • utf8_length:字符串的 utf-8 编码长度;

  • utf8:字符串使用 utf-8 编码的结果,这里是缓存起来从而避免重复的编码运算;


至于字符串文本则依旧是跟在结构体对象的后面。

然后再看下它的大小,PyASCIIObject 是 40 字节,utf8_length 和 utf8 都是 8 字节,所以加起来是 56 字节。因此以后在看到一个字符串时,我们可以很轻松地计算出它的大小。

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


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


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


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

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

non_ascii_with_ucs1 = "sator¡"

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

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

non_ascii_with_ucs2 = "憨pi"

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

non_ascii_with_ucs4 = "🍌君"

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



字符串的内存申请


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

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

// Objects/unicodeobject.c
PyObject *
PyUnicode_New(Py_ssize_t size, Py_UCS4 maxchar)
{
    // 如果 size 为 0,返回空字符串,注:空字符串是单例的
    if (size == 0) {
        return unicode_new_empty();
    }
    // 声明相关变量
    PyObject *obj;
    PyCompactUnicodeObject *unicode;
    void *data;
    int kind;
    int is_ascii;
    Py_ssize_t char_size;
    Py_ssize_t struct_size;
    is_ascii = 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;
    }
    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;
    }

    // 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();
    }
    // 初始化引用计数和类型
    _PyObject_Init(obj, &PyUnicode_Type);
    // 将 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.ascii 字段设置为 is_ascii
    _PyUnicode_STATE(unicode).ascii = is_ascii;
    // 将 state.statically_allocated 字段设置为 0
    _PyUnicode_STATE(unicode).statically_allocated = 0;
    // 如果 is_ascii 等于 1,即字符串为 ASCII 字符串
    // 将字符数组索引为 size 的元素设置为 '\0'
    if (is_ascii) {
        ((char*)data)[size] = 0;
    }
    // 否则说明结构体是 PyCompactUnicodeObject,还要多初始化两个字段
    // 将 utf8 设置为 NULL,将 utf8_length 设置为 0
    else if (kind == PyUnicode_1BYTE_KIND) {
        ((char*)data)[size] = 0;
        unicode->utf8 = NULL;
        unicode->utf8_length = 0;
    }
    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 /* kind == PyUnicode_4BYTE_KIND */
            ((Py_UCS4*)data)[size] = 0;
    }
    // 最后返回泛型指针 PyObject *
    return obj;
}

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

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

古明地觉的编程教室
Python、Rust 程序猿,你感兴趣的内容我都会写,点个关注吧(#^.^#)
 最新文章