【笔记】Effective Python《编写高质量 Python 代码的 90 个有效方法》——第 31 ~ 51 条读书笔记
Apr 14, 2024笔记python【笔记】Effective Python《编写高质量 Python 代码的 90 个有效方法》——第 31 ~ 51 条(函数、类)读书笔记
(书基于 Python 3.7+ 语法规范)
知识点概括:
1 | iter函数 |
第31条、谨慎地迭代函数所收到的参数
函数和方法如果要把收到的参数遍历很多遍,那就必须特别小心。因为如果这些参数为迭代器,那么程序可能得不到预期的值,从而出现奇怪的效果。
为了应对大规模的数据,其中一个变通方案是让normalize函数接受另外一个函数(也就是下面的get_iter),使它每次要使用迭代器时,都去向那个函数索要。
1 | def normalize_func(get_iter): |
使用normalize_func函数时,需要传入一条lambda表达式,让这个表达式去调用read_visits生成器函数。这样normalize_func每次向get_iter索要迭代器时,程序都会给出一个新的迭代器。
这样做虽然可行,但传入这么一个lambda表达式显得有点儿生硬。要想用更好的办法解决这个问题,可以新建一种容器类,让它实现迭代器协议(iterator protocol)。
Python的for循环及相关的表达式,正是按照迭代器协议来遍历容器内容的。Python执行for x in foo这样的语句时,实际上会调用iter(foo),也就是把foo传给内置的iter函数。这个函数会触发名为foo.iter的特殊方法,该方法必须返回迭代器对象(这个迭代器对象本身要实现next特殊方法)。最后,Python会用迭代器对象反复调用内置的next函数,直到数据耗尽为止(如果抛出StopIteration异常,就表示数据已经迭代完了)。
下面定义这样一种可迭代的容器类
1 | class ReadVisits: |
我们只需要把新的容器传给最早的那个normalize函数运行即可,函数本身的代码不需要修改。
这样做为什么可行呢?因为normalize函数里面的sum会触发ReadVisits.iter,让系统分配一个新的迭代器对象给它。接下来,normalize通过for循环计算每项数据占总值的百分比时,又会触发iter,于是系统会分配另一个迭代器对象。这些迭代器各自推进,其中一个迭代器把数据耗尽,并不会影响其他迭代器。所以,在每一个迭代器上面遍历,都可以分别看到一套完整的数据。这种方案的唯一缺点,就是多次读取输入数据。
第32条、考虑用生成器表达式改写数据量较大的列表推导
列表推导可以根据输入序列中的每个元素创建一个包含派生元素的新列表。如果输入的数据量比较小,那么这样做没问题,但如果数据量很大,那么程序就有可能因为内存耗尽而崩溃。
要想处理大规模的数据,可以使用生成器表达式(generator expression)来做,它扩展了列表推导式与生成器机制。程序在对生成器表达式求值时,并不会让它把包含输出结果的那个序列立刻构建出来,而是会把它当成一个迭代器,该迭代器每次可以根据表达式中的逻辑给出一项结果。
生成器表达式的写法,与列表推导式语法类似,但它是写在一对圆括号内,而不是方括号里面。下面这种写法的效果与刚才一样,但是程序并不会立刻给出全部结果,而是先将生成器表达式表示成一个迭代器返回。
1 | it = (len(x) for x in open('my_file.txt')) |
返回的迭代器每次可以推进一步,这时它会根据生成表达式的逻辑计算出下一项输出结果(这项结果可以通过内置的next函数取得)。需要多少项结果,就把迭代器推进多少次,这种采用生成器表达式来实现的写法不会消耗太多内存。
生成器表达式还有个强大的特性,就是可以组合起来。例如,可以用刚才那条生成器表达式所形成的it迭代器作为输入,编写一条新的生成器表达式。
1 | roots = ((x, x**0.5) for x in it) |
这条表达式所形成的roots迭代器每次推进时,会引发连锁反应:它也推进内部迭代器it以判断当前是否还能在it上面继续迭代,如果可以,就把it所返回的值代入(x, x**0.5)里面求出结果。这种写法使用的内存同样不会太多。
多个生成器嵌套而成的代码,执行起来还是相当快的。所以,如果要对数据量很大的输入流做一系列处理,那么生成器表达式应该是个很好的选择。唯一需要注意的是,生成器表达式返回的迭代器是有状态的,跑完一整轮之后,就不能继续使用了。
第33条、通过yield from把多个生成器连起来用
这种形式,会先从嵌套进去的小生成器里面取值,如果该生成器已经用完,那么程序的控制流程就会回到yield from所在的这个函数之中,然后它有可能进入下一套yield from逻辑。
如:
1 | def animate_composed(): |
Python解释器看到yield from形式的表达式后,会自己想办法实现与带有普通yield语句的for循环相同的效果,而且这种实现方式要更快。
yield from的性能要胜过那种在for循环里手工编写yield表达式的方案。
第34条、不要用send给生成器注入数据
yield表达式通道是单向的,也就是说,无法让生成器在其一端接收数据流,同时在另一端给出计算结果。假如能实现双向通信,那么生成器的适用面会更广。
Python的生成器支持send方法,这可以让生成器变为双向通道。send方法可以把参数发给生成器,让它成为上一条yield表达式的求值结果,并将生成器推进到下一条yield表达式,然后把yield右边的值返回给send方法的调用者。然而在一般情况下,我们还是会通过内置的next函数来推进生成器,按照这种写法,上一条yield表达式的求值结果总是None。
1 | def my_generator(): |
如果不通过for循环或内置的next函数推进生成器,而是改用send方法,那么调用方法时传入的参数就会成为上一条yield表达式的值,生成器拿到这个值后,会继续运行到下一条yield表达式那里。可是,刚开始推进生成器的时候,它是从头执行的,而不是从某一条yield表达式那里继续的,所以,首次调用send方法时,只能传None,要是传入其他值,程序运行时就会抛出异常。
最简单的一种写法,是把迭代器传给wave函数,让wave每次用到振幅的时候,通过Python内置的next函数推进这个迭代器并返回一个输入振幅。于是,这就促使多个生成器之间,产生连环反应。
1 | def wave_cascading(amplitude_it, steps): |
这样,只需要把同一个迭代器分别传给这几条yield from语句里的wave_cascading就行。
这种写法最大的优点在于,迭代器可以来自任何地方,而且完全可以是动态的(例如可以用生成器函数来实现迭代器)。此方案只有一个缺陷,就是必须假设负责输入的生成器绝对能保证线程安全,但有时其实保证不了这一点。如果代码要跨越线程边界,那么用async函数实现可能更好。
通过迭代器向组合起来的生成器输入数据,要比采用send方法的那种方案好,所以尽量避免使用send方法。
第35条、不要通过throw变换生成器的状态
生成器还有一项高级功能,就是可以把调用者通过throw方法传来的Exception实例重新抛出。这个throw方法用起来很简单:如果调用了这个方法,那么生成器下次推进时,就不会像平常那样,直接走到下一条yield表达式那里,而是会把通过throw方法传入的异常重新抛出。
1 | class MyError(Exception): |
生成器函数可以用标准的try/except复合语句把yield表达式包裹起来,如果函数上次执行到了这条表达式这里,而这次即将继续执行时,又发现外界通过throw方法给自己注入了异常,那么这个异常就会被try结构捕获下来,如果捕获之后不继续抛异常,那么生成器函数会推进到下一条yield表达式。
1 | def my_generator(): |
这项机制会在生成器与调用者之间形成双向通信通道,这项机制会在生成器与调用者之间形成双向通信通道,这在某些情况下是有用的。例如,要编写一个偶尔可以重置的计时器程序。如定义下面的Reset异常与timer生成器方法,让调用者可以在timer给出的迭代器上通过throw方法注入Reset异常,令计时器重置。
1 | class Reset(Exception): |
按照这种写法,如果timer正准备从yield表达式往下推进时,发现有人注入了Reset异常,那么它就会把这个异常捕获下来,并进入except分支,在这里它会把表示倒计时的current变量重新调整成最初的period值。
这个计时器可以与外界某个按秒轮询的输入机制对接起来。为此,定义一个run函数以驱动timer生成器所给出的那个it迭代器,并根据外界的情况做处理,如果外界要求重置,那就通过it迭代器的throw方法给计时器注入Reset变量,如果外界没有这样要求,那就调用announce函数打印生成器所给的倒计时值。
1 | def check_for_reset(): |
这样写没错,但是有点儿难懂,因为用了许多层嵌套结构。
有个简单的办法,能够改写这段代码,那就是用可迭代的容器对象定义一个有状态的闭包。
1 | class Timer: |
这样写所输出的结果与前面一样,但是这种实现方案理解起来要容易得多
凡是想用生成器与异常来实现的功能,通常都可以改用异步机制去做。
如果确实遇到了这里讲到的这种需求,那么应该通过可迭代的类来实现生成器,而不要用throw方法注入异常。
第36条、考虑用itertools拼装迭代器与生成器
Python内置的itertools模块里有很多函数,可以用来安排迭代器之间的交互关系。
如果要实现比较难写的迭代逻辑,那么应该先查看itertools的文档(在Python解释器界面输入help(itertools)
)。
连接多个迭代器
内置的itertools模块有一些函数可以把多个迭代器连成一个使用。
chain
可以把多个迭代器从头到尾连成一个迭代器。repeat
可以制作这样一个迭代器,它会不停地输出某个值。调用repeat时,也可以通过第二个参数指定迭代器最多能输出几次。cycle
可以制作这样一个迭代器,它会循环地输出某段内容之中的各项元素tee
可以让一个迭代器分裂成多个平行的迭代器,具体个数由第二个参数指定。如果这些迭代器推进的速度不一致,那么程序可能要用大量内存做缓冲,以存放进度落后的迭代器将来会用到的元素。zip_longest
与Python内置的zip函数类似,但区别在于,如果源迭代器的长度不同,那么它会用fillvalue参数的值来填补提前耗尽的那些迭代器所留下的空缺。
过滤源迭代器中的元素
Python内置的itertools模块里有一些函数可以过滤源迭代器中的元素。
islice
可以在不拷贝数据的前提下,按照下标切割源迭代器。可以只给出切割的终点,也可以同时给出起点与终点,还可以指定步进值。takewhile
会一直从源迭代器里获取元素,直到某元素让测试函数返回False为止。dropwhile
与takewhile
相反,dropwhile会一直跳过源序列里的元素,直到某元素让测试函数返回True为止,然后它会从这个地方开始逐个取值。filterfalse
和内置的filter函数相反,它会逐个输出源迭代器里使得测试函数返回False的那些元素。
用源迭代器中的元素合成新元素
Python内置的itertools模块里,有一些函数可以根据源迭代器中的元素合成新的元素。
accumulate
会从源迭代器里取出一个元素,并把已经累计的结果与这个元素一起传给表示累加逻辑的函数,然后输出那个函数的计算结果,并把结果当成新的累计值。product
会从一个或多个源迭代器里获取元素,并计算笛卡尔积(Cartesian product),它可以取代那种多层嵌套的列表推导代码permutations
会考虑源迭代器所能给出的全部元素,并逐个输出由其中N个元素形成的每种有序排列(permutation)方式,元素相同但顺序不同,算作两种排列。combinations
会考虑源迭代器所能给出的全部元素,并逐个输出由其中N个元素形成的每种无序组合(combination)方式,元素相同但顺序不同,算作同一种组合。combinations_with_replacement
与combinations
类似,但它允许同一个元素在组合里多次出现。
5.类与接口
第37条、用组合起来的类来实现多层结构,不要用嵌套的内置类型
Python内置的字典类型,很适合维护对象在生命期内的动态内部状态。所谓动态的(dynamic),是指我们无法获知那套状态会用到哪些标识符。
例如,如果要用成绩册(Gradebook)记录学生的分数,而我们又没办法提前确定这些学生的名字,那么受到记录的每位学生与各自的分数,对于Gradebook对象来说,就属于动态的内部状态。
1 | class SimpleGradebook: |
字典与相关的内置类型用起来很方便,但同时也容易遭到滥用导致代码出问题。例如,我们现在要扩展这个SimpleGradebook类的功能,让它按照科目保存成绩,而不是把所有科目的成绩存在一起。通过修改_grades字典的用法,使它必须把键(也就是学生的名字)与另一个小字典相对应,而不是像刚才那样,直接与列表对应起来。那份小字典以各科的名称作键与一份列表对应起来,以保存学生在这一科的全部考试成绩。这次笔者用defaultdict来实现这个小字典,这样可以方便地处理科目名称还不存在的那些情况。
1 | from collections import defaultdict |
如果遇到的是类似这种比较复杂的需求,那么不要再嵌套字典、元组、集合、列表等内置的类型了,而是应该编写一批新类并让这些类形成一套体系。
把多层嵌套的内置类型重构为类体系
namedtuple的局限:namedtuple类无法指定默认的参数值[1]。如果数据的可选属性比较多,那么采用这种类来表示,会很不方便。在属性较多的情况下,应该改用内置的dataclasses模块实现。namedtuple实例的属性值仍然可以通过数字下标与迭代来访问,所以可能还是会有人(尤其是那些通过你发布的API来编程的人)会采用这种方式访问这些属性,这样的话,将来就不太容易把它转成普通的类了。如果无法完全控制这些namedtuple实例的用法,那么最好还是明确定义一个新的类。
有了叫作Grade的具名元组,我们就可以写出表示科目的Subject类,让它容纳许多个这样的元组。
1 | class Subject: |
然后,就可以写一个表示学生的Student类,用它来记录某位学生各科目(Subject)的考试成绩。
1 | class Student: |
最后,写这样一个表示成绩册的Gradebook容器类,把每位学生的名字与表示这位学生的Student对象关联起来,如果成绩册里还没有记录过这位学生,那么在调用get_student方法时,Gradebook就会构造一个默认的Student对象给调用者使用。
1 | class Gradebook: |
虽然比原来那种写法长了一倍,但理解起来却要容易得多。而且用这些类写出来的调用代码,也比原来更清晰、更便于扩展。
不要在字典里嵌套字典、长元组,以及用其他内置类型构造的复杂结构。
第38条、让简单的接口接受函数,而不是类的实例
Python有许多内置的API,都允许我们传入某个函数来定制它的行为。这种函数可以叫作挂钩(hook),API在执行过程中,会回调(call back)这些挂钩函数。
例如,list类型的sort方法就带有可选的key参数,如果明确指定了这个参数,那么它就会按照你提供的挂钩函数来决定列表中每个元素的先后顺序。下面的代码把内置的len函数当成挂钩传给key参数,让sort方法根据长度排列这些名字。
1 | names = ['Scorates', 'Archimedes', 'Plato', 'Aristotle'] |
在其他编程语言中,挂钩可能会用抽象类(abstract class)来定义。但在Python中,许多挂钩都是无状态的函数(stateless function),带有明确的参数与返回值。挂钩用函数来描述,要比定义成类更简单。用作挂钩的函数与别的函数一样,都是Python里的头等(first-class)对象,也就是说,这些函数与方法可以像Python中其他值那样传递与引用。
例如,我们要定制defaultdict类的行为。这种defaultdict数据结构允许调用者提供一个函数,用来在键名缺失的情况下,创建与这个键相对应的值。只要字典发现调用者想要访问的键不存在,就会触发这个函数,以返回应该与键相关联的默认值。下面定义一个log_missing函数作为键名缺失时的挂钩,该函数总是会把这种键的默认值设为0。
1 | def log_missing(): |
下面这段代码通过定制的defaultdict字典,把increments列表里面描述的增量添加到current这个普通字典所提供的初始量上面,但字典里一开始没有’red’和’orange’这两个键,因此log_missing这个挂钩函数会触发两次,每次它都会打印’Key added’信息。
1 | from collections import defaultdict |
通过log_missing这样的挂钩函数,我们很容易构建出便于测试的API,这种API可以把挂钩所实现的附加效果(side effect)与数据本身所应具备的确定行为分开。
例如,假设我们要在传给defaultdict的挂钩里面,统计它总共遇到了多少次键名缺失的情况。要实现这项功能,其中一个办法是采用有状态的闭包(stateful closure,参见第21条)。下面就定义一个辅助函数,把missing闭包当作挂钩传给defaultdict字典,以便为缺失的键提供默认值。
1 | def increment_with_report(current, increment): |
统计键名缺失次数所用的added_count状态是由missing挂钩维护的,采用挂钩来运作的defaultdict字典并不需要关注这个细节。于是,这就体现了把简单函数传给接口的另一个好处,也就是方便稍后添加新的功能,因为我们可以把实现这项功能所用的状态隐藏在这个简单的闭包里面。
1 | result, count = increment_with_report(current, increments) |
与无状态的闭包函数相比,用有状态的闭包作为挂钩写出来的代码会难懂一些。为了让代码更清晰,可以专门定义一个小类,把原本由闭包所维护的状态给封装起来。
1 | class CountMissing: |
在其他编程语言中,可能需要修改defaultdict以便与CountMissing接口相适应。但在Python中,方法与函数都是头等的(first-class)对象,因此可以直接通过对象引用它所属的CountMissing类里的missing方法,并把这个方法传给defaultdict充当挂钩,让字典可以用这个挂钩制作默认值。
在Python中,这种通过对象实例而引用的方法,很容易就能满足函数的接口
1 | counter = ConutMissing() |
把有状态的闭包所具备的行为,改用辅助类来实现,要比前面的increment_with_report函数更清晰。但如果单看这个类,可能没办法立刻了解它的意图。
为了让这个类的意义更加明确,可以给它定义名为__call__
的特殊方法。这会让这个类的对象能够像函数那样得到调用。同时,也让内置的callable函数能够针对这种实例返回True值,用以表示这个实例与普通的函数或方法类似,都是可调用的。凡是能够像这样(在后面加一对括号来)执行的对象,都叫作callable。
1 | class BetterCountMissing: |
下面,就用这样的BetterCountMissing实例给defaultdict当挂钩,让它在字典里没有键名时,创建默认的键值,并把这种情况记入键名缺失的总次数里。
1 | counter = BetterCountMissing() |
上面这段代码要比CountMissing更清晰,因为它里面有__call__
方法,这说明这个类的实例可像普通的函数那样使用(例如可以传给API当挂钩)。即便是初次看到这段代码,也能明白这个类的主要目标。因为你应该会注意到那个比较显眼的__call__
方法。它强烈暗示着这个类可以像有状态的闭包那样使用。
总之,最大的优势在于,defaultdict仍然不需要关注__call__
方法触发之后究竟会做什么。它只知道自己可以用这样一个挂钩,来给缺失的键制作默认值。Python很容易就能设计这种把挂钩函数当参数来用的接口,面对这种接口,调用者可以采用最适合自己的,把符合接口要求的东西传进去。
第39条、通过@classmethod多态来构造同一体系中的各类对象
在Python中,不仅对象支持多态,类也支持多态。
多态机制使同一体系中的多个类可以按照各自独有的方式来实现同一个方法,这意味着这些类都可以满足同一套接口,或者都可以当作某个抽象类来使用,同时,它们又能在这个前提下,实现各自的功能。
例如,要实现一套MapReduce(映射-归纳/映射-化简)流程,并且以一个通用的类来表示输入数据。
1 | class InputData: |
然后,编写一个具体的InputData子类,例如,可以从磁盘文件中读取数据的PathInputData类。
1 | class PathInputData(InputData): |
通用的InputData类以后可能会有很多个像PathInputData这样的子类,每个子类都会实现标准的read接口,并按照各自的方式把需要处理的数据读取进来。例如,有的InputData子类可从网上读取数据,有的InputData可读取压缩格式的数据并将其解压成普通数据,等等。
除了输入数据要通用,我们还想让处理MapReduce任务的工作节点(Worker)也能有一套通用的抽象接口,这样不同的Worker就可以通过这套标准的接口来消耗输入数据。
1 | class Worker: |
定义一种具体的Worker子类,使它按照特定的方式实现MapReduce。也就是统计每份数据里的换行符个数,然后把所有的统计值汇总起来。
1 | class LineCountWorker(Worker): |
这样实现似乎不错,但接下来会碰到一个大难题,也就是如何把这些组件拼接起来。输入数据与工作节点都有各自的类体系,而且这两套体系也抽象出了合理的接口,然而,它们都必须落实到具体的对象上面,只有构造出了具体对象,才能写出有用的程序。
在其他编程语言中,可以利用构造函数多态(constructor polymorphism)来解决,也就是子类不仅要具备与超类一致的构造函数,而且还必须各自提供一个特殊的构造函数以实现和自身有关的构造逻辑。这样,刚才那些辅助方法在编排MapReduce流程时,就可以按照超类的形式统一地构造这些对象,并使其根据所属的子类分别去触发相关的特殊构造函数(类似工厂模式)。但是我们在Python里不能这样做,因为Python的类只能有一个构造方法(即__init__
方法),没办法要求所有的InputData子类都采用同一种写法来定义__init__
(因为它们必须用各自不同的数据来完成构造)。
我们现在运用方法多态来实现MapReduce流程所用到的这些类。首先改写InputData类,把generate_inputs方法放到该类里面并声明成通用的@classmethod
,这样它的所有子类都可以通过同一个接口来新建具体的InputData实例。
1 | class GenericInputData: |
新的generate_inputs方法带有一个叫作config的字典参数,调用者可以把一系列配置信息放到字典里中,让具体的GenericInputData子类去解读。例如PathInputData这个子类就会通过’data_dir’键从字典里寻找含有输入文件的那个目录。
1 | class PathInputData(GenericInputData): |
然后,可以用类似的思路改写前面的Worker类。把名叫create_workers的辅助方法移到这个类里面并且也声明成@classmethod
。新方法的input_class参数将会是GenericInputData的某个子类,我们要通过这个参数触发那个子类的generate_inputs方法,以创建出Worker所需的输入信息。有了输入信息之后,通过cls(input_data)这个通用的形式来调用构造函数,这样创建的实例,其类型是cls所表示的具体GenericWorker子类。
1 | class GenericWorker: |
上面的代码创建输入信息时,用的是input_class.generate_inputs这样的写法,这么写正是为了触发类多态机制,以便将generate_inputs派发到input_class所表示的那个实际子类上面。另外还要注意,在构造GenericWorker的子类对象时,用的是cls(…)这样的通用写法,而没有直接调用__init__
方法。
接下来要修改具体的Worker类,这其实很简单,只需要把超类的名称改为GenericWorker就好。
1 | class LineCountWorker(GerericWorker): |
最后,重新编写mapreduce函数,让它通过worker_class.create_workers来创建工作节点,这样它就变得通用了。
1 | def mapreduce(worker_class, input_class, config): |
把原来那套实现方案所处理的随机文件,再采用这套新的工作节点来处理,可以产生相同的结果。区别只在于,这次调用mapreduce时,必须多传几个参数,因为它现在是个通用的函数,必须把实际的输入数据与实际的工作节点告诉它。
1 | config = {'data_dir': tmpdir} |
这套方案让我们能够随意编写其他的GenericInputData与GenericWorker子类,而不用再花时间去调整它们之间的拼接代码(glue code)。
第40条、通过super初始化超类
以前有种简单的写法,能在子类里面执行超类的初始化逻辑,那就是直接在超类名称上调用__init__
方法并把子类实例传进去。
1 | class MyBaseClass: |
这个办法能够应对比较简单的类体系,但是在其他的情况下容易出现问题。
假如某个类继承了多个超类,那么直接调用超类的__init__
方法会让代码产生误会。
直接调用__init__
方法所产生的第一个问题在于,超类的构造逻辑不一定会按照它们在子类class语句中的声明顺序执行。例如,在MyBaseClass之外再定义两个类,让它们也分别去操纵本实例的value字段。
1 | class TimesTwo: |
下面这个子类继承了刚才那三个类,而且它在class语句里指定的超类顺序与它执行那些超类的init时所用的顺序一致。
1 | class OneWay(MyBaseClass, TimesTwo, PlusFive): |
这样写,程序会按正常顺序初始化那几个超类。
1 | foo = OneWay(5) |
但如果子类在class语句里指定的超类顺序,与它执行那些超类的__init__
时的顺序不同,那么运行结果就会让人困惑(例如这次先声明它继承PlusFive类,然后才声明它继承TimesTwo类,但执行__init__
的顺序却刚好相反)。
1 | class AnotherWay(MyBaseClass, PlusFive, TimesTwo): |
该子类调整了两个超类的声明顺序,但没有相应调整构造逻辑的执行顺序,因此它还是会跟前面那个子类一样,先初始化TimesTwo,然后初始化PlusFive。这样写,就让初次看到这段代码的人很难理解程序的运行结果,他们以为程序先加5,再乘2,这样算出来是20;但实际上,程序依照的是__init__
的调用顺序,而不是class语句中的声明顺序,这是个很难察觉的问题。
直接调用__init__
所产生的第二个问题在于,无法正确处理菱形继承(diamond inheritance)。这种继承指的是子类通过类体系里两条不同路径的类继承了同一个超类。如果采用刚才那种常见的写法来调用超类的__init__
,那么会让超类的初始化逻辑重复执行,从而引发混乱。例如,下面先从MyBaseClass派生出两个子类。
1 | class TimesSeven(MyBaseClass): |
然后,定义最终的子类,让它分别继承刚才那两个类,这样MyBaseClass就会出现在菱形体系的顶端。
1 | class ThisWay(TimesSeven, PlusNine): |
当ThisWay调用第二个超类的__init__
时,那个方法会再度触发MyBaseClass的init,导致self.value重新变成5。所以,最后的结果是5 + 9 = 14,而不是(5 * 7) + 9= 44,因为早前由TimesSeven.__init__
所做的初始化效果已经被第二次执行的MyBaseClass.__init__
覆盖了。这是个违背直觉的结果,如果情况更为复杂,那么调试起来会特别困难。
为了解决这些问题,Python内置了super函数并且规定了标准的方法解析顺序(method resolution order,MRO)。super能够确保菱形继承体系中的共同超类只初始化一次。MRO可以确定超类之间的初始化顺序,它遵循C3线性化(C3 linearization)算法。
改用super()
来调用超类的初始化逻辑
1 | class TimeSevenCorrect(MyBaseClass): |
位于菱形结构顶端的MyBaseClass,会率先初始化,而且只会初始化一次。接下来,程序会参照菱形底端那个子类在class语句里声明超类时的顺序,来执行菱形结构中部的那两个超类。
1 | class GoodWay(TimesSevenCorrect, PlusNineCorrect): |
这个执行顺序,似乎与看上去的相反。既然GoodWay在指定超类时,先写的是TimesSevenCorrect,那就应该先执行TimesSevenCorrect.__init
__才对,这样结果应该是(5* 7) + 9 = 44。但实际上并非如此。这两个超类之间的初始化顺序,要由子类的MRO确定,它可以通过mro方法来查询。
1 | mro_str = '\n'.join(repr(cls) for cls in GoodWay.mro()) |
调用GoodWay(5)时,会先触发TimesSevenCorrect.__init__
,进而触发PlusNine-Correct.__init__
,而这又会触发MyBaseClass.__init__
。程序到达菱形结构的顶端后,开始执行MyBaseClass的初始化逻辑,然后按照与刚才相反的顺序,依次执行PlusNineCorrect、TimesSevenCorrect与GoodWay的初始化逻辑。所以,程序首先会在MyBaseClass.__init__
中,把value设为5,然后在PlusNineCorrect.init里面给它加9,这样就成了14,接着又会在TimesSevenCorrect.init里面将它乘7,于是等于98。
除了可以应对菱形继承结构,通过super()
调用__init__
,与在子类内通过类名直接调用__init__
相比,可使代码更容易维护。现在不用从子类里面指名调用MyBaseClass.__init__
方法了,因此可以把MyBaseClass改成其他名字,或者让TimesSevenCorrect与PlusNineCorrect从另外一个超类里面继承,这些改动都无须调整super这一部分的代码。假如像原来那样写,那就必须手工修改__init__
方法前面的类名。
super函数也可以用双参数的形式调用。第一个参数表示从这个类型开始(不含该类型本身)按照方法解析顺序(MRO)向上搜索,而解析顺序则要由第二个参数所在类型的__mro__
决定。例如,按照下面这种写法,如果在super所返回的内容上调用init方法,那么程序会从ExplicitTrisect类型开始(不含该类型本身)按照MRO向上搜索,直至找到这样的__init__
方法为止,而解析顺序是由第二个参数(self)所属的类型(ExplicitTrisect)决定的,所以解析顺序是ExplicitTrisect -> MyBaseClass -> object。
1 | class ExplicitTrisect(MyBaseClass): |
一般来说,在类的__init__
方法里面通过super初始化实例时,不需要采用双参数的形式,而是可以直接采用不带参数的写法调用super,这样Python编译器会自动将__class__
和self当成参数传递进去。所以,下面这两种写法跟刚才那种写法是同一个意思。
1 | class AutomaticTrisect(MyBaseClass): |
只有一种情况需要明确给super指定参数,这就是:我们想从子类里面访问超类对某项功能所做的实现方案,而那种方案可能已经被子类覆盖掉了(例如,在封装或复用功能时,就会遇到这样的情况)。
第41条、考虑用mix-in类来表示可组合的功能
Python是面向对象的编程语言,而且内置了相关的机制,使开发者能够正确处理多重继承。尽管如此,但还是应该尽量少用多重继承。
如果既要通过多重继承来方便地封装逻辑,又想避开可能出现的问题,那么就应该把有待继承的类写成mix-in类。这种类只提供一小套方法给子类去沿用,而不定义自己实例级别的属性,也不需要__init__
构造函数。
在Python里很容易编写mix-in,因为无论对象是什么类型,我们都可以方便地检视(inspect)它当前的状态。这种动态检测机制,让我们只需要把通用的功能在mix-in实现一遍即可,将来也可以把这项功能应用到其他许多类里面。可以把这些mix-in类有层次地组合起来,从而用相当少的代码表达出丰富的功能。
例如,现在要实现这样一个功能,把内存中的Python对象表示成字典形式以便做序列化(serialization)处理。不妨将这项功能写为通用代码,以供其他类使用。
1 | class ToDictMixin: |
具体的实现代码写得很直观,我们可以通过isinstance函数动态地检视值的类型,并利用hasattr函数判断值里面有没有叫作__dict__
的字典。
1 | def _traverse_dict(self, instance_dict): |
下面以二叉树为例,演示如何使表示二叉树的BinaryTree类具备刚才那个mix-in所提供的功能。
1 | class BinaryTree(ToDictMixin): |
定义了这样的BinaryTree类后,很容易就能把二叉树里面那些相互关联的Python对象转换成字典的形式。
1 | tree = BinaryTree(10, left=BinaryTree(7, right=BinaryTree(9)), right=BinaryTree(13, left=BinaryTree(11))) |
mix-in最妙的地方在于,子类既可以沿用它所提供的功能,又可以对其中一些地方做自己的处理。
例如,我们从普通的二叉树(BinaryTree)派生了一个子类,让这种特殊的BinaryTreeWithParent二叉树能够把指向上级节点的引用保留下来。但问题是,这种二叉树的to_dict方法是从ToDictMixin继承来的,它所触发的_traverse方法,在面对循环引用时,会无休止地递归下去。
1 | class BinaryTreeWithParent(BinaryTree): |
为了避免无限循环,我们可以覆盖BinaryTreeWithParent._traverse方法,让它对指向上级节点的引用做专门处理,而对于其他的值,则继续沿用从mix-in继承的_traverse逻辑。下面这段代码,首先判断当前值是不是指向上级节点的引用。如果是,就直接返回上级节点的value值;如果不是,那就通过内置的super函数沿用由mix-in超类所给出默认实现方案。
1 | def _traverse(self, key, value): |
现在调用BinaryTreeWithParent.to_dict就没有问题了,因为它所触发的是BinaryTreeWithParent自己的_traverse方法,该方法不会再递归地处理循环引用。
1 | root = BinaryTreeWithParent(10) |
只要BinaryTreeWithParent._traverse没问题,带有BinaryTreeWithParent属性的其他类就可以直接继承ToDictMixin,这样的话,程序在把这种对象转化成字典时,会自动对其中的BinaryTreeWithParent属性做出正确处理。
1 | class NamedSubTree(ToDictMixin): |
多个mix-in可以组合起来用。例如,我们要再写一个mix-in,让所有的类都可以通过继承它来实现JSON序列化功能。在编写这个mix-in时,假设继承了它的那个类肯定有自己的to_dict方法(这个方法有可能是从另一个mix-in(如ToDictMixin)继承的)。
1 | import json |
请注意,JsonMixin既定义了实例方法,也定义了类方法。于是,继承了这个mix-in的其他类也会拥有这两种行为。在本例中,继承JsonMixin的类只需要提供to_dict方法以及能够接受关键字参数的__init__
方法即可。
有了这样两个mix-in,我们很容易就能创建一套含有工具类的体系,让其中的各种类型都可以把对象序列化成JSON格式并且能够根据JSON格式的数据创建这样的对象。而这只需要开发者按照固定的样式多写一点点代码即可。例如,可以用这样一套由数据类所构成的体系表示数据中心的各种设备与它们之间的结构关系。
1 | class DatacenterRack(ToDictMixin, JsonMixin): |
这样写之后,我们很容易就能根据JSON格式的信息把这些对象还原出来,另外,也可以把它们再序列化成JSON格式。下面我们就先把serialized变量所指的一段JSON信息反序列化(也就是还原)成DatacenterRack对象,然后再把这个对象序列化成JSON信息并保存到roundtrip变量,最后通过json.loads方法验证这两个变量中的信息是否等效。
对于JsonMixin这样的mix-in来说,即便直接继承它的那个类还通过类体系中的其他更高层类型间接地继承了它,程序也依然能够正常运行,因为Python可以把相关的方法正确地派发给JsonMixin类。
第42条、优先考虑用public属性表示应受保护的数据,不要用private属性表示
Python类的属性只有两种访问级别,也就是public与private。
public属性能够公开访问,只需要在对象后面加上圆点操作符(dot operator),并写出属性的名称即可。
如果属性名以两个下划线开头,那么即为private字段。属性所在的类可以通过实例方法访问该属性。
但如果在类的外面直接通过对象访问private字段,那么程序就会抛出异常。
类方法(@classmethod)可以访问本类的private属性,因为这种方法也是在这个类(class)的范围里面声明的。
1 | class MyOtherObject: |
private字段只给这个类自己使用,子类不能访问超类的private字段。
1 | class MyParentObject: |
这种防止其他类访问private属性的功能,其实仅仅是通过变换属性名称而实现的。当Python编译器看到MyChildObject.get_private_field这样的方法想要访问__private_field
属性时,它会把下划线和类名加在这个属性名称的前面,所以代码实际上访问的是_MyChildObject__private_field
。在上面的例子中,__private_field
是在MyParentObject的__init__
里面定义的,所以,它变换之后的真实名称是_MyParentObject__private_field
。子类不能通过__private_field
来访问这个属性,因为这样写实际上是在访问不存的_MyChildObject__private_field
,而不是_MyParentObject__private_field
。
了解名称变换规则后,我们就可以从任何一个类里面访问private属性。无论是子类还是外部的类,都可以不经许可就访问到这些属性。
1 | assert baz._MyParentObject__private_field == 71 |
查看该对象的属性字典,就会发现private属性的名称其实是以变换后的名称存储的。
1 | print(baz.__dict__) |
为什么Python不从语法上严格禁止其他类访问private属性呢?这可以用一句常见的Python格言来回答:我们都是成年人了(We are all consenting adults here)。意思是说,我们用不着让编程语言把自己给拦住。你可以按照自己的想法扩展某个类的功能,同时也必须考虑到这样做的风险,并为此负责。Python开发者相信,虽然开放访问权限可能导致别人不按默认方式扩展这个类,但总比封闭起来要好。
另外,Python里面还有一些挂钩函数可以访问到这种属性,我们可以通过这些机制按需操纵对象的内部数据。既然这样,那即便Python阻止我们通过圆点加名称的办法访问private属性,我们也还是有其他办法能访问到,那么Python阻止private属性访问又有何意义?
为了减少在不知情情况下访问内部数据而造成的损伤,Python开发者会按照风格指南里面建议的方式来给字段命名。以单下划线开头的字段(例如_protected_field),习惯上叫作受保护的(protected)字段,表示这个类以外的用户在使用这种字段时必须慎重。
尽管如此,有许多Python新手还是喜欢把内部的API设计成private字段,想通过这种写法防止子类或外部类访问这些字段。
如果把属性设为private,那么子类在覆盖或扩充这个类的时候,就必须采用变换之后的名称来访问那个属性,这会让代码变得很容易出错。例如,在写这样一个子类时,就不得不去访问超类中的private字段。
1 | class MyStringClass: |
这样写的问题是,如果类体系发生变动,那么引用private属性的那些代码就失效了。例如,MyIntegerSubclass类的直接超类(也就是MyStringClass)本身现在也继承自一个类(叫作MyBaseClass),而且它把早前的__value属性移到了那个类里面。
1 | class MyBaseClass: |
现在的__value
属性,是在MyBaseClass里面设置的,而不是在MyStringClass里面,所以MyIntegerSubclass没办法再通过self._MyStringClass__value访问这个属性。
一般来说,这种属性应该设置成protected字段,这样虽然有可能导致子类误用,但还是要比直接设为private好。我们可以在每个protected字段的文档里面详细解释,告诉用户这是属于可以由子类来操作的内部API,还是属于完全不应该触碰的数据。这样的文档,既可以给别人提供建议,也可以指导自己安全地扩充代码。
1 | class MyStringClass: |
只有一种情况是可以考虑用private属性解决的,就是子类属性有可能与超类重名的情况。如果子类定义属性时使用的名称,恰巧与超类相同,那就会出现这个问题。
1 | class ApiClass: |
如果超类属于开放给外界使用的API,那么你就没办法预料哪些子类会继承它,也不知道那些子类会添加什么属性,此时很有可能出现这个问题,而且你无法通过重构代码来解决。属性名越常见(如本例中的value),越容易发生冲突。为了减少冲突,我们可以把超类的属性设计成private属性,使子类的属性名不太可能与超类重复。
第43条、自定义的容器类型应该从collections.abc继承
编写Python程序时,要花很多精力来定义类,以存放数据并描述这种对象与其他对象之间的关系。每个Python类其实都是某种容器,可以把属性与功能封装进来。除了自定义的类之外,Python本身还提供了一些内置的容器类型,例如列表(list)、元组(tuple)、集合(set)、字典(dict)等,也可以用来管理数据。
如果要定义的是那种用法比较简单的类,那么我们自然就会想到直接从Python内置的容器类型里面继承,例如通过继承list类型实现某种序列。
1 | class FrequencyList(list): |
继承list类,可以自动获得标准的Python列表所具备的各项功能。这样的话,其他开发者就可以像使用普通列表那样使用FrequencyList了。此外,我们还可以定义其他一些方法,来提供想要实现的各种行为。
1 | foo = FrequencyList(['a', 'b', 'a', 'c', 'b', 'a', 'd']) |
有的时候,某个对象所属的类本身虽然不是list的子类,但我们还是想让它能像list那样,可以通过下标来访问。例如,下面这个表示二叉树节点的BinaryNode类就不是list的子类,但我们想让它能够像序列(list或tuple等)那样,通过下标来访问。
1 | class BinaryNode: |
让这个类可以像序列一样访问需要实现一些名称特殊的实例方法。当通过下标访问序列中的元素时:
1 | bar = [1, 2, 3] |
Python会把访问操作解读为:
1 | bar.__getitem__(0) |
所以,为了让BinaryNode类能像序列那样使用,我们可以定义__getitem__
方法(一般叫作dunder getitem,其中的dunder为double underscore(双下划线)的简称)。这个方法可以按照深度优先的方式遍历BinaryNode对象所表示的二叉树。
1 | class IndexableNode(BinaryNode): |
我们可以像使用BinaryNode那样,用这种定制过的IndexableNode对象来构造二叉树。
但问题是,除了下标索引,list实例还支持其他一些功能,所以只实现__getitem__
这样一个特殊方法是不够的。例如,我们现在还是没办法像查询list长度那样查询这种二叉树的长度(元素总数)。
要想让定制的二叉树支持内置的len函数,必须再实现一个特殊方法,也就是__len__
方法。
1 | class SequenceNode(IndexableNode): |
实现完这样两个方法之后,我们仍然没办法让这种二叉树具备列表所应支持的全套功能。因为,有些Python开发者可能还想在二叉树上面调用count与index等方法,他们觉得,既然list或tuple这样的序列支持这些方法,那二叉树也应该支持才对。这样看来,要定制一个与标准容器兼容的类,似乎比想象中麻烦。
Python内置的collections.abc模块定义了一系列抽象基类(abstract base class),把每种容器类型应该提供的所有常用方法都写了出来。我们只需要从这样的抽象基类里面继承就好。同时,如果忘了实现某些必备的方法,那么程序会报错,提醒我们这些方法必须实现。
1 | from collections.abc import Sequence |
如果这些必备的方法都已经实现好了,那我们就可以从collections.abc模块的抽象基类里面继承了。例如,下面这个BetterNode二叉树类就是正确的,因为它已经通过继承前面的SequenceNode类实现了序列容器所应支持的全部必备方法,至于其他一些方法(例如index与count)则会由Sequence这个抽象基类自动帮我们实现(它在实现的时候,会借助BetterNode从SequenceNode继承的那些必备方法)。
1 | class BetterNode(SequenceNode, Sequence): |
对于定制集合或可变映射等复杂的容器类型来说,继承collections.abc模块里的Set或MutableMapping等抽象基类所带来的好处会更加明显。假如自己从头开始实现,那必须编写大量的特殊方法才能让这些容器类的对象也能像标准的Python容器那样使用。collections.abc模块要求子类必须实现某些特殊方法,另外,Python在比较或排列对象时,还会用到其他一些特殊方法,无论定制的是不是容器类,有时为了支持某些功能,你都必须定义相关的特殊方法才行。
6.元类与属性
元类(metaclass)是一种在类之上、超乎于类的概念。元类能够拦截Python的class语句,让系统每次定义类的时候,都能实现某些特殊的行为。
第44条、用纯属性与修饰器取代旧式的setter与getter方法
从其他编程语言转入Python的开发者,可能想在类里面明确地实现getter与setter方法。
1 | class OldResistor: |
虽然这些setter与getter用起来很简单,但这并不符合Python的风格。
1 | r0 = OldResistor(50e3) |
例如,想让属性值变大或者变小,采用这些方法来写会特别麻烦。
1 | r0.set_ohms(r0.get_ohms() - 4e3) |
这种工具方法确实有助于将类的接口定义得更加清晰,并方便开发者封装功能、验证用法、划定界限。这些都是在设计类时应该考虑的目标,实现这些目标可以确保我们在完善这个类的过程中不影响已经写好的调用代码。可是,在Python中实现这些目标时,没必要明确定义setter与getter方法。而是应该从最简单的public属性开始写起,例如像下面这样:
1 | class Resistor: |
按照这种写法,很容易就能实现原地增减属性值。
将来如果想在设置属性时,实现特别的功能,那么可以先通过@property
修饰器来封装获取属性的那个方法,并在封装出来的修饰器上面通过setter属性来封装设置属性的那个方法。下面这个新类继承自刚才的Resistor类,它允许我们通过设置voltage(电压)属性来改变current(电流)。为了正确实现这项功能,必须保证设置属性与获x取属性所用的那两个方法都跟属性同名。
1 | class VoltageResistance(Resistor): |
按照这种写法,给voltage属性赋值会触发同名的setter方法,该方法会根据新的voltage计算本对象的current属性。
1 | r2 = VoltageResistance(1e3) |
为属性指定setter方法还可以用来检查调用方所传入的值在类型与范围上是否符合要求。例如,下面这个Resistor子类可以确保用户设置的电阻值总是大于0的。
1 | class BoundedResistance(Resistor): |
给这个类的属性设置无效电阻值,程序会抛出异常。
1 | r3 = BoundedResistance(1e3) |
如果构造时所用的值无效,那么同样会触发异常。
之所以会出现这种效果,是因为子类的构造器(BoundedResistance.init)会调用超类的构造器(Resistor.init),而超类的构造器会把self.ohms设置成-5。于是,就会触发BoundedResistance里面的`@ohms.setter方法,该方法立刻发现属性值无效,所以程序在对象还没有构造完之前,就会抛出异常。我们还可以利用
@property`阻止用户修改超类中的属性。
1 | class FixedResistance(Resistor): |
构造好对象之后,如果试图给属性赋值,那么程序就会抛出异常。
1 | r4 = FixedResistance(1e3) |
用@property实现setter与getter时,还应该注意不要让对象产生反常的行为。例如,不要在某属性的getter方法里面设置其他属性的值。
1 | class MysteriousResistor(Resistor): |
假如在获取属性的getter方法里面修改了其他属性的值,那么用户查询这个属性时,就会觉得相当奇怪,他可能不理解为什么另外一个属性会在我查询这个属性时发生变化。
最好的办法是,只在@property.setter方法里面修改状态,而且只应该修改对象之中与当前属性有关的状态。同时还得注意不要产生让调用者感到意外的其他一些副作用,例如,不要动态地引入模块,不要运行速度较慢的辅助函数,不要做I/O,不要执行开销较大的数据库查询操作等。类的属性用起来应该跟其他的Python对象一样方便而快捷。如果确实要执行比较复杂或比较缓慢的操作,那么应该用普通的方法来做,而不应该把这些操作放在获取及设置属性的这两个方法里面。
@property最大的缺点是,通过它而编写的属性获取及属性设置方法只能由子类共享。与此无关的类不能共用这份逻辑。但是没关系,Python还支持描述符,我们可以利用这种机制把早前编写的属性获取与属性设置逻辑复用到其他许多地方。
第45条、考虑用@property实现新的属性访问逻辑,不要急着重构原有的代码
@property还有一种更为高级的用法,其实也很常见,这就是把简单的数值属性迁移成那种实时计算的属性。这个用法的意义特别大,因为它可以确保,按照旧写法来访问属性的那些代码依然有效,而且会自动按照新逻辑执行,也不需要重写原来那些访问代码(这一点相当关键,因为那些代码未必都在你控制之下)。@property可以说是一种重要的缓冲机制,使开发者能够逐渐改善接口而不影响已经写好的代码。
可以利用@property给已有的实例属性增加新的功能。可以利用@property逐渐改善数据模型而不影响已经写好的代码。
@property可以帮助解决实际工作中的许多问题,但不应该遭到滥用。如果你发现自己总是在扩充@property方法,那可能说明这个类确实应该重构了。在这种情况下,就不要再沿着糟糕的方案继续往下写了。
第46条、用描述符来改写需要复用的@property方法
Python内置的@property机制的最大的缺点就是不方便复用。我们不能把它修饰的方法所使用的逻辑,套用在同一个类的其他属性上面,也不能在无关的类里面复用。
例如,我们要编写一个类来记录学生的家庭作业成绩,而且要确保设置的成绩位于0到100之间。
1 | class Homework: |
受@property修饰的属性用起来很简单。
1 | galileo = Homework() |
假设,我们还需要写一个类记录学生的考试成绩,而且要把每科的成绩分别记录下来。
1 | class Exam: |
这样写很费事,因为每科的成绩都需要一套@property方法,而且其中设置属性值的那个方法还必须调用_check_grade验证新值是否位于合理的范围内。
在Python里,这样的功能最好通过描述符(descriptor)实现。描述符协议(descriptor protocol)规定了程序应该如何处理属性访问操作。充当描述符的那个类能够实现__get__
与__set__
方法,这样其他类就可以共用这个描述符所实现的逻辑而无须把这套逻辑分别重写一遍。
下面重新定义Exam类,这次我们采用类级别的属性来实现每科成绩的访问功能,这些属性指向下面这个Grade类的实例,而这个Grade类则实现刚才提到的描述符协议。
1 | class Grade: |
当程序访问Exam实例的某个属性时,Python如何将访问操作派发到Exam类的描述符属性上面。例如,如果要给Exam实例的writing_grade属性赋值:
1 | exam = Exam() |
那么Python会把这次赋值操作转译为:
1 | Exam.__dict__['writing_grade'].__set__(exam, 40) |
获取这个属性时也一样:
1 | Exam.__dict__['writing_grade'].__get__(exam) # exam.writing_grade |
这样的转译效果是由object的__getattribute__
方法促成的。
简单地说,就是当Exam实例里面没有名为writing_grade的属性时,Python会转而在类的层面查找,查询Exam类里面有没有这样一个属性。如果有,而且还是个实现了__get__
与__set__
方法的对象,那么系统就认定你想通过描述符协议定义这个属性的访问行为。
知道了这条规则之后,我们来尝试把Homework类早前用@property实现的成绩验证逻辑搬到Grade描述符里面。
1 | class Grade: |
这样写其实不对,而且会让程序出现混乱。但在同一个Exam实例上面访问不同的属性是没有问题的。
出现这种问题的原因在于,这些Exam实例之中的writing_grade属性实际上是在共享同一个Grade实例。在整个程序的运行过程中,这个Grade只会于定义Exam类时构造一次,而不是每创建一个Exam实例都有一个新的Grade来与writing_grade属性相搭配。为解决此问题,我们必须把每个Exam实例在这个属性上面的取值都记录下来。可以通过字典实现每个实例的状态保存。
1 | class Grade: |
这种实现方案很简单,而且能得到正确结果,但仍然有一个缺陷,就是会泄漏内存。在程序运行过程中,传给__set__
方法的那些Exam实例全都会被Grade之中的_values字典所引用。于是,指向那些实例的引用数量就永远不会降到0,这导致垃圾回收器没办法把那些实例清理掉。
为了解决这个问题,我们可以求助于Python内置的weakref模块。该模块里有一种特殊的字典,名为WeakKeyDictionary,它可以取代刚才实现_values时所用的普通字典。这个字典的特殊之处在于:如果运行时系统发现,指向Exam实例的引用只剩一个,而这个引用又是由WeakKeyDictionary的键所发起的,那么系统会将该引用从这个特殊的字典里删掉,于是指向那个Exam实例的引用数量就会降为0。总之,改用这种字典来实现_values会让Python系统自动把内存泄漏问题处理好,如果所有的Exam实例都不再使用了,那么_values字典肯定是空的。
1 | from weakref import WeakKeyDictionary |
不要太纠结于__getattribute__
是怎么通过描述符协议来获取并设置属性的。
第47条、针对惰性属性使用__getattr__
、__getattribute__
及__setattr__
Python的object提供了一套挂钩,使开发者很容易就能写出通用的代码,将不同的系统粘合到一起。
如果类中定义了__getattr__
,那么每当访问该类对象的属性,而且实例字典里又找不到这个属性时,系统就会触发__getattr__
方法。
1 | class LazyRecord: |
我们试着访问foo属性。data实例中并没有这样一个属性,因此Python会触发上面定义的__getattr__
方法,而该方法又会通过setattr修改本实例的dict字典。
我们通过子类给LazyRecord增加日志功能,用来观察程序在什么样的情况下才会调用__getattr__
方法。我们先写入第一条日志,然后通过super()调用超类所实现的__getattr__
方法,并把那个方法返回的结果记录到第二条日志里面。假如不加super(),那么程序就会无限递归,因为那样调用的是本类所写的__getattr__
方法
1 | class LoggingLazyRecord(LazyRecord): |
exists属性本来就在实例字典里,所以访问data.exists时不会触发__getattr__
。接下来,开始访问data.foo。foo属性不在实例字典中,因此系统会触发__getattr__
方法,这个方法会通过setattr把foo属性添加到实例字典。然后,我们第二次访问data.foo,这次data实例的__dict__
字典已经包含这个属性,所以不会触发__getattr__
。
如果要实现惰性的(lazy,也指按需的)数据访问机制,而这份数据又没有schema,那么通过__getattr__
来做就相当合适。它只需要把属性加载一次即可,以后再访问这个属性时,系统会直接从实例字典中获取。
假设我们现在还需要验证数据库系统的事务状态。也就是说,用户每次访问某属性时,我们都要确保数据库里面的那条记录依然有效,而且相应的事务也处在开启状态。这个需求没办法通过__getattr__
实现,因为一旦对象的实例字典里包含了这个属性,那么程序就会直接从字典获取,而不会再触发__getattr__
。
为了应对这种比较高级的用法,Python的object还提供了另一个挂钩,叫作__getattribute__
。只要访问对象中的属性,就会触发这个特殊方法,即便这项属性已经在__dict__
字典里,系统也还是会执行__getattribute__
方法。于是,我们可以在这个方法里面检测全局的事务状态,这样就能对每一次属性访问操作都进行验证了。同时,我们必须注意这种写法开销很大,而且会降低程序的效率,但有的时候确实值得这么做。下面就定义ValidatingRecord类,让它实现__getattribute__
方法,并在系统每次调用这个方法时,打印相关的日志消息。
1 | class ValidatingRecord: |
如果要访问的属性根本就不应该存在,那么可以在__getattr__
方法里面拦截。无论是__getattr__
还是__getattribute__
,都应该抛出标准的AttributeError来表示属性不存在或不适合存在的情况。
1 | class MissingPropertyRecord: |
在编写通用的Python代码时,我们经常要依靠内置的hasattr函数判断属性是否存在,并且通过内置的getattr函数获取属性值。这些函数也会先在实例的__dict__
字典里面查找,如果找不到,则会触发__getattr__
。
1 | data = LoggingLazyRecord() |
在运行上面那段代码的过程中,__getattr__
只触发了一次。假如data所属的类实现的不是__getattr__
,而是__getattribute__
方法,那么效果就不一样了,程序每次对实例做hasattr与getattr操作时,都会触发这个方法。
1 | data = ValidatingRecord() |
假设程序给Python对象赋值时,我们不想立刻更新数据库,而是打算稍后再推送回去。这个功能可以通过__setattr__
实现,而它也是object提供的挂钩,可以拦截所有的属性赋值操作。属性的获取操作分别通过__getattr__
与__getattribute__
挂钩拦截,但设置操作只需要这一个挂钩就行。只要给实例中的属性赋值(不论是直接赋值,还是通过内置的setattr函数赋值),系统就触发__setattr__
方法。
1 | class SavingRecord: |
__getattribute__
与__setattr__
这样的方法有个问题,就是只要访问对象的属性,系统就会触发该方法。但有时候,我们其实并不希望出现这种效果。
为解决这个问题,我们可以改用super().__getattribute__
方法获取_data属性,由于超类的__getattribute__
是直接从实例的属性字典获取的,不会继续触发__getattribute__
,这样就避开了递归。
1 | class DictionaryRecord: |
在__setattr__
里面为这种对象实现属性修改逻辑时,也需要通过super().__setattr__
来获取_data字典。
第48条、用__init_subclass__
验证子类写得是否正确
元类最简单的一种用法是验证某个类定义得是否正确。如果要构建一套比较复杂的类体系,那我们可能得确保这套体系中的类采用的都是同一种风格,为此我们可能需要判断这些类有没有重写必要的方法,或者判断类属性之间的关系是否合理。元类提供了一种可靠的手段,只要根据这个元类来定义新类,就能用元类中的验证逻辑核查新类的代码写得是否正确。
一般来说,我们会在类的__init__
方法里面检查新对象构造得是否正确。但有的时候,整个类的写法可能都是错的,而不单单是该类的某个对象构造得有问题,所以我们想尽早拦住这种错误。例如,当程序刚刚启动并把包含这个类的模块加载进来时,我们就想验证这个类写得对不对,此时便可利用元类来实现。
在讲解如何用自定义的元类验证子类之前,我们首先必须明白元类的标准用法。元类应该从type之中继承。在默认情况下,系统会把通过这个元类所定义的其他类发送给元类的__new__
方法,让该方法知道那类个的class语句是怎么写的。下面就定义这样一个元类,如果用户通过这个元类来定义其他类,那么在那个类真正构造出来之前,我们可以先在__new__
里面观察到它的写法并做出修改。
1 | class Meta(type): |
元类可以获知那个类的名称(name)、那个类的所有超类(bases)以及class语句体中定义的所有类属性(class_dict)。因为每个类最终都要继承object,所以这个object名字不会体现在罗列超类名称的bases元组之中。
我们可以在元类的__new__
方法里面添加一些代码,用来判断根据这个元类所定义的类的各项参数是否合理。例如,要用不同的类来表示边数不同的多边形(polygon)。如果把这些类都纳入同一套体系,那么可以定义这样一个元类,让该体系内的所有类都受它约束。我们在这个元类的__new__
里面检查那些类的边数(sides)是否有效。注意,不要把检查逻辑运用到类体系的顶端,也就是基类Polygon上面。
1 | class ValidatePolygon(type): |
如果我们试着定义边数小于3的多边形子类,那么刚把那个子类的class语句体写完,元类就会通过__new__
方法察觉到这个问题。这意味着,只要定义了无效的多边形子类,程序就无法正常启动,除非那个类是在动态引入的模块里面定义的。
这样一项基本的任务竟然要写这么多代码才能实现。好在Python 3.6引入了一种简化的写法,能够直接通过__init_subclass__
这个特殊的类方法实现相同的功能,这样就不用专门定义元类了。下面我们改用这个机制来实现与刚才相同的验证逻辑。
1 | class BetterPolygon: |
现在的代码简短多了,完全不需要定义ValidatePolygon这样一个元类。在__init_subclass__
方法里面,我们可以直接通过cls实例来访问类级别的sides属性,而不用像原来那样,在存放类属性的class_dict里面查询’sides’键。现在的多边形子类应该继承刚写的BetterPolygon基类。如果子类定义的边数无效,那么程序会抛出同样的异常。
用标准的Python元类机制来实现验证还有个缺点,就是每个类只能定义一个元类。
要解决这个问题,我们可以创建一套元类体系,让不同层面上的元类分别完成各自的验证逻辑(也就是先在下层元类里面验证填充色,如果验证无误,那么再去上层元类里面验证边数)。
1 | class ValidatePolygon(type): |
同时,这也要求我们必须设计一个支持填充色的多边形类(FilledPolygon),让它在多边形类(Polygon)的基础上增加填充色逻辑,而不能像刚才那样,把填充色与边数分别放在Filled与Polygon两个类中。现在,带有具体填充色与边数的多边形需要从这个FilledPolygon里面继承。
1 | class GreenPentagon(FilledPolygon): |
如果采用不受支持的填充色来定义FilledPolygon子类,那么ValidateFilledPolygon里面的验证逻辑就会查出这个问题。ValidateFilledPolygon元类继承自ValidatePolygon,因此边数的错误也可以检查出来。
但是按照现在这种写法,如果想把颜色的验证逻辑施加在多边形之外的另一套类体系中,那么必须按照刚才的样板重复编写许多代码才行,而没办法很方便地复用已有的代码。
这个问题,同样可以通过__init_subclass__
这个特殊的类方法来解决。在多层的类体系中,只要通过内置的super()函数来调用__init_subclass__
方法,系统就会按照适当的解析顺序触发超类或平级类的__init_subclass__
方法,以保证那些类在各自的__init_subclass__
里面所实现的验证逻辑也能够正确地执行(类似案例参见第40条)。这种写法可以正确应对多重继承。例如,下面这个Filled类就通过__init_subclass__
来验证填充色,这样的话,子类可以同时继承该类以及刚才的BetterPolygon类,从而把这两个类所实现的验证逻辑组合起来。
1 | class Filled: |
__init_subclass__
还可以处理更为复杂的情况,例如菱形继承
1 | class Top: |
可以看到,菱形体系底部的Bottom类通过Left与Right两条路径重复继承了体系顶端的Top类。然而,由于是通过super()触发__init_subclass__
,系统在处理Bottom类的定义时,只会把Top类的__init_subclass__
执行一遍。
第49条、用__init_subclass__
记录现有的子类
元类还有个常见的用途,是可以自动记录(或者说注册)程序之中的类型。利用这项功能,我们就能根据某个标识符反向查出它所对应的类。
让元类把子类的class定义拦截下来,然后自动调用register_class去注册
1 | class Meta(type): |
用户只要把RegisteredSerializable的子类定义完,就可以确信程序已经通过register_class将这个子类注册过了,所以它肯定支持反序列化。
还有一种办法比上面的实现方式更简单,那就是通过名为__init_subclass__
的特殊类方法来实现。这是Python 3.6引入的新写法,我们只需要编很少的代码,就可以把自己的逻辑运用到子类上面
1 | class BetterRegisteredSerializable(BetterSerializable): |
在类体系正确无误的前提下,通过__init_subclass__
(或元类)自动注册子类可以避免程序由于用户忘记注册而引发问题。这不仅适用于上述序列化与反序列化功能的实现,而且还可以用在数据库的对象关系映射(object-relational mapping,ORM)、可扩展的插件系统以及回调挂钩上面。
第50条、用__set_name__
给类属性加注解
元类还有一个更有用的功能,那就是可以在某个类真正投入使用之前,率先修改或注解这个类所定义的属性。这通常需要与描述符(descriptor)搭配使用,这样可以让我们更详细地了解这些属性在定义它们的那个类里是如何使用的。
例如,我们要定义一个新的类,来表示客户数据库中的每一行数据。这个类需要定义一些属性,与数据表中的各列相对应,每个属性都分别表示这行数据在这一列的取值。下面用描述符类来实现这些属性,把它们和数据表中同名的列联系起来。
1 | class Field: |
Field描述符的name属性指的就是数据表中那一列的列名,所以,我们可以通过内置的setattr函数把每行数据在这个属性上面的取值保存到那行数据自己的状态字典里面去,只不过属性名应该稍加调整,我们给它前面加个下划线表示它是受到保护的属性。另外,我们通过getattr函数实现属性加载功能。这种写法,看上去似乎要比把每个实例在这项属性上面的取值都保存到weakref字典里面更简单(那种字典是weakref模块所提供的特殊字典,用以防止内存泄漏)。
下面定义Customer类,每个Customer都表示数据表中的一行数据,其中的四个属性分别对应于这行数据在那四列上面的取值。
1 | class Customer: |
元类可以当作class语句的挂钩,只要class语句体定义完毕,元类就会看到它的写法并尽快做出应对。在本例中,我们可以让元类自动给每个Field描述符的name与internal_name赋值,而不用再像原来那样,需要开发者把字段名称重复书写一遍并手动传给Field的构造函数。
1 | class Meta(type): |
下面定义一个基类,让该基类把刚才定义好的Meta当成元类。凡是表示数据库某行的类都继承自该基类,以确保它们可以利用元类所提供的功能。
1 | class DatabaseRow(metaclass=Meta): |
为了跟元类配合,Field描述符需要稍加调整。它的大部分代码都可以沿用,只是现在已经不用再要求调用者把名称传给构造函数了,因为这次,元类的__new__
方法会自动设置名称。
1 | class Field: |
有了元类、DatabaseRow基类以及修改过的Field描述符,我们在给客户类定义字段时,就不用手工传入字段名了,代码也不像之前那样冗余了。
这个办法的缺点是,要想在类中声明Field字段,这个类必须从DatabaseRow继承。假如忘了继承,或者所面对的类体系在结构上不方便这样继承,那么代码就无法正常运行。
这个问题可以通过给描述符定义__set_name__
特殊方法来解决。这是Python 3.6引入的新功能:如果某个类用这种描述符的实例来定义字段,那么系统就会在描述符上面触发这个特殊方法。系统会把采用这个描述符实例作字段的那个类以及字段的名称,当成参数传给__set_name__
。下面我们将Meta.__new__
之中的逻辑移动到Field描述符的__set_name__
里面,这样一来,就不用定义元类了。
1 | class Field: |
现在,我们可以直接在类里通过Field描述符来定义字段,而不用再让这个类继承某个基类,还能把元类给省掉。
第51条、优先考虑通过类修饰器来提供可组合的扩充功能,不要使用元类
通过元类自动修饰那个类的所有方法。例如,下面的就是这样一个元类,它可以拦截利用本类所写的新类型,并把那个类型里面的每个函数或方法都分别封装到trace_func修饰器之中。
1 | import types |
现在,我们只需要让子类继承dict,并把刚写的TraceMeta当作子类的metaclass就行了
1 | class TraceDict(dict, metaclass=TraceMeta): |
这种办法确实有效,而且还把前面的实现方案中忘记修饰的new方法也自动修饰了。但如果子类所继承的那个超类本身已经指定了它自己的metaclass,这次程序无法运行,因为子类的metaclass是TraceMeta,而超类的metaclass是OtherMeta,但TraceMeta并不是从OtherMeta里面继承来的。从理论上讲,我们可以让TraceMeta继承OtherMeta,从而解决这个问题。
然而,如果TraceMeta不是我们自己写的,而是来自某个程序库,那就没办法手工修改它了。另外,如果想同时使用多个像TraceMeta这样的元类所提供的逻辑,那么这套方案无法满足需求,因为在定义类的时候,metaclass后面只能写一个元类。总之,这个方案对受元类控制的子类提出了过多的要求。
为此,我们可以换一种方案,也就是改用类修饰器(class decorator)来实现。这种修饰器与函数修饰器相似,都通过@符号来施加,但它并不施加在函数上面,而是施加在类的上面。编写类修饰器时,我们可以修改或重建它所修饰的类,并通过return语句返回处理结果。
1 | def my_class_decorator(klass): |
现在就来实现这样一个类修饰器,它可以施加在类上面,让该类的所有方法与函数都能自动封装在trace_func之中。这个类修饰器本身是个独立的函数,它的代码基本上可以沿用早前所写的TraceMeta.__new__
。这套方案要比采用元类实现的方案简单得多。
类修饰器其实就是个函数,只不过它可以通过参数获知自己所修饰的类,从而重建或调整这个类并返回修改结果。如果要给类中的每个方法或属性都施加一套逻辑,而且还想尽量少写一些例行代码,那么类修饰器是个很值得考虑的方案。元类之间很难组合,而类修饰器则比较灵活,它们可以施加在同一个类上,并且不会发生冲突。
Author
My name is Micheal Wayne and this is my blog.
I am a front-end software engineer.
Contact: michealwayne@163.com