手把手教你学python第十三讲(MRO详解和神奇的魔法方法)

8 篇文章 0 订阅
7 篇文章 0 订阅

如果图片刷不出来,转到https://www.bilibili.com/read/cv286207

MRO重制

关于MRO和C3算法,我又去看了一些文章,然后发现了讲的很清楚的文章http://kaiyuan.me/2016/04/27/C3_linearization/。里面有关于目前python3的MRO的精辟总结。其实这就是一个遍历节点问题,我这里就来实例演示一下(你们忘掉上一讲里的MRO算法啊,上一讲是有问题的)


就拿这个来解释一下,我们上一讲是把根画在上面(就是最底层的子类,没有其他类继承它,也就是它不是任何其它类的父类),MRO顺序是从左到右先顺着一条线去找,上图我们就是按照d->b->a->c->a的顺序(这样的搜索方式对c是不利的,c里面的属性可能要被a覆盖掉),但是python3的解释器会对这个顺序就行修正,查看每一个节点是,也就是中间的每一个类,有没有右边路径上的子类(什么叫右边路径上的子类呢?拿上图为例,b虽然有子类d,但是这个子类是在我们的当前路径上的,所以不算,而a除了在目前路径上还有c这个子类,所以python自动把a这个a从路径里删除),如果有前面说的子类,就丢弃这个节点,这里注意只是丢弃这个a,后面c->a这条路径上,a的子类都在当前路径的左边,保留,所以说最后的顺序是

object是所有类的父类,这个上节课也说过。那么我们就用这套理论来看一看上节课的几个例子



左下角c到a少画了一条线。

第一个例子,我们来看看为什么不能把父类a写在子类b前面,为什么这样没有办法写出一个MRO顺序,从左到右,c的父类在左边的是a,顺序c->a->b,然后python开始检查节点,这里要注意一件事,搜索的方向除了从左到右,大方向还必须是从下到上的,这个图的上下是怎么画的?(左右上一讲已经讲过,写在括号左边就画在左,右边就画在右)如果两个类没有继承关系,那么它们就是同一等级,如果有,子类在下,父类在上。什么叫做大方向是从下到上呢?就拿上图左上来说,c到达a有两条路,左边这条路c->b->a,右边c->a,a在b的上面,这就叫大方向从下到上,还不明白的话看右下,从左到右的路:d-->c->b->a,d->b->a,d->a,a在b上面,a和b在c上面。而我们看右边两张图都不满足这个条件,右上c->a,c->b->a,a在b上,右下我就不写了。

总结一下如何生成MRO,首先要判断从左到右,从下到上的大方向是不是满足,满足的话下一步就要去生成路径然后检查节点是丢弃还是保留,不满足直接就会报错

然后,我们来处理上一讲的遗留,注意修改的地方,start[:]


为什么要改成浅拷贝呢?这主要还是深拷贝和列表的特性决定的,s.content=start(上一讲最后是这么写的),已经把s.content和start指向同一个地方了,或者说贴在同一个id上了。然偶下面s.push(0)这一句是什么意思呢?是改变s.content这个指针指向的地址的内容或者说标签所在的id的内容,因为s.content.pop这一句嘛,因为深拷贝的原因,start也变成了[0],如果你还不是很明白,我们来复习下列表,元组,字典,字符串,集合对应的特点。列表,集合,字典都是有很多内置方法来改变内容而不是id的,但是元组,字符串,frozenset就不一样了,它们是无法直接改变内容的,要想改变内容,只能改变它们指向的地址或者说把标签从一个id转移到另一个id上,列表,集合,字典的很多内置方法它们用不了,很好理解,因为它们没办法直接改变内容,所以那些改变内容的方法都不能用,但是它们也有方法,只是方法返回的是一个新的元组,字符串,frozenset而已,不改变原有内容,还不理解我们上代码


下面开始神奇的魔法方法

魔法方法

魔法方法的特征就是__双下划线,关于魔法方法自动被调用这个特性,我们前面再讲类和对象的时候一直都在用__init__方法,应该多少都已经体会到了,虽然不需要调用,但是有时候我们还是需要重写它们来实现一些功能,所以我们还是有必要学习魔法方法的。

以下图片来自http://bbs.fishc.com/thread-48793-1-1.html

基本的魔法方法

我们看到第一个不是我们讲的init方法,而是


这是有原因的,__new__其实才是类实例化时第一个被调用的方法,如果你不修改它,它返回的是一个实例化对象,这个实例化对象就是__init__里面的第一个参数self,从这个意义上来说,__new__也必须先于__init__方法被调用。我们先来针对上面的__new__方法里面写的四条来一一进行尝试,第一条已经解释过了,我们先来解释第四条__new__主要用来继承元组或者字符串,我们知道字符串和元组都是不能改变内容的(改它指向的地址不认为是改变内容),就以元组为例吧

