去年 2021
年的时候,我的工作主要集中在改进 Lua虚拟机
,后来由于工作变动,现在主要的工作语言已经切换为了 Python
,因此打算阅读下 Python 3.10
的源码,学习一下它的设计,对比 Lua
的优势。
希望在接下来的阅读过程中,能够体会到一种 回家
的畅快感。
本篇将以 float
作为起点,了解如何创建出一个浮点对象,深入剖析 float
其内部实现。
一切皆对象
一切皆对象 这句话都要被讲烂了,但是还要讲多一次。
Python
是一门面向对象的强类型动态语言,里面的任何东西都是对象,以浮点数为例。
1 | # a 是一个浮点实例对象,类型是 float |
以上我们可以确定,Python
中类型也是对象。
此外所有对象的类型都是 type
,可以称其为元类。而所有对象都继承自 object
。
1 | type(int) |
而 object
的类型也是 type
,type
的类型也为 type
。
1 | type(object) |
至此我们可以得出以下几个结论,方便后续继续阅读 float 的实现。
- 一切皆对象,包括类型也是对象
- 所有类都继承自
object
- 所有类的类型都是
type
1 | type.__base__ |
type
的父类也是object
type
的类型也是type
object
的类型也是type
object
的父类为None
两者互为表里,相辅相成。
PyObject
理解了以上的内容,就能开始正式阅读源码了。CPython
为了表示一种继承的关系,但苦于 C语言
没有这种机制,不得不手动模拟,抽出 PyObject
作为父类。
PyObject
的结构相当简单,和 Lua
一样,需要自动垃圾回收,给每个对象头部都加了 double-link
,当创建对象的时候就将所有对象串起来,主要用于扫描与分代垃圾回收。
1 |
|
PyObject
是所有对象的起点,后续任何一个对象都继承自它。它包含双向链表和引用计数(ob_refcnt),通过这两个结构运用了多种垃圾回收机制。
ob_type
则是类型指针,指向该对象真正的类型,表示该对象的一些行为,用于实现多态。
PyVarObject
则是 PyObject
的增强版,用于支持 变长对象。
1 | typedef struct { |
之所以需要 变长对象 是因为有的类型是一个容器,需要存储动态变更大小,例如 List
。既然 PyVarObject
是变长对象,那么 PyObject
就可以看作是定长对象。
PyTypeObject
前面我们知道,在 Python
的世界中,类型也是对象,实例是由类型对象生成出来的。 PyTypeObject
就是所谓的类型实例对象, PyType_Type
则是类型的类型对象,它用于表示该类型的一些行为,生成出来的实例也会遵循它的规则进行,一定要先搞清楚这两者的关系,才好去理解 Python
。
具体的 PyTypeObject
结构在此处先不展开,留到后续阅读各个内建对象时,再解释说明。
1 |
|
在 Python
虚拟机启动后,内建类型对象就可以拿来实例化对象了,这说明内建类型对象是在启动时就准备好了。
而 PyType_Type
就是提前准备好的类型对象。
1 | // 垃圾回收链表, 之所以都为空, 是因为这些提前准备好的对象不是动态生成的, 不需要垃圾回收 |
我们可以看出,type
的类型还是 type
。其次有好多地方都是空的,这是因为有的参数是等到用到的时候再添加,由 PyType_Ready
函数完成,内置对象都会在 _PyTypes_Init
时就已经初始化好。
现在,我们已经知道 所有的对象都是先由 type
这一元类生成,那么对象是怎么被生成的?
对象生成主要有两种方式,一种是调用类型对象,也就是使用类型对象的 __call__
,另一种则是在语法分析时,就可确定该对象的类型,直接调用内部的CAPI(对应指令为 LOAD_CONST
)。
1 | # 1 |
这两种的区别主要在于性能上,在语法分析阶段直接能确定类型的,会比调用类型对象生成的要快的多。
float(1.5)
⇒ float.__class__.__call__(float, 1.5)
⇒ type.__call__(float, 1.5)
⇒ type_call(float, 1.5)
而在 type_call
中还会去检查是否可以转换为 float
对象,自然就慢了。
f = 1.5
⇒ PyFloat_FromDouble(1.5)
一步到位,没有更多的类型判断。
怎么证明以上的结论呢?有个很简单的方法。
1 | print(float.__call__) |
可以看出 类型对象的 __call__
实际上就是 type
的 __call__
。同时我们还可以知道,结构体中的 slot 的函数指针,在 Python 的世界中也是对象! PyWrapperDescrObject
对函数指针进行包装还加了一些描述。
有了以上的前置知识,接下来就是要关注一个对象的创建流程了,从 type_call
函数开始阅读,因为 type
的 __call__
调用的是 type_call
。
1 | static PyObject * |
这么看就简单多了,通过调用类型对象进行实例化,会先执行 __new__
,若返回的类型正确则继续调用 __init__
。
PyBaseObject
如果说 PyTypeObject
是万物的元类,那么 PyBaseObject
就是万物的父类。而父也是由造物主 type
创造出来的,它们两是一体,不可分割(因为 object 的类型 也是 type)。
整体上看非常普通,没什么特别的,主要是定义了一些最基础的方法,给子类用,比如比较之类的。
1 | PyDoc_STRVAR(object_doc, |
现在不去关注这里面的内容,等到对其他的对象足够了解后,再回到 type
和 object
中剖析。这样做的好处是,自上而下阅读,不容易产生疑惑。
PyFloatObject
终于到了本文的重点,PyFloatObject
是一个浮点数实例对象,我们就以它为起点,去窥探其中的设计。之所以选择它,是因为它是所有对象里面最简单的了。
1 | // 可以看出是个定长对象,里面就只有一个 double |
PyFloat_Type
看命名就知道是浮点数的类型对象了。
里面的行为都比较简单,要注意的是没有 __init__
,因为浮点对象比较简单,可以在 __new__
的时候就填充好。
1 | PyTypeObject PyFloat_Type = { |
为了接下来阅读方便,我将 floatobject.h
的一部分宏作了注释贴上来。
1 | // 浮点数缓存池大小 |
浮点数初始化
虚拟机在启动后,会进行浮点数的一些初始化,主要包含以下两个操作
- 判断当前机器为
ieee-754
的大端还是小端编码。
1 | void |
- 填充
float info
数据。
1 | // floatinfo 浮点数一些信息 |
这样就可以通过 sys.float_info
来查看当前环境的浮点数参数。
1 | import sys |
浮点数的创建与销毁
浮点数创建
浮点数创建主要在 float_new_impl
中。
1 | static PyObject * |
判断类型是否为 float_type
,不是则看看是否为 float
的子类,否则就尝试将字符串转为浮点数。
1 | static PyObject * |
重点关注 PyFloat_FromDouble
,可以看出,float 有个对象缓存链表,各个对象采用 ob_type
进行串联。
1 | // 通过C浮点数获取python 浮点对象, 注意虚拟机中有浮点缓存器。 |
float_vectorcall
除了 float_new
还有一个创建 浮点数的新方法 float_vectorcall
,内部也是调用的 float_new_impl
,用于提高性能,但是浮点数里面没有启用!因为它的 flag 没有 Py_TPFLAGS_HAVE_VECTORCALL
,可能只是暂时预留一个位置,还没有开发到,所以就先跳过吧
浮点数销毁
1 | // 析构 确保一定是 PyFloat_Type 类型 |
如何验证浮点数是不是真的用到了缓存池?有个很简单的方法验证。
1 | 1.3 a = |
a 与 b 的 id 一致 说明复用了浮点数对象。
浮点数操作
浮点数的大部分操作都比较简单,唯独比较操作是一个非常麻烦的操作。
浮点数比较
作者也曾提到,浮点数比较是一个噩梦,之所以这么麻烦,主要是当浮点数和整数比较时,将浮点数转换为整数去比较会丢失精度,用整数转换为浮点数也不可行,因为一个整数的有效位高达63位,而双精度浮点数的有效位为53位,无法直接进行比较。
大致步骤如下:
- 如果 j 为浮点数 且 无穷,则可直接判定。
- 如果 j 为整数 则检查符号,符号不同也可直接判定。
- j 为整数且符号相同,判定是否可以转换为浮点数(通过计算 整数的比特位,只要不超过48位,就可直接转换为浮点数),后直接判定。
- 若j为负数,转换为整数,计算 i 的指数,指数小于 j 的位数,则可直接判定(因为指数也可以看作是位数)。
- j为整数,分离 i 这个浮点数的小数与整数部分,如果小数部分存在,则将 i 左移后异或上 1,保留精度后与j左移一位进行判定即可。
1 | static PyObject* |
看完这一段我就有疑惑了,我记得 Lua
实现浮点数比较非常简单啊。翻阅 Lua 5.3.6
源码进行查阅得知,Lua
直接将两个浮点数转换为整数进行比较,这样会有精度丢失的问题(将浮点直接向下取整取到整数)。
1 | int luaV_equalobj (lua_State *L, const TValue *t1, const TValue *t2) { |
copysign
copysign 是 ieee-754
中关于浮点数定义的一个辅助函数,用于确定一个浮点数的符号,在 Python
中为了支持符号0,实现了这个方法。
这个函数使用方法是 将 y 的符号赋给 x 并返回。
实现方式也挺巧妙的,利用 atan2(0, -1.)
会得到一个 -PI 的结果,如果机器支持-0,则为-PI,若不支持则为 +PI,以此来确定机器是否支持符号0。
1 | double |
总结
本篇剖析了 Python3.10
的 float
对象的内部结构与实现,对比 Lua
可知其优势。
- 拥有浮点数缓存池。
- 比较函数实现更为靠谱。
- 考虑到机器是否支持符号0,通过
copysign
实现。