Python面向对象编程09-描述符

描述符简介

为什么需要描述符

在介绍对象的属性时,介绍了 property 属性。property 属性为一大堆 getter 和 setter 方法提供了一个完美的封装方式,使用者可以像操作普通属性一样操作 property 属性,同时隐藏了底层相应的 getter 和 setter 方法的实现细节,实现了对属性的透明访问和控制,使得代码更加清晰、灵活,并提高了封装性。

property 最大的缺点在于它无法复用:每定义一个 property 属性,就需要编写 3 个方法,并且通过 property 定义的属性只能被该类以及它的子类使用。在类之间甚至同一个类内都可能存在一些处理方式类似的属性,如果要为它们逐个编写 property 就会显得有些麻烦。

例如,以下实现了一个文章类,并提供了一个 published_at 属性管理发布的时间:

class Article:
    def __init__(self):
        self.__published_at = None
    @property
    def published_at(self):
        if self.__published_at is None:
            self.__published_at = datetime.now()
        return self.__published_at
    @published_at.setter
    def published_at(self, value):
        self.__published_at = datetime.fromisoformat(value)
    @published_at.deleter
    def published_at(self):
        self.__published_at = None

propertypublished_at 属性隐藏了实现细节,并提供了一个更加易用的借口:

>>> article = Article() >>> article.published_at 2023-01-24 16:58:50.365764 >>> article.published_at = '2023-01-22 12:00' >>> article.published_at 2023-01-26 12:00:00

但是,如果这个类中还有其它类似的日期时间属性如更新时间,或者其它类似的类如评论类也会有类似属性,那就需要为每个属性编写相似的方法,这还是显得有些复杂且不便维护。

为此,Python 提供了一种称为 描述符(descriptor) 的特殊类,一个描述符类可以管理一类属性的检索、存储和删除,使用了描述符的类可以共用实现描述符的逻辑而无需重写一遍。

描述符的应用非常广泛,实际上之前也曾经接触过描述符,不是别的,正是 property 对象。property 对象的原始形式用法是这样的:

class Article:
    updated_at = property(updated_at_getter,
                          updated_at_setter,
                          updated_at_deleter,
                          "Datetime that article was updated")

这么做看起来有一个问题:通过 property 得到的似乎是一个类属性,而不是实例属性,这里并不是通过 self.updated_at = property(...) 的形式添加实例属性,而是直接写在类的定义中。但是通过这样形式创建的属性,用起来却像一个实例属性。

这并没用违背 Python 的语法,也不是 Python 为 property 开了特例,而是底层的描述符在起作用。在深究它的底层原理之前,需要对描述符有一个清晰的认识。

创建并使用描述符

在 Python 中,存在这样三个特殊方法:

__get__(self, instance, owner=None)
__set__(self, instance, value)
__delete__(self, instance)

这三个特殊方法称为描述符协议(descriptor protocol),实现了描述符协议的对象称为描述器。描述符对实例属性的管理,从 instance 参数就可以窥见。

首先介绍 .__get__() 方法,它是一个实例方法。.__get__() 方法定义对象的属性读取行为,并控制对象在访问属性时应该返回的值。

因为描述符有些复杂,接下来通过一个简单的测试类来说明它的使用,也更好地说明它参数的含义:

class Descriptor:
    def __get__(self, instance, owner=None):
        print(instance, owner)
        return 'return value from __get__()'

注意,描述符对象都是充当类属性而不是实例属性的,这个问题会在后续说明。所以,描述符用起来都像这样:

class Test:
    desc = Descriptor()

首先看看直接以类属性形式访问描述符对象会怎样:

>>> Test.desc None <class '__main__.Test'> 'return value from __get__()'

结果显示,直接访问类属性经过描述符对象的 .__get__() 取到了值,而不是直接访问得到描述符对象。.__get__() 方法的参数 owner 代表描述符所绑定的类。因为不同的描述符实例将作为不同类的属性,这时参数 owner 随之改变。

由于实例也可以访问类属性,也可以从实例的角度尝试访问描述符对象:

>>> Test().desc <__main__.Test object at 0x000002B05DB50898> <class '__main__.Test'> 'return value from __get__()'

当通过描述符绑定的类的实例来访问描述符类属性时,参数 instance 代表该实例,否则通过类访问类属性时,该参数为 None 。注意区分 self 参数和 instance 参数。它们都代表实例,但是 self 代表的是描述符类的实例,而 instance 代表的是描述符绑定类的实例。

