本文最后更新于:星期二, 六月 16日 2020, 1:09 下午

最近在用Python写项目,发现平时Python虽然用的多,但是有很多基础的、底层的东西其实并没有真正搞懂,借这个机会把Python面向对象中关于类和继承这一块弄清楚,按自己的思路总结一下

继承

继承的定义是建立在面向对象的基础上的,每一个功能、模块,在面向对象思想下的产物就是我们现在经常看到的类。一个类的创建有两种方式,一种是直接创建,另一种就是继承。被继承的类叫做父类、超类,继承而产生的类一般称为子类。

继承的出现是为了减少代码的冗余、提高重用性。当我们想要对一个类进行功能的添加,或者增加一些新的属性时,为了保证对之前代码的兼容性,最好就不要直接改动原来的类,但是为了这个新功能重写一个类又不经济划算,继承就可以帮助我们继承父类的属性和方法,并在这个基础上对属性和方法进行修改和扩充。

Python3以来,每一个类都默认继承于 Object 类。下面是一个简单的继承的例子

class Father():
    name = "Diaos"
    def __init__(self):
        print("I'm Father.")
        
class Child(Father):
    def __init__(self):
        print("I'm Child.")    

a = Father()
# I'm Father.
print(a.name)
# Diaos
b = Child()
# I'm Child.
print(b.name)
# Diaos

可以看到 Child 类继承了 Father 类的 name 属性

Python有两个方法可以判断继承关系:

  1. __bases__:这是每个类都有的内建的静态属性,文档中是这么描述的:

    class.__bases__

    ​ The tuple of base classes of a class object.

    这个变量包含了该类所有父类的名称

    print(Child.__bases__)
    # (<class '__main__.Father'>,)
  2. issubclass(son, father):当 son 类是 father 类的子类时,该方法会返回True

    print(issubclass(Child, Father))
    # True

多态

有了继承,我们可以选择在子类中重写父类的属性以及方法,即用同名属性或者方法覆盖父类的方法,这样我们调用子类的同名方法时,就会优先考虑子类中的方法。

例如,Father 类中实现了 get_name()方法,如果子类 Child 中没有同名函数,那么调用Child().get_name()时,调用的就是 Father 类中的get_name()方法,如果子类中有get_name(),则优先调用子类的get_name()

这样的好处就是,当我们需要继承一个类时,我们完全不需要考虑父类的实现,我们只需要保证我们的代码没有问题,代码就一定可以运行,只管调用,不管细节。这样大大提高了代码的可扩展性,又实现了代码自身的封闭,提高了稳定性。

这里需要注意的是,Python虽然有多态,但准确的来说应该属于动态多态不支持重载,即不允许一个类中存在两个同名函数,即使其接收的参数不同。(大概是因为Python是动态语言的关系,所以它没有与C++类似的静态多态的特性,即通过函数参数的不同来区分两个同名函数)

继承过程中父类初始化问题

接下来就是我在这次项目开发中遇到的实际问题了,项目使用了Scrapy这一爬虫框架,当我需要实现自己的爬虫时,需要继承scrapy.Spider这个类,在初始化这个类时,我需要新建一些属性,所以需要重写父类的__init__()方法,来初始化这一新类。

这个时候就出现一个问题,当父类的__init__()被重写后,我们新建子类时,就只会调用子类的__init__(),而父类的__init__()则不会被调用。这一点与C++就有区别了(说来惭愧,我对继承和多态的基础了解都是来源于C++,Python并没有深挖基础),C++默认会先去调用父类的构造函数,再匹配子类的构造函数,进行调用。

但是父类的初始化函数是一定需要执行的,因为父类中的许多属性都依靠其进行初始化,那在Python中要怎么执行父类的构造函数呢,这时候就需要使用到super()函数了。为了能对super()有更好的理解,我觉得需要先提一提两个东西,一个是Python类中的self,另一个是MRO(方法解析顺序)

MRO(方法解析顺序)

对于面向对象的编程语言,我们调用某个属性或者方法时,这个属性和方法有可能在当前类中定义,也可能在父类中定义。那么当我们调用对象的某个方法时,Python搜索该方法的顺序就称为方法解析顺序。

