# str

# python中的unicode

  • Python 在 3 之后,str 对象内部改用 Unicode 表示,因而被源码称为 Unicode 对象。这么做的好处是程序核心逻辑统一用 Unicode ,只需在输入、输入层进行编码、解码,可最大程度避免各种编码问题。

    image

  • 由于Unicode 收录字符已经超过 13 万个,每个字符至少需要 4 个字节来保存。cpython源码中对此做了一定优化-根据文本内容选择底层存储单元。

    • Go对此类似的全部采用了UTF-8编码来解决这个问题字符串
    • 可考虑变长存储-英文1字节,中文2字节,但是采用变长存储单元后,就无法在 O(1) 时间内取出文本第 n 个字符—只能从头遍历直到第 n 个字符。
    >>> import sys
    # 英文字符需要1字节
    >>> sys.getsizeof('ab') - sys.getsizeof('a')
    1
    # 中文字符需要2字节
    >>> sys.getsizeof('中国') - sys.getsizeof('中')
    2
    # Emoji表情需要4字节
    # 注意这里是显示问题所以实际测试时直接复制代码的话结果为1
    >>> sys.getsizeof('??') - sys.getsizeof('?')
    4
    

# 内部实现

# 文本类型与存储结果

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

  • PyUnicode_1BYTE_KIND​ ,所有字符码位均在 U+0000 到 U+00FF 之间,对此使用uint_8​来进行存储。
  • PyUnicode_2BYTE_KIND​ ,所有字符码位均在 U+0000 到 U+FFFF 之间,且至少一个大于 U+00FF,对此使用uint_16​来进行存储。
  • PyUnicode_4BYTE_KIND​ ,所有字符码位均在 U+0000 到 U+10FFFF 之间,且至少一个大于 U+FFFF,对此使用uint_32​来进行存储。
文本类型 字符存储单元 字符存储单元大小(字节)
PyUnicode_1BYTE_KIND Py_UCS1 1
PyUnicode_2BYTE_KIND Py_UCS2 2
PyUnicode_4BYTE_KIND Py_UCS4 4
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
};
typedef uint32_t Py_UCS4;
typedef uint16_t Py_UCS2;
typedef uint8_t Py_UCS1;

Python Unicode内部存储结果因文本类型而不同,因此采用了哪种字符存储单元必须作为Unicode公共字段进行保存,类似的还有:

  • interned ,是否为 interned 机制维护
  • 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

根据试验可以看出,在指定了kind类型后,其内部所有字符都以这种方式进行保存。

>>> sys.getsizeof('')  
49
>>> sys.getsizeof('中') 
76
>>> sys.getsizeof('中国') 
78
>>> sys.getsizeof('中国a') 
80
>>> sys.getsizeof('a')   
50
>>> sys.getsizeof('ab') 
51
>>> sys.getsizeof('a中')  
78
>>> sys.getsizeof('ab中') 
80

# PyASCIIObject

  • 如果 str 对象保存的文本均为 ASCII​,即 maxchar<128​​​,则底层由 PyASCIIObject​ 结构存储
  • 同时PyASCIIObject​也似乎其他Unicode​底层存储结构体的基础,所有字段均为Unicode​公共字段。
  • 注意,与Unicode​一致,PyASCIIObject​也会在结构体末尾加上\0​字节以此兼容C字符串。
  • 因此对于长度为n的纯ANCII字符串对象,需要消耗n+48+1​字节的内存空间。
  • 注意,字符串虽然是变长对象,但是使用的是PyObject_HEAD​​,因为PyObject_VAR_HEAD​用于描述每个元素大小都一样的变长对象,元素大小由类型对象tp_itemsize​字段描述。而str对象,每个元素(字符)到底用多大的存储单元,与字符范围有关,因此底层作了特殊处理。
/* 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;

注意会出现一个4字节的内存空洞做内存对齐用。

image

# PyCompactUnicodeObject

  • 如果文本不全是ANCII,Unicode​对象底层便由PyCompactUnicodeObject​结构体来保存。

  • PyCompactUnicodeObject​结构体在PyASCIIObject​结构体的基础上增加了utf8_length、utf_7、wstr_length​三个字段分别保存文本 UTF8 编码长度以及文本 UTF8 编码形式(wstr_length​暂不涉及)。

    • ASCII本身即是合法的UTF8,无需保存UTF8编码形式,这也是ASCII文本底层由PyASCIIObject保存的原因。
/* 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;

# 内存优化

# interned机制 - 编译时优化

注意,这种优化可能随着版本变更而变化,不可以将程序逻辑建立在这些优化之上。

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

>>> 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,即使是一样的name也会创建很多份,将会浪费不少内存空间。

因为str是不可变对象,因此Python有潜在可能将重复的字符串做成单例模式,即interned机制。

具体做法是在内部为一个全局dict对象,所有开启interned机制的str对象均保存在这里,后续需要用到相关对象的地方则优先到全局dict中取,避免重复创建。

这个dict对象key、value都是被缓存的str对象,大致思路:

  1. Python先创建一个字符串对象s;

  2. dict里面可能已经缓存了相同的对象ss;

  3. Python执行dict.setdefault(s, s),这一步分为两种情况:

    1. dict缓存了相同对象ss,setdefault返回ss,Python那ss替换s,这样就保证相同str对象只有一个实例
    2. 如果dict未缓存s,就会缓存s,key和value都是s,这样一来后续创建跟s相同的新对象时,都会被s替换。

例如:

s1 = 'a' + 'b' + 'c'
s2 = 'ab' + 'c'
s3 = 'abc'
s4 = 'a' + 'bc'
s5 = 'abc' + ''
p = ''
s6 = 'abc' + p

for item in [s1, s2, s3, s4, s5, s6]:
	print(id(item))

"""
140699274168160
140699274168160
140699274168160
140699274168160
140699274168160
140699274168160
"""

# QA

# 为什么使用PyObject_HEAD而不是PyObject_VAR_HEAD

PyObject_VAR_HEAD用于描述每个元素大小都一样的变长对象,元素大小由类型对象tp_itemsize字段描述。而str对象,每个元素(字符)到底用多大的存储单元,与字符范围有关,因此底层作了特殊处理。

​#TODO#​