《深度剖析CPython解释器》34. 侵入 Python 虚拟机,动态修改底层数据结构和运行时

楔子

之前分析了那么久的虚拟机,多少会有点无聊,那么本次我们来介绍一个好玩的,看看如何修改 Python 解释器的底层数据结构和运行时。了解虚拟机除了可以让我们写出更好的代码之外,还可以对 Python 进行改造。举个栗子:

是不是很有趣呢?通过 Python 内置的 ctypes 模块即可做到,而具体实现方式我们一会儿说。所以本次我们的工具就是 ctypes 模块(Python 版本为 3.8),需要你对它已经或多或少有一些了解,哪怕只有一点点也是没关系的。

注意:本次介绍的内容绝不能用于生产环境,仅仅只是为了更好地理解 Python 虚拟机、或者做测试的时候使用,用于生产环境是绝对的大忌。

不可用于生产环境!!!

不可用于生产环境!!!

不可用于生产环境!!!

那么废话不多说,下面就开始吧。

使用 Python 表示 C 的数据结构

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("😂")
print(lst)  # ['😂', 2]

元组

下面来看看元组,我们可以把元素看成不支持元素添加、修改、删除等操作的列表。元组的实现机制非常简单,可以看做是在列表的基础上丢弃了增删改等操作。既然如此,那要元组有什么用呢?毕竟元组的功能只是列表的子集。元组存在的最大一个特点就是,它可以作为字典的 key、以及可以作为集合的元素。因为字典和集合存储数据的原理是哈希表,对于列表这样的可变对象来说是可以动态改变的,而哈希值是一开始就计算好的,显然如果支持动态修改的话,那么哈希值肯定会变,这是不允许的。所以如果我们希望字典的 key 是一个序列,显然元组再适合不过了。

typedef struct {
    PyObject_VAR_HEAD
    PyObject *ob_item[1];
} PyTupleObject;

可以看到,对于不可变对象来说,它底层结构体定义也非常简单。一个引用计数、一个类型、一个指针数组。这里的 1 可以想象成 n,我们上面说过它的含义。并且我们发现不像列表,元组没有 allocated,这是因为它是不可变的,不支持扩容操作。

这里再对比一下元组和列表的 ob_item 成员,PyTupleObject 的 ob_item 是一个指针数组,数组里面是泛型指针 PyObject *;而 PyListObject 的 ob_item 是一个二级指针,该指针指向了一个存放 PyObject * 的指针数组的首元素。

所以 Python 中的 "列表本身" 和 "列表里面的值" 在底层是分开存储的,因为 PyListObject 结构体实例并没有存储相应的指针数组,而是存储了指向这个指针数组首元素的二级指针。显然我们添加、删除、修改元素等操作,都是通过这个二级指针来间接操作这个指针数组。这么做的原因就在于对象一旦被创建,那么它在内存中的大小就不可以变了,因此这就意味着那些可以容纳可变长度数据的可变对象,要在内部维护一个指向可变大小的内存区域的指针,遵循这样的规则可以使维护对象的工作变得非常简单。

试想一下这样一个场景:一旦允许对象的大小可在运行期改变,那么假设在内存中有对象 A,并且其后面紧跟着对象 B。如果运行的某个时候,A 的大小增大了,这就意味着必须将 A 整个移动到内存中的其他位置,否则 A 增大的部分会覆盖掉原本属于 B 的数据。只要将 A 移动到内存的其他位置,那么所有指向 A 的指针就必须立即得到更新。可想而知这样的工作是多么的繁琐,而通过一个指针去操作就变得简单多了。

可以看到 PyListObject 实例本身和指针数组之间是分离的,两者通过二级指针(ob_item)建立联系;但元组不同,它的大小不允许改变,因此 PyTupleObject 直接存储了指针数组本身(ob_item)。

from ctypes import *


class PyVarObject(Structure):
    _fields_ = [
        ("ob_refcnt", c_ssize_t),
        ("ob_type", c_void_p),
        ("ob_size", c_ssize_t)
    ]


class PyTupleObject(PyVarObject):
    _fields_ = [
        # 这里我们假设里面可以存 10 个元素
        ("ob_item", (py_object * 10)),
    ]


tpl = (11, 22, 33)
tuple_obj = PyTupleObject.from_address(id(tpl))
print(tuple_obj.ob_size)  # 3
print(len(tpl))  # 3

# 这里我们修改元组内的元素
print(f"修改前:id(tpl) = {id(tpl)},tpl = {tpl}")
tuple_obj.ob_item[0] = py_object("🍑")
print(f"修改后:id(tpl) = {id(tpl)},tpl = {tpl}")
"""
修改前:id(tpl) = 140570376749888,tpl = (11, 22, 33)
修改后:id(tpl) = 140570376749888,tpl = ('🍑', 22, 33)
"""