你可能对上面代码有很多疑惑,不要紧我们慢慢来。上图的代码提示我们。元组除了在定义的时候可以不用小括号,其它时候最好还是带上小括号,不然python会自动认为逗号隔开的是参数,而不是元组的元素。我们看到,这个__new__方法实现了元组的颠倒。不要对a1=a((1,2,3))感到奇怪,因为a继承了tuple,其实就是相当于a1=tuple((1,2,3))。a1这个实例化对象就是一个元组,也许你还有下面的方法来实现这个功能,

这种方法有个什么问题呢?首先它代码多,然后他没有改变实例化对象,为什么呢?这其实就是局部变量和全局变量的问题,self是a1的形式参数,如果在函数里(方法里也是一样),你去改变形参的值,python会开辟一个新的内存来放改变后的值,或者说,self的指向的内存空间就变了,而不影响全局变量的指向。也就是说你只是把形式参数元组颠倒了而没有改变原来的实例对象,这就像

也可以这么说,在函数里面如果你改变全局变量的值,这个改变是会被python屏蔽掉的(除非你用global),有联想能力的朋友也许可以想到前面

这种情况和上面是不一样的,这是要改变实例化对象的属性,而没有改变实例化对象本身。实例化对象本身指向的地址和实例化对象属性指向的地址是分开的,这其实也很好理解,因为根本就不是一个东西嘛。我们肯定是通过实例化对象来找他的属性的,在方法里面self有没有被改变,所以id(self)=id(a1),我猜测属性这个东西是不分局部和全局的。

话先回过来,其实我们还可以这样实现颠倒一个元组

这和上面的问题其实是一样的,并没有改变原来实例化对象的内容,只是改变了对象的属性。而且代码还多,还不直接,这不是python社区的小伙伴愿意看到的,于是我们就可以像最上面那样去修改__new__方法的内容去实现对实例化对象本身的一个操作。也许会有点不习惯,因为以前的实例化对象本身都是没什么内容的,像这样

但是现在实例化对象是一个元组或者其它的一个东西,它是有内容的。然后我们看第3条,__new__如果返回的不是一个实例化对象,__init__方法不会被调用。


我们来仔细分析上面的代码,第一段代码我们返回的是tuple.__new__(cls,t),我就来说说怎么理解这个事情,首先你进入__new__的时候不是有两个参数嘛,第一个参数cls据相当于面的self的作用(我前面几讲都偷了懒,把self简写为s,这是不规范的,我们约定俗成的还是要写self),遇到cls我们就换成a,因为是在a的类定义里嘛。然后tuple.__new__(cls,t)怎么理解呢?就是返回是cls类的一个t元组作为cls的一个实例,你可能会问为什么写的这么麻烦?这是因为你还在cls的定义里面,cls这个类还没有生成,所以我们只能通过它的父类tuple去创建这个实例对象,如果你像下面这样写,就会陷入无限递归。

其实cls这个参数其实还可以变的,比如说我先定义一个tuple的子类a,然后在定义tuple子类b,在b的类定义里__new__返回一个a类的实例化对象,可以吗?试一试

__new__方法很骚的是,上面b1是披着b类的外衣去实例化的,但是它确实a的实例化对象,a类方法b1都是调用不了的,b类方法可以调用,__init__就是个例子,因为打印出了a而不是b。这就是__new__实现的'移花接木',所以以后见到实例化的时候要小心,要看__new__有没有被重写,可能会有披着羊皮的狼在里面哦。第二段代码__new__返回的是一个t,也就是一个元组,这里要注意哈,元组是什么?是a的父类对吧,我们要明白一个概念,子类的实例化对象也是父类的,但是父类的实例化对象不一定是子类的,这有点像你是你爸的孩子,但是你爸的孩子不一定是你,因为你有可能还有兄弟姐妹对吧。所以说__init__方法没办法调用,因为self参数必须是a类的实例化对象啊,没有实例化对象怎么调用?所以说return只有返回是这个类的实例化对象时,这个类的__init__才会被调用。然后是第二点,__new__的除了第一个参数都是传递给__init__的,其实在上面已经有体现了。

这里总结一下__new__和__init__它们的区别,__new__(cls,..)顾名思义就是要创建一个新的实例化对象的意思,__init__(self,...)呢是初始化实例化对象的属性,cls就是类的名字,self是__new__返回的实例化对象,注意只有__new__返回的是这个类的实例化对象self才有对应的参数传入,没有参数传入,__init__方法就不会被调用。所以要想改变元组,frozenset和字符串的实例化对象的内容,只能用__new__,为什么说元组,frozenset和字符串,你们心里应该有数了。一个形象的比喻,__new__是按照图纸把房子盖出来,__init__是去装修这个房子。下一个



我们上一讲讲过del是干嘛的,它就是删除标签或者说指针,只有当没有指针指向id26921072的时候,__del__就会自动执行,python回收机制把这个内存空间释放掉。




不知道有没有人还记得我们前面讲过int,float,str,list,tuple,dict,set,frozenset都是工厂函数,但是我们没有说什么叫做工厂函数对吧,下面我们就来看看这些工厂函数的本质

我们看到int,str,set(float,dict,tuple)一样的,都是type类型,而len,max,sorted都是内置的函数或方法。而我们下面定义个一个类a,发现a的类型也是type,是不是有点感觉了呢?没错,它们这些工厂函数其实都是类的名字。我们再来看看实锤的证据

