Python面向对象编程05-特殊方法与自定义类

上一节中,已经介绍了几个基本的自定义类的方法:.__new__().__init__().__del__() 。本节介绍更多的特殊方法,以此了解一个自定义类是如何工作的。

自定义基础类

字符串表示:__str__

之前介绍类的实例化时,第一步就是尝试使用 print() 打印实例,观察它是什么东西。例如,对于以下自定义类和实例:

class C:
    ...
c = C()
print(c)

它的打印结果为:

$ python page05.py <__main__.C object at 0x000001D2D0FED080>

得到的结果是一串位于尖括号内的奇怪信息,信息中似乎包含类名和内存地址之类的东西。

之所以会打印这一段信息,是因为 print() 函数会将需要打印的内容转换成字符串,再将这个字符串显示在输出中。如果使用 str() 将实例转换为字符串,得到的也是这个结果:

>>> str(c) <__main__.C object at 0x000001D2D0FED080>

这种奇怪的表示并没用展示有价值的信息。之所以会输出以上的结果,这是因为类有一个特殊方法 .__str__() 该方法是一个实例方法,用来获得实例的字符串表达方式。在调用内置函数 str()format()print() 时,都是通过该方法的返回值将对象转换为字符串。

在创建一个类时,它会自动继承父类 object.__str__() 特殊方法,而 object 可用到的信息十分有限,因此默认输出的会是这样一个结果。

可以编写一个自定义的类,重写 object 方法,给实例一个更好的描述,使 print() 等函数可以更好地展示它,例如:

class Student:
    def __init__(self, name, scores):
        self.name, self.scores = name, scores
    def __str__(self):
        return self.name
   
s01 = Student('Tom', {'math': 92, 'English': 89})
print(s01)
print(f'He is {s01}')

这样得到的结果明显易读多了:

$ python page05.py Tom He is Tom

.__str__() 方法等价于许多编程语言的 to_string 方法。它在 Python 内置语法中相当常见,几乎所有的 Python 内置数据类型都重写了该方法,使得打印结果更易读。另一个典型的例子就是 Python 的异常,它使用 .__str__() 方法描述引起该类异常时携带的信息,当使用 except ... as 捕捉到异常实例后,直接打印这个异常实例就可以打印出引起异常的信息。

对象描述:__repr__

以上的 .__str__() 方法是针对字符串表示的,它们与内置的一些函数相关联。如果在交互式控制台中检查对象,结果为:

>>> class Test: ... def __str__(self): ... return 'This is a test' >>> t = Test() >>> t <__main__.Test object at 0x00000223597407B8>

得到的结果仍然是一串 ... object at ... 的信息。

实际上,在控制台中检查对象,得到的信息是由特殊方法 .__repr__() 给出的。.__repr__() 是一个实例方法,它返回的结果是对象的规范字符串表示(canonical string representation),内置函数 repr() 实际上也调用的是它。

从表达含义上说,.__repr__().__str__() 的区别在于,.__repr__() 是用来调试的,它应该表达尽量多且明确的信息,而 .__str__() 主要用于格式化,它应该提供简洁美观的结果(例如尽可能在一行内表达清楚)。

可以重写类的 .__repr__() 方法展示实例的更多细节。一般来说,自定义类 .__repr__().__str__() 展示的信息差别不大,它们的实现往往可能是相同的。相比重复的定义,在定义了其中一个方法后,可以通过赋值让另外一个方法作为它的引用,就像这样:

class Student:
    def __str__(self): ...
    __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 TrueTrue
__bool__() returns FalseFalse
__bool__() returns non-boolean valueTypeError
__bool__() undefined, but __len__() returns 0False
__bool__() undefined, but __len__() returns non-zero valueTrue
__len__() returns non-integer valueTypeError
both __bool__() and __len__() are undefinedTrue

其它类型转换

除了以上提到的关于类型转换的特殊方法外,一个类还可以定义 .__bytes__().__int__().__float__().__complex__() 特殊方法,它们分别被内置类 bytes()int()float()complex() 的构造方法调用,获取对应类型的表示方式。这些方法应该返回对应类型的对象,以实现向对应类型的转换。

列举属性:__dir__

在之前编程程序中,有时会使用 dir() 函数来查看类实现的属性及方法。例如,以下是查看 object 类的属性及方法:

>>> dir(object) ['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__']

可以看到结果还不少,有一部分是前面已经介绍的,有一部分需要留到后面才介绍。在其中,可以看到一个名为 '__dir__' 的方法,它是类使用 dir() 查找的依据。.__dir__() 是一个实例方法,方法的返回值应该是一个可迭代对象,并且最好是一个列表。不管如何,Python 都会将它的返回值转换为一个列表并排序:

>>> class Test: ... def __dir__(self): ... return 'sort' >>> dir(Test()) ['o', 'r', 's', 't']

尽管 dir() 函数也可以直接查看类,但是 .__dir__() 方法却是一个实例方法,重写该方法只能影响到使用 dir() 查看实例的属性与方法。不管怎么样,一般情况下都没有必要重写该方法。

实例与类的特殊属性

除了特殊方法外,类也具有一些特殊属性,它们也由双下划线开始,双下划线结束,负责记录类的基本信息,一般只需要查看即可。其中一部分特殊属性甚至不在 dir() 的结果列表中。

类的__name__

类最基本的属性就是它的 .__name__ 了。类的 .__name__ 就是它的名称,.__name__ 属性可以获取字符串形式的类名,这个值在,可以用在调试和元编程等场合。

>>> {cls.__name__: cls for cls in [float, Exception, list, enum.Enum]} {'float': <class 'float'>, 'Exception': <class 'Exception'>, 'list': <class 'list'>, 'Enum': <enum 'Enum'>}

注意,只有类、函数和模块才具有这个属性,普通的实例是没有这个属性的。因此定义一个变量后,无法获取一个普通变量的字符串形式的名称(除非使用一些复杂的技术)。

实例的__class__

实例的 .__class__ 保存了它的类型,等价于对其使用 type() 返回的结果。例如 [1, 2].__class__ 是 Python 内置数据类型 list 。它等价于对实例调用 type()

__class__ 也用在类内部通过 self.__class__ 来指代自身的类,与直接使用类名相比,.__class__ 可能指代的是子类,这样做更方便该类的继承。

类的__bases__

以元组的形式返回该类继承的所有基类。除此之外,类还有一个 __base__ 属性,它只会返回类的第一个基类。

>>> class MyDict(dict, object): ... >>> print(MyDict.__base__) <class 'dict'> >>> print(MyDict.__bases__) (<class 'dict'>, <class 'object'>)

类的__subclasses__()

它是一个方法,用于返回该类的子类。事实上,每一个类在创建时,都会生成一个 .__weakref__ 属性,它用于对所有直接继承它的子类产生一个弱引用(weak reference),而该方法的作用就是以列表的形式返回所有仍然存在的引用,并以类定义的先后排序。例如:

>>> object.__subclasses__() [<class 'type'>, <class 'weakref'>, ..., <class 'MultibyteStreamWriter'>]

结果有点多,因为它还包含了 Python 内置模块中的一些类。

类与实例的__dict__

接下来的这个属性非常重要。Python 是一门动态的编程语言,实例的属性也是可以动态添加的。假设有如下的类定义:

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

p = Point(10, 4)

虽然在定义时,看起来类只使用了两个属性,但在后续的代码中随时都可以这样动态地为实例赋予一个新的属性:

p.z = 4
p.z  # 4

一些静态的编程语言(如 C++ 和 Java )在创建实例时就为它们的属性分配了内存空间,每个属性的值都保存在固定的位置,因此无法直接为实例动态添加属性。但Python 既然允许动态添加属性,就需要一种动态分配空间的机制。实际上,Python 采用字典来存储对象的属性。每一个基本的 Python 类或者实例都有一个 .__dict__ 属性,它的键保存着属性名,值保存着属性对应的值:

>>> Point.__dict__ {'__module__': '__main__', '__init__': <function Point.__init__ at 0x00000268758D7E50>, '__dict__': <attribute '__dict__' of 'Point' objects>, '__weakref__': <attribute '__weakref__' of 'Point' objects>, '__doc__': None} >>> p.__dict__ {'x': 10, 'y': 4, 'z': 4}

当 Python 要取用或创建属性时,它就会去到类或者实例的 .__dict__ 字典中查找或添加。字典保存着类与实例的属性,所以是 Python 中非常重要的数据类型。

.__dict__ 属性也为类的继承带来了方便,子类的 __dict__ 并没有包含继承的方法和属性;当子类需要时,就会到父类的 __dict__ 中寻找。而 MRO 机制的存在也使得这一套流程不易出错。

.__dict__ 可以用于快速合并属性。例如,一些复杂的类的构造方法有很多不必要的参数,它们可能会被放在 **kwargs 可变参数内,初始化可能会这么写:

class Window:
    def __init__(self, x, y, width, height, **kwargs):
        # ...
        self.parent = kwargs.get('parent', None)
        self.visible = kwargs.get('visible', None)

这样逐个操作较为麻烦,那么就可以借助字典的 .update() 方法快速合并:

class Window:
    def __init__(self, x, y, width, height, **kwargs):
        # ...
        self.__dict__.update(kwargs)