此时我们就成功修改了元组里面的元素,并且修改前后元组的地址没有改变。

要是以后谁跟你说 Python 元组里的元素不能修改,就拿这个例子堵他嘴。好吧,元组就是不可变的,举这个例子有点不太合适。

给类对象增加属性

我们知道类对象(或者说类型对象)是有自己的属性字典的,但这个字典不允许修改,因为准确来说它不是字典,而是一个 mappingproxy 对象。

print(str.__dict__.__class__)  # <class 'mappingproxy'>

try:
    str.__dict__["嘿"] = "蛤"
except Exception as e:
    print(e)  # 'mappingproxy' object does not support item assignment

我们无法通过修改 mappingproxy 对象来给类增加属性,因为它不支持增加、修改以及删除操作。当然对于自定义的类可以通过 setattr 方法实现,但是内置的类是行不通的,内置的类无法通过 setattr 进行属性添加。因此如果想给内置的类增加属性,只能通过 mappingproxy 入手,我们看一下它的底层结构。

所谓的 mappingproxy 就是对字典包了一层,并只提供了查询功能。而且从函数 mappingproxy_len、mappingproxy_getitem 可以看出,mappingproxy 对象的长度就是内部字典的长度,获取 mappingproxy 对象的元素实际上就是获取内部字典的元素,因此操作 mappingproxy 对象就等价于操作其内部的字典。

所以我们只要能拿到 mappingproxy 对象内部的字典,那么可以直接操作字典来修改类属性。而 Python 有一个模块叫 gc,它可以帮我们实现这一点,举个栗子:

import gc

lst = ["hello", 123, "😒"]
# gc.get_referents(obj) 返回所有被 obj 引用的对象
print(gc.get_referents(lst))  # ['😒', 123, 'hello']
# 显然 lst 引用的就是内部的三个元素

# 此外还有 gc.get_referrers(obj),它是返回所有引用了 obj 的对象

那么问题来了,你觉得 mappingproxy 对象引用了谁呢?显然就是内部的字典。

import gc

# str.__dict__ 是一个 mappingproxy 对象,这里拿到其内部的字典,
d = gc.get_referents(str.__dict__)[0]
# 随便增加一个属性
d["嘿"] = "蛤"
print(str.嘿)  # 蛤
print("嘿".嘿)  # 蛤

# 当然我们也可以增加一个函数,记得要有一个 self 参数
d["smile"] = lambda self: self + "😊"
print("微笑".smile())  # 微笑😊
print(str.smile("微笑"))  # 微笑😊

但是需要注意的是,我们上面添加的是之前没有的新属性,如果是覆盖一个已经存在的属性或者函数,那么还缺一步。

from ctypes import *
import gc

s = "hello world"
print(s.split())  # ['hello', 'world']

d = gc.get_referents(str.__dict__)[0]
d["split"] = lambda self, *args: "我被 split 了"  # 覆盖 split 函数
# 可以通过 pythonapi 来调用 CPython 对外暴露的 API,后面会说
# 这里需要调用 pythonapi.PyType_Modified 来更新上面所做的修改
# 如果没有这一步,那么是没有效果的,甚至还会出现丑陋的段错误,使得解释器异常退出
pythonapi.PyType_Modified(py_object(str))
print(s.split())  # 我被 split 了

不过上面的代码还有一个缺点,那就是函数的名字没有修改:

from ctypes import *
import gc

s = "hello world"
print(s.split.__name__)  # split

d = gc.get_referents(str.__dict__)[0]
d["split"] = lambda self, *args: "我被 split 了"  # 覆盖 split 函数
pythonapi.PyType_Modified(py_object(str))

print(s.split.__name__)  # <lambda>

我们看到函数在修改之后名字就变了,匿名函数的名字就叫 <lambda>,所以我们可以再完善一下。

from ctypes import *
import gc


