Python 对象的行为是怎么区分的?

23天前 18.5k 0

我们知道所有类型对象在底层都由结构体 PyTypeObject 实例化得到,但内部字段接收的值不同,得到的类型对象就不同。类型对象不同,那么实例对象的表现就不同,这也正是一种对象区别于另一种对象的关键所在。

比如 PyLong_Type 的 tp_iter 是空,那么整数就不是可迭代对象,而 PyList_Type 的 tp_iter 不是空,那么列表就是可迭代对象。再比如 PyLong_Type 和 PyFloat_Type,虽然内部都实现了 tp_hash,但它们是不同的类型,所以整数和浮点数的哈希值计算方式也不一样。

因此类型对象决定了实例对象的行为,比如能否调用、能否计算哈希值、能否迭代等等,这些都由类型对象决定。

PyTypeObject 里面定义了很多函数指针,比如 tp_call、tp_hash 等等,它们可能指向某个具体的函数,也可能为空。这些函数指针可以看做是类型对象所定义的操作,这些操作决定了其实例对象在运行时的行为。

class A:
# tp_new
def __new__(cls, *args, **kwargs):
    pass

# tp_init
def __init__(self):
    pass

# tp_call
def __call__(self):
    pass

# tp_getattr
def __getattr__(self, attr):
    pass

# tp_setattr
def __setattr__(self, key, value):
    pass

...
...

像 tp_call、tp_hash、tp_new 等字段会直接对应 Python 里的魔法函数,它们以双下划线开头、以双下划线结尾。但除了魔法函数之外,每种类型还可以有很多自定义的成员函数。

# 自定义 foo 和 bar
class A:

    def foo(self):
        pass

    def bar(self):
        pass

# 当然内置类型也是如此
# 像 str 定义了 join、split、upper
print(str.join)
print(str.split)
print(str.upper)
# 像 list 定义了 append、extend,insert
print(list.append)
print(list.extend)
print(list.insert)

这些自定义的函数会一起保存在类型对象的 tp_methods 里面,负责让实例对象更具有表现力。需要补充的是,类型对象里面定义的是函数,也叫成员函数,实例对象在获取之后会自动包装成方法。

所以实例对象能调用的方法都定义在类型对象里面,并且通过实例调用本质上就是一个语法糖,但用起来更加优雅。假设有一个类 A,实例对象为 a,那么 a.some() 底层会转成 A.some(a),至于这背后的细节后续再聊。

但除了以上这些,PyTypeObject 还提供了三个字段。

Python 对象的行为是怎么区分的?-1图片

每个字段各自指向一个结构体实例,结构体实例中有大量的字段,这些字段也是函数指针,指向了具体的函数。所以它们也被称为方法簇,分别应用于如下操作。

  • tp_as_number:负责数值型操作,比如整数、浮点数的加减乘除;
  • tp_as_sequence:负责序列型操作,比如字符串、列表、元组等通过索引取值的行为;
  • tp_as_mapping:负责映射型操作,比如字典通过 key 映射出 value;

我们以 tp_as_number 为例,它指向 PyNumberMethods 类型的结构体实例,那么这个结构体长什么样子呢?

// Include/cpython/object.h
typedef struct {
// add,对应 + 操作符,如 a + b
binaryfunc nb_add;
// sub,对应 - 操作符,如 a - b
binaryfunc nb_subtract;
// mul,对应 * 操作符,如 a * b
binaryfunc nb_multiply;
// mod,对应 % 操作符,如 a % b
binaryfunc nb_remainder;
// divmod,对应 divmode 函数,如 divmod(a, b)
binaryfunc nb_divmod;
// power,对应 ** 操作符,如 a ** b
ternaryfunc nb_power;
// neg,对应 - 操作符,如 -a
unaryfunc nb_negative;
// pos,对应 + 操作符,如 +a
unaryfunc nb_positive;
// abs,对应 abs 函数,如 abs(a)
unaryfunc nb_absolute;
// bool,如 bool(a)
inquiry nb_bool;
// invert,对应 ~ 操作符,如 ~a
unaryfunc nb_invert;
// lshift,对应  操作符,如 a >> b
binaryfunc nb_rshift;
// and,对应 & 操作符,如 a & b
binaryfunc nb_and;
// xor,对应 ^ 操作符,如 a ^ b
binaryfunc nb_xor;
// or,对应 | 操作符,如 a | b
binaryfunc nb_or;
// int,如 int(a)
unaryfunc nb_int;
// ...
} PyNumberMethods;

你看到了什么?是不是想到了 Python 里面的魔法方法,所以它们也被称为方法簇。

在 PyNumberMethods 这个方法簇里面定义了作为一个数值应该支持的操作,如果一个对象能被视为数值,比如整数,那么在其对应的类型对象 PyLong_Type 中,tp_as_number->nb_add 就指定了该对象进行加法操作时的具体行为。