通过 .__dict__ 属性可以检查类提供的接口,它比 __dir__ 更加灵活,能筛选不同的对象。例如,以下通过 .__dict__ 检查 list 的可调用的方法,并筛去了以双下划线开头的特殊方法:

print(
    *[attr[0] for attr in dict.__dict__.items()
        if callable(attr[1])
            and not attr[0].startswith('__')]
)
# clear copy append insert extend pop remove index count reverse sort

使用类的__slots__

字典赋予了 Python 强大的动态运行机制,但要认识到的是,字典是一种非常耗费存储空间的数据类型。哪怕 Python3 对保存属性的字典做了一定优化,存储仅仅 3 个属性还是耗费了 104 字节的空间:

>>> import sys >>> sys.getsizeof(p.__dict__) 104

这在创建很多小对象时会占用大量内存。要解决这个问题,可以采用 .__slots__ 属性。.__slots__ 属性是用来限制实例属性成员的。它是一个类属性,可以为其赋予一个字符串或含有字符串的可迭代对象,例如:

class Point:
    __slots__ = ['x', 'y', 'z']
    def __init__(self, x, y, z):
        self.x, self.y, self.z = x, y, z

.__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 类,可以通过属性访问的方式获取字典的值:

class RecordDict(dict):
    def __getattr__(self, name):
        try:
            return self[name]
        except KeyError as e:
            raise AttributeError(
                f'{self.__class__.__name__!r} object'
                f' has no attribute {name!r}'
            ) from None

如果确实访问的是一个存在的属性,那么 .__getattr__() 方法就不会被调用,防止影响到使用字典的方法。.__getattr__() 方法在没有得到合适的结果时也应该抛出 AttributeError 异常,从而便于统一捕获:

>>> rec = RecordDict(name='record', type=dict, default=100) >>> rec.name record >>> rec.default 100 >>> rec.defaults Traceback (most recent call last): File "<stdin>", line 1, in <module> AttributeError: 'RecordDict' object has no attribute 'defaults'

需要特别注意的是,如果确实要重写 .__getattribute__() 方法,该方法内不能使用任何形如 self.attribute 的点号运算符,包括直接查找实例的 .__dict__ ;否则,该过程又会调用实例的 .__getattribute__() 方法,造成无限递归,并且这种形式的递归是很难正常退出的。访问属性应该使用超类没有重写的 .__getattribute__() 方法完成此操作:

class RecordDict(dict):
    def __getattribute__(self, name):
        try:
            return super().__getattribute__(name)
        except AttributeError:
            ...

但是这种写法显然很不优雅,还会使得异常变得很复杂。因此,.__getattr__() 方法一般被视为点号运算符的重载而不是获取属性的途径。

有 get 就会有 set ,但是严格来说 .__setattr__() 是和 .__getattribute__() 配对使用的。__setattr__(self, name, value) 是实例属性的赋值 instance.name = value 的底层方法。例如,以下扩展了 RecordDict ,支持通过添加属性的方式添加键值对:

class RecordDict(dict):
    def __setattr__(self, name, value):
        self[name] = value

类似于 .__getattribute__() ,在 __setattr__() 方法内注意不能使用 instance.name = value 这种语句,否则会造成无限递归。

最后,还有一个 .__delattr__(self, name) 方法用来删除实例的属性,在使用 del 关键字删除实例属性时,便会调用该方法。同样注意该方法要防止无限递归的情况。

注意区分这几个特殊方法和 property 属性的 gettersetterdeleter ,它们也是用于类似的数据访问之中,区别在于 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() 函数允许为一个对象设置不是合法标识符的属性名,通过点号就无法做到:

>>> class C: pass >>> o = C() >>> setattr(o, 'this is (an) attribute!', 123) >>> vars(o) {'this is (an) attribute!': 123}

当然还有对应的 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() ,它的作用就是将某个对象“关闭”:

from typing import Any

def close_object(obj: Any):
    obj.close()

这个类型标注并没有写错,因为只需要关注它有没有实现 .close() 方法,而不是是否继承了类似 IClosable 的接口。至于这个对象是文件对象,还是套接字对象,又或者是什么书籍类,并且它的 .close() 方法是修改对象状态还是执行一些计算,这并不是函数关心的内容。

因此,Python 中的特殊方法为一个对象的通用行为提供了统一的接口。在后续的章节中,还会看到更多类似本节的“特殊方法决定对象行为”的情况,这也就是为什么研究特殊方法对认识 Python 语言具有重要意义。

参考资料/延伸阅读

Python3 内置类型官方文档对特殊属性的介绍

Python3 DataModel 官方文档对特殊方法的介绍

京ICP备2021034974号
contact me by hello@frozencandles.fun