例如对于单继承来说,方法解析顺序就非常的简单易懂,比如下列例子:

A是最基础的类,不继承于任何类

B继承于A

C继承于B

将其写成代码就是:

class A:
    def call(self):
        print("A called.")
        
class B(A): pass
class C(B): pass

C().call()
# A called.

那么当调用C().call()时,方法解析顺序就是C.call() -> B.call() -> A.call()

但是对于多继承呢?例如:

A是最基础的类,不继承于任何类

B继承于A

C继承于A

D继承于B,C

写一个示例代码:

class A:
    def call(self):
        print("A called.")
        
class B(A): pass
class C(A): 
    def call(self):
        print("C called.")

class D(B, C): pass

D().call()
# Python2: A called.
# Python3: C called.

这是一个菱形继承,你会发现Python2和Python3运行该段代码时的输出完全不同,Python2调用的是A.call(),而Python3调用的是C.call(),这就引出了不同的方法解析顺序

Python对于方法解析顺序,在历史上提出了三种不同的算法,分别是:

  1. 经典类MRO(classic class)(Python2.2以前)
  2. 新式类MRO(Python2.2)
  3. 新式类C3算法(Python2.3及以后)

经典类指的是Python2.2之前默认的类,它不继承于object,而在2.2起就新增了object类,新式类需要继承于object,在Python3之前需要显式继承,从Python3起就废除了经典类,所有的类都默认为新式类,继承于object

经典类MRO

引入了经典类MRO的实现思路很简洁,就是一个深度优先遍历,对于上面的菱形继承例子,经典类MRO如下:

[D, B, A, C, A],其中A重复出现,以第一个出现的顺序为准,最后的结果就为 [D, B, A, C]

但这种算法有一种问题,明明C中我也实现了call(),并且C继承于A,理论上我应该调用到C.call()才更符合逻辑。所以就有了下一种算法。

新式类MRO

新式类MRO相比经典类的修改其实很小,就是将重复出现的类以最后一个为准,本质还是深度优先遍历。但是这时候A类有了一个基类object

还是上面那个例子,深度优先遍历的结果变为 [D, B, A, object, C, A, object],这时A和object重复出现,保留最后一个,那么最后的结果就为 [D, B, C, A, object]

这样看来好像非常符合逻辑了,但是我们考虑一个更加复杂的例子

A继承于object

X继承于A

Y继承于A

B继承于X,Y

C继承于Y,X

D继承于B,C

(注意这里B,C对于X,Y的继承顺序)

我们把它实现一下

class A(object):
    def call(self):
        print("A called.")

class X(A):
    def call(self):
        print("X called.")

class Y(A):
    def call(self):
        print("Y called.")
        
class B(X, Y): pass
class C(Y, X): pass
class D(B, C): pass

B().call()
# Python2.2: X called.
C().call()
# Python2.2: Y called.
D().call()
# Python2.2: X called.

这部分代码我们分析一下,如果采用新式类MRO,D类MRO的分析结果应该如下:

[D, B, X, A, object, Y, A, object, C, Y, A, object, X, A, object],重复类保留最后一个,最后结果为 [D, B, C, Y, X, A, object](这里Python2.2在实现方法的时候进行了调整,使其更尊重基类中类出现的顺序,实际结果为 [D, B, C, X, Y, A, object],所以上述代码才会输出 “X called.”)

我们再来看看B类的MRO,应该为 [B, X, Y, A, object]

C类的MRO为:[C, Y, X, A, object]

这样我们又发现了不合理的地方,D明明继承于C,但是但是与C的行为相悖,这就违反了继承的原则,所以这种算法依然是存在问题的。

可以看到上述的代码我只标注了Python2.2的执行结果,因为上述代码在Python2.3及以后的版本后是会报错的:

λ python test.py                                                                            
Traceback (most recent call last):
  File "test.py", line 15, in <module>
    class D(B, C): pass
TypeError: Cannot create a consistent method resolution
order (MRO) for bases X, Y

因为这种继承方式存在二义性,违反了继承的单调性原则。Python如何发现这种继承存在二义性?这就引出了C3算法。