def patch_builtin_class(cls, name, value):
    """
    :param cls: 要修改的类
    :param name: 属性名或者函数名
    :param value: 值
    :return:
    """
    if type(cls) is not type:
        raise ValueError("cls 必须是一个内置的类对象")
    # 获取 cls.__dict__ 内部的字典
    cls_attrs = gc.get_referents(cls.__dict__)[0]
    # 如果该属性或函数不存在,结果为 None;否则将值取出来,赋值给 old_value
    old_value = cls_attrs.get(name, None)
    # 将 name、value 组合起来放到 cls_attrs 中,为 cls 这个类添砖加瓦
    cls_attrs[name] = value

    # 如果 old_value 为 None,说明我们添加了的一个新的属性或函数
    # 如果 old_value 不为 None,说明我们覆盖了的一个已存在的属性或函数
    if old_value is not None:
        try:
            # 将原来函数的 __name__、__qualname__ 赋值给新的函数
            # 如果不是函数,而是普通属性,那么会因为没有 __name__ 而抛出 AttributeError
            # 这里我们直接 pass 掉即可,无需关心
            value.__name__ = old_value.__name__
            value.__qualname__ = old_value.__qualname__
        except AttributeError:
            pass
        # 但是原来的属性或函数最好也不要丢弃,我们可以改一个名字
        # 假设我们修改 split 函数,那么修改之后,原来的 split 就需要通过 _str_split 进行调用
        cls_attrs[f"_{cls.__name__}_{name}"] = old_value

    # 不要忘了最关键的一步
    pythonapi.PyType_Modified(py_object(cls))


s = "hello world"
print(s.title())  # Hello World
# 修改内置属性
patch_builtin_class(str, "title", lambda self: "我单词首字母大写了")
print(s.title())  # 我单词首字母大写了
print(s.title.__name__)  # title
# 而原来的 title 则需要通过 _str_title 进行调用
print(s._str_title())  # Hello World

很明显,我们不仅可以修改 str,任意的内置的类都是可以修改的。

lst = [1, 2, 3]
# 将 append 函数换成 pop 函数
patch_builtin_class(list, "append", lambda self: list.pop(self))
# 我们知道 append 需要接收一个参数,但这里我们不需要传,因为函数已经被换掉了
lst.append()
print(lst)  # [1, 2]
# 而原来的 append 函数,则需要通过 _list_append 进行调用
lst._list_append(666)
print(lst)  # [1, 2, 666]

我们还可以添加一个类方法或静态方法:

patch_builtin_class(
    list,
    "new",
    classmethod(lambda cls, n: list(range(n)))
)
print(list.new(5))  # [0, 1, 2, 3, 4]

还是很有趣的,但需要注意的是,我们目前的 patch_builtin_class 只能为类添加属性或函数,但其 "实例对象" 使用操作符时的表现是无法操控的。什么意思呢?我们举个栗子:

a, b = 3, 4
# 每一个操作背后都被抽象成了一个魔法方法
print(int.__add__(a, b))  # 7
print(a.__add__(b))  # 7
print(a + b)  # 7

# 重写 __add__
patch_builtin_class(int, "__add__", lambda self, other: self * other)
print(int.__add__(a, b))  # 12
print(a.__add__(b))  # 12
print(a + b)  # 7 

我们看到重写了 __add__ 之后,直接调用魔法方法的话是没有问题的,打印的是重写之后的结果。而使用操作符的话(a + b),却没有走我们重写之后的 __add__,所以 a + b 的结果还是 7。

s1, s2 = "hello", "world"
patch_builtin_class(str, "__sub__", lambda self, other: (self, other))
print(s1.__sub__(s2))  # ('hello', 'world')
try:
    s1 - s2
except TypeError as e:
    print(e)  # unsupported operand type(s) for -: 'str' and 'str'

我们重写了 __sub__ 之后,直接调用魔法方法的话也是没有问题的,但是用操作符(s1 - s2)就会报错,告诉我们字符串不支持减法操作,但我们明明实现了 __sub__ 方法啊。想要知道原因并改变它,我们就要先知道类对象在底层是怎么实现的。

类对象的底层结构 PyTypeObject

首先思考两个问题:

  • 1. 当在内存中创建对象、分配空间的时候,解释器要给该对象分配多大的空间?显然不能随便分配,那么该对象的内存信息在什么地方?
  • 2. 一个对象是支持相应的操作的,解释器怎么判断该对象支持哪些操作呢?再比如一个整型可以和一个整型相乘,但是一个列表也可以和一个整型相乘,即使是相同的操作,但不同类型的对象执行也会有不同的结果,那么此时解释器又是如何进行区分的?

想都不用想,这些信息肯定都在对象所对应的类型对象中。而且占用的空间大小实际上是对象的一个元信息,这样的元信息和其所属类型是密切相关的,因此它一定会出现在与之对应的类型对象当中。至于支持的操作就更不用说了,我们平时自定义类的时候,方法都写在什么地方,显然都是写在类里面,因此一个对象支持的操作显然定义在类型对象当中。