可以看出,描述符的特点首先体现在属性访问上:当通过点号表达式去访问对象的属性时,如果 Python 发现这个属性是一个描述符,那么就转而调用这个描述符的 .__get__() 方法,并将该方法的返回值作为属性访问的结果。

换句话说,描述符为对象的属性访问添加了一个新的步骤,通过这一步骤提供的额外信息,描述符可以在属性访问时实现更复杂的功能。例如,通过检查 instance 参数是否为 None ,就可以知道是否通过实例访问该属性,如果是的话,还可以通过 instance 参数读取实例的其它属性。

事实上,之前的章节已经见过了利用这一过程实现的功能:classmethodstaticmethod 类就是利用这一过程实现的描述符(虽然它们底层源码是用 C 语言编写的,但是它们的行为和描述符是一致的,检查它们可以发现确实拥有 __get__ 方法),所以它们的第一个参数可以不为 self 。以 staticmethod 为例,用纯 Python 模拟它的实现非常简单:

class StaticMethod:
    "Emulate PyStaticMethod_Type() in Objects/funcobject.c"
    def __init__(self, f):
        self.f = f
    def __get__(self, instance, owner=None):
        return self.f

因为 StaticMethod 作为装饰器的本质就是创建了一个类属性 method = StaticMethod(StaticMethod) 。它首先被实例或类访问,发现这是一个描述符而不是方法,走的是普通属性访问而不是方法调用的路线,因此 Python 并不会将实例作为第一个位置参数传入。

利用这个特点,可以进一步扩展 classmethod 类,实现一个只能由类调用的类方法:

class classonlymethod(classmethod):
    def __get__(self, instance, cls=None):
        if instance is not None:
            raise AttributeError(
                "This method is available only on the class, not on instances."
            )
        return super().__get__(instance, cls)

顺便一提,这段代码来自著名的 Python 框架 Django 。除了直接抛出错误外,自定义的操作还可以执行更加复杂的功能,例如对属性进行计算、验证或惰性加载等,从而实现更灵活、可控的属性管理。不过在介绍这些用法之前,还需要认识描述符的另外两个方法。

数据描述符

在认识了 .__get__() 方法的基础上,便不难理解 .__set__() 和 .__delete__() 的用法了:

当一个描述符绑定类的实例尝试为描述符充当的类属性赋值,即 ins.desc = val 时,便会调用 .__set__() 方法,参数 instance 就代表通过来修改描述符类属性的实例,而参数 value 就代表试图赋予的新值。该方法通过拦截属性的赋值行为,使得在赋值前可以对值做一些检查或修改。

同样地,当一个描述符绑定类的实例尝试使用 del 语句删除描述符类属性时,便会调用 .__delete__() 方法。在这种情况下,参数 instance 同样代表通过来删除描述符类属性的实例。该方法通过拦截属性的删除行为,使得可以使用清零、恢复初始值等操作代替实际删除属性的行为。

如果一个对象同时实现了这三个方法,那么该描述符被称为数据描述符(data descriptor)。注意,这些方法有一定联系,但同时也可能会造成混淆,理清这些方法的区别有助于理解它们的用法:

  • propertyproperty.setterproperty.deleter 可以拦截对属性的操作,并添加自定义的行为,实现对属性的封装
  • __get____set____delete__ 也可以拦截对属性的操作,但它可以应用到不同类当中。记住这句话就等于学会了描述符:描述符是可以复用的 property 属性instance 参数实现了对复用时用在哪的支持。所以当不知道应该使用描述符还是 property 时,只需要想清楚这个属性到底要不要复用
  • __getattr____setattr____delattr__ 同样可以拦截对属性的操作,但它是针对所有属性的,而其它两类方法只是针对单一属性。如果不需要更改所有属性的表现行为,一般不要重写该方法

所以,回到本文最开始的问题:存储日期时间的字段在获取、设置、删除时都需要一些操作来规范它们的行为,所以需要 property 提供统一的属性接口;但表示文章、评论等结构的各个日期时间字段的行为都是类似的,这时就出现了复用的问题,可以考虑使用描述符来处理。

针对这一过程,将 property 的各个行为提取为以下描述符类:

class DateTimeField:
    def __init__(self, field_name):
        self.field_name = '_' + field_name
    def __get__(self, instance, owner=None):
        if hasattr(instance, self.field_name):
            field = getattr(instance, self.field_name)
            if field is not None:
                return field
        now = datetime.now()
        setattr(instance, self.field_name, now)
        return now
    def __set__(self, instance, value):
        setattr(instance, self.field_name, datetime.fromisoformat(value))
    def __delete__(self, instance):
        setattr(instance, self.field_name, None)

注意使用描述符相比 property 的区别:为了考虑复用,具体用到的属性不能写的太死,这就需要动态添加和获取属性。而且由于缺少了初始化环节,还需要使用 hasattr 做必要的初始化。

封装了这样一个描述符对象后,批量生成属性就方便多了:

class Article:
    published_at = DateTimeField('published_at')
    updated_at = DateTimeField('updated_at')
class Comment:
    published_at = DateTimeField('published_at')

这样生成的属性和通过 property 定义的属性用起来是一样的。

完善描述符

虽然以上定义的描述符已经解决了属性复用的问题了,但它还有一个小问题。注意到之前描述符都是这样使用的:

class Article:
    published_at = DateTimeField('published_at')

在使用描述符时,需要将这个字段名写两遍:创建类属性时要给出它的名称,描述符也要通过初始化函数知道这个名称,不然描述符不知道自己绑定给了哪个属性。但 Python 需要先创建描述符,再将其赋值给属性,在创建描述符时它不可能得知自己将被绑定到哪个属性上,只能借助初始化方法。

这可能会导致一些潜在的问题。例如,描述符在使用时,也存在之前说过的无限递归的问题:访问描述符绑定的属性会触发描述符的 .__get__() 方法获取值,如果在该方法内又一次访问了这个属性,就会造成无限递归。例如,对于以下这个描述符:

class Desc:
    def __get__(self, instance, owner=None):
        print(getattr(instance, 'desc'))
        return 10

如果这个描述符被绑定到了 .desc 属性上:

class Demo:
    desc = Desc()

那么通过实例访问 .desc 属性立刻就会造成无限递归:

d = Demo()
d.desc  # RecursionError

这个问题是无法避免的,因为递归发生在描述符内部:只要描述符的实现需要访问属性,就有可能发生这个问题。

好在 Python3.6 引入了另一个与描述符有关的特殊方法,解决了这个名称问题:

__set_name__(self, owner, name)

该方法是一个实例方法,会在描述符绑定类被创建(类属性赋值了一个描述符实例)时调用,通过 name 参数通知描述符绑定属性的变量名称。

如果以上示例的 Desc 类实现了 __set_name__ 方法,那么在创建描述符 desc = Desc() 时,就会自动调用 desc.__set_name__(Demo, 'desc')__set_name__ 方法为描述符引入了一个专门用于设置字段名的额外初始化环节,这样初始化方法就可以用作其它用途了。

因此,以上示例的日期时间字段类只需要将初始化方法改成 __set_name__ 方法:

class DateTimeField:
    def __set_name__(self, owner, name):
        self.field_name = '_' + name

这样字段名就不用写两遍了:

class Article:
    published_at = DateTimeField()
    updated_at = DateTimeField()
class Comment:
    published_at = DateTimeField()

不过有些时候也会利用递归调用的特性实现一些功能。例如缓存属性:如果 __get__ 方法需要执行一些耗时的操作才能返回结果(例如从某个大型文件中读出配置,或者从网络下载数据),但不希望每次访问属性时都执行这一耗时操作,就可以在第一次调用获取结果后,将描述符替换为该结果:

class CachedProperty:
    def __init__(self, meth):
        self.meth = meth
        self.name = meth.__name__
    def __get__(self, instance, owner=None):
        setattr(instance, self.name, self.meth(instance))
        return getattr(instance, self.name)

这个描述符可以作为装饰器使用:

class SomeTask:
    @CachedProperty
    def config(self):
        sleep(3)  # time-consuming operation
        return {'procedure': '...'}

task = SomeTask()
print(task.config)  # wait for a moment and print result
print(task.config)  # immediately print result
print(task.config)  # immediately print result

最后再看一个更复杂的示例。由于描述符本身是一个类,可以通过继承实现更广泛的用途,并隐藏基类的实现细节。以下是一个校验器类,它可以在赋值前检查这个值是否合理:

