前言
提到编程不得不提到面向对象,一个说烂了的话题,几十年来一直都有人在争论面向对象的好坏。从宏观角度来说,一个庞大的程序本质是对业务中实体集合以及其中的关系的模拟,虽然解决实体的关系问题,用面向过程、组合等方式去体现比较方便,但要解决实体概念的抽象,就需要面向对象的编程基础。因此,面向对象在编程中是非常重要的一部分思想,不能随随便便被否定或者忽略。
面向对象的灵魂在于对类(class)概念的剖析,而python中也有对类的支持,虽然不像其它静态语言有比较强的约束,但如果想将代码组织成面向对象式的话也是完全满足的。在平常python的交流以及面试的过程中,也会涉及到许多关于类的问题,比如最常见的就是阐述类与实例的关系。要理解类与实例的关系,从类属性和实例属性切入是为最直观的。因此,今天这篇文章就来讲讲python里类属性和实例属性的二三事。
在类和实例中访问属性
首先上一段测试代码:
1 | def print_seg(msg): |
这一段代码构造了这样的场景:
- 首先创建两个学生,创建的过程中将学生名加入到集合
students - 用
dir打印学生实例student-1及学生类Student的属性&方法,再通过点或者getattr的方式访问实例属性 - 更换
headmaster - 删除
student_1 - 更换
teacher
我们看到打印的结果是这样:
1 | ======================================== |
可以看到:
- 创建两个学生的时候,
student的名字被加入到了类属性students中。不论在类还是还是在实例去访问students,都能得到相同的结果。 - 通过
getattr或者带点的方法,能够正常访问到类或者实例的属性。 - 更换
headmaster。headmaster是一个带getter跟setter的descriptor- 所谓
descriptor,可以简单理解为一个支持根据不同访问方式做出特定行为的特殊属性。我们对headmaster执行get操作,获得的是__get__方法的返回结果,当对headmaster赋新值时,如果发现了带__set__的descriptor,就会触发这个逻辑,改变类属性headmaster这个descriptor里面维护的值。 - 否则,属性在类里没有被定义成为
descriptor,且在实例里面有定义的话,修改操作只会改实例本身维护的属性值,就像下面的teacher一样。
- 删除
student_1,触发了类定义的__del__函数,将student_1的名字从students里面移除 student_2更换teacher,只更换了自己的,类属性里面还是默认原来的。
从表现上来看,类相对于实例更多是起到一个模板的作用,每一个实例类似于fork了一个类,然后在__init__中添加属于自己的属性。如果一个实例要访问某个属性,这个属性在__init__里面没有定义到的话,就会下一步从自己的类里寻找属性。如果还找不到,就得从父类里找了,就到了(多重)继承相关的话题。
所以,为什么会有这样的呈现呢?接下来我们深入源码,一探究竟。
获取类的属性,其字节码为LOAD_ATTR。经过一番深入,最终会落实到_PyObject_GenericGetAttrWithDict
1 | // object.c |
从这段代码以及其中的部分函数调用定义,可以知悉,获取属性有以下的优先级:
- 首先在类继承链搜索是否有对应名字的
descriptor。如果有带__get__的descriptor,且包含__set__的话(PyDescr_IsData的判断),优先选择这个descriptor - 其次是在实例的
__dict__中寻找属性- 有兴趣的同学可以在上面的测试代码中给实例和类加上
__dict__,看一下输出结果
- 有兴趣的同学可以在上面的测试代码中给实例和类加上
- 然后如果这个
descriptor是有带__get__的,就通过这个descriptor的__get__方法获取这个属性值 - 最后如果没有带
__get__的,那可能这只是个普通实例,不算严格意义上的descriptor(比如Student.teacher),就返回对应的值即可
类的继承链,可以通过类的__mro__属性获得到,是用C3线性化算法得到的。有兴趣的同学可以了解背后的原理以及代码实现。python的多重继承,就是基于这一套机制。
由先前代码的例子我们可以看到带__get__、__set__的descriptor确实从类、实例里都获得的是同一个descriptor。
同样,当设置属性时,最终会调用_PyObject_GenericSetAttrWithDict(object.c里,这里不放源码了,有兴趣自行查阅)。其中的优先级是:
- 在继承链查找属性有带
__set__的descriptor,直接调用descriptor.__set__ - 如果没有,就在实例的
__dict__里直接设置属性
在类方法中访问实例属性
在类定义里面我们通常会加各式各样的函数(方法),在方法的定义里面也会大量访问self的属性。但我们要知道,python是一个动态语言,函数定义里的self,并不一定只能是这个类的实例。不仅传进子类实例是可以的,而是只要满足函数里面属性的访问的类实例,都行。
如果有学过golang,或是了解过组合、ecs的概念的同学都会一下明白——如果一个东西,它皮肤是土黄色无花纹,体型大,跑得快,牙齿锋利,肉食,有金黄色的头发,那么它应当是个雄狮。就算世界上可能有其它动物也满足这些条件,但如果我们只关心这些特征属性,那就可以一视同仁。类方法也是这样,如果没有对实例类型的约束,只有对实例属性的约束,那么只要满足这些属性的实例,都可以成为类方法的参数。我们看一个例子:
1 | class Lion: |
重点在于最后一句:Student.output(Lion(name='simba', age=5))——这句话是成立的,从打印的结果来看也没有报错。而同时,s.output()和Student.output(s)则呈现了相同的效果。
我们打印s.output()以及Student.output(s)的opcode结果(上面代码稍微改了点内容,比如实例s变成了student),可以发现其中的不同:
1 | 29 26 LOAD_FAST 0 (student) |
调用student.output时,直接LOAD_METHOD后就call了;而调用Student.output时,还要load一个参数student(当然,也可以是其它实例)再call。opcode的呈现和源代码是一致的。
我们首先看LOAD_METHOD对应的内容
1 | case TARGET(LOAD_METHOD): { |
LOAD_METHOD会落实到_PyObject_GetMethod方法:
1 | int |
_PyObject_GetMethod包含了多处判断。对于student.output和Student.output,走到了不同的分支:
- 当调用
student.output时,通过_PyType_Lookup(tp, name)找到了output函数(PyFunction_Type)的descriptor,调用tp_descr_get对应的方法- 最终回到
LOAD_METHOD,走到if (meth_found)对应的分支 - 栈顶是
student实例,然后是output方法
- 最终回到
- 当调用
Student.output时,由于Student是一个type,Py_TYPE(obj)->tp_getattro是PyType_Type里面的type_getattro而不是PyObject_GenericGetAttr,且name合法,所以直接用PyObject_GetAttr(obj, name)找到了对应的output方法- 最终回到
LOAD_METHOD,走到if (meth_found)的else对应的分支 - 栈顶是
output方法,然后是NULL
- 最终回到
LOAD_METHOD之后就是CALL_METHOD,这个时候我们要记得Student.output是有传参的,因此还会额外push一个student到栈上。之后,我们再来看CALL_METHOD:
1 | case TARGET(CALL_METHOD): { |
在CALL_METHOD中会对栈做检查。如果不出意外,栈上自顶向下应该是这样的结构(看下LOAD_METHOD跟CALL_METHOD的注释就能明白):
student.output:参数N~参数1(假使有的话),student实例,output方法Student.output:参数N+1~参数2(假使有的话),student实例(参数1),output方法,NULL
最终都会调用call_function,而两者最终的效果都是Student.output(student, *args, **kwargs)了。
因此我们可以看到,假使在Student.output传参另外一个类的实例,如果调用到相关属性的话,最终还是会在另外一个类的实例里面调用PyObject_GenericGetAttrWithDict。这个方法是通用的,并约束为单一的类,所以这才能解释Student.output(Lion(name='simba', age=5))为什么成立(当然,现实编程不建议这样写哦!有其它更好的workaround提升代码可读性)。
descriptor属性的应用:property
在上两个小节当中,我们多次提到了descriptor这个概念。在平时的python编程中,我们基本上很难接触到descriptor,但在python的内部实现中,descriptor是非常核心的一部分,可以说是一种为了适配python的类属性访问接口而生的数据结构。因此,每个修习python的同学,都有必要了解这个概念。
就说一些常见的内置类属性定义,像是property、classmethod、staticmethod,虽然它们以装饰器的形式呈现在我们的程序里,但它们的实质,都是descriptor。这也是descriptor的魔力所在。
本文以property为例解析下descriptor的应用。首先来看测试代码:
1 | import pprint |
打印出来的结果是:
1 | first name: James |
property常用于一些私有变量定义的规范化,这里我们从实例的__dict__也可以额外看到带两个下划线前缀的私有变量在代码编译时就已经被偷偷地改名了(有兴趣的同学可以了解下_Py_Mangle)。在类里面,所有property相关的属性都是property object;而在实例里面,所有property相关的属性相互独立,并且是特定的值。
property是如何实现这个效果的?我们需要进入源码一探究竟。
首先我们看property对应的类型定义PyProperty_Type
1 | PyTypeObject PyProperty_Type = { |
从定义中我们可以看到,PyProperty_Type的tp_descr_get和tp_descr_set都有对应的回调函数,因此任何一个property的实例都可以当作是一个descriptor
我们创建一个property实例的时候,是采用装饰器的写法,解析出来就是类似于property(function)的形式。因此,我们需要再看一下property实例创建的过程。
property本身是一个type object,创建实例的时候,是按照property(function)的写法来解析,这就相当于把property当成一个函数来看待。我们可以首先看下type object的类PyType_Type的定义:
1 | PyTypeObject PyType_Type = { |
易知,如果把type object当作函数(严格意义上讲,叫做可调用的,callable)看待,会触发tp_call对应的type_call函数。type_call函数这里就不列出来了,因为只有两步是最为关键的:
obj = type->tp_new(type, args, kwds)res = type->tp_init(obj, args, kwds)
也就是说,要创建property实例,需要找到property的tp_new对应的函数先弄一个空的实例,然后再找tp_init对应的函数初始化实例。由PyProperty_Type的定义可知,property的tp_new对应的是PyType_GenericNew,简单分配了内存空间;而tp_init则对应property_init,property_init实质的实现是property_init_impl,因此我们直接看property_init_impl的定义。
1 | static int |
property实例初始化,传入的参数是fget、fset、fdel、doc,从property_init_impl中我们易知,四个参数最终会放到prop_get、prop_set、prop_del、prop_doc中。一般我们用property装饰器包裹的函数,就对应了fget。
在先前的“访问属性”中,我们了解到如果一个类实例访问属性,第一优先级是判断继承链上是不是有名字对应了一个data-descriptor(同时包含__get__与__set__)。从上面的例子我们最终会发现在类里面定义了first_name之类的property object,而property object的类型PyProperty_Type就是同时拥有tp_descr_get跟tp_descr_set,可以当作一个data-descriptor,因此在_PyObject_GenericGetAttrWithDict中,会直接走到f(descr, obj, (PyObject *)Py_TYPE(obj))这一个逻辑来返回结果。转换一下函数里面涉及的变量名,就变成了tp_descr_get(property实例, 类实例, 类)
property的tp_descr_get对应的是property_descr_get,我们看下其中的定义:
1 | static PyObject * |
我们看到,getter的逻辑最终会调用property object的prop_get,传参obj。显然,这个prop_get就是我们用property包裹的fget函数,而obj就是类实例,我们的fget函数第一个参数就是self。那么显然,我们就直接通过fget获取了类实例对应的属性。
至此我们也可以发现,在上面的python测试代码中,h.first_name和Human.first_name.fget(h),两者就是相同的表达。再引申一下,假使有个类的实例叫做fakehuman,它包含一个属性叫做_Human__first_name(记得上面说的,双下划线的变量会被改名),那么Human.first_name.fget(fakehuman)这个表达式也是通过的!
property的setter也是一样的套路,最终会落实到property_descr_set
1 | static int |
从property_descr_set里可以看出,当value为NULL(不是传进python的None哈),就会调用prop_del对应的deleter(删除属性用del,对应opcode为DELETE_ATTR,实质就是设置attr为NULL);当value为非null的时候,就会调用prop_set对应的setter。这样,一个属性setter跟deleter的的需求也就轻而易举地被满足了。
从property的实现可以看到,descriptor相当于在属性本身和用户访问之间起到了一层桥梁的作用,是非常灵活巧妙的构思。而classmethod、staticmethod,本质上也是non-data-descriptor(不带setter),有兴趣的同学也可以深入研究。
总结
本文通过类/实例属性之间的联系,对python的类和实例之间的关系做了剖析,并引申了python内部实现中重要的一个概念——descriptor。在三个月前,笔者甚至根本不知道descriptor是什么东西,而经过了这一段时间的学习,也对python类/实例属性实现机制有了全新的理解。希望大家阅读此文,也能有所收获。