当前位置: 首页 > 科技观察

为什么从Python的内置类型继承是一个问题?!

时间:2023-03-14 21:05:10 科技观察

《流畅的Python》值得反复阅读,温故知新。最近偶然在书上看到一个有点奇葩的知识点,所以就来聊聊这个话题——说不定子类化内置类型会出问题?!1.内置类型有哪些?在正式开始之前,我们首先要科普一下:Python的内置类型有哪些?根据官方文档的分类,内置类型(Built-inTypes)主要包括以下内容:详细文档:https://docs.python.org/3/library/stdtypes.html其中,有well-已知的数值类型、序列类型、文本类型、映射类型等,当然还有我们之前介绍过的布尔类型、……对象等。在这么多内容中,本文只关注那些可调用对象(callable)的内置类型,也就是表面上类似于内置函数的那些:int,str,list,tuple,range,set,dict...这些类型(types)在其他语言中可以简单理解为类(classes),但是Python在这里并没有使用惯用的大驼峰命名法,所以很容易引起一些误解。在Python2.2之后,这些内置类型可以被子类化(subclassing),也就是可以被继承(inherit)。2、内置类型的子类化众所周知,对于一个普通的对象x,Python需要使用public内置函数len(x)求出它的长度,不像Java等面向对象语言,其对象一般都有自己的x.length()方法。(PS:对于这两种设计风格的分析,推荐阅读这篇文章)现在,假设我们要定义一个列表类,希望它有自己的length()方法,同时保留普通列表的所有特性应该有。实验代码如下(仅供演示):#定义一个列表子类classMyList(list):deflength(self):returnlen(self)我们制作自定义类MyList,继承list,定义一个新的length()方法同时。因此,MyList具有append()、pop()等方法,以及length()方法。#添加两个元素ss=MyList()ss.append("Python")ss.append("cat")print(ss.length())#Output:2上面提到的其他内置类型也可以这样做子类化应该不难理解。顺便问一下,子类化内置类型有哪些好处/使用场景?有一个很直观的例子,当我们需要在自定义类中频繁使用一个列表对象时(向其添加/删除元素,整体传递...),此时如果我们的类继承自列表,我们可以直接写self.append(),self.pop(),或者把self作为一个对象传递,这样就不需要额外定义一个list对象,写起来也会简洁。还有其他好处/使用场景吗?欢迎留言讨论~~3.子类化内置类型的“问题”终于要进入本文的正题了:)一般来说,在我们教科书式的认知中,子类中的方法会覆盖父类中的同名方法,也就是说,子类的方法的搜索优先级高于父类。让我们看一个例子。父类Cat和子类PythonCat都有一个say()方法,用于说出当前对象的inner_voice:#PythoncatisacatclassCat():defsay(self):returnsself.inner_voice()definner_voice(self):return"喵"classPythonCat(Cat):definner_voice(self):return"喵喵"当我们创建子类PythonCat的对象时,它的say()方法会优先调用自己定义的inner_voice()方法,不是Cat父类的inner_voice()方法:my_cat=PythonCat()#如下结果符合预期print(my_cat.inner_voice())#输出:喵print(my_cat.say())#输出:喵喵这是编程语言建立的约定,是一个基本原则。学过面向对象程序设计基础的同学应该都知道。但是,Python在实现继承的时候,似乎并没有完全按照上面的规则来工作。分为两种情况:常识:对于用Python实现的类,会遵循“子类先于父类”的原则违背常理:对于真正用C实现的类(即str、list、dict等)这些内置类型),在显式调用子类方法时,遵循“子类先于父类”的原则;但是,**当有隐式调用时,**他们似乎遵循了“parentbeforechildClass”的原则,即通常的继承规则在这里会失效。与PythonCat例子相比,相当于说当直接调用my_cat.inner_voice()时,会得到正确的“喵”结果,但是当调用my_cat.say()时,会得到超出的“喵”结果期望。下面是《流畅的Python》(12.1节)给出的例子:classDoppelDict(dict):def__setitem__(self,key,value):super().__setitem__(key,[value]*2)dd=DoppelDict(one=1)#{'one':1}dd['two']=2#{'one':1,'two':[2,2]}dd.update(three=3)#{'three':3,'one':1,'two':[2,2]}在本例中dd['two']会直接调用子类的__setitem__()方法,所以结果符合预期。如果其他测试也符合预期,则最终结果将是{'three':[3,3],'one':[1,1],'two':[2,2]}。但是initialization和update()分别直接调用父类继承的__init__()和__update__(),然后隐式调用了__setitem__()方法,但此时并没有调用子类的方法,而是调用了父类的方法,导致意想不到的结果!Python官方实现双重规则的做法有点违背大家的常识。一不留神,可能很容易踩坑。那么,为什么会出现这样的异常呢?4.内置类型方法的真面目我们知道,内置类型不会隐式调用子类重写的方法。电话呢?《流畅的Python》书上没继续追问,只是胡乱猜测(应该从源码上验证):内置类型的方法都是用C语言实现的,而在事实上,它们彼此无关。有相互调用,所以调用时不存在搜索优先级问题。也就是说,之前的“__init__()和__update__()会隐式调用__setitem__()方法”是不准确的!这些法术其实是相互独立的!__init__()有自己setitem的实现,并没有调用父类的__setitem__(),当然也和子类的__setitem__()没有关系。从逻辑上理解,字典的__init__()方法包含了__setitem__()的函数,所以我们以为前者会调用后者,**这是惯性思维的表现,**然而实际的调用关系可能是这样的:左边的方法打开了语言界面的大门,进入了右边的世界,实现了它所有的使命,不会再回到原来的界面去寻找下一条指令(也就是图中红线路径不存在)。不重入的原因很简单,就是C语言之间的代码调用效率更高,实现路径更短,实现过程更简单。同样,dict类型的get()方法与__getitem__()也没有调用关系。如果子类只重写__getitem__(),当子类调用get()方法时,实际上会使用父类的get()方法。(PS:关于这一点,《流畅的Python》和PyPy文档的描述并不准确,他们误认为get()方法会调用__getitem__())底层C语言实现时,可能有共同的逻辑或方法可以重复使用。我想到了我在《WhyPython》系列中分析过的《Python 为什么能支持任意的真值判断?》。我们在写ifxxx的时候,看似隐式调用了__bool__()和__len__()魔术方法,但实际上程序会根据POP_JUMP_IF_FALSE指令直接进入纯C代码的逻辑,不需要这些两个魔术方法方法调用!所以,在意识到C实现的特殊方法是相互独立的之后,我们再回过头来看内置类型的子类化,就会有一个新的发现:父类的__init__()魔术方法会打破languageinterface实现了自己的使命,但是,它和子类的__setitem__()之间没有路径,也就是图中红线的路径是不可达的。特殊方法各行其道,所以我们会得出与上一篇不同的结论:其实Python严格遵循“子类方法先于父类方法”的继承原则,这并不违背常识!最后,值得一提的是__missing__()是一个特例。《流畅的Python》简单模糊地写了一句话,没有过多的展开。经过初步实验,我发现当子类定义这个方法时,当get()读取到一个不存在的key时,一般会返回None;但是当__getitem__()和dd['xxx']读到一个不存在的key时,会按照子类定义的__missing__()进行处理。没来得及深入分析,还请知道答案的朋友给我留言。5.子类化内置类型的最佳实践综上所述,子类化内置类型是没有问题的,但是结果偏差是我们没有认清特殊方法(C语言实现的方法)的真面目造成的。那么,这就提出了一个新的问题:如果必须继承内置类型,最佳实践是什么?首先,如果继承内置类型后不重写(overwrite)它的特殊方法,子类化是没有问题的。其次,如果继承后要重写特殊的方法,记得把要改的方法全部重写。例如,如果要更改get()方法,则必须重写get()方法。如果要改变__getitem__()方法,我们就得重写它……但是,如果我们只是想重写一些逻辑(也就是C语言的一部分),让所有使用这个逻辑的特殊方法都改变,比如重写__setitem__()的同时,初始化和update()等操作都变了,那我们该怎么办呢?我们知道,特殊方法之间是没有复用的,也就是说,仅仅定义一个new__setitem__()是不够的,那么,如何才能同时影响多个方法呢?Python的非官方版本PyPy发现了这个问题。它的做法是调用内置类型的特殊方法,并在它们之间建立连接路径。当然Python官方也意识到了这个问题,但是并没有改变内置类型的特性,而是提供了新的解决方案:UserString、UserList、UserDict……除了名字不同,基本都可以被认为等同于内置类型。这些类的基本逻辑都是用Python实现的,相当于把之前C语言接口的一些逻辑搬到了Python接口上,在左边建立了一个调用链,这样就解决了一些特殊方法的复用。.与前面的例子相比,采用新的继承方式后,结果如预期:fromcollectionsimportUserDictclassDoppelDict(UserDict):def__setitem__(self,key,value):super().__setitem__(key,[value]*2)dd=DoppelDict(one=1)#{'one':[1,1]}dd['two']=2#{'one':[1,1],'two':[2,2]}dd.update(three=3)#{'one':[1,1],'two':[2,2],'three':[3,3]}显然,如果要继承str/list/dict,最好的最佳实践是继承集合库提供的类。6.总结写了这么多,该收尾了~~在本系列的上一篇文章中,Python猫从搜索顺序和运行两个方面分析了“为什么内置函数/内置类型不是万能的”speed,本文一脉相承,同时也揭示了一些神秘的、看似有缺陷的内置类型的行为特征。这篇文章虽然受到了《流畅的Python》一书的启发,但在语言表象之外,我们多问了一个“为什么”,从而进一步分析现象背后的原理。总之,内置类型的特殊方法是由C语言独立实现的,在Python语言接口中没有调用关系,所以当内置类型被子类化时,重写的特殊方法只会影响方法本身,不会影响其他特殊方法的效果。如果我们对特殊方法之间的关系有错误的理解,我们可能会认为Python打破了“子类方法先于父类方法”的基本继承原则。(不幸的是《流畅的Python》和PyPy都有这种错误的认知)为了迎合对内置类型的普遍期望,Python在标准库中提供了UserString、UserList、UserDict等扩展类,方便程序员使用继承这些基本类型。数据类型。本文转载自微信公众号“蟒猫”,可通过以下二维码关注。转载本文请联系Python猫公众号。