在上一节中,已经介绍了几个基本的自定义类的方法:.__new__()
、.__init__()
和 .__del__()
。本节介绍更多的特殊方法,以此了解一个自定义类是如何工作的。
自定义基础类
字符串表示:__str__
在之前介绍类的实例化时,第一步就是尝试使用 print()
打印实例,观察它是什么东西。例如,对于以下自定义类和实例:
它的打印结果为:
得到的结果是一串位于尖括号内的奇怪信息,信息中似乎包含类名和内存地址之类的东西。
之所以会打印这一段信息,是因为 print()
函数会将需要打印的内容转换成字符串,再将这个字符串显示在输出中。如果使用 str()
将实例转换为字符串,得到的也是这个结果:
这种奇怪的表示并没用展示有价值的信息。之所以会输出以上的结果,这是因为类有一个特殊方法 .__str__()
该方法是一个实例方法,用来获得实例的字符串表达方式。在调用内置函数 str()
、format()
和 print()
时,都是通过该方法的返回值将对象转换为字符串。
在创建一个类时,它会自动继承父类 object
的 .__str__()
特殊方法,而 object
可用到的信息十分有限,因此默认输出的会是这样一个结果。
可以编写一个自定义的类,重写 object
方法,给实例一个更好的描述,使 print()
等函数可以更好地展示它,例如:
这样得到的结果明显易读多了:
.__str__()
方法等价于许多编程语言的 to_string
方法。它在 Python 内置语法中相当常见,几乎所有的 Python 内置数据类型都重写了该方法,使得打印结果更易读。另一个典型的例子就是 Python 的异常,它使用 .__str__()
方法描述引起该类异常时携带的信息,当使用 except ... as
捕捉到异常实例后,直接打印这个异常实例就可以打印出引起异常的信息。
对象描述:__repr__
以上的 .__str__()
方法是针对字符串表示的,它们与内置的一些函数相关联。如果在交互式控制台中检查对象,结果为:
得到的结果仍然是一串 ... object at ... 的信息。
实际上,在控制台中检查对象,得到的信息是由特殊方法 .__repr__()
给出的。.__repr__()
是一个实例方法,它返回的结果是对象的规范字符串表示(canonical string representation),内置函数 repr()
实际上也调用的是它。
从表达含义上说,.__repr__()
与 .__str__()
的区别在于,.__repr__()
是用来调试的,它应该表达尽量多且明确的信息,而 .__str__()
主要用于格式化,它应该提供简洁美观的结果(例如尽可能在一行内表达清楚)。
可以重写类的 .__repr__()
方法展示实例的更多细节。一般来说,自定义类 .__repr__()
与 .__str__()
展示的信息差别不大,它们的实现往往可能是相同的。相比重复的定义,在定义了其中一个方法后,可以通过赋值让另外一个方法作为它的引用,就像这样:
格式化:__format__
该方法可以被内置函数 format()
或类似的字符串格式化应用中调用。关于格式字符串的更多信息参见 Python3 字符串格式化。
布尔等价:__bool__
在 Python 中,整数值 0
代表布尔值 False
,数字 1 代表布尔值 True
,这是因为 bool
类继承自 int
。但是浮点数 0.0
和复数 0 + 0j
在判断时也被当做布尔值 False
,而其余很小的浮点数和复数均被当做 True
处理,这说明浮点数在用作判断时并没有被转换为整数处理。
布尔的等价关系是由实例的特殊方法 .__bool__()
决定的,它的返回结果是一个布尔值,该布尔值将被用作判断。如果该方法返回的不是布尔值,在判断时将会引发 TypeError
异常。
一个空字符串 ""
、空列表 []
、空字典 {}
等也代表布尔值 False
,而一旦它们不为空时,便代表布尔值 True
。但尝试调用会发现,它们并没有提供 .__bool__()
方法。这是由于当类没有定义 .__bool__()
方法时,判断语句会根据 .__len__()
方法来决定实例的布尔的等价关系。不难看出,这个特殊方法与内置函数 len()
有关系:它用于计算一个对象所包含的元素个数,如果该方法的返回值非零,那么对象将被视为 True
,否则将视为 False
。如果一个类既没有实现 .__len__()
也没有实现 .__bool__()
( object
不提供这两个方法),那么对象将永远被视为 True
。
也就是说,一个实例对应的布尔值可能为:
特征 | 结果 |
---|---|
__bool__() returns True | True |
__bool__() returns False | False |
__bool__() returns non-boolean value | TypeError |
__bool__() undefined, but __len__() returns 0 | False |
__bool__() undefined, but __len__() returns non-zero value | True |
__len__() returns non-integer value | TypeError |
both __bool__() and __len__() are undefined | True |
其它类型转换
除了以上提到的关于类型转换的特殊方法外,一个类还可以定义 .__bytes__()
、.__int__()
、.__float__()
和 .__complex__()
特殊方法,它们分别被内置类 bytes()
、int()
、float()
和 complex()
的构造方法调用,获取对应类型的表示方式。这些方法应该返回对应类型的对象,以实现向对应类型的转换。
列举属性:__dir__
在之前编程程序中,有时会使用 dir()
函数来查看类实现的属性及方法。例如,以下是查看 object
类的属性及方法:
可以看到结果还不少,有一部分是前面已经介绍的,有一部分需要留到后面才介绍。在其中,可以看到一个名为 '__dir__'
的方法,它是类使用 dir()
查找的依据。.__dir__()
是一个实例方法,方法的返回值应该是一个可迭代对象,并且最好是一个列表。不管如何,Python 都会将它的返回值转换为一个列表并排序:
尽管 dir()
函数也可以直接查看类,但是 .__dir__()
方法却是一个实例方法,重写该方法只能影响到使用 dir()
查看实例的属性与方法。不管怎么样,一般情况下都没有必要重写该方法。
实例与类的特殊属性
除了特殊方法外,类也具有一些特殊属性,它们也由双下划线开始,双下划线结束,负责记录类的基本信息,一般只需要查看即可。其中一部分特殊属性甚至不在 dir()
的结果列表中。
类的__name__
类最基本的属性就是它的 .__name__
了。类的 .__name__
就是它的名称,.__name__
属性可以获取字符串形式的类名,这个值在,可以用在调试和元编程等场合。
注意,只有类、函数和模块才具有这个属性,普通的实例是没有这个属性的。因此定义一个变量后,无法获取一个普通变量的字符串形式的名称(除非使用一些复杂的技术)。
实例的__class__
实例的 .__class__
保存了它的类型,等价于对其使用 type()
返回的结果。例如 [1, 2].__class__
是 Python 内置数据类型 list
。它等价于对实例调用 type()
__class__
也用在类内部通过 self.__class__
来指代自身的类,与直接使用类名相比,.__class__
可能指代的是子类,这样做更方便该类的继承。
类的__bases__
以元组的形式返回该类继承的所有基类。除此之外,类还有一个 __base__
属性,它只会返回类的第一个基类。
类的__subclasses__()
它是一个方法,用于返回该类的子类。事实上,每一个类在创建时,都会生成一个 .__weakref__
属性,它用于对所有直接继承它的子类产生一个弱引用(weak reference),而该方法的作用就是以列表的形式返回所有仍然存在的引用,并以类定义的先后排序。例如:
结果有点多,因为它还包含了 Python 内置模块中的一些类。
类与实例的__dict__
接下来的这个属性非常重要。Python 是一门动态的编程语言,实例的属性也是可以动态添加的。假设有如下的类定义:
虽然在定义时,看起来类只使用了两个属性,但在后续的代码中随时都可以这样动态地为实例赋予一个新的属性:
一些静态的编程语言(如 C++ 和 Java )在创建实例时就为它们的属性分配了内存空间,每个属性的值都保存在固定的位置,因此无法直接为实例动态添加属性。但Python 既然允许动态添加属性,就需要一种动态分配空间的机制。实际上,Python 采用字典来存储对象的属性。每一个基本的 Python 类或者实例都有一个 .__dict__
属性,它的键保存着属性名,值保存着属性对应的值:
当 Python 要取用或创建属性时,它就会去到类或者实例的 .__dict__
字典中查找或添加。字典保存着类与实例的属性,所以是 Python 中非常重要的数据类型。
.__dict__
属性也为类的继承带来了方便,子类的 __dict__
并没有包含继承的方法和属性;当子类需要时,就会到父类的 __dict__
中寻找。而 MRO 机制的存在也使得这一套流程不易出错。
.__dict__
可以用于快速合并属性。例如,一些复杂的类的构造方法有很多不必要的参数,它们可能会被放在 **kwargs
可变参数内,初始化可能会这么写:
这样逐个操作较为麻烦,那么就可以借助字典的 .update()
方法快速合并:
通过 .__dict__
属性可以检查类提供的接口,它比 __dir__
更加灵活,能筛选不同的对象。例如,以下通过 .__dict__
检查 list
的可调用的方法,并筛去了以双下划线开头的特殊方法:
使用类的__slots__
字典赋予了 Python 强大的动态运行机制,但要认识到的是,字典是一种非常耗费存储空间的数据类型。哪怕 Python3 对保存属性的字典做了一定优化,存储仅仅 3 个属性还是耗费了 104 字节的空间:
这在创建很多小对象时会占用大量内存。要解决这个问题,可以采用 .__slots__
属性。.__slots__
属性是用来限制实例属性成员的。它是一个类属性,可以为其赋予一个字符串或含有字符串的可迭代对象,例如:
.__slots__
是一种编译时的特性:一旦 .__slots__
被创建出来,那么该类所具有的实例属性只能为 .__slots__
所表示或包含的字符串对应的实例属性。对于以上定义的 Point
类,它的实例属性只能包含 .x
、.y
和 .z
,如果要为它添加不在以上范围中的属性,会引发 AttributeError 异常。
也就是说,.__slots__
限定了实例允许创建的属性。它的本质就是在创建实例时不向它添加 .__dict__
,而是仅分配够用的空间,从而使实例不能拥有动态增加属性的能力。
合理使用 .__slots__
属性不仅可以节省一个对象所消耗的空间,也可以防止添加无意义的属性,并增加实例查找属性的速度。
使用 .__slots__
属性需要注意以下几点:
.__slots__
限制实例属性的根本原因在于实例缺少.__dict__
属性。如果在.__slots__
属性内添加"__dict__"
项,那么实例又恢复动态修改属性的能力,不过这样的话使用.__slots__
的意义就没有了。- 可以为
.__slots__
添加一个非字符串的数据,不过目前这么做还没有意义。 .__slots__
声明只在创建类时有效,且只对它所处的类有效,而对继承的子类无效,因此,含有.__slots__
的子类仍然会自动创建.__dict__
,除非在子类中也声明一个.__slots__
。如果父类和子类都有.__slots__
属性,那么子类实例对象允许使用的属性是它们的并集。- 除了
.__dict__
外,.__slots__
同时也会阻止实例创建.__weakref__
属性,使得对实例的弱引用将失效。
特殊方法与属性操作
之前看到了 __dict__
和 __slots__
是如何影响实例的属性存储方式的。但不管使用什么方式存储,都可以采用点号 .
访问一个对象的属性。本文已经多次看到了特殊方法是如何影响一个对象的行为的,显然也有一类特殊方法决定对象如何查找属性。
修改属性访问行为
.__getattribute__(self, name)
特殊方法决定了属性查找的基本行为,当一个实例 instance
尝试使用点号 .
访问属性 instance.attribute
时,便会调用其 .__getattribute__()
方法,其中参数 self
代表准备查找属性的实例,name
代表了查找属性的名称,是一个字符串。
基类 object
的 .__getattribute__()
方法实现了通用的属性查找行为。它会得到两种结果:如果实例属性存在,便会返回该实例属性。如果实例属性不存在,便会引发 AttributeError 异常。
一般情况下,不要重写对象的 .__getattribute__()
方法。如果想要稍微改变对象的属性访问行为,可以重写 .__getattr__(self, name)
方法。这个方法也影响实例的属性访问行为,它是 .__getattribute__()
方法的一种 fallback 机制:当 .__getattribute__()
方法找不到实例的属性(即引起 AttributeError )时,就会调用实例的 .__getattr__()
方法。
例如,以下通过继承字典实现了一个 RecordDict
类,可以通过属性访问的方式获取字典的值:
如果确实访问的是一个存在的属性,那么 .__getattr__()
方法就不会被调用,防止影响到使用字典的方法。.__getattr__()
方法在没有得到合适的结果时也应该抛出 AttributeError 异常,从而便于统一捕获:
需要特别注意的是,如果确实要重写 .__getattribute__()
方法,该方法内不能使用任何形如 self.attribute
的点号运算符,包括直接查找实例的 .__dict__
;否则,该过程又会调用实例的 .__getattribute__()
方法,造成无限递归,并且这种形式的递归是很难正常退出的。访问属性应该使用超类没有重写的 .__getattribute__()
方法完成此操作:
但是这种写法显然很不优雅,还会使得异常变得很复杂。因此,.__getattr__()
方法一般被视为点号运算符的重载而不是获取属性的途径。
有 get 就会有 set ,但是严格来说 .__setattr__()
是和 .__getattribute__()
配对使用的。__setattr__(self, name, value)
是实例属性的赋值 instance.name = value
的底层方法。例如,以下扩展了 RecordDict
,支持通过添加属性的方式添加键值对:
类似于 .__getattribute__()
,在 __setattr__() 方法内注意不能使用 instance.name = value
这种语句,否则会造成无限递归。
最后,还有一个 .__delattr__(self, name)
方法用来删除实例的属性,在使用 del
关键字删除实例属性时,便会调用该方法。同样注意该方法要防止无限递归的情况。
注意区分这几个特殊方法和 property
属性的 getter
、setter
和 deleter
,它们也是用于类似的数据访问之中,区别在于 property
属性只是改变了单个属性的行为,而这些特殊方法是用于类的所有属性之中。因此 property
使用时考虑的因素会少一些,一般情况下都是创建额外的私有属性与它交互,只要不访问自身,不用考虑递归的发生。
反射和鸭子类型
反射(reflection)是面向对象编程语言的一个术语,指程序在运行时能够访问、检测和修改其本身状态或行为的一种能力。反射使得程序能够动态地获取类、对象、方法、属性等信息,并能在运行时操作和调整它们。反射使得在编程时可以创建通用代码、实现动态框架等。但是滥用反射也会导致安全漏洞、性能问题和可维护性降低等问题。
由于 Python 特殊的动态性质,可以将值、方法、实例、类统一视为对象,并且在运行时可以直接通过 __dict__
像使用字典一样操作对象的属性和方法,因此在 Python 中很少强调“反射”这一概念。
如果要操作多种类型,最好使用内置函数
vars(obj)
,它会返回obj
对象的__dict__
属性,相当于直接使用点号。不过它会在对象没有__dict__
属性(例如类中使用了__slots__
)时引发 TypeError 异常。不带参数的
vars()
也可以用于获取当前命名空间(如类和模块)的所有变量,等效于访问当前作用域的locals()
。
Python 还提供了以下内置函数,使得反射的使用更为简单:
getattr(object, name, default=None)
函数用于返回 object
对象的 name
属性,其中 name
应该为一个字符串,表示 object
对应的类属性或实例属性。
getattr(object, "name")
等价于 object.name
,结果也由 __getattribute__
和 __getattr__
决定。但可以向 getattr()
传入第三个参数 default
,那么当访问的属性不存在时,便会返回该值而不是引起 AttributeError 异常。
setattr(object, name, value)
是一个配套的函数,用来设置对象的属性,name
需要是一个代表需要设置的属性名,同样需要是一个字符串;value
可以是任意合理的值。setattr(object, "name", value)
等价于 object.name = value
。但是 setattr()
函数允许为一个对象设置不是合法标识符的属性名,通过点号就无法做到:
当然还有对应的 delattr(object, name)
。
不过除此之外还有一个 hasattr(object, name)
用于判断对象是否存在 name
属性,返回的结果是布尔值。它的实质是通过调用 getattr(object, name)
并检查是否引发 AttributeError 来实现的。
在前文中,看到了特殊属性和特殊方法是如何决定对象的行为的,在 Python 的设计理念中,对象的行为不是靠继承了什么类或实现了什么接口决定的,而是靠拥有什么属性、实现了什么方法。这种设计理念称为鸭子类型(duck typing),来源于一句俚语“如果它走起路来像鸭子,叫起来也像鸭子,那么它就是一只鸭子(If it walks like a duck and it quacks like a duck, then it must be a duck)”,只要一个对象实现了特定的方法或拥有特定的属性,就可以视为具有某种类型,而无需显式地继承该类型或使用特定的类型声明。
在编程中,鸭子类型表达了这样一种思想:只需要关注对象是否具有执行特定操作的能力,而不关心对象的实际类型。例如,以下实现了一个函数 close_object()
,它的作用就是将某个对象“关闭”:
这个类型标注并没有写错,因为只需要关注它有没有实现 .close()
方法,而不是是否继承了类似 IClosable
的接口。至于这个对象是文件对象,还是套接字对象,又或者是什么书籍类,并且它的 .close()
方法是修改对象状态还是执行一些计算,这并不是函数关心的内容。
因此,Python 中的特殊方法为一个对象的通用行为提供了统一的接口。在后续的章节中,还会看到更多类似本节的“特殊方法决定对象行为”的情况,这也就是为什么研究特殊方法对认识 Python 语言具有重要意义。