这里篇幅原因啊,只给了int的help。其实

都是在生产这些工厂函数类的实例化对象,还有一点就是c=2被python自动识别为c是int的一个实例化对象,同样d=[1,2,3]自动被识别为是list的一个实例化对象。其实我们的加减乘除,整除,取余,求幂都是调用了int或者float的内置的魔法方法,每一个算术运算符都有这魔法方法和它对应。如下表

当然你help(int)是都可以看到这些魔法方法的

我们来试着改几个

中间省略很多行


我们看到我们把继承于int的a类的)__add__方法改为返回值是int.__mul__(self,value),

也就是乘而且我们看到a1=2,a2=3而a1+a2=6,我们还可以把__add__返回a.__sub__(self,value)是不是很骚。但是要注意返回值,return self+value为什么会导致RuntimeError:达到最大递归深度呢?是不是有朋友想起了我在11讲https://www.bilibili.com/read/cv273778说过的函数和方法的不同呢?那时候我说的是方法不能递归和调用,那为什么还会出现递归的问题?其实上面__new__方法的介绍里也有一个无限递归,相信看到这里的朋友已经明白了,第十一讲那里其实讲的是不对的,因为调用函数和方法的形式差了很多,调用方法必须要有实例化对象或者类对象因为要什么.什么。我们这里来修正一下

方法是可以调用方法的,只是调用方法必须要正确,也就是说方法和函数的区别也就是调用方法的不同。话先回过去,为什么会出现RuntimeError,因为self+value就相当于调用了a.__add__方法,会一直递归下去。我们可以这样改

为什么可以出最后的结果呢?这是因为我们只修改了int的子类a__add__魔法方法,并没有修改int的__add__方法,我们可以创建一个与int类重名继承自int类的int类

注意这种并不是修改了int的魔法方法,除非你去源代码那里修改。我们上图只是创建一个与int重名的类,当a1=int(5)时,a1就被实例化为我们自己定义的int类,所以我们看到a1+a2=2,而我们看到5+3的结果是8没有被影响,为什么呢?因为5和3自动地被python识别为int类,而a1,a2是我们自己定义的int类。其它的方法我就不试了,大家可以照葫芦画瓢。上面的__divmod__稍微说一下

返回的是一个元组,第一个元素是商,第二个元素是余数。python还有一种反运算

有什么算术运算就有什么反运算,什么叫左操作数不支持相应操作,我们看个例子

a1+a2和a2+4是正常计算的,为什么4+a2就进入了a.__radd__方法呢?首先我们要知道做算术运算其实也是有MRO顺序的,就拿上图来说,先从两边对象所归属的类对象集合最底层的子类开始找+自动调用的魔法方法,对于a1+a2来说,也就是a类,而a类并没有修改__add__方法,所以a1+a2结果是没有问题的,a2+4类也是没有问题,为什么呢?因为方法的参数特性,只需要self是这个类的实例化对象,并不要求value也是实例化对象。只要你这个value是可以和int相加的就行,你加一个string自然是不行的

那么遇到4+a2呢?前面说过,父类的实例化对象不一定是子类的实例化,所以self不能传入,这时候就叫做左操作数不支持想应操作,那么这时候就轮到a类的__radd__方法登场了,并且括号里的self一定是a2,因为a2是a类的实例化对象,才能作为实例化对象传给self,于是出现了4+a2=-2,如果两边的两个参数都不是a类呢?

我们就在int里找__add__方法和__radd__方法呗,而2是int,所以调用__add__,当然int里的它们结果一样,除非你去修改源码。我们再来看

如果是上面这种+两边没有继承关系,python是优先去搜寻精确度高的,也就是float的方法,其实也很容易理解,因为float的方法一定可以算int而int的方法不一定可以计算float,就比如

而我们第一段代码是没有去修改float的方法的,所以结果都是对的。下面的第二段代码1+a1就调用了float的__radd__方法而a1+1就调用了float的__add__方法,而我们修改了float的__add__方法。再看一个例子

我们看到本质其实左边操作数的操作优先级比右边高,也就是说__add__的优先级高于__radd__的,a1+b1计算过程,我们看到继承int的a类里的__add__是被调用了,但是返回失败了,而是进去b.__radd__,为什么呢?因为继承int的a类无法处理b1这个浮点数,所以调用了b.__radd__。而b1+a1只返回了badd,因为进到b.__add__方法,float是可以处理int的,就直接返回了。整形加浮点返回的一定是浮点。

下面的增量运算其实就是简写的形式,我就不举例了。

有了上面的讲解,一元操作符也很容易理解,它更简单,不举例了。



鸭子类型

这些小知识有时候会在每讲的最后补充

其实就是一句话,鸭子类型的特点就是不关心对象的类型,而是关心实例化对象有没有这个属性和类对象有没有这个方法。我们来举个例子

我们就去看看list和str有没有__add__方法



我们来看鸭子类型报错的情况

因为int和tuple都没有reverse方法。

鸭子类型可以参考http://bbs.fishc.com/thread-51471-1-1.html

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值