同样,PySequenceMethods 和 PyMappingMethods 中分别定义了作为一个序列对象和映射对象应该支持的行为,这两种对象的典型例子就是 list 和 dict。

所以,只要类型对象提供相关操作,实例对象便具备对应的行为,因为实例对象所调用的方法都是由类型对象提供的。

class Girl:

class Girl:
def __init__(self, name, age):
    self.name = name
    self.age = age

def say(self):
    pass

def cry(self):
    pass

实例对象的属性字典,只包含了一些在 init 里面设置的属性而已,而实例能够调用的 say、cry 都是定义在类型对象中的。

因此一定要记住:类型对象定义的操作,决定了实例对象的行为。

class Int(int):

    def __getitem__(self, item):
        return item


a = Int(1)
b = Int(2)

print(a + b)  # 3
print(a["你好"])  # 你好

继承自 int 的 Int 在实例化之后自然是一个数值对象,但看上去 a[""] 是一个类似于字典才具有的行为,那为什么可以实现呢?

原因就是我们重写了 getitem 这个魔法函数,该方法在底层对应 PyMappingMethods 中的 mp_subscript 操作,因此最终 Int 实例对象表现的像一个字典一样。

归根结底就在于这几个方法簇都只是 PyTypeObject 的一个字段罢了,默认使用 PyTypeObject 结构体创建的 PyLong_Type 所生成的实例对象是不具备列表和字典的属性特征的。但我们通过继承 PyLong_Type,同时指定 getitem,使得构建出来的类型对象所生成的实例对象,同时具备多种属性特征,就是因为解释器支持这种做法。

自定义的类在底层也是 PyTypeObject 结构体实例,而在继承 int 的时候,将其内部定义的 PyNumberMethods 方法簇也继承了下来,而我们又单独实现了 PyMappingMethods 中的 mp_subscript。所以自定义类 Int 的实例对象具备了整数的全部行为,以及字典的部分行为(因为我们只实现了 getitem)。

我们再通过 PyLong_Type 实际考察一下:

Python 对象的行为是怎么区分的?-2图片

整数对象显然不支持序列和映射操作,所以在创建 PyLong_Type 时,字段 tp_as_sequence 和 tp_as_mapping 就是 0,相当于空。但整数明显支持数值型操作,所以实现了 tp_as_number。

而 tp_as_number 字段被赋值为 long_as_number,看一下它长什么样。

Python 对象的行为是怎么区分的?-3图片

里面的 long_add、long_sub、long_mul 等等显然都是已经定义好的函数指针,在创建 PyNumberMethods 结构体实例 long_as_number 的时候,分别赋值给了字段 nb_add、nb_substract、nb_multiply 等等。

创建完整数相关操作的 PyNumberMethods 结构体实例 long_as_number 之后,再将其指针交给 PyLong_Type 的 tp_as_number 字段。

然后整数在操作的时候,比如相加,会先通过 变量->ob_type->tp_as_number->nb_add 获取该操作对应的函数指针,其中 int 类型对象的 tp_as_number 字段的值是 &long_as_number,因此获取其字段 nb_add 的时候,拿到的就是 long_add 函数指针,然后调用。

同理 float 类型里的 tp_as_number 则被赋值成了 &float_as_number,获取 nb_add 字段的时候,拿到的就是 float_add 函数指针。不同类型的对象的行为不同,它们都有属于自己的一组方法簇。

最后再画一张图总结一下,假设有两个变量,分别是 e = 2.71 和 num = 666。

Python 对象的行为是怎么区分的?-4

所以对象的行为是由其类型对象定义的操作所决定的,比如一个对象可以计算长度,那么它的类型对象要实现 len;一个对象可以转成整数,那么它的类型对象要实现 int 或 index。

class A:

    def __len__(self):
        return 123

    def __int__(self):
        return 456

a = A()
print(len(a))  # 123
print(int(a))  # 456
# len(a) 在底层会转成 A.__len__(a)
# int(a) 在底层会转成 A.__int__(a)
print(A.__len__(a))  # 123
print(A.__int__(a))  # 456

a = A()
print(len(a)) # 123
print(int(a)) # 456

len(a) 在底层会转成 A.len(a)int(a) 在底层会转成 A.int(a)

print(A.len(a)) # 123
print(A.int(a)) # 456
总之核心就是一句话:类型对象定义了哪些操作,决定了实例对象具有哪些行为。

相关文章

pip、conda7种Python包管理工具对比测评
基于 Go 语言实现的 Ollama 大语言模型框架
深入理解Rust的线程安全机制
289M→259M得物包体积治理实践
记一次 .NET某工控视觉自动化系统卡死分析
面试官:对于 MQ 中的消息丢失你是如何理解的?

发布评论