而将一个对象和其类型对象关联起来的,毫无疑问正是该对象内部的 PyObject 中的 ob_type,也就是类型的指针。我们通过对象的 ob_type 成员即可获取指向的类型对象的指针,通过该指针可以获取存储在类型对象中的某些元信息。

下面我们来看看类型对象在底层是怎么定义的:

typedef struct _typeobject {
    // 头部信息,PyVarObject ob_base; 里面包含了 引用计数、类型、ob_size
    // 而创建这个结构体实例的话,Python 提供了一个宏,PyVarObject_HEAD_INIT(type, size)
    // 传入类型和 ob_size 可以直接创建,至于引用计数则默认为 1
    PyObject_VAR_HEAD
    // 创建之后的类名
    const char *tp_name;
    // 大小,用于申请空间的,注意了,这里是两个成员
    Py_ssize_t tp_basicsize, tp_itemsize; 
    // 析构方法__del__,当删除实例对象时会调用这个操作
    // typedef void (*destructor)(PyObject *); 函数接收一个 PyObject *,没有返回值
    destructor tp_dealloc;
    // 打印其实例对象时调用的函数
    // typedef int (*printfunc)(PyObject *, FILE *, int); 函数接收一个PyObject *、FILE * 和 int
    printfunc tp_print;
    // 获取属性,内部的 __getattr__ 方法, typedef PyObject *(*getattrfunc)(PyObject *, char *);
    getattrfunc tp_getattr;
    // 设置属性,内部的 __setattr__ 方法,typedef int (*setattrfunc)(PyObject *, char *, PyObject *);
    setattrfunc tp_setattr;
    // Python3.5 新增,协程对象所拥有的方法
    PyAsyncMethods *tp_as_async; 
    // 内部的 __repr__方法,typedef PyObject *(*reprfunc)(PyObject *);
    reprfunc tp_repr;
    // 一个对象作为数值所有拥有的方法
    PyNumberMethods *tp_as_number;
    // 一个对象作为序列所有拥有的方法
    PySequenceMethods *tp_as_sequence;
    // 一个对象作为映射所有拥有的方法
    PyMappingMethods *tp_as_mapping;
    // 内部的 __hash__ 方法,typedef Py_hash_t (*hashfunc)(PyObject *);
    hashfunc tp_hash;
    // 内部的 __call__ 方法, typedef PyObject * (*ternaryfunc)(PyObject *, PyObject *, PyObject *);
    ternaryfunc tp_call;
    // 内部的 __str__ 方法,typedef PyObject *(*reprfunc)(PyObject *);
    reprfunc tp_str;
    // 获取属性,typedef PyObject *(*getattrofunc)(PyObject *, PyObject *);
    getattrofunc tp_getattro;
    // 设置属性,typedef int (*setattrofunc)(PyObject *, PyObject *, PyObject *);
    setattrofunc tp_setattro;
    // 用于实现缓冲区协议,实现了该协议可以和 Numpy 的 array 无缝集成
    PyBufferProcs *tp_as_buffer;
    // 这个类的特点,比如:
    // Py_TPFLAGS_HEAPTYPE:是否在堆区申请空间
    // Py_TPFLAGS_BASETYPE:是否允许这个类被其它类继承
    // Py_TPFLAGS_IS_ABSTRACT:是否为抽象类
    // Py_TPFLAGS_HAVE_GC: 是否被垃圾回收跟踪
    unsigned long tp_flags;
    // 这个类的注释
    const char *tp_doc; 
    // 用于检测是否出现循环引用,和下面的 tp_clear 是一组
    // typedef int (*traverseproc)(PyObject *, visitproc, void *);
    traverseproc tp_traverse;
    // 清除对包含对象的引用
    inquiry tp_clear;
    // 富比较,typedef PyObject *(*richcmpfunc) (PyObject *, PyObject *, int);
    richcmpfunc tp_richcompare;
    // 弱引用,不需要关心
    Py_ssize_t tp_weaklistoffset;
    // __iter__方法,typedef PyObject *(*getiterfunc) (PyObject *);
    getiterfunc tp_iter;
    // __next__方法,typedef PyObject *(*iternextfunc) (PyObject *);
    iternextfunc tp_iternext;
    // 内部的方法
    struct PyMethodDef *tp_methods;
    // 内部的成员
    struct PyMemberDef *tp_members;
    // 用于实现 getset
    struct PyGetSetDef *tp_getset;
    // 继承的基类
    struct _typeobject *tp_base;
    // 内部的属性字典
    PyObject *tp_dict;
    // 描述符,__get__ 方法,typedef PyObject *(*descrgetfunc) (PyObject *, PyObject *, PyObject *);
    descrgetfunc tp_descr_get;
    // 描述符,__set__ 方法
    descrsetfunc tp_descr_set;
    // 生成的实例对象是否有属性字典
    Py_ssize_t tp_dictoffset;
    // 初始化函数,typedef int (*initproc)(PyObject *, PyObject *, PyObject *);
    initproc tp_init;
    // 为实例对象分配空间的函数,typedef PyObject *(*allocfunc)(struct _typeobject *, Py_ssize_t);
    allocfunc tp_alloc;
    // __new__ 方法,typedef PyObject *(*newfunc)(struct _typeobject *, PyObject *, PyObject *);
    newfunc tp_new;
    // 释放一个实例对象,typedef void (*freefunc)(void *); 一般会在析构函数中调用
    freefunc tp_free; 
    // typedef int (*inquiry)(PyObject *); 是否被 gc 跟踪
    inquiry tp_is_gc; 
    // 继承哪些类,这里可以指定继承多个类
    PyObject *tp_bases;
    // __mro__
    PyObject *tp_mro; 
    // 下面的就不用管了
    PyObject *tp_cache;
    PyObject *tp_subclasses;
    PyObject *tp_weaklist;
    destructor tp_del;
    unsigned int tp_version_tag;
    destructor tp_finalize;
#ifdef COUNT_ALLOCS
    Py_ssize_t tp_allocs;
    Py_ssize_t tp_frees;
    Py_ssize_t tp_maxalloc;
    struct _typeobject *tp_prev;
    struct _typeobject *tp_next;
#endif
} PyTypeObject;
#endif

