IT狗工作室C语言C语言&嵌入式

第3篇:CPython内部探究:字符串对象的内存模型

2020-07-27  本文已影响0人  铁甲万能狗

首要的事情,再来一遍—让我们回顾一下到目前为止我们学到的知识:

注意:本文中讨论的行为特定于CPython 3.3及更高版本。您不能保证在不同的Python实现或版本上具有相同的行为。

Python字符串的内存模型

正如我在上一篇文章中提到那样,Python中的字符串对象实际上是unicode字符序列,我们将它们称为专有的“文本”序列。这可以通过比较字符串中各个字符来证明这些特征,下图通过变量a和b分别引用两个不同的字符串。

不同的字符串位于不同的堆内存区块。这个通过id函数非常轻易区分出示例中a和b引用的内存地址都是不一样的。当我们再次调用is关键字比较a[3]和b[5]会返回True,因为a[3]、b[5]引用都是同一个内存位置的字符‘n’,我们说这样的对象叫共享对象(Share Object)。

因为每次初始化Python解释器时,CPython会将Latin-1范围内的unicode编码(0到255)作为共享库加载到一个静态的unicode_latin1数组,该数组的长度为256,每个ascii字符占用一个字节,并且位于计算机的静态内存区域。 后续对该范围内的值的任何调用都将引用到那些预先存在unicode_latin1数组的对象

unicode_latin1数组的源代码定义在Objects/unicodeobject.c文件中有定义

#ifdef LATIN1_SINGLETONS
/* Single character Unicode strings in the Latin-1 range are being
   shared as well. */
static PyObject *unicode_latin1[256] = {NULL};
#endif

上面的示例,我们用一个内存图表示,我们知道变量s1、s2各自引用不同堆内存实体上的PyASCIIObject对象。并且要指出的是Python字符串对象的内存实体是其头部信息和紧挨着的有效负载组成,

这个内存图解除一部份人的疑惑,对于仅掌握Python语法,并没有阅读过CPython源代码的新手来说,会错误地认为Python字符串就是一个类似数组的字符序列。现在应该恍然大悟了吧!可以形象地认为Python字符串对象就是一个带了“套”(就是头部信息)的字符串序列(或unicode字节序列),为什么这么说呢?因为Python字符串对象按照内存组织来说,它是一个容器对象,关于容器对象可以查看下文的简要描述。

备注:字符串对象的内存分配由PyUnicode_New函数定义,前面两篇说得很清楚了,没必要再解析。

在CPython中,Unicode字符存储为PyUnicodeObject实例。 我们可以通过查看源代码来查看PyUnicodeObject的格式:PyUnicodeObject根据三种不同编码之一存储字符。 这些编码中的每一种占用不同的字节大小-Latin-1编码为1字节,UCS-2编码为2字节,UCS-4编码为4字节。 此大小可在Python中访问(需要减法,因为存储字符串所需的实际字节数大于其字符的大小):

typedef struct {
    PyCompactUnicodeObject _base;
    union {
        void *any;
        Py_UCS1 *latin1;
        Py_UCS2 *ucs2;
        Py_UCS4 *ucs4;
    } data;                     /* Canonical, smallest-form Unicode buffer */
} PyUnicodeObject;

我们来一个简单的示例,你们明白为什么gesizeof函数返回50个字节吗?

我们来看看首先我们s1指向的是一个仅包含一个ASCII字符‘M’的字符串。由于是一个ASCII字符串,那么CPython会优先以一个字节的位宽来实例化该对象,显然该对象类型是PyASCIIObject,我们用如下图来说明一切。

那么s1='M'表示:“变量标签s引用C底层堆中一个PyASCIIObject内存实体”。该PyASCIIObject对象的头部尺寸是48字节,而有效负载的尺寸是2字节。

我们说‘!’这个字符实际上在堆中有一个对应的尺寸为50字节的PyASCIIObject内存实体,类似如上图,我这里不再贴图。只不过没有一个变量去引用该字符串的内存实体,我们称为这样的字符串对象叫“匿名字符串

问题1:s1+'!'表达式背后的内存含义是什么呢?

该表达式实际上执行concat操作,以就是说该表达式会将s1引用的PyASCIIObject内存实体和‘!’字符对应的PyASCIIObject内存实体,它们各自的有效负载部分执行合并操作,生成一个新的PyASCIIObject内存实体,如下图所示。

也就是说现在堆内存中有3个不同的内存实体,一个是s1变量所指向的内存实体、一个是'!'对应的匿名字符串内存实体、一个是s1+'!'表达式对应的匿名字符串内存实体,有趣的是在Python语义中 id(s1+'!')同样会获取该字符串对象的内存地址。

问题2:sys.getsizeof(s1+'!')-sys.getsizeof(s1)这个表达式的含义是什么呢
Ok,这个表达式就表示,读取字符串内存实体的有效负载内的字节数据,以1个字节位宽去解码每个字符。

那么我们再来一个稍微复杂一点的例子,下图的例子我想你应该心中有数了吧。

©和®这两个字符在CPython内部是以1个字节的位宽来表示,他们的ASCII编码分别是169和174,这些都是ASCII字符集范围内的字符。而🐍这个属于需要4个字节的位宽来表示,它的unicode编码是128013,那么4字节位宽的二进制表示为"00000000 00000001 11110100 00001101"

>>> ord('©')
169
>>> ord('®')
174
>>> ord('🐍')
128013
>>> bin(ord('🐍'))
00000000 00000001 11110100 00001101
>>> 

如果字符串严格由Latin-1范围内的字符组成,则Python将占用尽可能少的空间,并完全使用1字节的字符对象。 但是,只要该字符串包含UCS-2字符,就必须将所有其他字符也转换为占用2个字节。同理, 如果字符包含UCS-4字符,那么字节序列中的所有字符都转换为4字节。

节选:容器对象简介

容器对象最早是在C++中提出的一个面向对象的数据结构概念。容器对象通过一个头部(Overhead)内部一个数据指针来维护着实质上持有对象内存数据的堆内存区域。从而减少程序员对指针的人为操作,因为像C语言那样任由程序员操作指针,C++认为这是很危险的,因此容器对象通过一个类并定义了很多相关的属性,当中包含一个内部数据指针(一般来说是void指针)用于指向存放对象数据的堆内存区域,容器的这些属性字段就实时记录整个对象数据的运行时状态。并且C++的容器对内部的数据指针是私有,外部代码通常无法访问或操作其内部指针,这是面向对象编程中容器对象是类型安全和友好的。而CPython也借鉴了这一构思,但并没有提供有效的运行时访问限制,更谈不上类型安全了。又因为C++所有内置的容器对象基本上是开源,你可以做一些hack处理,仍然能够任意蹂躏其内部指针。但Java、.Net对容器的构思的实现更彻底了,他们的虚拟机从实现层面已经彻底封装任何可能涉及指针类型的操作。因此Java、.Net的语法层面并不存在指针这一说法。

从内存布局来说,这里PyASCIIObject/PyUnicodeObject属于紧凑型的容器对象,因为头部和有效负载部分是紧挨着的。这样的编码设计对于内存回收非常有利,因为内存释放时,能够将一大片连续的内存归还操作系统的虚拟内存管理器(VM),从而减少碎片的产生。还有一种叫做分离的容器对象,也就是说因为头部有效负载部分是分离的,例如CPython内部的arena对象就属于这一类型。分离的容器对象会在内存回收时产生不必要的内存碎片,对操作系统造成一定的困扰。如果你曾深入领悟C/C++,一定会明白我说的个中体会。

更新中....

上一篇下一篇

猜你喜欢

热点阅读