本文出自“Python 为什么”系列,请查看全副文章
不久前,Python 猫
给大家举荐了一本书《晦涩的 Python》(点击可跳转浏览),那篇文章有比拟多的“溢美之词”,显得比拟空洞……
然而,《晦涩的 Python》一书值得重复回看,能够温故知新。最近我偶尔翻到书中一个有点诡异的知识点,因而筹备来聊一聊这个话题——子类化内置类型可能会出问题?!
1、内置类型有哪些?
在正式开始之前,咱们首先要科普一下:哪些是 Python 的内置类型?
依据官网文档的分类,内置类型(Built-in Types)次要蕴含如下内容:
具体文档:https://docs.python.org/3/lib…
其中,有大家熟知的数字类型、序列类型、文本类型、映射类型等等,当然还有咱们之前介绍过的布尔类型、… 对象 等等。
在这么多内容里,本文只关注那些作为 可调用对象
(callable)的内置类型,也就是跟内置函数(built-in function)在表面上类似的那些:int、str、list、tuple、range、set、dict……
这些类型(type)能够简略了解成其它语言中的类(class),然而 Python 在此并没有用习惯上的大驼峰命名法,因而容易让人产生一些误会。
在 Python 2.2 之后,这些内置类型能够被子类化(subclassing),也就是能够被继承(inherit)。
2、内置类型的子类化
家喻户晓,对于某个一般对象 x,Python 中求其长度须要用到公共的内置函数 len(x),它不像 Java 之类的面向对象语言,后者的对象个别领有本人的 x.length() 办法。(PS:对于这两种设计格调的剖析,举荐浏览 这篇文章)
当初,假如咱们要定义一个列表类,心愿它领有本人的 length() 办法,同时保留一般列表该有的所有个性。
实验性的代码如下(仅作演示):
# 定义一个 list 的子类
class MyList(list):
def length(self):
return len(self)
咱们令 MyList 这个自定义类继承 list,同时新定义一个 length() 办法。这样一来,MyList 就领有 append()、pop() 等等办法,同时还领有 length() 办法。
# 增加两个元素
ss = MyList()
ss.append("Python")
ss.append("猫")
print(ss.length()) # 输入:2
后面提到的其它内置类型,也能够这样作子类化,应该不难理解。
顺便发散一下,内置类型的子类化有何益处 / 应用场景呢?
有一个很直观的例子,当咱们在自定义的类外面,须要频繁用到一个列表对象时(给它增加 / 删除元素、作为一个整体传递……),这时候如果咱们的类继承自 list,就能够间接写 self.append()、self.pop(),或者将 self 作为一个对象传递,从而不必额定定义一个列表对象,在写法上也会简洁一些。
还有其它的益处 / 应用场景么?欢送大家留言探讨~~
3、内置类型子类化的“问题”
终于要进入本文的正式主题了:)
通常而言,在咱们教科书式的认知中,子类中的办法会笼罩父类的同名办法,也就是说,子类办法的查找优先级要高于父类办法。
上面看一个例子,父类 Cat,子类 PythonCat,都有一个 say() 办法,作用是说出以后对象的 inner_voice:
# Python 猫是一只猫
class Cat():
def say(self):
return self.inner_voice()
def inner_voice(self):
return "喵"
class PythonCat(Cat):
def inner_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 等等这些内置类型),在显式调用子类办法时,会遵循“子类先于父类”的准则;然而,在存在隐式调用时,它们仿佛会遵循“父类先于子类”的准则,即通常的继承规定会在此生效
对照 PythonCat 的例子,相当于说,间接调用 my_cat.inner_voice() 时,会失去正确的“喵喵”后果,然而在调用 my_cat.say() 时,则会失去超出预期的“喵”后果。
上面是《晦涩的 Python》中给出的例子(12.1 章节):
class DoppelDict(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]}。
然而,初始化和 update() 间接调用的别离是从父类继承的 \\_init\\()和 \\update\\(),再由它们 隐式地 调用 \setitem\\_() 办法,此时却并没有调用子类的办法,而是调用了父类的办法,导致后果超出预期!
官网 Python 这种实现双重规定的做法,有点违反大家的常识,如果不加以留神,搞不好就容易踩坑。
那么,为什么会呈现这种例外的状况呢?
4、内置类型的办法的真面目
咱们晓得了内置类型不会隐式地调用子类笼罩的办法,接着,就是 Python 猫
的刨根问底时刻:为什么它不去调用呢?
《晦涩的 Python》书中没有持续诘问,不过,我试着胡乱猜想一下(应该能从源码中失去验证):内置类型的办法都是用 C 语言实现的,事实上它们彼此之间并不存在着互相调用,所以就不存在调用时的查找优先级问题。
也就是说,后面的“\\_init\\()和 \\update\\()会隐式地调用 \setitem\\_() 办法”这种说法并不精确!
这几个魔术办法其实是互相独立的!\\_init\\()有本人的 setitem 实现,并不会调用父类的 \\setitem\\(),当然跟子类的 \\setitem\\_()就更没有关系了。
从逻辑上了解,字典的 \\_init\\()办法中蕴含 \\setitem\\_()的性能,因而咱们认为前者会调用后者,这是惯性思维的体现,然而理论的调用关系可能是这样的:
左侧的办法关上语言界面之门进入右侧的世界,在那里实现它的所有使命,并不会折返回原始界面查找下一步的指令(即不存在图中的红线门路)。不折返的起因很简略,即 C 语言间代码调用效率更高,实现门路更短,实现过程更简略。
同理,dict 类型的 get() 办法与 \\_getitem\\()也不存在调用关系,如果子类只笼罩了 \\getitem\\()的话,当子类调用 get() 办法时,理论会应用到父类的 get() 办法。(PS:对于这一点,《晦涩的 Python》及 PyPy 文档的形容都不精确,它们误以为 get() 办法会调用 \\getitem\\_())
也就是说,Python 内置类型的办法自身不存在调用关系,只管它们在底层 C 语言实现时,可能存在公共的逻辑或能被复用的办法。
我想到了“Python 为什么”系列曾剖析过的《Python 为什么能反对任意的真值判断?》。在咱们写 if xxx
时,它仿佛会隐式地调用 \\_bool\\()和 \\len\\_()魔术办法,然而实际上程序根据 POP_JUMP_IF_FALSE 指令,会间接进入纯 C 代码的逻辑,并不存在对这俩魔术办法的调用!
因而,在意识到 C 实现的非凡办法间互相独立之后,咱们再回头看内置类型的子类化,就会有新的发现:
父类的 \\_init\\()魔术办法会突破语言界面实现本人的使命,然而它跟子类的 \\setitem\\_()并不存在通路,即图中红线门路不可达。
非凡办法间各行其是,由此,咱们会得出跟前文不同的论断:实际上 Python 严格遵循了“子类办法先于父类办法”继承准则,并没有毁坏常识!
最初值得一提的是,\\_missing\\_()是一个特例。《晦涩的 Python》仅仅简略而含糊地写了一句,没有过多开展。
通过初步试验,我发现当子类定义了此办法时,get() 读取不存在的 key 时,失常返回 None;然而 \\_getitem\\() 和 dd[‘xxx’] 读取不存在的 key 时,都会按子类定义的 \\missing\\_()进行解决。
我还没空深入分析,恳请晓得答案的同学给我留言。
5、内置类型子类化的最佳实际
综上所述,内置类型子类化时并没有出问题,只是因为咱们没有认清非凡办法(C 语言实现的办法)的真面目,才会导致后果偏差。
那么,这又号召出了一个新的问题:如果非要继承内置类型,最佳的实际形式是什么呢?
首先,如果在继承内置类型后,并不重写(overwrite)它的非凡办法的话,子类化就不会有任何问题。
其次,如果继承后要重写非凡办法的话,记得要把所有心愿扭转的办法都重写一遍,例如,如果想扭转 get() 办法,就要重写 get() 办法,如果想扭转 \\_getitem\\_()办法,就要重写它……
然而,如果咱们只是想重写某种逻辑(即 C 语言的局部),以便所有用到该逻辑的非凡办法都产生扭转的话,例如重写 \\_setitem\\_()的逻辑,同时令初始化和 update()等操作跟着扭转,那么该怎么办呢?
咱们已知非凡办法间不存在复用,也就是说单纯定义新的 \\_setitem\\_()是不够的,那么,怎么能力对多个办法同时产生影响呢?
PyPy 这个非官方的 Python 版本发现了这个问题,它的做法是令内置类型的非凡办法产生调用,建设它们之间的连贯通路。
官网 Python 当然也意识到了这么问题,不过它并没有扭转内置类型的个性,而是提供出了新的计划:UserString、UserList、UserDict……
除了名字不一样,根本能够认为它们等同于内置类型。
这些类的根本逻辑是用 Python 实现的,相当于是把前文 C 语言界面的某些逻辑搬到了 Python 界面,在左侧建设起调用链,如此一来,就解决了某些非凡办法的复用问题。
对照前文的例子,采纳新的继承形式后,后果就合乎预期了:
from collections import UserDict
class DoppelDict(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 的话,最佳的实际就是继承 collections
库提供的那几个类。
6、小结
写了这么多,是时候作 ending 了~~
在本系列的前一篇文章中,Python 猫从查找程序与运行速度两方面,剖析了“为什么内置函数 / 内置类型不是万能的”,本文跟它一脉相承,也是揭示了内置类型的某种神秘的看似是缺点的行为特色。
本文尽管是从《晦涩的 Python》书中取得的灵感,然而在语言表象之外,咱们还多诘问了一个“为什么”,从而更进一步地剖析出了景象背地的原理。
简而言之,内置类型的非凡办法是由 C 语言独立实现的,它们在 Python 语言界面中不存在调用关系,因而在内置类型子类化时,被重写的非凡办法只会影响该办法自身,不会影响其它非凡办法的成果。
如果咱们对非凡办法间的关系有谬误的认知,就可能会认为 Python 毁坏了“子类办法先于父类办法”的根本继承准则。(很遗憾《晦涩的 Python》和 PyPy 都有此谬误的认知)
为了投合大家对内置类型的广泛预期,Python 在规范库中提供了 UserString、UserList、UserDict 这些扩大类,不便程序员来继承这些根本的数据类型。
写在最初:本文属于“Python 为什么”系列(Python 猫出品),该系列次要关注 Python 的语法、设计和倒退等话题,以一个个“为什么”式的问题为切入点,试着展示 Python 的迷人魅力。若你有其它感兴趣的话题,欢送填在《Python 的十万个为什么?》里的考察问卷中。
公众号【Python 猫】,本号连载优质的系列文章,有 Python 为什么系列、喵星哲学猫系列、Python 进阶系列、好书举荐系列、技术写作、优质英文举荐与翻译等等,欢送关注哦。