Python面向对象
Classes - Python Tutorial
Data model - Python Tutorial
Python OOP Tutorials - Corey Schafer
程序 = 数据 + 算法,数据用于记录状态,算法用于修改状态。其中的“+”,即如何将数据与算法有机的融合,可以有不同选择,对应于不同编程范式:最简单的就是数据与算法混杂,对应于过程化编程;对于复杂问题可以选择将数据与相关联的算法封装为整体,即所谓面向对象编程;也可选择将数据与算法剥离,使函数成为不含任何状态的数据管道,并通过函数的组合构成数据加工的流水线,即所谓函数式编程。
面向对象编程中,对于数据和方法封装的抽象被称为“类”。同一个类的(实例)对象,所能执行的操作(函数/方法)是一样的,区别通常仅限于数据,而这些携带了不同数据的对象被称为类的“实例”。类之间可通过继承复用基类的方法,且可以继承多个基类,面向对象编程的核心就在于类的设计。
Object、Class、Type
首先,一般意义上,Python中一切都是对象(object);而同时Python中还有一个具体的、名为’object’的类,用作一切类的基类。
其次,一切对象都有各自(所从属)的类,英文用type或class都可以;同时Python中还有一个具体的、名为’type’的类,作为类的类。
每个对象都会有标识(ID)、类型/类别(type)以及取值(value):ID可通过id()
获取,可理解为内存地址;Type则可通过type()
获取。ID与type在对象创建后都不可变,type决定了value是否可变,具体分为可变对象(mutable)和不可变对象(immutable)。
1 | class A: |
“实例”a的type为某个自定义类A,而“类”的type为’type’类,即类属于名为type的特殊类,内置的object也属于这个type类,甚至type自身也属于这个type类。除了这个具体的type类,一般意义上的type与class使用也有细微区分,大致可理解为:type是class的抽象指代,而class是type的具体实现,但本质上两者指的是一个东西,没有区别。早期的 Python 2 中class与type是有明确划分的,现在已无需在意。更多讨论可参考What are Python’s type “objects” exactly?
注:类的类其实就是元类,即这个名为type
的特殊类其实就是所谓的元类。
类、实例、属性
类的创建与函数创建类似,将关键字def
替换为class
即可,不同处在于名称后的括号:函数名后括号内变量用于标明要传递的形参,当没有参数时,括号可以留空,但不可省略;类名后括号内的变量用于标明要继承的基类,当没有基类时,括号可以留空,也可直接省略。类的实例化同样类似函数调用,此时类名后括号不能省略,当括号内有参数时,会自动传递给特殊方法__init__()
,用于实例对象的初始化(后面会介绍)。
- 类内部定义的函数与普通函数在参数上有一定区别:第一个参数被限定为实例(self)或类(cls),具体讨论参考命名空间及类方法部分。
- 类内部定义的变量及函数会被录入其属性字典,通过
<name_obj>.<attr>
方式访问。 - 类对象 :类本身也是对象,除了实例化,也可通过
<name_cls>.<attr>
访问其属性。 - 实例对象:只能执行属性访问操作
<name_inst>.<attr>
,属性又分数据属性及方法。 - 方法对象:函数及对象指针的封装,调用时将对象指针插入参数列表作第一个参数。
类与实例的属性访问结果并不相同:除了数据属性,类通常返回函数对象,实例则返回方法对象。函数对象与方法对象区别在于对第一个参数的要求:前者通常需要显式提供实例对象作为首参。这也很容易理解:因为方法要作用的对象是确定的,而函数却并没有明确的作用对象;下面会看到,访问类的类方法返回的也是方法对象,因为此时方法要作用的对象(类)也是明确的。
<name_inst>.func(*args, **kwargs) == <name_cls>.func(<name_inst>, *args, **kwargs)
Duck Typing: If it looks like a duck and quacks like a duck, it must be a duck.
鸭子类型:如果一个东西看起来、叫起来都像鸭子,那它一定就是鸭子!
具体到编程,鸭子类型关注接口(方法)而非类型本身,即代码不会检查对象的类型,而只关注对象是否提供了特定方法。这使得用于处理特定数据类型的代码,通常也可以处理模仿了该类型特定方法的自定义类(它们都被视为鸭子!),从而轻松实现多态(polymorphism)。比如要用处理文件对象的函数来处理字符串缓冲,你只需要模仿文件对象,定义一个有read()及readline()方法,实际却从字符串缓冲获取数据的对象,之后直接交给处理文件对象的函数就可以了。
使用鸭子类型,意味着应当尽量避免用type()或isinstance()检查对象的类型,而仅通过hasattr()检查对象是否实现了特定方法,或者直接try…except…(EAFP v.s. LBYL)。
命名空间与作用域
类有自己的局部命名空间,在类定义(而非调用)时创建,类定义结束后其命名空间被封装为类对象;类似的,实例对象的局部命名空间,在实例对象生成(类实例化)时创建,之后便被打包为实例对象。随着类/实例对象创建结束,其局部空间被打包带走,不允许直接访问,只能以属性方式间接访问。注意,实例会自动共享类的属性:访问实例对象属性时,若在对象属性中找不到,会到类属性中继续查找。由于类的数据属性在其所有实例对象间共享,应注意避免滥用,仅提供必要的变量,尤其是尽量避免列表字典等可变类型。
类/实例对象局部命名空间中变量的作用域都仅限于类/实例对象自身,而不包含其中定义的函数或方法!这些函数(方法)内不能直接访问类(实例对象)的局部命名空间,而只能以属性方式间接访问。而函数(方法)内未定义的变量(自由变量)将直接到类的外层命名空间中查找,而非其局域空间!Python 3 中列表推导及生成器表达式均基于函数实现,会引入新的局部作用域,也存在上述限制。
1 | class Cls: |
Python类定义中函数参数self
是什么?
前面提到,类(实例对象)的函数(方法)无法直接访问其局部命名空间,只能以属性方式间接访问。但Python没有提供在对象内用于指向对象本身的关键字。因此,为实现属性访问,Python规定:函数的第一个参数用于指向对象自身。而对于这个形参的名字Python未做任何限制,但根据约定,通常命名为self
。This is nothing more than a convention: the name self has absolutely no special meaning to Python.
需注意的是:虽然方法的第一个参数指向对象自身,在定义时必须显式提供;但由于方法对象
在进行参数传递前会自动将实例对象作为第一个参数插入参数列表,因此在使用调用方法时,无需(也不能)将实例对象作为实参提供。
属性与__init__()
Python中类及实例的属性(数据/函数)都可以随时直接赋值,而无需预先定义!而所有属性都会被记录在属性字典__dict__
中,通过<obj>.__dict__
或vars(<obj>)
可查看全部属性。
1 | class Cat(): |
虽然属性可随时添加,但这样代码会很混乱,因此通常还是会在类定义时预先进行定义:
1 | class Cat(): |
类定义主体(函数之外)中创建的变量都属于类的数据属性(会在所有实例中共享),要想设置实例对象的数据属性,必须借助实例方法
,而方法又都需要手动调用才会执行,特殊方法__init__()
应运而生。__init__()
同样需要实例作为首个参数,与其他实例方法唯一的区别在于它会在类实例化时自动被调用,用于初始化实例对象的属性。
1 | class Cat(): |
注意:__init__
方法只会在实例化时执行一次(主动调用除外),这意味着,若实例化后某个数据属性更新,__init__
中依赖它的属性并不会随之更新!!而__init__
方法通常都不会去主动调用,因此其中的变量间最好不要存在任何依赖关系,依赖于其它变量的属性应当在普通的实例方法中去创建。
类方法及静态方法
方法可分为类方法、实例方法、静态方法,由装饰器区分,区别仅在于函数第一个参数:
1 | class Cls(): |
方法默认为实例方法,类方法、静态方法需分别通过@classmethod
与@staticmethod
装饰器标识。类/实例方法第一个参数为“奇怪”的特殊参数cls/self
,静态方法则没有特殊参数,除此之外三者没有区别。类(实例)方法默认是要对类(实例)进行操作,因此 规定 第一个参数为类(实例),并 约定 命名为cls/self
。类(实例)对象调用对应的类(实例)方法时,返回的是方法对象,因此无需显式传递类名或实例名;实例同样可直接调用类方法,但容易造成困惑,不推荐这样用;最后,类也可以调用实例方法,返回的是函数对象,而非方法对象(需显式传递实例名)。
在逻辑上,类方法和静态方法都从属于当前类,而不依赖任何子类或具体对象实例,也不能在子类中改写。其中类方法与类本身关联,修改类的属性或行为;静态方法则是普通函数,用于实现特定处理逻辑,放在类定义内可实现一定程度的封装隔离,只能通过类/实例对象访问。
类方法的一个重要应用是用于实现不同方式的类实例化。__init__
方法只有一个,因此类实例化的参数是固定的,但我们实际中却会遇到各种形式的数据。比如某个类的__init__
方法接受一系列特定参数,如果要让它能从文件读取数据进行实例化,可以添加一个名为from_file
的方法,从文件读入数据,转换为__init__
所需参数,再执行类实例化,甚至不借助__init__
,以完全独立的方式初始化类。这里的操作发生在实例初始化之前,同时又需要访问类的属性,因此适合用类方法实现。
1 | class Cls(): |
类方法或静态方法要在类(实例)对象上去调用,在类定义的内部不可直接调用,实例方法则可以直接调用。类方法在要作用在类身上,在类还没定义时就调用,没什么意义;这里主要讨论下调用静态方法的选项:
- 将静态方法移出对象,作为全局空间中的一个普通函数,在类内可直接访问
- 用普通的实例方法替代静态方法,此时参数传递时第一个参数必须是实例,而在类定义时并没有可用实例。由于原本是静态方法,函数内实际并不会真正用到实例对象,从而可选择用任意的实例替代(仅用于占位),比如
None
。 - 通过静态方法的特殊属性
__func__
获得到原本的函数对象
1 | class Cls(): |
派生与类继承
派生类会自动继承基类的所有属性:类的局部命名空间(属性字典)中找不到的属性,会继续到基类中查找。派生类属性可以覆盖掉基类的同名属性。值得注意的,在派生类中调用基类未被覆盖的方法时,该基类方法如果调用了另一个基类方法,最终实际调用的可以是被覆盖之后的派生类方法!😂️
1 | class Cat(): |
派生类可通过<name_basecls>.<attr>
直接访问基类被派生类覆盖掉的的属性,也可借助内置函数super()访问。单继承时,super()可实现不具名的情况下获取基类属性;多继承时,super()会影响属性搜索的默认顺序。super()有两个可选参数,前一个(type)指定搜索的起点,后一个(obj/type)则决定搜索的顺序(确切说是由其属性__mro__
决定)。
注:mro代表Method Resolution Order,如果某个对象是派生自其他基类的,则其帮助文档(help(<obj_name>)
)的的第一条就是mro。
1 | class WildCat(Cat): |
多继承时,属性搜索顺序可大致理解为:深度优先、由左到右(depth-first, left-to-right),对重复出现的类以最后出现的位置为准,而不会搜索二次。下面例子中:Derived中找不到的属性,会到Base1及其基类中查找,仍找不到才会到Base2及其基类,依次类推;但若存在菱形结构,如Base1, Base2都继承自Base4,则Base4会在Base2之后搜索。
1 | class Derived(Base1, Base2, Base3): |
当然实际情况会更复杂,可参考Guido van Rossum对此的介绍,基本原则就是由左到右、由特殊到一般(深度优先)的同时,并确保单调性,否则将报错。比如类X派生自C, D,而C, D又都派生自A, B,但定义C时将A放在了前面(左边),定义D时却又将B放在了前面,最终解析时会同时出现相互冲突的A-B与B-A序,无法保持单调,因此会报错。更深入的理解可查看此处对于C3线性化算法的介绍。
继承(Inheritance)与委派(delegation) why?
与继承相类似的还有委派,可在不进行继承的情况下,使一个类获得另一个类的部分功能。继承中,派生类自动获得全部基类属性;而在委派中,委派方主动选取指定方法。Python中委派通过特殊方法__getatrr__
(及内置函数getattr) 实现
私有属性 __xyz
Python中变量默认都是公共的,名字以下划线开始的变量会被视作非公开的“私有”成员。但严格来说Python中并不存在真正的私有,要访问总可以做到,所谓私有只是一种约定:名字以双下划线起始,且至多以单下划线结束(__xyz
)的变量会自动触发Python的名称改写。除了类自身正常访问外,派生类及外部引用时需手动修改名字:访问方式由通常的<obj>.<attr>
被改写为<obj>._<name_cls><attr>
。
注意,不要混淆类私有属性与模块的保护变量:模块保护变量,名称以下划线起始(_xyz
),效果是不会被from <module> import *
导出。类内部定义的变量不会出现于全局命名空间,本来就不会被导出,用这种命名方式没有任何效果。
最后,还有一些名字以双下划线起始和结束的方法(__xyz__
),如__init__
。这些方法通常是Python内建的特殊方法,用于在特定条件下执行特定操作,具体可参考Python官方文档中对Datamodel的介绍。注意,这些特殊方法并非私有属性,不会触发名称改写。
属性装饰器 @property
方法调用必须带括号,而Python内置的@property
装饰器可将方法转化为同名的只读数据属性,从而无需括号即可访问;@<method>.setter
, @<method>.deleter
则进一步为上述只读数据属性增加覆写和删除操作,而不再是只读。需注意的是,一旦使用了property装饰器,就只能通过数据属性方式访问,而无法再通过方法调用的方式访问。
1 | class People(): |
特殊方法 datamodel
1 | #### 不建议自定义的特殊方法 ##### |
其中最常用的特殊方法有:
__init__
用于初始化实例对象
__repr__
被repr()调用,主要是为调试、日志等提供明确、有效的信息(developer-friendly)
__str__
被str(), print()调用,主要面向用户,提供对象的直观信息(user-friendly)
当只有__repr__
时,__str__
会自动回落至__repr__
,反之则不然,因此至少应提供前者
1 | class Cat(): |
最后,需要注意的是,特殊方法必须在类中定义,而非实例中,才能确保隐式调用正常。
1 | class A: |
其背后机制在于,特殊方法的隐式调用会自动绕过实例属性及__getattribute__
方法,原因则是为了避免“元类混淆”(以及提升查找速度),具体可参考官方文档,我没看懂😵️
属性访问控制 descriptor
- 一般属性:
__getattribute__
,__getattr__
,__setattr__
,__delattr__
,__dir__
- 描述符属性(descriptor):
__get__
,__set__
,__delete__
,__set_name__
- 属性插槽:
__slots__
推荐阅读Guido van Rossum所写的The inside story on new-style classes,以了解这些特殊属性被引入的目的。
一般属性访问
属性获取默认通过调用__getattribute__
实现,__getattr__
用于处理属性访问失败的情况(捕获AttributeError
),__setattr__
及__delattr__
用于修改(或添加)和删除属性。
__dir__
用于列出对象所有属性及方法名,可由vars()调用,不过通常会用dir()返回经过排序的(和缩减)的属性方法/列表(缺少自动补齐时很有用)。Is there a built-in function to print all the current properties and values of an object?
注1:对于特殊方法,隐式调用时会跳过__getattribute__
(及实例属性)。
注2:重写属性访问方法时,若__getattribute__
方法中出现self.<attr>
,__setattr__
方法中出现self.<attr> = <value>
,__delattr__
方法中出现del self.<attr>
则意味着方法会指向自身,从而触发无限递归,应注意避免。此外,在获取(get)或设置(set)属性时,应通过super()将相关操作向基类传递。
描述符属性
上面介绍的特殊方法用于控制一般的属性访问,但有些情况我们可能需要调整某个特定属性的访问:比如一般的方法访问,会返回函数与实例对象指针所打包成的方法对象,但如果要实现类方法,就需要调整为打包函数与类对象指针。直接修改前面的特殊方法显然行不通,它们针对的是所有属性,任何修改都将改变其它属性的访问。
为此,Python另外提供了一组特殊方法__get__
, __set__
, __delete__
, __set_name__
。属性访问时,在属性字典中找到属性后,会先确认其是否定义了这些特殊方法,若定义了则调用相应方法,未定义时才会按一般属性访问规则操作。定义了这些特殊方法的属性被称为描述符。需注意的是,描述符必须作为类属性定义,而不能定义为实例属性。关于描述符的具体使用指南可参考Descriptor HowTo Guide。
根据所定义的特殊方法,描述符被分为数据描述符和非数据描述符:前者指定义有__set__
或__delete__
任一方法(或两者都定义)的属性,相对的,两者均未定义的为非数据描述符。通常,非数据描述符只定义__get__
,而数据描述符则会同时定义__get__
, __set__
。
描述符只能作为类属性,且对于同时定义了__get__
和__set__
的(数据)描述符,根据属性访问规则,始终会覆盖掉实例的同名属性,无法被重写;而非数据描述符则会被实例的同名属性覆盖。上文的类方法、静态方法及方法变的属性都属于描述符,装饰器@classmethod
, @staticmethod
, @property
所做的就是在其装饰的方法中插入__get__
, __set__
等特殊方法,从而改变相应方法的访问方式(将其变为描述符)。其中类方法及静态方法为非数据描述符,可以被实例覆盖;@property
则为数据描述符,无法被实例覆盖!
1 | from pprint import pprint as print |
属性插槽
描述符只能以类属性定义,同时操作优先级高于一般属性,因此任何实例对象的属性访问都要先检查类的属性字典,以确认是否为描述符。由于担心描述符的额外性能负担,在引入描述符的同时引入了属性插槽(__slots__
)。属性插槽允许人们明确声明实例对象所拥有的属性:__slots__
可赋值为字符串或由字符串组成的容器类型(列表、元组等),对应于实例对象的属性列表,插槽中未声明的属性不能被实例所新建。
1 | class A: |
注意:属性插槽中所声明的变量名只能作为实例属性,而不能用作类属性。因为属性插槽的实现是基于描述符,其中的每个属性都会被转换为描述符。而如果在类定义中对变量赋值(从而成为类属性),就会覆盖掉描述符。
1 | class A: |
属性插槽能提升属性的访问速度,同时避免了为实例对象创建属性字典__dict__
,可节省内存,尤其是实例对象非常多时,可实现相当可观的内存节省。定义了__slots__
的类实例化时会阻止__dict__
(及__weakref__
)的自动创建,但却无法阻止其基类或者派生类自动创建__dict__
,此时__slots__
优势将不复存在。因此想通过属性插槽提升性能或节省内存,就要贯彻到底,从基类到所有派生类都定义__slots__
。当然属性插槽还可以起到“保护”作用,如前面所说,属性插槽中未声明的属性是不允许实例新建的。
1 | class A: |
数据类 @dataclasses
根据前面的介绍:在实例化时初始化对象需要定义特殊方法__init__
;打印对象时返回有意义的内容,还至少需要定义特殊方法__repr__
。这些方法的使用很普遍,而实现起来又需要很多模式化的代码,于是人们希望能自动化这些方法的定义,尤其是对主要用于存储数据的类。数据类(Data Class)由此诞生,通过@dataclass
装饰器在创建对象时使用,自动为用户定义的类添加__init__
, __repr__
等特殊方法,简化类定义的同时增加代码可读性。数据类在使用上有点类似命名数组(namedtuple
),不过后者为不可变类型,且存在一些其它限制,具体可参考这里的讨论。注意,数据类需Python 3.7+。
数据类使用变量注释(variable annotations
)对其需要初始化的数据属性进行描述,其中变量注释是必须的。没有加注释的变量会被视为类属性,从而不会加入__init__
函数的参数列表!注释可以是任何Python表达式,但最好能提供有用信息,推荐使用类型提示(typing hints
)。虽然变量注释是必须的,但其中的类型提示并不具有强制效用,只是“注释”。
1 | from dataclasses import dataclass |
可以看到除了数据属性的定义方式有所变化,数据类的方法属性定义与普通类没有区别。使用@dataclasses后,类定义被大大简化:自动生成了__init__
, __repr__
;变量注释信息被自动加入__annotations__
;__hash__
被设为None,表示数据类实例不可hash;还添加了__eq__
方法。
1 | Cat = CatA |
需要注意的是,数据类自动生成的__init__
函数的参数顺序与属性的定义顺序是对应的。而Python中定义函数时,要求不指定默认值的参数位于指定默认值的参数之前,因此定义数据类时,设置默认值的属性必须放在最后面。而且这一规则对于类继承同样有效,即由数据类派生新数据类时,若基类中属性设置了默认值,则派生类所有属性也都需要有默认值。更复杂的默认参数设置可使用数据类提供的field()
函数,具体可参考官方文档。
1 | # TypeError: non-default argument 'color' follows default argument |
最后,make_dataclass()
函数可进一步可简化数据类的创建,但随着问题变复杂,使用这种方式代码可读性非常差。
1 | from dataclasses import make_dataclass |
元类 metaclasses
元类(metaclass)指类的类(the class of a class),本文开篇提到过类默认属于名为type的特殊类,这个名为type的类就是Python内置的元类。类用于批量创建(实例化)实例对象,元类用于批量创建类。
1 | class A: |
自定义元类非常简单:(直接或间接)由type派生的类都属于元类。
1 | class MyType(type): |
可以看到类B属于自定义的MyNewType(元)类,而不再是默认的type(元)类,而且B的所有派生类也都将属于MyNewType(元)类。
类创建的具体过程为:
- 解析MRO(Method Resolution Order)
- 确定元类(继承关系上最近的)
- 准备命名空间(
<metaclass>.__prepare__
) - 运行类主体代码
- 创建类(
<metaclass>(<name>:str, <bases>:tuple, <namespace>:dict, **kwds)
)。
上述最后一步中会自动调用特殊方法__new__
(类实例化__init__
之前也会调用__new__
)。
自定义元类的主要作用就是可以在类创建过程中“做手脚”,比如修改__prepare__
或__new__
,又或者添加其它自定义方法,进而实现接口检查、日志、委派、属性创建、单例(singleton)、代理、框架、自动资源锁定/同步等等功能。以派生类接口(方法)检查为例:
1 | class Meta(type): |
元类过于强大,使用起来也有点复杂,若只是想简单干预派生类的创建,Python 还提供了特殊方法__init_subclass__
。定义了该方法的类,其派生类在创建前(类创建的最后一步中,__new__
执行之后)会自动调用该方法,类似于类实例化时自动调用__init__
方法:
- 类实例化:
__new__
+__init__
类实例化时的实参会先传给__new__
,再传给__init__
- 类继承:
__new__
+__init_subclass__
类继承时的关键字参数(位置参数全部用作基类)会先传给__new__
,再传给__init_subclass__
1 | class Meta(type): |
注意:如上所示,虽然实例化与继承都会调用__new__
,但两者却不是同一个__new__
!!类实例化时的__new__
是类自身(或其基类)中所定义的,类继承时的__new__
则是其元类中所定义的,而非其基类中所定义的。这也是为什么控制类创建需要用到元类。而另一方面,__init_subclass__
却只需要在基类中定义,无需用到元类,因为它被引入的意义就在于不借助元类干预派生类的创建。基类中的__init_subclass__
相比元类中的__new__
,唯一区别在于前者无法作用于基类自身。利用__init_subclass__
前面接口检查的例子可简化为:
1 | class Base: |
注意:这里__init_subclass__
方法有点特别,在基类中是作为普通的“实例方法”定义的(没有用装饰器),在派生类创建前被调用时,却是作为派生类的“类方法”,传入的首个参数是派生类自身(所以形参用了cls),这中间存在一个隐式的转换,由Python解释器自动完成。
抽象基类 ABC
鸭子类型直接提供了类似于原型(protocol)/接口(interface)的便利性,但也存在局限:
- 对象只有被调用才会判断是否提供了必要的接口,无法在创建类时强制其实现接口
- 当要检查大量接口时,hasattr()或try…except…就不再优雅,代码可读性降低
- hasattr()在处理特殊方法时存在问题(?)
于是Python引入了抽象基类(Abstract Base Classes, ABC)的概念,以真正实现接口定义。此外,还提供了很多常用的抽象基类(collections.abc
, numbers
, io
, importlib.abc
)。推荐阅读PEP 3119了解相关概念。
与抽象类相关的,Python 3.8中引入了typing.Protocol用于原型类的静态类型检查。
抽象类的定义
abc
模块提供了自定义抽象类的必要工具,包括:元类abc.ABCMeta
、基类abc.ABC
以及抽象方法装饰器@abc.abstractmethod
。ABCMeta
与ABC
的关系,类似内置的type
与object
对象的关系,前者是抽象类的元类,需依照元类的用法使用;后者是普通的抽象类,可直接派生新的抽象类,以便于在不接触元类的前提下使用抽象类。
1 | from abc import ABC, ABCMeta |
而所谓“抽象类”,是指声明了必要的接口(方法),却并未给出实现的类,这些未给出实现的方法则被称为抽象方法。装饰器@abc.abstractmethod
就是用于定义抽象方法,可与方法的其它装饰器@classmethod
, @staticmethod
, @property
组合使用,注意此时@abc.abstractmethod
需放在最内层。
1 | from abc import ABC, ABCMeta, abstractmethod |
不过除非是创建框架或基础库,通常你不需要自定义抽象类,而应直接使用Python内置的抽象类:容器(collections.abc
)、数值(numbers
)、数据流(io
)以及导入(importlib.abc
)。
抽象类的使用
Python中的抽象类有两种使用方式:
- 继承:直接继承抽象类,这种方式派生类必须实现所有的抽象方法才能实例化;
- 注册:任意类都可直接注册为抽象类的虚拟派生类,不受上述抽象方法的限制;
这里的任意类包括内置类型及其它抽象类等。注册为虚拟派生类后,issubclass()
,isinstance()
将会返回True
;但该类与抽象类间不存在实际继承关系,因此抽象类不会出现在其方法查找链MRO中,抽象类中定义的方法(非抽象方法)也无法被访问。
1 | from collections.abc import Sequence |
继承的方式,可提供类似上一节中元类或__init_subclass__
所实现的强制接口检查;而注册的方式,则只是声明实现了必要的接口(抽象方法),具体是否实现完全靠自觉。
注册机制的背原理为改写issubclass
, isinstance
,所对应的特殊函数为__subclasshook__
(更底层则是__instancecheck__
, __subclasscheck__
),这意味自定义抽象类时,通过改写该特殊方法,你可以获得更大的自由度。当然如前面所说,通常你不应自定义抽象类。
最后,关于抽象类ABCs的使用,总结如下:
- 尽量依照鸭子类型,直接try…except…或用
hasattr()
,避免isinstance()
; - 如果上面的方式太繁琐,则可使用ABCs +
isinstance()
,尽量避免强制具体类; - 除非是创建框架或基础库,通常你无需自定义ABCs,Python内置了常用的ABCs;
- 使用内置抽象基类时,应选择你希望支持的最一般的(接口要求最少的)ABCs。
扩展阅读
Guido van Rossum: The History of Python
James Powell: So you want to be a Python expert?