之前分析了那么久的虚拟机,多少会有点无聊,那么本次我们来介绍一个好玩的,看看如何修改 Python 解释器的底层数据结构和运行时。了解虚拟机除了可以让我们写出更好的代码之外,还可以对 Python 进行改造。举个栗子:
是不是很有趣呢?通过 Python 内置的 ctypes 模块即可做到,而具体实现方式我们一会儿说。所以本次我们的工具就是 ctypes 模块(Python 版本为 3.8),需要你对它已经或多或少有一些了解,哪怕只有一点点也是没关系的。
注意:本次介绍的内容绝不能用于生产环境,仅仅只是为了更好地理解 Python 虚拟机、或者做测试的时候使用,用于生产环境是绝对的大忌。
不可用于生产环境!!!
不可用于生产环境!!!
不可用于生产环境!!!
那么废话不多说,下面就开始吧。
Python 是用 C 实现的,如果想在 Python 的层面修改底层逻辑,那么我们肯定要能够将 C 的数据结构用 Python 表示出来。而 ctypes 提供了大量的类,专门负责做这件事情,下面按照类型属性分别介绍。
C 语言的数值类型分为如下:
int:整型
unsigned int:无符号整型
short:短整型
unsigned short:无符号短整型
long:长整形
unsigned long:无符号长整形
long long:64 位机器上等同于 long
unsigned long long:64 位机器上等同于 unsigned long
float:单精度浮点型
double:双精度浮点型
long double:看成是 double 即可
_Bool:布尔类型
ssize_t:等同于 long 或者 long long
size_t:等同于 unsigned long 或者 unsigned long long
和 Python 以及 ctypes 之间的对应关系如下:
下面来演示一下:
import ctypes # 下面都是 ctypes 中提供的类,将 Python 中的数据传进去,就可以转换为 C 的数据 print(ctypes.c_int(1)) # c_long(1) print(ctypes.c_uint(1)) # c_ulong(1) print(ctypes.c_short(1)) # c_short(1) print(ctypes.c_ushort(1)) # c_ushort(1) print(ctypes.c_long(1)) # c_long(1) print(ctypes.c_ulong(1)) # c_ulong(1) # c_longlong 等价于 c_long,c_ulonglong 等价于 c_ulong print(ctypes.c_longlong(1)) # c_longlong(1) print(ctypes.c_ulonglong(1)) # c_ulonglong(1) print(ctypes.c_float(1.1)) # c_float(1.100000023841858) print(ctypes.c_double(1.1)) # c_double(1.1) # 在64位机器上,c_longdouble等于c_double print(ctypes.c_longdouble(1.1)) # c_double(1.1) print(ctypes.c_bool(True)) # c_bool(True) # 相当于c_longlong和c_ulonglong print(ctypes.c_ssize_t(10)) # c_longlong(10) print(ctypes.c_size_t(10)) # c_ulonglong(10)
而 C 的数据转成 Python 的数据也非常容易,只需要在此基础上调用一下 value 即可。
import ctypes print(ctypes.c_int(1024).value) # 1024 print(ctypes.c_int(1024).value == 1024) # True
C 语言的字符类型分为如下:
char:一个 ascii 字符或者 -128~127 的整型
wchar:一个 unicode 字符
unsigned char:一个 ascii 字符或者 0~255 的一个整型
和 Python 以及 ctypes 之间的对应关系如下:
举个栗子:
import ctypes # 必须传递一个字节(里面是 ascii 字符),或者一个 int,来代表 C 里面的字符 print(ctypes.c_char(b"a")) # c_char(b'a') print(ctypes.c_char(97)) # c_char(b'a') # 和 c_char 类似,但是 c_char 既可以传入单个字节、也可以传整型 # 而这里的 c_byte 和则要求必须传递整型 print(ctypes.c_byte(97)) # c_byte(97) # 传递一个 unicode 字符,当然 ascii 字符也是可以的,并且不是字节形式 print(ctypes.c_wchar("憨")) # c_wchar('憨') # 同样只能传递整型, print(ctypes.c_ubyte(97)) # c_ubyte(97)
下面看看如何构造一个 C 中的数组:
import ctypes # C 里面创建数组的方式如下:int a[5] = {1, 2, 3, 4, 5} # 使用 ctypes 的话 array = (ctypes.c_int * 5)(1, 2, 3, 4, 5) # (ctypes.c_int * N) 等价于 int a[N],相当于构造出了一个类型,然后再通过类似函数调用的方式指定数组的元素即可 # 这里指定元素的时候直接输入数字即可,会自动转成 C 中的 int,当然我们也可以使用 c_int 手动包装 print(len(array)) # 5 print(array) # <__main__.c_int_Array_5 object at 0x7f96276fd4c0> for i in range(len(array)): print(array[i], end=" ") # 1 2 3 4 5 print() array = (ctypes.c_char * 3)(97, 98, 99) print(list(array)) # [b'a', b'b', b'c']
我们看一下数组在 Python 里面的类型,因为数组存储的元素类型为 c_int、数组长度为 5,所以这个数组在 Python 里面的类型就是 c_int_Array_5,而打印的时候则显示为 c_int_Array_5 的实例对象。我们可以调用 len 方法获取长度,也可以通过索引的方式去指定的元素,并且由于内部实现了迭代器协议,我们还可以使用 for 循环去遍历,或者使用 list 直接转成列表等等,都是可以的。
结构体应该是 C 里面最重要的结构之一了,假设 C 里面有这样一个结构体:
typedef struct { int field1; float field2; long field3[5]; } MyStruct;
要如何在 Python 里面表示它呢?
import ctypes # C 中的结构体在 Python 里面显然通过类来实现,但是这个类一定要继承 ctypes.Structure class MyStruct(ctypes.Structure): # 结构体的每一个成员对应一个元组,第一个元素为字段名,第二个元素为类型 # 然后多个成员放在一个列表中,并用变量 _fields_ 指定 _fields_ = [ ("field1", ctypes.c_int), ("field2", ctypes.c_float), ("field3", (ctypes.c_long * 5)), ] # field1、field2、field3 就类似函数参数一样,可以通过位置参数、关键字参数指定 s = MyStruct(field1=ctypes.c_int(123), field2=ctypes.c_float(3.14), field3=(ctypes.c_long * 5)(11, 22, 33, 44, 55)) print(s) # <__main__.MyStruct object at 0x7ff9701d0c40> print(s.field1) # 123 print(s.field2) # 3.140000104904175 print(s.field3) # <__main__.c_long_Array_5 object at 0x7ffa3a5f84c0>
就像实例化一个普通的类一样,然后也可以像获取实例属性一样获取结构体成员。这里获取之后会自动转成 Python 中的数据,比如 c_int 类型会自动转成 int,c_float 会自动转成 float,而数组由于 Python 没有内置,所以直接打印为 "c_long_Array_5 的实例对象"。
指针是 C 语言灵魂,而且绝大部分的 Bug 也都是指针所引起的,那么指针类型在 Python 里面如何表示呢?非常简单,通过 ctypes.POINTER 即可表示 C 的指针类型,比如:
C 中的 int *,在 Python 里面就是 ctypes.POINTER(c_int)
C 中的 float *,在 Python 里面就是 ctypes.POINTER(c_float)
from ctypes import * class MyStruct(Structure): _fields_ = [ ("field1", POINTER(c_long)), ("field2", POINTER(c_double)), ]
所以通过 POINTER(类型) 即可表示对应类型的指针,而获取指针则是通过 pointer 函数。
# 在 C 里面就相当于,long a = 1024; long *p = &a; p = pointer(c_long(1024)) print(p) # <__main__.LP_c_long object at 0x7ff3639d0dc0> print(p.__class__) # <class '__main__.LP_c_long'> # pointer 可以获取任意类型的指针 print(pointer(c_float(3.14)).__class__) # <class '__main__.LP_c_float'> print(pointer(c_double(2.71)).__class__) # <class '__main__.LP_c_double'>
同理,我们也可以通过指针获取指向的值,也就是对指针进行解引用。
from ctypes import * p = pointer(c_long(123)) # 调用 contents 即可获取指向的值,相当于对指针进行解引用 print(p.contents) # c_long(123) print(p.contents.value) # 123 # 如果对 p 再使用一次 pointer 函数,那么相当于获取 p 的指针 # 此时相当于二级指针 long **,所以类型为 LP_LP_c_long print(pointer(pointer_p)) # <__main__.LP_LP_c_long object at 0x7fe6121d0bc0> # 三级指针,类型为 LP_LP_LP_c_long print(pointer(pointer(pointer_p))) # <__main__.LP_LP_LP_c_long object at 0x7fb2a29d0bc0> # 三次解引用,获取对应的值 print(pointer(pointer(pointer_p)).contents.contents.contents) # c_long(123) print(pointer(pointer(pointer_p)).contents.contents.contents.value) # 123
总的来说,还是比较好理解的。但我们知道,在 C 中数组等于数组首元素的地址,我们除了传一个指针过去之外,传数组也是可以的。
from ctypes import * class MyStruct(Structure): _fields_ = [ ("field1", POINTER(c_long)), ("field2", POINTER(c_double)), ] # 结构体也可以先创建,再实例化成员 s = MyStruct() s.field1 = pointer(c_long(1024)) s.field2 = (c_double * 3)(3.14, 1.732, 2.71)
数组在作为参数传递的时候会退化为指针,所以此时数组的长度信息就丢失了,使用 sizeof 计算出来的结果就是一个指针的大小。因此将数组作为参数传递的时候,应该将当前数组的长度信息也传递过去,否则可能会访问非法的内存。
然后在 C 里面还有 char *、wchar_t *、void *,这些指针在 ctypes 里面专门提供了几个类与之对应。
from ctypes import * # c_char_p 就是 c 里面字符数组了,其实我们可以把它看成是 Python 中的 bytes 对象 # char *s = "hello world"; # 那么这里面也要传递一个 bytes 类型的字符串,返回一个地址 print(c_char_p(b"hello world")) # c_char_p(140451925798832) # 直接传递一个字符串,同样返回一个地址 print(c_wchar_p("古明地觉")) # c_wchar_p(140451838245008)
最后看一下如何在 Python 中表示 C 的函数,首先 C 的函数可以有多个参数,但只有一个返回值。举个栗子:
long add(long *a, long *b) { return *a + *b; }
这个函数接收两个 long *、返回一个 long,那么这种函数类型要如何表示呢?答案是通过 ctypes.CFUNCTYPE。
from ctypes import * # 第一个参数是函数的返回值类型,然后函数的参数写在后面,有多少写多少 # 比如这里的函数返回一个 long,接收两个 long *,所以就是 t = CFUNCTYPE(c_long, POINTER(c_long), POINTER(c_long)) # 如果函数不需要返回值,那么写一个 None 即可 # 然后得到一个类型 t,此时的类型 t 就等同于 C 中的 typedef long (*t)(long*, long*); # 定义一个 Python 函数,a、b 为 long *,返回值为 c_long def add(a, b): return a.contents.value + b.contents.value # 将我们自定义的函数传进去,就得到了 C 语言可以识别的函数 c_add = t(add) print(c_add) # <CFunctionType object at 0x7fa52fa29040> print( c_add(pointer(c_long(22)), pointer(c_long(33))) ) # 55
以上就是 C 中常见的数据结构,然后再说一下类型转化,ctypes 提供了一个 cast 函数,可以将指针的类型进行转化。
from ctypes import * # cast 的第一个参数接收的必须是某种指针的 ctypes 对象,第二个参数是 ctypes 指针类型 # 这里相当于将 long * 转成了 float * p1 = pointer(c_long(123)) p2 = cast(p1, POINTER(c_float)) print(p2) # <__main__.LP_c_float object at 0x7f91be201dc0> print(p2.contents) # c_float(1.723597111119525e-43)
指针在转换之后,还是引用相同的内存块,所以整型指针转成浮点型指针之后,打印的结果乱七八糟。当然数组也可以转化,我们举个栗子:
from ctypes import * t1 = (c_int * 3)(1, 2, 3) # 将 int * 转成 long * t2 = cast(t1, POINTER(c_long)) print(t2[0]) # 8589934593
原来数组元素是 int 类型(4 字节),现在转成了 long(8 字节),但是内存块并没有变。因此 t2 获取元素时会一次性获取 8 字节,所以 t1[0] 和 t1[1] 组合起来等价于 t2[0]。
from ctypes import * t1 = (c_int * 3)(1, 2, 3) t2 = cast(t1, POINTER(c_long)) print(t2[0]) # 8589934593 print((2 << 32 & 0xFFFFFFFFFFFFFFFF) + (1 & 0xFFFFFFFFFFFFFFFF)) # 8589934593
我们说 Python 的对象本质上就是 C 的 malloc 函数为结构体实例在堆区申请的一块内存,比如整数是 PyLongObject、浮点数是 PyFloatObject、列表是 PyListObject,以及所有的类型都是 PyTypeObject 等等。那么在介绍完 ctypes 的基本用法之后,下面就来构造这些数据结构来观察 Python 对象在运行时的表现。
这里先说浮点数,因为浮点数比整数要简单,先来看看底层的定义。
typedef struct { PyObject_HEAD double ob_fval; } PyFloatObject;
除了 PyObject 这个公共的头部信息之外,只有一个额外的 ob_fval,用于存储具体的值,而且直接使用的 C 中的 double。
from ctypes import * class PyObject(Structure): """PyObject,所有对象底层都会有这个结构体""" _fields_ = [ ("ob_refcnt", c_ssize_t), ("ob_type", c_void_p) # 类型对象一会说,这里就先用 void * 模拟 ] class PyFloatObject(PyObject): """定义 PyFloatObject,继承 PyObject""" _fields_ = [ ("ob_fval", c_double) ] # 创建一个浮点数 f = 3.14 # 构造 PyFloatObject,可以通过对象的地址进行构造 # float_obj 就是浮点数 f 在底层的表现形式 float_obj = PyFloatObject.from_address(id(f)) print(float_obj.ob_fval) # 3.14 # 修改一下 print(f"f = {f},id(f) = {id(f)}") # f = 3.14,id(f) = 140625653765296 float_obj.ob_fval = 1.73 print(f"f = {f},id(f) = {id(f)}") # f = 1.73,id(f) = 140625653765296
我们修改 float_obj.ob_fval 也会影响 f,并且修改前后 f 的地址没有发生改变。同时我们也可以观察一个对象的引用计数,举个栗子:
f = 3.14 float_obj = PyFloatObject.from_address(id(f)) # 此时 3.14 这个浮点数对象被 3 个变量所引用 print(float_obj.ob_refcnt) # 3 # 再来一个 f2 = f print(float_obj.ob_refcnt) # 4 f3 = f print(float_obj.ob_refcnt) # 5 # 删除变量 del f2, f3 print(float_obj.ob_refcnt) # 3
所以这就是引用计数机制,当对象被引用,引用计数加 1;当引用该对象的变量被删除,引用计数减 1;当对象的引用计数为 0 时,对象被销毁。
再来看看整数,我们知道 Python 中的整数是不会溢出的,换句话说,它可以计算无穷大的数。那么问题来了,它是怎么办到的呢?想要知道答案,只需看底层的结构体定义即可。
typedef struct { PyObject_VAR_HEAD digit ob_digit[1]; // digit 等价于 unsigned int } PyLongObject;
明白了,原来 Python 的整数在底层是用数组存储的,通过串联多个无符号 32 位整数来表示更大的数。
from ctypes import * class PyVarObject(Structure): _fields_ = [ ("ob_refcnt", c_ssize_t), ("ob_type", c_void_p), ("ob_size", c_ssize_t) ] class PyLongObject(PyVarObject): _fields_ = [ ("ob_digit", (c_uint32 * 1)) ] num = 1024 long_obj = PyLongObject.from_address(id(num)) print(long_obj.ob_digit[0]) # 1024 # PyLongObject 的 ob_size 除了表示 ob_digit 数组的长度,此时显然为 1 print(long_obj.ob_size) # 1 # 但是在介绍整型的时候说过,ob_size 除了表示 ob_digit 数组的长度之外,还表示整数的符号 # 我们将 ob_size 改成 -1,再打印 num long_obj.ob_size = -1 print(num) # -1024 # 我们悄悄地将 num 改成了负数
当然我们也可以修改值:
num = 1024 long_obj = PyLongObject.from_address(id(num)) long_obj.ob_digit[0] = 4096 print(num) # 4096
digit 是 32 位无符号整型,不过虽然占 32 个位,但是只用 30 个位,这也意味着一个 digit 能存储的最大整数就是 2 的 30 次方减 1。如果数值再大一些,那么就需要两个 digit 来存储,第二个 digit 的最低位从 31 开始。
# 此时一个 digit 能够存储的下,所以 ob_size 为 1 num1 = 2 ** 30 - 1 long_obj1 = PyLongObject.from_address(id(num1)) print(long_obj1.ob_size) # 1 # 此时一个 digit 存不下了,所以需要两个 digit,因此 ob_size 为 2 num2 = 2 ** 30 long_obj2 = PyLongObject.from_address(id(num2)) print(long_obj2.ob_size) # 2
当然了,用整数数组实现大整数的思路其实平白无奇,但难点在于大整数 数学运算 的实现,它们才是重点,也是也比较考验编程功底的地方。
字节串也就是 Python 中的 bytes 对象,在存储或网络通讯时,传输的都是字节串。bytes 对象在底层的结构体为 PyBytesObject,看一下相关定义。
typedef struct { PyObject_VAR_HEAD Py_hash_t ob_shash; char ob_sval[1]; } PyBytesObject;
我们解释一下里面的成员对象:
PyObject_VAR_HEAD:变长对象的公共头部
ob_shash:保存该字节序列的哈希值,之所以选择保存是因为在很多场景都需要 bytes 对象的哈希值。而 Python 在计算字节序列的哈希值的时候,需要遍历每一个字节,因此开销比较大。所以会提前计算一次并保存起来,这样以后就不需要算了,可以直接拿来用,并且 bytes 对象是不可变的,所以哈希值是不变的
ob_sval:这个和 PyLongObject 中的 ob_digit 的声明方式是类似的,虽然声明的时候长度是 1, 但具体是多少则取决于 bytes 对象的字节数量。这是 C 语言中定义"变长数组"的技巧, 虽然写的长度是 1, 但是你可以当成 n 来用, n 可取任意值。显然这个 ob_sval 存储的是所有的字节,因此 Python 中的 bytes 对象在底层是通过字符数组存储的。而且数组会多申请一个空间,用于存储 \0,因为 C 中是通过 \0 来表示一个字符数组的结束,但是计算 ob_size 的时候不包括 \0
from ctypes import * class PyVarObject(Structure): _fields_ = [ ("ob_refcnt", c_ssize_t), ("ob_type", c_void_p), ("ob_size", c_ssize_t) ] class PyBytesObject(PyVarObject): _fields_ = [ ("ob_shash", c_ssize_t), # 这里我们就将长度声明为 100 ("ob_sval", (c_char * 100)) ] b = b"hello" bytes_obj = PyBytesObject.from_address(id(b)) # 长度 print(bytes_obj.ob_size, len(b)) # 5 5 # 哈希值 print(bytes_obj.ob_shash) # 967846336661272849 print(hash(b)) # 967846336661272849 # 修改哈希值,再调用 hash 函数会发现结果变了 # 说明 hash(b) 会直接获取底层已经计算好的 ob_shash 成员的值 bytes_obj.ob_shash = 666 print(hash(b)) # 666 # 修改 ob_sval bytes_obj.ob_sval = b"hello world" print(b) # b'hello' # 我们看到打印的依旧是 "hello",原因是 ob_size 为 5,只会选择前 5 个字节 # 修改之后再次打印 bytes_obj.ob_size = 11 print(b) # b'hello world' bytes_obj.ob_size = 15 print(b) # b'hello world\x00\x00\x00\x00'
除了 bytes 对象之外,Python 中还有一个 bytearray 对象,它和 bytes 对象类似,只不过 bytes 对象是不可变的,而 bytearray 对象是可变的。
Python 中的列表可以说使用的非常广泛了,在初学列表的时候,有人会告诉你列表就是一个大仓库,什么都可以存放。但我们知道,列表中存放的元素其实都是泛型指针 PyObject *。
下面来看看列表的底层结构:
typedef struct { PyObject_VAR_HEAD PyObject **ob_item; Py_ssize_t allocated; } PyListObject;
我们看到里面有如下成员:
PyObject_VAR_HEAD: 变长对象的公共头部信息
ob_item:一个二级指针,指向一个 PyObject * 类型的指针数组,这个指针数组保存的便是对象的指针,而操作底层数组都是通过 ob_item 来进行操作的。
allocated:容量, 我们知道列表底层是使用了 C 的数组, 而底层数组的长度就是列表的容量
from ctypes import * class PyVarObject(Structure): _fields_ = [ ("ob_refcnt", c_ssize_t), ("ob_type", c_void_p), ("ob_size", c_ssize_t) ] class PyListObject(PyVarObject): _fields_ = [ # ctypes 下面有一个 py_object 类,它等价于底层的 PyObject * # 但 ob_item 类型为 **PyObject,所以这里类型声明为 POINTER(py_object) ("ob_item", POINTER(py_object)), ("allocated", c_ssize_t) ] lst = [1, 2, 3, 4, 5] list_obj = PyListObject.from_address(id(lst)) # 列表在计算长度的时候,会直接获取 ob_size 成员的值,该值负责维护列表的长度 # 对元素进行增加、删除,ob_size 也会动态变化 print(list_obj.ob_size) # 5 print(len(lst)) # 5 # 修改 ob_size 为 2,打印列表只会显示两个元素 list_obj.ob_size = 2 print(lst) # [1, 2] try: lst[2] # 访问索引为 2 的元素会越界 except IndexError as e: print(e) # list index out of range # 修改元素,注意:ob_item 里面的元素是 PyObject*,所以这里需要调用 py_object 转一下 list_obj.ob_item[0] = py_object("