新式类C3算法MRO

C3算法计算MRO的方法如下:

我们把类X的MRO记为一个列表 MRO[X] = [X1, X2, X3, …],其中,规定X1为MRO的头部,记为Header,规定其余的部分(X2, X3, …)为尾部,并且规定 MRO[object] = [object]。

C3算法的步骤如下,按照上面的例子,类B继承于X,Y,那么就有:

MRO[B] = [B] + merge(MRO[X], MRO[Y], [X], [Y])

可以看到这是个比较明显的递归结构,重点在于这个merge()的定义,merge()接收的参数类型为列表,返回的值类型也为列表,计算步骤如下:

  1. 检查第一个列表的头部,记作Header
  2. 如果Header没有出现在其他列表的尾部,那么将其插入(append)到返回列表中,并将该Header从所有列表中删去,然后重复步骤1;如果Header出现在了其他列表的尾部,检查下一个列表的头部,将其记为Header,重复步骤2。
  3. 如果参数传入的列表为空,则结束算法,返回结果。如果反复执行以上步骤却找不到可以输出的元素,则抛出异常,说明无法构建该继承关系。

按照这个算法,我们可以计算出上述例子中 A,X,Y,B,C的MRO

MRO[A] = [A] + merge(MRO[object], [object])
       = [A] + [object]
       = [A, object]
        
MRO[X] = [X] + merge(MRO[A], [A])
       = [X] + merge([A, object], [A])
       = [X, A] + merge([object])
       = [X, A, object]
       
# MRO[Y]的计算与MRO[X]类似
MRO[Y] = [Y, A, object]

MRO[B] = [B] + merge(MRO[X], MRO[Y], [X], [Y])
       = [B] + merge([X, A, object], [Y, A, object], [X], [Y])
       = [B, X] + merge([A, object], [Y, A, object], [Y])
       = [B, X, Y] + merge([A, object], [A, object])
       = [B, X, Y, A, object]
       
MRO[C] = [C] + merge(MRO[Y], MRO[X], [Y], [X])
       = [C] + merge([Y, A, object], [X, A, object], [Y], [X])
       = [C, Y] + merge([A, object], [X, A, object], [X])
       = [C, Y, X] + merge([A, object], [A, object])
       = [C, Y, X, A, object]

最后我们来看看D类的MRO结果

MRO[D] = [D] + merge(MRO[B], MRO[C], [B], [C])
       = [D] + merge([B, X, Y, A, object], [C, Y, X, A, object], [B], [C])
       = [D, B] + merge([X, Y, A, object], [C, Y, X, A, object], [C])
       = [D, B, C] + merge([X, Y, A, object], [Y, X, A, object])

然后,从这一步开始,获取到的Header为 X,但是 X 又存在于第二个列表的尾部, 所以又将 Y 记为Header,但是 Y 又存在于第一个列表的尾部。此时已经找不到下一个列表了,即没有元素可以输出了,那么抛出异常,证明这个继承关系是存在二义性的。所以我们只能修改继承关系来消除这个二义性。

获取MRO的方式

至此已经将所有MRO的算法捋了一遍,应该算比较好理解。那么在Python中要怎么获取MRO?

  1. class.__mro__

    这是每一个类都会有的常量属性,返回一个元组,其中按顺序给出了该类的MRO

  2. inspect.mro()

    需要引入inspect库,返回值也是一个元组,其中按顺序给出了该类的MRO

self

Python的self一般出现在一个类的内部,self指向的是该类实例化之后的某个具体的实例本身,例如:

class A:
    def __init__(self):
        print(self)

a = A()
# <__main__.A object at 0x0000020767EFA288>
b = A()
# <__main__.A object at 0x0000020767EE8988>

对于实例化对象 a 来说,它的self就指向它本身,对于b来说,self也指向它本身。所以self在不同的实例中指向也不同(有点类似于C++类的 this 指针)

所以我们在定义类的时候就可以用self来做各种操作,不需要管之后使用时该类的实例的名称,只需要用self就可以为不同的实例定义不同的表现方式。