from abc import ABC, abstractmethod

class Validator(ABC):
    def __set_name__(self, owner, name):
        self.private_name = '_' + name
    def __get__(self, instance, owner=None):
        return getattr(instance, self.private_name)
    def __set__(self, instance, value):
        self.validate(value)
        setattr(instance, self.private_name, value)
    @abstractmethod
    def validate(self, value):
        pass

通过继承这个抽象类并重写 .validate() 方法,可以实现自定义的校验逻辑,例如限制属性的字符串长度:

class VarChar(Validator):
    def __init__(self, max_length=1024):
        self.max_length = max_length
    def validate(self, value):
        if not isinstance(value, str):
            raise TypeError(f'Expected {value!r} to be an str')
        if len(value) > self.max_length:
            raise ValueError(
                f'Expected {value!r} to be no bigger than {self.max_length!r}'
            )

又或是限制属性只能为某些特定值之一:

class Choice(Validator):
    def __init__(self, *options):
        self.options = set(options)
    def validate(self, value):
        if value not in self.options:
            raise ValueError(f'Expected {value!r} to be one of {self.options!r}')

这些自定义的校验器可以应用于属性之中,并对不规范的赋值行为报错:

class ArticleModel:
    title = VarChar(max_length=128)
    status = Choice('published', 'draft', 'deleted')
    def __init__(self, title, status):
        self.title = title
        self.status = status
       
article = ArticleModel('Implementing Descriptors', 'draft')

这项技术被用在很多对象关系映射(ORM)的数据库中,ORM 可以把编程语言(特别是面向对象的编程语言)里面的一个对象映射成数据库里面的一个表,通过操作对象代替直接操作数据库中的表实现增删改查的操作,在增加了可读性的同时也有效减少了注入的风险。

SQLAlchemy 是 Python 的一个关于 SQL 类数据库的 ORM 框架。使用 SQLAlchemy ,可以通过定义一个类的方式创建一张表:

import sqlalchemy as sa
from sqlalchemy.ext.declarative import declarative_base

BASE = declarative_base()

class User(BASE):
    __tablename__ = 'user'
    id = sa.Column(sa.Integer, primary_key=True, autoincrement=True)
    username = sa.Column(sa.String(64), unique=True)
    password = sa.Column(sa.String(64))
    email = sa.Column(sa.String(128), unique=True)
    create_at = sa.Column(sa.DateTime, default=datetime.now)

可以看出,这里的 sa.Column 就是一个描述符,它负责管理属性的行为。只需要在类定义时引入 sa.Column ,它就能自动将属性映射到数据库中的字段,并处理自增、默认值等相关的工作。并且数据库中关于字段的创建、查找、过滤、排序等功能,都可以对应到 Python 中关于属性的操作:

# create users
user1 = User(username='admin', password='admin123456', email='admin@test.com')
user2 = User(username='assistant', password='assist654321', email='assist@test.com')
# query users
users = session.query(User).filter(User.level > 100).order_by(User.create_at)

其它话题

关于属性

在之前介绍属性时,曾经将属性粗略地划分为实例属性和类属性,并指出查找属性时先查找实例属性,再查找类属性。实际上这个表述是不对的,特别是在上文中看到了描述符作为类属性是如何影响实例属性的行为的。在认识了描述符这一概念后,便可以介绍 Python 属性查找的完整逻辑了。

