对象的多态性
Python 创建一个对象,比如 PyFloatObject,会分配内存并进行初始化。然后内部统一使用泛型指针 PyObject
来保存和维护这个对象,而不是PyFloatObject
。通过 PyObject * 保存和维护对象,可以实现更加抽象的上层逻辑,而不用关心对象的实际类型和实现细节。比如:哈希计算。
Py_hash_t
PyObject_Hash(PyObject *v);
该函数可以计算任意对象的哈希值,而不用关心对象的类型是啥,它们都可以使用这个函数。
但是不同类型的对象,其行为也千差万别,哈希值计算的方式亦是如此,那么PyObject_Hash函数是如何解决这个问题的呢?不用想,因为元信息存储在对应的类型对象之中,所以肯定会通过其ob_type拿到指向的类型对象。而类型对象中有一个成员叫做tp_hash,它是一个函数指针,指向的函数专门用来计算其实例对象的哈希值。所以我们看一下PyObject_Hash的函数定义吧,看看它内部都做了什么,该函数位于Object/Object.c中。
Py_hash_t
PyObject_Hash(PyObject *v)
{
//Py_TYPE是一个宏,用来获取PyObject *内部的ob_type
PyTypeObject *tp = Py_TYPE(v);
//获取对应的类型对象内部的tp_hash
//tp_hash是一个函数指针,对应 __hash__
if (tp->tp_hash != NULL)
//如果tp_hash不为空,证明确实指向了具体的hash函数
//那么拿到拿到函数指针之后,通过*获取对应的函数
//然后将PyObject *传进去计算哈希值,返回。
return (*tp->tp_hash)(v);
//走到这里说明tp_hash为空,但这存在两种可能。
//1. 说明该类型对象可能还未完全初始化, 导致tp_hash暂时为空
//2. 说明该类型本身就不支持其 "实例对象" 被哈希
//如果是第 1种情况,那么它的 tp_dict、也就是属性字典一定为空
//tp_dict是动态设置的,它为空,是类型对象没有完全初始化的重要特征
//但如果tp_dict不为空,说明类型对象一定已经被完全初始化了
//所以此时tp_hash要是还为空,就真的说明该类型不支持实例对象被哈希
if (tp->tp_dict == NULL) {
//属性字典为空,那么先进行类型的初始化
if (PyType_Ready(tp) < 0)
return -1;
//然后再看是否tp_hash是否为空,为空的话,说明不支持哈希
//不为空则调用对应的哈希函数
if (tp->tp_hash != NULL)
return (*tp->tp_hash)(v);
}
// 走到这里代表以上条件都不满足,说明该对象不可以被hash
return PyObject_HashNotImplemented(v);
}
函数先通过ob_type指针找到对象的类型,然后通过类型对象的tp_hash函数指针调用对应的哈希计算函数。所以PyObject_Hash根据对象的类型不同,然后调用不同的哈希函数,这不正是实现了多态吗?我们再以 Python 为例:
# 计算 v 的哈希值
hash(v)
# 而 hash(v) 等价于
v.__class__.__hash__(v)
# 如果 v 是一个列表,那么就是 list.__hash__(v)
# 如果 v 是一个字符串,那么就是 str.__hash__(v)
如果一个对象支持哈希操作,那么它的类型对象当中一定定义了 hash 方法,通过 v.__class__ 就可以获取它的类型对象,然后将 v 作为参数调用 hash 即可。
所以通过ob_type字段,Python 在 C 语言的层面实现了对象的多态特性,思路跟 C++ 中的虚表指针有着异曲同工之妙
另外可能有人觉得PyObject_Hash函数的源码写的不是很精简,比如一开始已经判断过内部的 tp_hash 是否为 NULL,然后在下面又判断了一次。那么可不可以先判断 tp_dict 是否为NULL,为 NULL 进行初始化,然后再判断 tp_hash 是否NULL,不为 NULL 的话执行 tp_hash。这样的话,代码会变得精简很多。
答案是可以的,而且这种方式似乎更直观,但是效率上不如源码。因为我们这种方式的话,无论是什么对象,都需要判断其类型对象中 tp_dict 和 tp_hash 是否为 NULL。而源码中先判断 tp_hash 是否为NULL,不为 NULL 的话就不需要再判断 tp_dict 了。所以对于已经初始化(tp_hash 不为 NULL)的类型对象,源码中少了一次对 tp_dict 是否为 NULL 的判断,效率会更高。
而这种行为叫做 CPython 的快分支,并且 CPython 中还有很多其它的快分支,快分支的特点就是命中率极高,可以尽早做出判断、尽早处理。回到当前这个场景,只有当类型对象未被初始化的时候,才会不走快分支;而一旦初始化完毕,那么后续就都走快分支。
也就是说,快分支只有在第一次调用的时候才可能不会命中,其余情况都是命中,因此没有必要每次都对 tp_dict 进行判断。因此源码的设计是非常合理的,我们在后面分析函数调用的时候,也会看到很多类似于这样的快分支。
为了更好地理解快分支,我们举一个生活中的栗子:好比你去见心上人,但是心上人说你今天没有打扮,于是你又跑回去打扮一番,然后再去见心上人。那么问题来了,为什么不能先打扮呢。
答案是在绝大部分情况下,即使你不打扮,心上人也不会介意,只有在极少数情况下,比如心情不好,才会让你回去打扮之后再过来。所以不打扮直接去见心上人就能牵手便属于快分支,它的特点就是命中率极高,绝大部分都会走这个情况。因此没必要每次都因为打扮而耽误时间,因为快分支只有在极少数情况下才不会命中。
对象的行为
了解完对象的多态性,我们再来说说对象的行为。虽然 Python 的类型对象和实例对象都属于对象,但我们更关注的是实例对象的行为。
而不同对象的行为不同,比如哈希值的计算方式,它是由类型对象的 tp_hash 成员决定的。但除了 tp_hash,PyTypeObject 中还定义了很多其它的函数指针,这些指针最终都会指向某个函数,或者为空表示不支持该操作。
这些函数指针可以看做是类型对象所定义的操作,这些操作决定了其实例对象在运行时的行为。虽然所有类型对象在底层都是由结构体PyTypeObject实例化得到的,但内部成员接收的值不同,得到的类型对象就不同;类型对象不同,导致其实例对象的行为就不同,这也正是一种对象区别于另一种对象的关键所在。
比如 int 和 str 内部都有 __hash__,但它们是不同的类型,因此哈希值的计算方式也不同。
而根据支持的操作不同,Python 中可以将对象进行以下分类:
- 数值型操作:比如整数、浮点数的加减乘除
- 序列型操作:比如字符串、列表、元组的通过索引、切片取值行为
- 映射型操作:比如字典通过 key 映射出 value
这三种操作,在 PyTypeObject 中分别对应三个指针。每个指针指向一个结构体实例,这个结构体实例中有大量的成员,成员也是函数指针,指向了具体的函数。我们回顾一下 PyTypeObject 的定义:
typedef struct _typeobject {
PyObject_VAR_HEAD
const char *tp_name;
// .......
PyNumberMethods *tp_as_number; // 数值型相关操作
PySequenceMethods *tp_as_sequence; // 序列型相关操作
PyMappingMethods *tp_as_mapping; // 映射型相关操作
// ......
} PyTypeObject;
PyNumberMethods、PySequenceMethods、PyMappingMethods 都是结构体,里面每一个成员也都是函数指针类型,指针指向的函数就是相应的操作。我们以 PyNumberMethods 为例,看看它是怎么定义的?
//object.h
typedef struct {
binaryfunc nb_add;
binaryfunc nb_subtract;
binaryfunc nb_multiply;
binaryfunc nb_remainder;
binaryfunc nb_divmod;
ternaryfunc nb_power;
unaryfunc nb_negative;
unaryfunc nb_positive;
unaryfunc nb_absolute;
inquiry nb_bool;
unaryfunc nb_invert;
binaryfunc nb_lshift;
binaryfunc nb_rshift;
//......
//......
binaryfunc nb_inplace_matrix_multiply;
} PyNumberMethods;
你看到了什么?是不是想到了Python里面的魔法方法,所以它们也被称为方法簇。
在PyNumberMethods这个方法簇里面定义了作为一个数值应该支持的操作,如果一个对象能被视为数值,比如整数,那么在其对应的类型对象 PyLong_Type中,tp_as_number -> nb_add 就指定了该对象进行加法操作时的具体行为。
同样,PySequenceMethods 和 PyMappingMethods中分别定义了作为一个序列对象和映射对象应该支持的行为,这两种对象的典型例子就是 list和 dict。
所以,只要类型对象提供相关操作 , 实例对象便具备对应的行为,因为实例对象对象所调用的方法都是由类型对象提供的。
class Girl:
def __init__(self, name, age):
self.name = name
self.age = age
def say(self):
pass
def cry(self):
pass
g = Girl("编程学习网", 16)
print(g.__dict__) # {'name': '编程学习网', 'age': 16}
print("say" in Girl.__dict__) # True
print("cry" in Girl.__dict__) # True
我们看到实例对象的属性字典里面只有在 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__)。
我们再通过PyFloat_Type实际考察一下:
//Object/floatobject.c
PyTypeObject PyFloat_Type = {
PyVarObject_HEAD_INIT(&PyType_Type, 0)
"float",
sizeof(PyFloatObject),
//......
&float_as_number, /* tp_as_number */
0, /* tp_as_sequence */
0, /* tp_as_mapping */
//......
};
我们看到了该类型对象float在创建时,给成员tp_as_number传入了一个float_as_number指针。那么这个float_as_number就是PyNumberMethods结构体实例,而其内部的每一个成员都是指向了浮点数运算函数的指针。
static PyNumberMethods float_as_number = {
float_add, /* nb_add */
float_sub, /* nb_subtract */
float_mul, /* nb_multiply */
float_rem, /* nb_remainder */
float_divmod, /* nb_divmod */
float_pow, /* nb_power */
// ...
};
里面的 float_add、float_sub、float_mul 等等显然都是已经定义好的函数指针,然后创建PyNumberMethods结构体实例float_as_number的时候,分别赋值给了成员 nb_add、nb_substract、nb_multiply 等等。
而创建完浮点数相关操作的PyNumberMethods结构体实例float_as_number之后,将其指针交给PyFloat_Type中的tp_as_number成员。而浮点数相加的时候,会先通过 变量 -> ob_type -> tp_as_number -> nb_add 获取该操作对应的函数指针,其中浮点类型对象的tp_as_number成员的值是&float_as_number,因此再获取其成员nb_add的时候,拿到的就是float_add指针,然后调用float_add函数。
整个过程还是不难理解的,另外我们在 PyFloat_Type 中看到tp_as_sequence和tp_as_mapping这两个成员接收到的值则不是一个函数指针,而是 0(相当于空)。
因此浮点数不支持序列型操作和映射型操作,比如:pi = 3.14,我们无法使用len计算长度、无法通过索引或者切片获取指定位置的值、无法通过key获取value,这和我们使用Python时候的表现是一致的。
小结
以上就是对象的多态性和行为,多态比较简单,是通过泛型指针 PyObject * 和 ob_type 实现的。
而对象的行为是由其类型对象内部定义的操作所决定的,比如一个对象可以计算长度,那么它的类型对象内部要实现 __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
# 注意:len(a)在底层调用的是 A.__len__(a),而不是 a.__len__()
# 举个栗子
print(a.__len__(), len(a)) # 123 123
a.__dict__["__len__"] = "哼哼哼"
print(a.__len__, len(a)) # 哼哼哼 123
# 其它内置函数同理
# 而且实例调用类型对象中定义的方法时,事实上也是通过类型对象调用的
# a.some_method(*args)只是 A.some_method(a, *args)的一个语法糖
总之核心就是一句话:类型对象定义了哪些操作 ,决定了实例对象具备哪些行为。
以上就是本次分享的所有内容,想要了解更多欢迎前往公众号:Python编程学习圈,每日干货分享