而 Python 中的类对象(类型对象)在底层就是一个 PyTypeObject 实例,它保存了实例对象的元信息,描述对象的类型。所以 Python 中的实例对象在底层对应不同的结构体实例,而类对象则是对应同一个结构体实例,换句话说无论是 int、str、dict,还是其它的类对象,它们在 C 的层面都是由 PyTypeObject 这个结构体实例化得到的,只不过成员的值不同,PyTypeObject 这个结构体在实例化之后得到的类型对象也不同。

这里我们重点看一下里面的 tp_as_number、tp_as_sequence、tp_as_mapping 三个成员,它们表示实例对象为数值、序列、映射时所支持的操作。它们都是指向结构体的指针,该结构体中的每一个成员都是一个函数指针,指向的函数便是实例对象可执行的操作。

我们再看一下类对象 int 在底层的定义:

我们注意到它的类型被设置成了 PyType_Type,所以在 Python 里面 int 的类型为 type。然后重点是 tp_as_number 成员,它被初始化为 &long_as_number,而整型对象不支持序列和映射操作,所以 tp_as_sequence、tp_as_mapping 设置为 0。当然这三者都是指向结构体的指针类型,我们看一下 long_as_number。

因此 PyNumberMethods 的成员就是整数所有拥有的魔法方法,当然也包括浮点数。

至此,整个结构就很清晰了。

若想改变操作符的表现行为,我们需要修改的是 tp_as_* 里面的成员的值,而不是简单的修改属性字典。比如我们想修改 a + b 的表现行为,那么就将类对象的 tp_as_number 里面的 nb_add 给改掉。如果是整形,那么就覆盖掉 long_add,也就是 "PyLong_Type -> long_as_number -> nb_add";同理,如果是浮点型,那么就覆盖掉 float_add,也就是 "PyFloat_Type -> float_as_number -> nb_add"。

重写操作符

我们说类对象里面有 4 个方法集,分别是 tp_as_number、tp_as_sequence、tp_as_mapping、tp_as_async,如果我们想改变操作符的表现结果,那么就重写里面对应的函数即可。

from ctypes import *
import gc

# 将这些对象提前声明好,之后再进行成员的初始化
class PyObject(Structure): pass


class PyTypeObject(Structure): pass


class PyNumberMethods(Structure): pass


class PySequenceMethods(Structure): pass


class PyMappingMethods(Structure): pass


class PyAsyncMethods(Structure): pass


class PyFile(Structure): pass


PyObject._fields_ = [("ob_refcnt", c_ssize_t),
                     ("ob_type", POINTER(PyTypeObject))]