之前说过,假设 obj 是一个类或实例,在使用 obj.attr 查找属性时,会先调用 .__getattribute__() 方法查找。如果查找失败,再调用 .__getattr__() 方法查找。基类 object 规范了标准的属性查找方式,它的 .__getattribute__() 方法实现细节是这样的:

  1. 首先查找属性在类中是否是一个描述符
  2. 如果是描述符,并且是数据描述符,则尝试调用数据描述符的 __get__() 方法,其调用细节为:
    • 如果 obj 是实例,那么尝试调用 type(obj).__dict__['attr'].__get__(obj, type(obj))
    • 如果 Obj 是类,那么直接调用 Obj.__dict__['attr'].__get__(None, Obj)
  3. 如果不是数据描述符,则从实例的 __dict__ 中查找(在这一步,可以查找到实例属性)
  4. 如果 __dict__ 中查找不到,再检查它是否是一个非数据描述符;如果是,则调用非数据描述符的 __get__() 方法
  5. 如果也不是一个非数据描述符,则从类属性中查找(包括类的 __dict__ 和父类的 __dict__
  6. 如果还是找不到,则抛出 AttributeError 异常

当类中提供了 __slots__ 属性后,情况有点特殊:Python 解释器会自动为 __slots__ 中的每个名称创建一个特殊的描述符对象,这也就是为什么 __slots__ 声明的属性不能设置默认值(否则默认值会覆盖自动创建的描述符)。

这也就是为什么描述符需要定义为类属性。因为一个描述符就可以用于处理所有实例的属性,将描述符定义为类属性可以使一个类型只保存一份描述符对象,而不需要每个实例都保存一个副本。

关于方法

在之前介绍方法时,曾经将“在类中定义的函数”称为“方法”,并指出实例调用方法时,Python 解释器会自动将实例作为第一个位置参数传入。实际上这个表述也是有一定偏差的,但在有了描述符相关知识后,就可以明白 Python 的方法究竟是什么。

接下来定义一个很简单的类作为示例:

class Student:
    def __init__(self, name, age):
        self.name = name
        self.age = age
    def __repr__(self):
        return f'Hello, I\'m {self.name}'
    def say_hello(self):
        print(repr(self))

如果直接通过类查看定义的方法,会发现它其实就是一个函数:

>>> Student.say_hello <function Student.say_hello at 0x000002511BDB7940>

但通过实例检查方法时,结果又不同了:它是一个称为“bound method”的对象:

>>> someone.say_hello <bound method Student.say_hello of <__main__.Student object at 0x000002511BBD7220>>

细心的读者可能会发现两者的地址似乎不同,这说明它们应该是不同的函数/方法。使用 is 运算符可以得出它们确实不是同一个对象,并且不同实例得到的实例方法也不是同一个对象:

>>> Student.say_hello is someone.say_hello False >>> someone.say_hello is Student('Alice', 16).say_hello False

在有了对描述符的认识后,应该就可以反应过来:say_hello 就是一个描述符。不仅如此,实际上任何使用 def 关键字定义的所谓“函数”都是一个描述符,不管它们是在类内部还是外部定义的,它们都提供了一个 __get__ 方法:

>>> def func(*args, **kwargs): print(args, kwargs) >>> dir(func) ['__annotations__', '__call__', ..., '__ge__', '__get__', '__getattribute__', ..., '__str__', '__subclasshook__']

当使用实例调用类属性后,函数的 __get__ 方法就发挥用途了,它将函数包装成了一个类似 functools.partial 的偏函数,这一过程中实例永远作为第一个位置参数,所以被称为“绑定”的方法。它们的 .__self__ 属性指示了绑定的对象:

>>> someone.say_hello.__self__ Hello, I'm Tom

还可以通过 .__func__ 属性获取原本的函数:

>>> someone.say_hello.__func__ is Student.say_hello True

所以,描述符在 Python 中无处不在,只是一般情况下没有意识到它的作用。完全可以将一个函数动态地添加为方法,并将它的第一个参数作为 self 。只不过为了发挥它的描述符功能,要添加到类上:

>>> def introduce(student): ... print(f'He is {student.name}, {student.age} years old') >>> Student.introduce = introduce >>> someone.introduce() He is Tom, 17 years old

如果要深究下去的话,其实这些所谓的“函数”也分为很多类型。例如,有些方法的返回结果是“slot wrapper”、“method-wrapper”:

>>> object.__eq__ <slot wrapper '__eq__' of 'object' objects> >>> object().__eq__ <method-wrapper '__eq__' of object object at 0x000002511B6084F0>

这些奇怪的类名只是因为它们是用 C 语言实现的,总的来说它们都可以被归为函数和绑定方法这两类。

所以,Python 的类其实并没有什么特殊的,它只是为一些函数提供了统一的命名空间,外加提供了一个生产实例的接口。而实例也没什么特殊的,它只不过访问了一个可调用的属性,再使用这个属性的 __call__ 方法。真正特殊的是 Python 的描述符机制,它会自动发生函数向绑定方法的偏函数转换,并在这一过程中自动发生绑定的机制。

参考资料/延伸阅读

Python 官方文档对描述符的介绍

Python 官方文档——描述器使用指南,本文中的部分示例代码改编或直接搬自该文档。

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