弄清楚self的含义以及MRO之后,我们就可以仔细的说一说super()函数

super()

所以在前面我提到super()可以用于获取父类,但其实表述的并不准确,准确的来说,根据官方文档:

super ([type[, object-or-type]])

Return a proxy object that delegates method calls to a parent or sibling class of type. This is useful for accessing inherited methods that have been overridden in a class.

返回一个代理对象,它会将方法调用委托给 type父类或兄弟类(parent or sibling class)。 这对于访问已在类中被重载的继承方法很有用。

什么是代理对象?我个人是这么理解的,这个代理对象根据初始化的参数不同,会指向某一个特定实例所属类的父类的实例。也就是说,super()返回的值对于同一个类的不同实例也是不同的,例如

class A: pass
class B(A):
    def __init__(self):
        self.father = super(B, self)
        
a = B()
b = B()

a, b 虽然都是类B的实例,且A都是他们的父类,但是a.fatherb.father是指向不同的代理对象的,只不过该代理对象都可以看做类A的实例。

这是怎么实现的呢?其实这就与self有关,上面有讲到,self实际上指向的某一特定的实例。super()函数有三种调用方式:

  1. super()
  2. super(type)
  3. super(type, obj)

第一种方式其实就是super(type, obj)的简写形式。不传入任何参数,那么默认传入的type为当前类, obj为当前实例(即self)。那么此时获得的这个代理对象,它的self指向的又是什么呢?我们来看下面的例子:

class A:
	def __init_(self):
        print("From father: ", self)
      
class B(A):
    def __init__(self):
        super().__init__()
        print("From child: ", self)
        
a = B()
# From father:  <__main__.B object at 0x00000269B2B72CC8>
# From child:  <__main__.B object at 0x00000269B2B72CC8>

可以看到,这时这个代理对象的self指向仍然为实例 a 。所以,在调用super()的过程中,self的值从来就没有变过,一直是指向类B的实例 a 。

接下来解释一下为什么它还可以返回一个兄弟类方法调用的代理对象。我们先看一个具体的例子

class A:
    def __init__(self):
        print("From father: ", self)
        super().__init__()

class B:
    def __init__(self):
        print("From sibling: ", self)
        super().__init__()

class C(A, B):
    def __init__(self):
        print("From child: ", self)
        super().__init__()
        
a = C()
# From child:  <__main__.C object at 0x0000020C94895D48>
# From father:  <__main__.C object at 0x0000020C94895D48>
# From sibling:  <__main__.C object at 0x0000020C94895D48>

可以看到,我们在类C中调用的super(),调用到了它的父类A,而A中的super()调用的则是A的兄弟类B,这就涉及到MRO了。

我们知道,在super(type, obj)传递参数的过程中,它的第二个参数 obj 永远都是self,而前面我们又说到,这个self永远指向的都是最顶层调用的实例,在这个例子中就是指向 a,也就是说,在整个super()的调用链中,self从来就没有变过,只有 type 在不停的变化,在 C 中 type 的值就为 class C,在 A 中就为 class A。只要self的指向不变,永远指向__main__.C object,那么它的MRO就是固定的,为 [C, A, B, object],那么super()就会在MRO中找到下一个类,并返回这个类的代理对象。

所以在A中调用super()时,此时的MRO中,A的下个类为B,那么就返回的是类B的代理对象。

这一特性,使得在Python中设计协作类成为了可能,但是这方面我并没有实践经验,所以实际的使用场景可以参考python中的super()用法以及多继承协同任务以及官方文档给出的使用 super() 的指南

总结

怎么说…第一次尝试查询资料学会一个东西之后将它们用自己的话表述出来,说实话挺不容易的,这要求你要对这个知识真正的消化并且掌握了。我的掌握显然就还不到位,写这篇博客的时候贼难受,要把这些知识点串起来属实比较伤脑筋。

当然这里面肯定会有理解比较片面甚至错误的地方,水平有限,只能写成这样了,各位师傅看了要是有不同意的地方也劳烦指点一下

参考链接

  1. python中的super()用法以及多继承协同任务
  2. Python的方法解析顺序(MRO)
  3. Python3.8.2官方文档