PyTypeObject._fields_ = [
    ('ob_base', PyObject),
    ('ob_size', c_ssize_t),
    ('tp_name', c_char_p),
    ('tp_basicsize', c_ssize_t),
    ('tp_itemsize', c_ssize_t),
    ('tp_dealloc', CFUNCTYPE(None, py_object)),
    ('printfunc', CFUNCTYPE(c_int, py_object, POINTER(PyFile), c_int)),
    ('getattrfunc', CFUNCTYPE(py_object, py_object, c_char_p)),
    ('setattrfunc', CFUNCTYPE(c_int, py_object, c_char_p, py_object)),
    ('tp_as_async', CFUNCTYPE(PyAsyncMethods)),
    ('tp_repr', CFUNCTYPE(py_object, py_object)),
    ('tp_as_number', POINTER(PyNumberMethods)),
    ('tp_as_sequence', POINTER(PySequenceMethods)),
    ('tp_as_mapping', POINTER(PyMappingMethods)),
    ('tp_hash', CFUNCTYPE(c_int64, py_object)),
    ('tp_call', CFUNCTYPE(py_object, py_object, py_object, py_object)),
    ('tp_str', CFUNCTYPE(py_object, py_object)),
    # 不需要的可以不用写
]

# 方法集就是一个结构体实例,结构体成员都是函数
# 所以这里我们要相关的函数类型声明好
inquiry = CFUNCTYPE(c_int, py_object)
unaryfunc = CFUNCTYPE(py_object, py_object)
binaryfunc = CFUNCTYPE(py_object, py_object, py_object)
ternaryfunc = CFUNCTYPE(py_object, py_object, py_object, py_object)
lenfunc = CFUNCTYPE(c_ssize_t, py_object)
ssizeargfunc = CFUNCTYPE(py_object, py_object, c_ssize_t)
ssizeobjargproc = CFUNCTYPE(c_int, py_object, c_ssize_t, py_object)
objobjproc = CFUNCTYPE(c_int, py_object, py_object)
objobjargproc = CFUNCTYPE(c_int, py_object, py_object, py_object)

PyNumberMethods._fields_ = [
    ('nb_add', binaryfunc),
    ('nb_subtract', binaryfunc),
    ('nb_multiply', binaryfunc),
    ('nb_remainder', binaryfunc),
    ('nb_divmod', binaryfunc),
    ('nb_power', ternaryfunc),
    ('nb_negative', unaryfunc),
    ('nb_positive', unaryfunc),
    ('nb_absolute', unaryfunc),
    ('nb_bool', inquiry),
    ('nb_invert', unaryfunc),
    ('nb_lshift', binaryfunc),
    ('nb_rshift', binaryfunc),
    ('nb_and', binaryfunc),
    ('nb_xor', binaryfunc),
    ('nb_or', binaryfunc),
    ('nb_int', unaryfunc),
    ('nb_reserved', c_void_p),
    ('nb_float', unaryfunc),
    ('nb_inplace_add', binaryfunc),
    ('nb_inplace_subtract', binaryfunc),
    ('nb_inplace_multiply', binaryfunc),
    ('nb_inplace_remainder', binaryfunc),
    ('nb_inplace_power', ternaryfunc),
    ('nb_inplace_lshift', binaryfunc),
    ('nb_inplace_rshift', binaryfunc),
    ('nb_inplace_and', binaryfunc),
    ('nb_inplace_xor', binaryfunc),
    ('nb_inplace_or', binaryfunc),
    ('nb_floor_divide', binaryfunc),
    ('nb_true_divide', binaryfunc),
    ('nb_inplace_floor_divide', binaryfunc),
    ('nb_inplace_true_divide', binaryfunc),
    ('nb_index', unaryfunc),
    ('nb_matrix_multiply', binaryfunc),
    ('nb_inplace_matrix_multiply', binaryfunc)]

PySequenceMethods._fields_ = [
    ('sq_length', lenfunc),
    ('sq_concat', binaryfunc),
    ('sq_repeat', ssizeargfunc),
    ('sq_item', ssizeargfunc),
    ('was_sq_slice', c_void_p),
    ('sq_ass_item', ssizeobjargproc),
    ('was_sq_ass_slice', c_void_p),
    ('sq_contains', objobjproc),
    ('sq_inplace_concat', binaryfunc),
    ('sq_inplace_repeat', ssizeargfunc)]

