07-Python字符串的底层实现
07-Python字符串的底层实现
楔子
这一次我们分析一下Python中的字符串,首先Python中的字符串是一个变长对象,因为不同长度的字符串所占的内存空间是不一样的;但同时字符串又是一个不可变对象,因为一旦创建就不可以再修改了。
而Python中的字符串是通过unicode来表示的,因此在底层对应的结构体是PyUnicodeObject。但是为什么需要unicode呢?
首先计算机存储的基本单位是字节,由8个比特位组成,由于英文字母算上大小写只有52个,再加上若干字符,数量不会超过256个,因此一个字节完全可以表示,这些字符称之为ASCII字符。但是随着非英文字符的出现,导致一个字节已经无法表示了,只能曲线救国,对于一个字节无法表示的字符,使用多个字节表示。
但是这样会出现两个问题:
不支持多国语言,例如中文的编码不可以包含日文;
没有统一标准,例如中文有GB2312、GBK、GB18030等多个标准;
所以由于编码不统一,开发人员经常在不同的编码间来回转换,会错误频出。为了彻底解决这个问题,unicode标准诞生了。unicode对世界上的文字系统进行了系统的整理、编码,让计算机可以用统一的方式处理文本,而且目前已经支持超过13万个字符,天然地支持多国语言。
但是问题来了,unicode能表示这么多的字符,那么占用的内存一定不低吧。是的,根据当时的编码,一个unicode字符最高会占用到4字节。但是对于西方人来说,明明一个字符就够用了,为啥需要那么多。于是又出现了utf-8,它是为unicode提供的新一个新的编码规则,具有可变长的功能。对于1个ASCII字符那么会使用一个字节存储,对于非ASCII字符会使用3个字节存储。
但Python3中表示unicode字符串时,使用的却不是utf-8,至于原因我们下面来分析一下。国外有一篇文章,题目翻译过来说的是”Python在存储字符串的时候如何节省内存”,写的非常好,我们来看看。
Python在存储字符串的时候如何节省内存
从Python3开始,str类型使用的是Unicode。而根据编码的不同,Unicode的每个字符最大可以占到4字节,从内存的角度来说, 这种编码有时会比较昂贵。
为了减少内存消耗并且提高性能,python的内部使用了三种编码方式表示Unicode。
Latin-1 编码:每个字符一字节;
UCS2 编码:每个字符两字节;
UCS4 编码:每个字符四字节;
在Python编程中,所有字符串的行为都是一致的,而且大多数时间我们都没有注意到差异。然而在处理大文本的时候,这种差异就会变得异常显著、甚至有些让人出乎意料。
为了看到内部表示的差异,我们使用sys.getsizeof
函数,返回一个对象所占的字节数。
1 | import sys |
我们看到都是一个字符,但是它们占用的内存却是不一样的。
正如你所见,Python面对不同的字符会采用不同的编码。需要注意的是,Python中的每一个字符串都需要额外占用49-80字节,因为要存储一些额外信息,比如:哈希、长度、字节长度、编码类型等等。
1 | import sys |
而采用不同的编码,那么底层结构体实例额外的部分也会占用不同大小的内存。如果编码是Latin-1,那么这个结构体实例额外的部分会占49个字阶;编码是UCS2,占74个字节;编码是UCS4,占76个字节。然后字符串所占的字节数就等于:额外的部分 + 字符个数 \* 单个字符所占的字节
。
1 | import sys |
为什么python底层存储字符串不使用utf-8编码
我们先来抛出一个问题:****首先我们知道Python支持通过索引查找一个字符串中指定位置的字符,而且Python中默认是以字符为单位的,不是字节**(我们后面还会提)
,比如s[2]搜索的就是字符串s中的第3个字符。**
1 | s = "My姫様" |
那么问题来了,我们知道Python中通过索引查找字符串的指定字符,时间复杂度为O(1),那么Python是怎么通过索引、比如这里的s[2],一下子就跳到第3个字符呢?显然是通过指针的偏移,用索引乘上每个字符占的字节数,得到偏移量,然后从头部向后偏移指定数量的字节即可,这样就能在定位到指定字符的同时保证时间复杂度为O(1),但是这就需要一个前提:字符串中每个字符所占的大小必须是相同的,如果字符占的大小不同(比如有的占用1字节、有的占用3字节)
,显然无法通过指针偏移的方式了,这个时候还想准确定位的话,只能按顺序对所有字符都逐个扫描,但这样的话时间复杂度肯定不是O(1),而是O(n)。
我们以golang为例,golang中的字符串默认就是使用的utf-8。
惊了,我们看到打印的并不是我们希望的结果。因为golang底层使用的是utf-8,不同的字符可能会占用不同的编码。但是golang中通过索引定位的时候,时间复杂度是O(1),所以golang它就无法定位到准确的字符。
golang的字符串在通过索引定位的时候,比如这里的s[2],会跳转两个字节,因为不同字符占的字节可能是不同的,因此在计算偏移量的时候只能以占用最小的、ASCII字符所占的字节为单位,即1个字节,所以计算的。只不过前面两个字符碰巧都是英文,每个占1字节,所以跳到了”姫”这个位置上。如果出现了非ASCII字符,那么是绝对跳不准的。而且在获取的时候,也只能获取1个字节。但是使用utf-8的话,非ASCII字符是占3个字节,显然一个字节是无法表示的。
所以Python会使用3个编码,对应编码的字符分别是1、2、4字节。因此Python在创建字符串的时候,会先扫描。或者尝试使用占字节数最少的Latin1编码存储,但是范围肯定有限。如果发现了存储不下的字符,只能改变编码,使用UCS2,继续扫描。但是又发现了新的字符,这个字符UCS2也无法存储,因为两个字节最多存储65535个不同的字符,所以会再次改变编码,使用ucs4存储。ucs4占四个字节,肯定能存下了。
一旦改变编码,字符串中的所有字符都会使用同样的编码,因为它们不具备可变长功能。比如这个字符串:”hello古明地觉”,肯定都会使用UCS2,不存在说”hello”使用Latin1,”古明地觉”使用UCS2,因为一个字符串只能有一个编码。当通过索引获取的时候,会将索引乘上每个字符占的字节数,这样就能跳到准确位置上,因为字符串里面的所有字符占用的字节都是一样的,然后获取也会获取指定的字节数。比如:使用UCS2编码,那么定位到某个字符的时候,会取两个字节,这样才能表示一个完整的字符。
1 | import sys |
除此之外,我们再举一个例子更形象地证明这个现象。
1 | import sys |
所以如果字符串中的所有字符对应的ASCII码都在0~255范围内,则使用1字节Latin1对其进行编码。基本上,Latin1能表示前256个Unicode字符。它支持多种拉丁语,如英语、瑞典语、意大利语、挪威语。但是它们不能存储非拉丁语言,比如汉语、日语、希伯来语、西里尔语。这是因为它们的代码点(数字索引)定义在1字节(0-255)范围之外。
大多数流行的自然语言都可以采用2字节(UCS2)编码。当字符串包含特殊符号、emoji或稀有语言时,使用4字节(UCS4)编码。Unicode标准有将近300个块(范围)。你可以在0XFFFF块之后找到4字节块。假设我们有一个10G的ASCII文本,我们想把它加载到内存中,但如果我们在文本中插入一个表情符号,那么字符串的大小将增加4倍。这是一个巨大的差异,你可能会在实践当中遇到,比如处理NLP问题。
1 | print(ord("a")) # 97 |
所以最著名和最流行的Unicode编码都是utf-8,但是python不在内部使用它,而是使用Latin1、UCS2、UCS4。至于原因我们上面已经解释的很清楚了,主要是Python的索引是基于字符:
当一个字符串使用utf-8编码存储时,根据它所表示的字符,每个字符会根据自身选择一个合适的编码。这是一种存储效率很高的编码,但是它有一个明显的缺点。由于每个字符的字节长度可能不同,因此就导致无法按照索引瞬间定位到单个字符,即便能定位,也无法定位准确。如果想准,那么只能逐个扫描所有字符。
因此要对使用utf-8编码的字符串执行一个简单的操作,比如s[5],就意味着Python需要扫描每一个字符,直到找到需要的字符,这样效率是很低的。但如果是固定长度的编码就没有这样的问题,所以当Latin 1存储的”hello”,在和”憨色儿”组合之后,整体每一个字符都会向大的方向扩展、变成了2字节。这样定位字符的时候,只需要将”索引 * 2”计算出偏移的字节数、然后跳转该字节数即可。但如果原来的”hello”还是一个字节、而汉字是2字节,那么只通过索引是不可能定位到准确字符的,因为不同类型字符的编码不同,必须要扫描整个字符串才可以。但是扫描字符串,效率又比较低。所以python内部才会使用这个方法,而不是使用utf-8。
所以对于golang来讲,如果想像Python一样,那么需要这么做:
1 | package main |
其实可以想一下C中的数组,比如int类型的数组,那么数组指针在往后偏移一个单位的时候,偏移的也是1个int(4字节)
,而不是1个字节,这是显然的;然后获取的时候也会一次获取4个字节,因为这样才能表示一个int。但是utf-8表示的unicode字符串里面的字符可能占用不同的字节,那么显然没办法实现Python中字符串的索引查找效果,所以Python内部的字符串没有使用utf-8。
因此Python才会提供了三种编码,先使用占用最小的Latin1,不行的话再使用UCS2、UCS4,总之会确保每个字符占用的字节是一样的,原因的话我们上面分析的很透彻了。并且无论是索引还是切片、还是计算长度等等,都是基于字符的,显然这也符合人类的思维习惯。
然后字符串还有intern机制,原文中也提到了,但是我们会在本文的后面介绍。下面先看字符串底层的结构,以及支持的相关操作是如何实现的。
字符串的底层实现
我们之前提到了,字符串采用不同的编码,底层的结构体实例所占用的额外内存是不一样的。其实本质上是,字符串会根据内容的不同,而选择不同的存储单元。
至于到底是怎么做到的,我们只能去源码中寻找答案了,与str相关的源码:Include/unicodeobject.h
和Objects/unicodeobject.c
1 | //Include/unicodeobject.h |
我们在unicodeobject.h中看到,str对象根据底层存储会根据unicode的不同而分为以下几类:
PyUnicode_1BYTE_KIND:所有字符码位均在 U+0000 到 U+00FF 之间
PyUnicode_2BYTE_KIND:所有字符码位均在 U+0000 到 U+FFFF 之间,且至少一个大于 U+00FF(否则每个字符就用1字节了)
PyUnicode_4BYTE_KIND:所有字符码位均在 U+0000 到 U+10FFFF 之间,且至少一个大于 U+FFFF
如果文本字符码位均在 *U+0000* 到 *U+00FF* 之间,单个字符只需 *1* 字节来表示;而码位在 *U+0000* 到 *U+FFFF* 之间的文本,单个字符则需要 2 字节才能表示;以此类推。这样一来,根据文本码位范围,便可为字符选用尽量小的存储单元,以最大限度节约内存。
1 | typedef uint32_t Py_UCS4; //我们看到4字节使用的是无符号32位整型 |
既然unicode内部的存储结构会因字符而异,那么unicode底层就必须有成员来维护相应的信息,所以Python内部定义了若干标志位:
interned:是否被intern机制维护,这个机制我们会在本文后面介绍
kind:类型,用于区分字符底层存储单元的大小。如果是Latin1编码,那么就是1;UCS2编码则是2;UCS4编码则是4
compact:内存分配方式,对象与文本缓冲区是否分离
ascii:字符串是否是纯ASCII字符串, 如果是1就是1, 否则就是0。注意: 虽然每个字符都会对应ASCII码,但是只有对应的ASCII码为0~127之间的才是ASCII字符。所以虽然一个字节可表示的范围是0~255,但是128~255之间的并不是ASCII字符。
而为unicode字符串申请空间,底层可以调用一个叫PyUnicode_New的函数,这也是一个特型API。比如:元组申请空间可以使用PyTuple_New,列表申请空间可以使用PyList_New等等,会传入一个整型,创建一个能够容纳指定数量元素的结构体实例。而PyUnicode_New则接受一个字符个数以及最大字符maxchar初始化unicode字符串对象,之所以会多出一个maxchar,是因为要根据它来为unicode字符串对象选择最紧凑的字符存储单元,以及结构体。
下面我们就来分析字符串底层对应的结构体。
PyASCIIObject
如果 *str* 对象保存的文本均为 *ASCII* ,即 maxchar<128,则底层由 PyASCIIObject 结构进行存储:
1 | /* ASCII-only strings created through PyUnicode_New use the PyASCIIObject |
PyASCIIObject结构体也是其他 *Unicode* 底层结构体的基础,所有字段均为 *Unicode* 公共字段:
ob_refcnt:引用计数
ob_type:类型指针
length:字符串长度
hash:字符串的哈希值
state:unicode对象标志位,包括intern、kind、ascii、compact等
wstr:一个指针,指向由宽字符组成的字符数组。字符串和字节序列一样,底层都是通过字符数组来维护具体的值。
以字符串”abc”为例,看看它在底层的存储结构:
注意:state 成员后面有一个 4 字节的空洞,这是结构体字段内存对齐造成的现象。在 64 位机器上,指针大小为 8 字节,为优化内存访问效率,必须以 8 字节对齐。现在我们知道一个空字符串为什么占据49个字节了,因为ob_refcnt、ob_type、length、hash、wstr 都是 8 字节,所以总共 40 字节;而 state 是 4 字节,但是留下了 4 字节的空洞,加起来也是 8 字节,所以总共占 40 + 8 = 48 个字节,但是 Python 的 unicode 字符串在 C 中也是使用字符数组来存储的,只不过此时的字符不再是 char 类型,而是 wchar_t。但是它的内部依旧有一个 ‘\0’,所以还要加上一个 1,总共 49 字节。
对于 “abc” 这个 unicode 字符串来说,占的总字节数就是 49 + 3 = 52。
1 | import sys |
PyCompactUnicodeObject
如果文本不全是 *ASCII* ,Unicode 对象底层便由 *PyCompactUnicodeObject* 结构体保存:
1 | /* Non-ASCII strings allocated through PyUnicode_New use the |
我们看到PyCompactUnicodeObject是在PyASCIIObject的基础上增加了3个字段。
utf8_length:字符串的utf-8编码长度
utf8:字符串使用utf-8编码的结果,这里是缓存起来从而避免重复的编码运算
这里为什么占四个字节呢?不理解wstr_length:宽字符的数量
我们说 PyCompactUnicodeObject 只是多了3个字段,显然多出了 24 字节。那么之前的 49+24 等于 73,咦不对啊,我们不是说一个是 74 一个 76 吗?你忘记了 ‘\0’,如果使用 UCS2,那么 ‘\0’ 也占两个字节,所以应该是 73 -1 + 2 = 74;同理 UCS4 是 73 - 1 + 4 = 76,所以此时 unicode 字符串所占内存我们算是分析完了。然后我们再来看看这几种不同编码下对应的字符串结构吧。
PyUnicode_1BYTE_KIND
如果 128 <= maxchar < 256
,虽然一个字节可以存储的下,但*Unicode* 对象底层也会由 *PyCompactUnicodeObject* 结构体保存,字符存储单元为 *Py_UCS1*(Latin-1)
,大小为 *1* 字节。以字符串 “sator¡” 为例:
1 | import sys |
PyUnicode_2BYTE_KIND
如果 256 <= maxchar < 65536
,*Unicode* 对象底层同样由 *PyCompactUnicodeObject* 结构体保存,但字符存储单元为 *UCS2* ,大小为 *2* 字节。以字符串 “My姫様” 为例:
1 | import sys |
当文本中包含了 Latin1 无法存储的字符时,会使用两字节的 UCS 保存,但是连前面的英文字符也变成两字节了。至于原因我们上面已经分析的很透彻了,因为定位的时候是获取的字符,但如果采用变长的 utf-8 方式存储导致不同字符占的内存大小不一,那么就无法在 O(1) 的时间内取出准确的字符了,只能从头到尾依次遍历。而 Go 基于 utf-8,因此它无法获取准确的字符,只能转成 rune,此时内部一个字符直接占4字节。
PyUnicode_4BYTE_KIND
如果 65536 <= maxchar < 429496296
,便只能使用4字节存储单元的UCS4了,以字符串”👴青回”为例:
1 | import sys |
因此此时每个字符都采用UCS4编码,因此每个字符占四个字节,这是Python内部采取的策略。
我们后面通过分析字符串的一些操作的时候,会更加深刻的体会到。
PyUnicodeObject
不是说Python中字符串底层对应PyUnicodeObject吗?目前出现了PyASCIIObject和PyCompactUnicodeObject,那么PyUnicodeObject呢?
1 | typedef struct { |
这便是 PyUnicodeObject 的定义了,里面 data 是一个共同体,这里我们没有必要关注,我们直接把它当成 PyCompactUnicodeObject 来用即可。
字符串的操作
先来看看str类型对象在底层的定义吧。
1 | //Objects/unicodeobject.c |
首先哈希操作(unicode_hash)
之类的肯定是支持的,然后我们关注一下tp_as_number、tp_as_sequence、tp_as_mapping,我们看到三个操作簇居然都满足。不过有了bytes的经验,我们知道tp_as_number里面的实际上只有取模,也就是格式化(bytes和str在很多行为上都是相似的,但是这两者的区别我们后面会说,目前认为str对象可以编码成bytes对象,bytes对象可以解码成str对象即可)
。
我们来看一下这几个操作簇吧。
1 | //不出我们所料, 只有一个取模 |
下面我们先来重点看一下PyUnicode_Concat这个操作,它是用来将两个字符串相加、组合成一个新的字符串。
1 | PyObject * |
和bytes对象一样,+的效率非常低下,所以官方建议通过join的方式。
1 | PyObject * |
代码比较长,但是逻辑不难理解,这里就不贴了。就是获取列表或者元组里面的每一个unicode字符串对象的长度,然后加在一起,并取最大的存储单元,然后一次性申请对应的空间,再逐一进行拷贝。所以拷贝是避免不了的,+这种方式导致低效率的主要原因就在于大量PyUnicodeObject的创建和销毁。
因此如果我们要拼接大量的PyUnicodeObject,那么使用join列表或者元组的方式;如果数量不多,还是可以使用+的,毕竟维护一个列表也是需要资源的。使用join的方式,只有在PyUnicodeObject的数量非常多的时候,优势才会凸显出来。
初始化
然后我们在看看PyUnicodeObject的初始化,Python很多方式,从C中原生的字符串创建PyUnicodeObject对象。比如:PyUnicode_FromString、PyUnicode_FromStringAndSize、PyUnicode_FromUnicodeAndSize、PyUnicode_FromUnicode、PyUnicode_FromWideChar等等
1 | PyUnicode_FromString(const char *u) |
字符串对象的intern机制
如果字符串的interned标识位为1,那么Python虚拟机将为其开启interned机制。那么,什么是interned机制呢?
在Python中,某些字符串也可以像小整数对象池中的整数一样,共享给所有变量使用,从而通过避免重复创建来降低内存使用、减少性能开销,这便是intern机制。
Python的做法是在内部维护一个全局字典,所有开启intern机制的字符串均会保存在这里,后续如果需要使用的话,会先尝试在全局字典中获取,从而实现避免重复创建的功能。
1 | //Objects/unicodeobject.c |
在PyDict_SetDefault
函数中首先会进行一系列的检查,包括类型检查、因为intern共享机制只能用在字符串对象上,所以检查传入的对象是否已经被intern机制处理过了。
我们在代码中看到了interned = PyDict_New()
,这个PyDict_New()
是python中的dict对象,因此可以发现在程序中有一个key、value映射关系的集合。
intern机制中的PyUnicodObject采用了特殊的引用计数机制,将一个PyUnicodeObject对象a的PyObject指针作为key和valu添加到intered中时,PyDictObjec对象会通过这两个指针对a的引用计数进行两次+1操作。这会造成a的引用计数在python程序结束前永远不会为0,这也是最后面Py_REFCNT(s) -= 2;
要将计数减2的原因。
Python在创建一个字符串时,会首先检测是否已经有该字符串对应的PyUnicodeObject对象了,如果有,就不用创建新的,这样可以节省空间。但其实不是这样的,事实上,节省内存空间是没错的,可Python并不是在创建PyUnicodeObject的时候就通过intern机制实现了节省空间的目的。从PyUnicode_FromString中我们可以看到,无论如何一个合法的PyUnicodeObject总是会被创建的,而intern机制也只对PyUnicodeObject起作用。
对于任何一个字符串,Python总是会为它创建对应的PyUnicodeObject,尽管创建出来的对象所维护的字符数组,在intern机制中已经存在了(有另外的PyUnicodeObject也维护了相同的字符数组)
。而这正是关键所在,通常Python在运行时创建了一个PyUnicodeObject对象temp之后,基本上都会调用PyUnicode_InternInPlace对temp进行处理,如果维护的字符数组有其他的PyUnicodeObject维护了,或者说其他的PyUnicodeObject对象维护了一个与之一模一样的字符数组,那么temp的引用计数就会减去1。temp由于引用计数为0而被销毁,只是昙花一现,然后归于湮灭。
所以现在我们就明白了intern机制,并不是说先判断是否存在,如果存在,就不创建。而是先创建,然后发现已经有其他的PyUnicodeObject维护了一个与之相同的字符数组,于是intern机制将引用计数减一,导致引用计数为0,最终被回收。
但是这么做的原因是什么呢?为什么非要创建一个PyUnicodeObject来完成intern操作呢?这是因为PyDictObject必须要求必须以PyObject *作为key。
关于PyUnicodeObject对象的intern机制,还有一点需要注意。实际上,被intern机制处理过后的字符串分为两类,一类处于SSTATE_INTERNED_IMMORTAL
,另一类处于SSTATE_INTERNED_MORTAL
状态,这两种状态的区别在unicode_dealloc
中可以清晰的看到,SSTATE_INTERNED_IMMORTAL
状态的PyUnicodeObject是永远不会被销毁的,它与python解释器共存亡。
PyUnicode_InternInPlace只能创建SSTATE_INTERNED_MORTAL
的PyUnicodeObject对象,如果想创建SSTATE_INTERNED_IMMORTAL
对象,必须通过另外的接口来强制改变PyUnicodeObject的intern状态。
1 | void |
但是问题来了,什么样的字符才会开启intern机制呢?
在Python3.8中,如果一个字符串的所有字符都位于0 ~ 127之间,那么会开启intern机制。
1 | "abc" * 1000 a = |
在Python3.8中,如果一个字符串只有一个字符,并且位于0~255之间,那么会开启intern机制。
1 | chr(255) * 2 a = |
实际上,存储单个字符这种方式有点类似于bytes对象中的缓存池。是的,正如整数有小整数对象池、bytes对象有字符缓存池一样,字符串也有其对应的PyUnicodeObject缓存池。
在Python中的整数对象中,小整数的对象池是在Python初始化的时候被创建的,而字符串对象体系中的缓存池则是以静态变量的形式存在的。在Python初始化完成之后,缓冲池的所有PyUnicodeObject指针都为空。
当创建一个PyUnicodeObject对象时,如果字符串只有一个字符,且位于0~255。那么会先对该字符串进行intern操作,再将intern的结果缓存到池子当中。同样当再次创建PyUnicodeObject对象时,检测维护的是不是只有一个字符,然后检查字符是不是存在于缓存池中,如果存在,直接返回。
str对象和bytes对象之间的关系
首先str对象我们称之为字符串,bytes对象我们称之为字节序列,把字符串中的每一个字符都转成对应的编码,那么得到就是字节序列了。因为计算机存储和网络通讯的基本单位都是字节,所以字符串必须以字节序列的形式进行存储或传输。
那么如何转化呢?首先我们需要清楚两个概念:字符集和编码。
字符集顾名思义就是由字符组成的集合,每个字符在集合中都有唯一编号,像ASCII、unicode都是字符集。只不过 ASCII 能够容纳的字符是有限的,而 unicode 可以容纳世界上所有的字符。
而编码则负责告诉你字符在字符集中对应的编号,编码有:gbk、utf-8等等
1 | s = "姫様" |
因此字符串和字节序列在某种程度上是很相似的,字符串按照指定的编码进行encode即可得到字节序列(将字符转成ASCII码)
,字节序列按照相同的编码decode即可得到字符串(将ASCII码转成字符)
。
1 | # 比如我有一个gbk编码的字节序列,但是在传输的时候需要utf-8编码的字节序列 |
但是对于ASCII字符来说,由于不管采用哪一种编码,它们得到的ASCII码都是固定的,所以在显示的时候直接以字符本身显示了。
1 | s = "abc" |
小结
字符串的内容还是比较多的,在源码中有一万六千多行,显然我们没办法一步一步地全部分析完,有兴趣的可以自己深入研究一下。其实我们能把字符串的存储搞明白,其实已经是前进了一大步了。