前面的章节中,我们提到了python中的float
对象是定长对象,那其内部又是怎么布局的呢?这节从源码的角度深入探究一下python中的浮点对象
在往下学习之前,有必要在提一下定长对象的头部
1 | typedef struct _object { |
其中_PyObject_HEAD_EXTRA
:
1 | /* Define pointers to support a doubly-linked list of all live heap objects. */ |
_PyObject_HEAD_EXTRA是个宏定义,这些字段仅在定义宏 Py_TRACE_REFS 时存在。它们被初始化为 NULL 由 PyObject_HEAD_INIT 宏来处理。对于静态分配的对象,这些字段始终保持 NULL。对于动态分配的对象,这两个字段用于将对象链接到堆上的 all 活对象的双向链表中。这可以用于各种调试目的;目前唯一的用途是在设置环境变量 PYTHONDUMPREFS 时打印在运行结束时仍然活动的对象。
这些字段不会由子类型继承。
ob_refcnt
引用计数
ob_type
指向对象类型的基类,是一个指向结构体_typeobject
的指针,关于结构体_typeobject
:
1 | typedef struct _typeobject { |
以上部分是float对象的头部,稍微提一下一些重点字段的含义,因为后续会不断出现雷同的字段,反复加强印象用:
- 从
PyObject_VAR_HEAD
,可以知道所有类型对象的基类PyTypeObject
是一个变长对象. tp_name
适用于输出打印出对象类型而设置的tp_basicsize, tp_itemsize
用于对象的内存分配设置的
float对象
实际上有float类型对象实例化出来的实例对象为PyFloatObject
定义位置/include/floatobject.h
1 |
|
可以看到相比头部对了个存放浮点数值的ob_fval
那么问题来了,究其背后,float是对象,属于类型对象,又该是什么样子呢?通过前面的学习,我们知道float类型对象影响着float实例对象的内存分配以及可进行的操作,具体又是如何一个过程呢?下面我们展开。
float类型对象
float类型对象PyFloat_Type
的定义位置/Objects/floatobject.c
1 | PyTypeObject PyFloat_Type = { |
字段解读
在python中,一切皆对象。类型对象也是对象,逃不开定长头部PyObject
或变长头部 PyVarObject
在PyFloat_Type
中,看到其使用的了宏PyVarObject_HEAD_INIT(&PyType_Type, 0)
初始化对象头部,向光宏定义在/include/object.h中
1 |
可以看到,变长对象头部比定长对象头部多了一个字段ob_size:
对于静态分配类型对象,应将其初始化为零。对于动态分配的类型对象,此字段具有特殊的内部含义。
有了初始化宏定义,我们知道了对于PyFloat_Type
的头部信息:
PyFloat_Type
中还保存着许多关于浮点对象的元信息:(由于比较多,介绍关键字段)
tp_name,指向包含类型名称的NUL终止字符串。对于动态分配的类型对象,这应该只是类型名称,并且模块名称显式地存储在类型dict中作为关键字
__module__
的值。tp_basicsize & tp_itemsize,分配对象内存需要的字段。
有两种类型:具有固定长度实例的类型
tp_itemsize
字段为0,具有可变长度实例的类型具有非零tp_itemsize
字段。对于具有固定长度实例的类型,所有实例具有相同的大小,在
tp_basicsize
中给出。对于具有可变长度实例的类型,实例必须具有
ob_size
字段,实例大小为tp_basicsize
加N乘以tp_itemsize
,其中N是对象的“长度”。 N的值通常存储在实例的ob_size
字段中。tp_dealloc,指向实例析构函数的指针。必须定义此函数,除非类型保证其实例永远不会被释放。
当新的引用计数为零时,析构函数由
Py_DECREF()
和Py_XDECREF()
宏调用。此时,实例仍然存在,可能在对应的缓冲池,下次直接分配直接调出来。1
2
3
4
5
6
7
8f = float('3.14')
print(id(f))
del(f)
g = float('3.14')
print(id(g))
#----------output---------#
140253030159792
140253030159792 //释放实例对象分,并不是将其资源完全释放掉,而是放进缓冲区tp_as_number,对应对象的数值型操作集
tp_as_sequence,对应对象的序列型操作集
tp_as_mapping,对应对象的关联型操作集
tp_hash,计算对象的hash值方法
tp_call,当对象为可调用对象时,不为null,否则为null
tp_flags,一些标识位
Py_TPFLAGS_HEAPTYPE
,当对象是在堆中分配,置该位。Py_TPFLAGS_BASETYPE
,该类型可作为另一类型的基本类型,置该位。Py_TPFLAGS_READY
,当 PyType_Ready() 完全初始化类型对象时,该位置位。Py_TPFLAGS_READYING
,当 PyType_Ready() 处于初始化类型对象的过程中时,该位被置位。Py_TPFLAGS_HAVE_GC
,当对象支持垃圾回收时,该位置位。如果设置此位,则必须使用PyObject_GC_New()
创建实例,并使用PyObject_GC_Del()
销毁。- …
tp_methods,指向静态 NULL 终止的
PyMethodDef
结构数组的可选指针,声明此类型的常规方法。tp_base,指向继承类型属性的基本类型的可选指针。
tp_dict,在调用PyType_Ready之前,该字段通常应该初始化为 NULL;它也可以被初始化为包含该类型的初始属性的字典。
tp_init,指向实例初始化函数的可选指针。
tp_alloc,指向实例分配函数的可选指针。
tp_new,指向实例创建函数的可选指针。
tp_free,指向实例释放函数的可选指针。它的签名是
freefunc
介绍完后,我们看回PyFloat_Type
字段名 | 字段说明 |
---|---|
tp_name | 类型名称为float |
tp_basicsize = sizeof(PyFloatObject),tp_itemsize = 0 | 鉴于float为定长对象,因此itemsize=0且对象大小为sizeof(PyFloatObject) |
tp_dealloc = float_dealloc | 对象析构函数为float_dealloc |
tp_as_number = &float_as_number | float对象支持数值操作集 |
tp_hash = (hashfunc)float_hash | float对象提供计算对象hash值的函数 |
tp_methods = float_methods | 其中存放float类型的常规方法 |
tp_new = float_new | 实例创建函数的可选指针 |
对象的创建
泛型
float实例对象的创建需要调用相应的可调用对象float,由于类型对象float是可调用对象,因此类型对象float的tp_call字段不为空。当实例对象创建,会调用类型对象的类型(PyType_Type中的tp_call),之所以是PyType_Type
是由于PyFloat_Type的ObjectHead宏初始化设置的
1 | PyVarObject_HEAD_INIT(&PyType_Type, 0) |
而PyType_Type.tp_call
又会调用PyFloat_Type.tp_new
进行内存分配,大小根据PyFloat_Type.ob_seize
进行分配,随后调用PyFloat_Type.tp_init
完成对象初始化。
回去看PyFloat_Type.tp_init
发现其内容为0,即指向PyFloat_Type
对象初始化的函数指针为空,这是由于float是一种简单对象,初始化操作只需要一个赋值语句,完成对PyFloatObject.ob_fval
的赋值,这是一个很简单的初始化操作,python直接在PyFloat_Type.tp_new
中完成了。
特型
除了通用的流程,python为内置对象实现了对象创建API,简化调用,提高效率:
查阅文档发现对应的两个API,一个使用C语言中的double完成对象创建,一个则是使用字符串对象创建
跟踪PyFloat_FromDouble
,看下特型方法创建对象的流程,代码位置/objects/floatobject.c:
1 | PyObject * |
首先需要搞清楚free_list
是定义在同文件中static PyFloatObject *free_list = NULL;
从字面意义上理解,free_list构建了一个空闲对象缓冲池。
使用特型方法PyFloat_FromDouble
创建对象会优先使用空闲对象缓冲池。
- 当空闲对象缓冲池不为空,则取出一个空闲对象进行分配,然后记录当前空闲对象池中对象个数的
numfree
-1 - 当空闲对象缓冲池尾空,则调用
PyObject_MALLOC
分配大小为一个PyFloatObject
大小的内存空间 - 对象所需内存空间分配成功后调用
PyObject_INIT
进行初始化工作 - 最后设置浮点对象的数值,将整个对象返回
跟踪PyObject_INIT
,看看初始化都完成了哪些工作,定位到/Objects/object.c
1 | PyObject * |
第7行位置是调用了宏
#define Py_TYPE(ob) (((PyObject*)(ob))->ob_type)
完成的是PyObject
的ob_type
设置,依据是传进来的tp
对应的类型然后是第8行位置,/include/object.h中找到相关的宏定义:
1
2
3
4发现是对象引用计数的一些设置
简言之,特型API创建对象先是从空闲对象池中优先分配,若空闲对象池为空,则使用PyObject_MALLOC
分配对应的对象需要的空间,随后调用PyObject_INIT
完成初始化工作,设置浮点对象的ob_fval
.
对象的销毁
我们在PyFloat_Type
中注意到有这样一个字段tp_dealloc
,关于这个字段的描述:
指向实例析构函数的指针。必须定义此函数,除非类型保证其实例永远不会被释放(如单例
None
和Ellipsis
的情况)。当新的引用计数为零时,析构函数由
Py_DECREF()
和Py_XDECREF()
宏调用。此时,实例仍然存在,但没有对它的引用。
不难判断,该字段与对象销毁行为挂钩。这里以PyFloat_Type
对象销毁为例,我们看到PyFloat_Type.tp_dealloc
字段对应的内容为(destructor)float_dealloc
是函数指针,顺藤摸瓜,来到同文件下的:
1 | static void |
第4行位置的
PyFloat_CheckExact
跟踪发现是个宏调用,定义于/include/floatobject.h,定义如下:#define PyFloat_CheckExact(op) (Py_TYPE(op) == &PyFloat_Type)
做的工作是当前确认要释放的对象是floatobject5-11行的内容是查看当前floatobject的空闲对象池是否满了,满了则直接释放对象,否则便将记录floatobject的空闲对象数
numfree
加一,将要释放的floatobject放入空闲对象池free_list
简言之:就是当 Py_DECREF()
和 Py_XDECREF()
宏调用导致对象引用计数为0 ,则有这两个宏调用析构函数完成对对象的回收,具体回收流程则遵循类似上述代码的分析流程。
空闲对象链表
上边提到了这个机制,这里依旧以浮点数对象为例,做一点内容补充
浮点对象运算背后设计大量临时对象的创建与销毁。
1 | pi = 3.14 |
第三行代码的浮点运算在python内部:
- 先计算半径r的平方,结果存放在python中的一个临时对象中,这里假设临时变量为 t
- 然后完成 t 与 pi的成绩运算,最终得到的运算结果存放在 area 中
- 随后对临时变量进行销毁
如此一来,不难想象会因为临时变量的创建与销毁而增加执行时的开销。
创建对象时需要分配内存,销毁对象需要回收内存。当程序内部存在大量的临时变量的创建&销毁,无疑会给程序带来极大的负担,因此python引入空闲对象缓冲池机制。在该机制下,python在对象销毁后,并不急于收回分配出去的资源,而是将当前销毁的对象放入一个空闲链表,后续当创建相同类型对象时直接从中取出,省去新对象的创建与销毁的开销。以浮点对象PyFloatObject为例,它的空闲链表定义位置在/objects/floatobject.c中:
1 |
|
PyFloat_MAXFREELIST
空闲缓冲链表最大的容量numfree
指示当前空闲缓冲链表内的空闲对象个数free_list
指向空闲缓冲链表
由于是保存的事空闲对象,并且以链表形式,存在遍历需要。Python在实现该机制中使用了对象中的ob_type
字段作为链表节点的next
指针使用
当创建了同样是PyFloat_Type的对象时,以PyFloat_FromDouble
为例,有下面这样的行为(这里只截取关键部分):
1 | PyFloatObject *op = free_list; |
销毁时也是类似的:
1 | if (numfree >= PyFloat_MAXFREELIST) { |
优先将待销毁对象放入空闲对象链表中,若空闲链表已满,则使用PyObject_FREE
进行对象释放
对象的行为
同样以PyFLoat_Type
为例,PyFLoat_Type
中定义了许多字段,其中某些字段是函数指针,例如:tp_hash、tp_repr等,这些函数指针决定了PyFLoat_Type
的允许进行的行为。
之前我们提过,类型不同的对象,其行为是存在行为的,这点相信很好理解,例如对于list对象,可以进行append,而float则不行,在者我们还知道了python根据行为的一些特点,进行了操作型的分类:数值型操作tp_as_number
、序列型操作tp_as_sequence
、关联型操作tp_as_mapping
比如对于浮点类型PyFloat_Type
,允许的操作:&float_as_number, /* tp_as_number */
,根据具体类型进行tp_as_number
特化处理
这里可以讨论稍微泛用一些,由于这些操作是所有对象通用,只是存在启用与否、行为差异也无妨,因此初步锁定相关代码在/Objects/object.h中定义
数值型操作
1 | typedef struct { |
其中泛化的PyNumberMethods
具体到PyFLoat_Type
下的 则是根据PyFLoat_Type
可进行的选择启用相关字段,不启用为0:
1 | static PyNumberMethods float_as_number = { |
由此,对象的行为是写在相关定义内部的
序列型操作
泛化方式具象到特定对象的过程也是类似上述的过程,不赘述。
关联型操作
泛化方式具象到特定对象的过程也是类似上述的过程,不赘述。
本节相关的精彩面试题
1 | pi = 3.14 |
- 上述代码是否存在临时变量的创建,怎么理解?
- 存在临时变量创建,根据优先级,先进行半径r的平方计算,计算结果存放在临时变量tmp中,随后将tmp与pi做乘积运算,结果存放在area中。最后对临时变量进行销毁
- Python是如何优化这个问题的?
- python引入了空闲对象链表机制,临时对象tmp销毁后并不直接回收其资源,而是将其放入空闲对象链表(不满的话)越过了对象销毁部分,随后在下一次创建于tmp相同类型对象时直接从空闲对象链表中取出,越过了对象创建部分。
1 | print(id(f)) |
- 上述代码中变量f、g的地址一致,这是为什么?
- f销毁时,对象被放入空闲对象链表头,g创建时,由于对象类型和f一致,从对应类型的空闲对象链表中取出使用,取出的正好是刚刚放入的f,因此指向的地址一致。