# 将这些魔法方法的名字和底层的结构体成员组合起来
magic_method_dict = {
    "__add__": ("tp_as_number", "nb_add"),
    "__sub__": ("tp_as_number", "nb_subtract"),
    "__mul__": ("tp_as_number", "nb_multiply"),
    "__mod__": ("tp_as_number", "nb_remainder"),
    "__pow__": ("tp_as_number", "nb_power"),
    "__neg__": ("tp_as_number", "nb_negative"),
    "__pos__": ("tp_as_number", "nb_positive"),
    "__abs__": ("tp_as_number", "nb_absolute"),
    "__bool__": ("tp_as_number", "nb_bool"),
    "__inv__": ("tp_as_number", "nb_invert"),
    "__invert__": ("tp_as_number", "nb_invert"),
    "__lshift__": ("tp_as_number", "nb_lshift"),
    "__rshift__": ("tp_as_number", "nb_rshift"),
    "__and__": ("tp_as_number", "nb_and"),
    "__xor__": ("tp_as_number", "nb_xor"),
    "__or__": ("tp_as_number", "nb_or"),
    "__int__": ("tp_as_number", "nb_int"),
    "__float__": ("tp_as_number", "nb_float"),
    "__iadd__": ("tp_as_number", "nb_inplace_add"),
    "__isub__": ("tp_as_number", "nb_inplace_subtract"),
    "__imul__": ("tp_as_number", "nb_inplace_multiply"),
    "__imod__": ("tp_as_number", "nb_inplace_remainder"),
    "__ipow__": ("tp_as_number", "nb_inplace_power"),
    "__ilshift__": ("tp_as_number", "nb_inplace_lshift"),
    "__irshift__": ("tp_as_number", "nb_inplace_rshift"),
    "__iand__": ("tp_as_number", "nb_inplace_and"),
    "__ixor__": ("tp_as_number", "nb_inplace_xor"),
    "__ior__": ("tp_as_number", "nb_inplace_or"),
    "__floordiv__": ("tp_as_number", "nb_floor_divide"),
    "__div__": ("tp_as_number", "nb_true_divide"),
    "__ifloordiv__": ("tp_as_number", "nb_inplace_floor_divide"),
    "__idiv__": ("tp_as_number", "nb_inplace_true_divide"),
    "__index__": ("tp_as_number", "nb_index"),
    "__matmul__": ("tp_as_number", "nb_matrix_multiply"),
    "__imatmul__": ("tp_as_number", "nb_inplace_matrix_multiply"),

    "__len__": ("tp_as_sequence", "sq_length"),
    "__concat__": ("tp_as_sequence", "sq_concat"),
    "__repeat__": ("tp_as_sequence", "sq_repeat"),
    "__getitem__": ("tp_as_sequence", "sq_item"),
    "__setitem__": ("tp_as_sequence", "sq_ass_item"),
    "__contains__": ("tp_as_sequence", "sq_contains"),
    "__iconcat__": ("tp_as_sequence", "sq_inplace_concat"),
    "__irepeat__": ("tp_as_sequence", "sq_inplace_repeat")
}


keep_method_alive= {}
keep_method_set_alive= {}


# 以上就准备就绪了,下面再将之前的 patch_builtin_class 函数补充一下即可
def patch_builtin_class(cls, name, value):
    """
    :param cls: 要修改的类
    :param name: 属性名或者函数名
    :param value: 值
    :return:
    """
    if type(cls) is not type:
        raise ValueError("cls 必须是一个内置的类对象")
    cls_attrs = gc.get_referents(cls.__dict__)[0]
    old_value = cls_attrs.get(name, None)
    cls_attrs[name] = value
    if old_value is not None:
        try:
            value.__name__ = old_value.__name__
            value.__qualname__ = old_value.__qualname__
        except AttributeError:
            pass
        cls_attrs[f"_{cls.__name__}_{name}"] = old_value
    pythonapi.PyType_Modified(py_object(cls))
    # 以上逻辑不变,然后对参数 name 进行检测
    # 如果是魔方方法、并且 value 是一个可调用对象,那么修改操作符,否则直接 return
    if name not in magic_method_dict and callable(value):
        return
    # 比如 name 是 __sub__,那么 tp_as_name, rewrite == "tp_as_number", "nb_sub"
    tp_as_name, rewrite = magic_method_dict[name]
    # 获取类对应的底层结构,PyTypeObject 实例
    type_obj = PyTypeObject.from_address(id(cls))
    # 根据 tp_as_name 判断到底是哪一个方法集,这里我们没有实现 tp_as_mapping 和 tp_as_async
    struct_method_set_class = (PyNumberMethods if tp_as_name == "tp_as_number"
                               else PySequenceMethods if tp_as_name == "tp_as_sequence"
                               else PyMappingMethods if tp_as_name == "tp_as_mapping"
                               else PyAsyncMethods)
    # 获取具体的方法集(指针)
    struct_method_set_ptr = getattr(type_obj, tp_as_name, None)
    if not struct_method_set_ptr:
        # 如果不存在此方法集,我们实例化一个,然后设置到里面去
        struct_method_set = struct_method_set_class()
        # 注意我们要传一个指针进去
        setattr(type_obj, tp_as_name, pointer(struct_method_set))
    # 然后对指针进行解引用,获取方法集,也就是对应的结构体实例
    struct_method_set = struct_method_set_ptr.contents
    # 遍历 struct_method_set_class,判断到底重写的是哪一个魔法方法
    cfunc_type = None
    for field, ftype in struct_method_set_class._fields_:
        if field == rewrite:
            cfunc_type = ftype
    # 构造新的函数
    cfunc = cfunc_type(value)
    # 更新方法集
    setattr(struct_method_set, rewrite, cfunc)
    # 至此我们的功能就完成了,但还有一个非常重要的点,就是上面的 cfunc
    # 虽然它是一个底层可以识别的 C 函数,但它本质上仍然是一个 Python 对象
    # 其内部维护了 C 级数据,赋值之后底层会自动提取,而这一步不会增加引用计数
    # 所以这个函数结束之后,cfunc 就被销毁了(连同内部的 C 级数据)
    # 这样后续再调用相关操作符的时候就会出现段错误,解释器异常退出
    # 因此我们需要在函数结束之前创建一个在外部持有它的引用
    keep_method_alive[(cls, name)] = cfunc
    # 当然还有我们上面的方法集,也是同理
    keep_method_set_alive[(cls, name)] = struct_method_set

