对象的多态性、行为

381 阅读11分钟

对象的多态性

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编程学习圈,每日干货分享