代码量还是稍微有点多的,但是不难理解,我们将这些代码放在一个单独的文件里面,文件名就叫 unsafe_magic.py,然后导入它。

from unsafe_magic import patch_builtin_class


patch_builtin_class(int, "__getitem__", lambda self, item: "_".join([str(self)] * item))
patch_builtin_class(str, "__matmul__", lambda self, other: (self, other))
patch_builtin_class(str, "__sub__", lambda self, other: other + self)

你觉得之后会发生什么呢?我们测试一下:

怎么样,是不是很好玩呢?

from unsafe_magic import patch_builtin_class


patch_builtin_class(tuple, "append", lambda self, item: self + (item, ))
t = ()
print(t.append(1).append(2).append(3).append(4))  # (1, 2, 3, 4)

因此 Python 给开发者赋予的权限是非常高的,你可以玩出很多意想不到的新花样。

另外再多说一句,当对象不支持某个操作符的时候,我们能够让它实现该操作符;但如果对象已经实现了某个操作符,那么其逻辑就改不了了,举个栗子:

from unsafe_magic import patch_builtin_class

# str 没有 __div__,我们可以为其实现,此时字符串便拥有了除法的功能
patch_builtin_class(str, "__div__", lambda self, other: (self, other))
print("hello" / "world")  # ('hello', 'world')

# 但 __add__ 是 str 本身就有的,也就是说字符串本身就可以相加
# 而此时我们就无法覆盖加法这个操作符了
patch_builtin_class(str, "__add__", lambda self, other: (self, other))
print("你" + "好")  # 你好
# 我们看到使用加号,并没有走我们重写之后的 __add__ 方法,因为字符串本身就支持加法运算
# 但也有例外,就是当出现 TypeError 的时候,那么解释器会执行我们重写的方法
# 字符串和整数相加会出现异常,因此解释器会执行我们重写的 __add__
print("你" + 123)  # ('你', 123)
# 但如果是调用魔方方法,那么会直接走我们重写的 __add__,前面说过的
print("你".__add__("好"))  # ('你', '好')

不过上述这个问题在 3.6 版本的时候是没有的,操作符会无条件地执行我们重写的魔法方法。但在 3.8 的时候出现了这个现象,可以自己测试一下。

最后再来说一说 Python/C API,Python 解释器暴露了大量的 C 一级的 API 供我们调用,而调用方式可以通过 ctypes.pythonapi 来实现。我们之前用过一次,就是 pythonapi.PyType_Modified。那么再举个例子来感受一下:

from ctypes import *

lst = [1, 2, 3]
# 函数原型:PyList_SetItem(PyObject *op, Py_ssize_t i, PyObject *newitem)
# 调用的时候类型一定要匹配,否则很容易导致解释器异常退出
pythonapi.PyList_SetItem(py_object(lst), 1, py_object(666))
print(lst)  # [1, 666, 3]

ctypes.pythonapi 用的不是很多,像 Python 提供的 C 级 API 一般在编写扩展的时候有用。

小结

以上我们就用 ctypes 玩了一些骚操作,内容还是有点单调,当然你也可以玩的再嗨一些。但是无论如何,一定不要在生产上使用,线上不要出现这种会改变解释器运行逻辑的代码。如果只是为了调试、或者想从实践的层面更深入的了解虚拟机,那么没事可以玩一玩。

posted @ 2021-10-31 13:50  古明地盆  阅读(1416)  评论(0编辑  收藏  举报