学会描述符之后,不仅有更多的工具集可用,还能对 Python 的运作方式有更深入的理解,不得不由衷赞叹 Python 设计的优雅。描述符是对多个属性运用相同存取逻辑的一种方式。例如,Django ORM 和 SQLAlchemy 等 ORM 中的字段类型就是描述符,其把数据库记录中字段里的数据与 Python 对象的属性对应了起来。
描述符是实现了动态协议的类,这个协议包括 __get__ 方法、__set__ 方法和 __delete__ 方法。property 类实现了完整的描述符协议。通常,动态协议可以部分实现。其实,我们在真实的代码中见到的大多数描述符只实现了 __get__ 方法和 __set__ 方法,还有很多只实现了其中一个方法。
描述符为去除存取方法中的重复逻辑提供了一种机制。描述符是 Python 独有的功能,不仅在应用程序层中使用,在语言的基础设施中也会用到。用户定义的函数就是描述符。我们将看到,描述符协议可以把方法变成绑定方法或非绑定方法,这取决于方法的调用方式。
描述符示例:属性验证
特性工厂函数借助函数式编程模式避免重复编写读值方法和设值方法。特性工厂函数是高阶函数,在闭包中存储 storage_name 等设置,由参数决定创建哪些存取函数,再使用存取函数构建自定义的特性实例。解决这种问题的面向对象方式是描述符类。
接下来将定义一个 Quantity 描述符,LineItem 类用到两个 Quantity 实例:一个用于管理 weight 属性,另一个用于管理 price 属性。
实现和使用描述符涉及多个组件,各个组件的命名务必准确:
- 描述符类,实现描述符协议的类,例如 Quantity 类是描述符类
- 托管类:把描述符实例声明为类属性的类,LineItem 类就是托管类
- 描述符实例:描述符类的各个实例,声明为托管类的类属性
- 托管实例:托管类的实例。在这个示例中,LineItem 实例是托管实例
- 储存属性:托管实例中存储托管属性的属性。LineItem 实例的 weight 属性和 price 属性是储存属性。这种属性与描述符属性不同,后者始终是类属性。
- 托管属性:托管类中由描述符实例处理的公开属性,值存储在储存属性中。也就是说,描述符实例和储存属性为托管属性建立了基础。
在这个例子中:
- 类是机器,用于生产小怪兽(实例)
- Quantity 机器生产了两个圆头小怪兽,依附在 LineItem 机器上,即 weight 和 price
- LineItem 机器生产的是方头小怪兽,有自己的 weight 属性和 price 属性,存储着相应的值
- Quantity 实例从 LineItem 实例中获取存储的值
如下是代码实现:
1 | class Quantity: |
- 描述符基于协议实现,无须子类化
- Quantity 实例有一个 storage_name 属性,这是托管实例中用于存储值的储存属性的名称
- 尝试为托管属性赋值时,调用
__set__方法。这里,self 是描述符实例(LineItem.weight或LineItem.price),instance 是托管实例(LineItem 实例),value 是要设定的值 - 必须把属性的值直接存入
__dict__。调用setattr(instance, self.storage_name)将再次触发__set__方法,导致无限递归 - 需要实现
__get__方法,因为托管属性的名称可能与 storage_name 不同。用户可能编写如下代码
1 | class House: |
- 这里托管属性是 rooms,而储存属性是 number_of_rooms
- 对于一个名为
chaos_manor的 House 实例,读写chaos_manor.rooms都经过依附在 rooms 上的 Quantity 描述符 - 但是读写
chaos_manor.number_of_rooms会绕过该描述符
注意,__get__ 方法接受 3 个参数:self、instance 和 owner:
- owner 参数是对托管类(例如LineItem)的引用,在希望描述符支持获取类属性时会用到:比如说模拟 Python 在实例中未找到指定名称的属性时获取类属性的默认行为。
- 如果通过类获取托管属性(例如
LineItem.weight),那么描述符的__get__方法收到的 instance 参数值为 None - 为了支持内省和其他元编程技巧,当通过类存取托管属性时,
__get__方法最好返回描述符实例,例如
1 | def __get__(self, instance, owner): |
如下则是 LineItem 类的实现,它使用了描述符类 Quantity:
1 | class LineItem: |
编写描述符的 __get__ 方法和 __set__ 方法时,要记住 self 参数和 instance 参数的意思:self是描述符实例,instance 是托管实例。管理实例属性的描述符应该把值存储在托管实例中。因此,Python 才为描述符中的方法提供了 instance 参数。
你可能想把各个托管属性的值直接存在描述符实例中,但这种做法是错误的。也就是说,在 __set__ 方法中,应该像下面这样写:
1 | instance.__dict__[self.storage_name] = value |
而这样写这是错误的:
1 | self.__dict__[self.storage_name] = value |
- self 是描述符实例,它其实是托管类的类属性。同一时刻,内存中可能有几千个 LineItem 实例,不过只会有两个描述符实例,即类属性 LineItem.weight 和 LineItem.price。因此如果将数据存储在描述符实例,这些数据居等同于成为了 LineItem 的类属性,从而由全部 LineItem 实例共享,而这是错误的
为存储属性自动命名
这段代码中有个小缺点,weight 名称编写了两次(price 也是类似的):
1 | weight = Quantity('weight') |
为了避免在描述符实例中重复输入属性名,我们将实现 __set_name__ 方法,设置各个 Quantity 实例的storage_name。特殊方法 __set_name__ 在 Python3.6 中加入了描述符协议。解释器会在 class 主体中找到的每个描述符上调用 __set_name__ 方法,当然前提是描述符实现了该方法。
LineItem 的描述符类不需要 __init__ 方法了,__set_name__ 方法负责保存储存属性的名称:
1 | class Quantity: |
__set_name__中,self 是描述符实例(不是托管实例),owner 是托管类,name 是在 owner 的类主体中把描述符实例赋给的那个属性的名称- 不需要实现
__get__方法,因为储存属性的名称与托管属性的名称一致。表达式product.price直接从 LineItem 实例中获取 price 属性
现在描述符的逻辑抽象到单独的代码单元中了:Quantity 类。通常,我们不在使用描述符的模块中定义描述符,而是在一个单独的实用工具模块中定义,以方便在整个应用程序中重用——如果是在开发库或框架,那么甚至可以在多个应用程序中使用。
通过继承创建新的描述符
由于描述符通过类实现,因此可以利用继承重用部分代码来创建新描述符。如下是个例子:
1 | import abc |
__set__ 方法把验证操作委托给 validate 方法,而 validate 是一个抽象方法,不同的具体描述符可以实现不同的验证逻辑。
1 | class Quantity(Validated): |
这个例子演示了描述符的典型用途,即管理数据属性。Quantity 这种描述符叫作覆盖型描述符,因为描述符的__set__方法使用托管实例中的同名属性覆盖(插手接管)了要设置的属性。除此之外,还有非覆盖型描述符。
覆盖型描述符与非覆盖型描述符对比
之前说过,Python 处理属性的方式特别不对等。通过实例读取属性时,通常返回的是实例中定义的属性。但是,如果实例中没有指定的属性,则会获取类属性。而为实例中的属性赋值时,往往会在实例中创建属性,根本不影响类。
这种不对等的处理方式对描述符也有影响。其实,根据是否实现 __set__ 方法,描述符可分为两大类:
- 实现
__set__方法的类是覆盖型描述符 - 未实现
__set__方法的类则是非覆盖型描述符
如下是一个例子:
1 | ### 辅助函数,仅用于显示### |
这里展示了三种描述符:
- 有
__get__方法和__set__方法的覆盖型描述符 - 没有
__get__方法的覆盖型描述符。 - 没有
__set__方法,所以这是一个非覆盖型描述符
Python官方文档使用 数据描述符,不过 覆盖型描述符 更能凸显它的特殊行为。覆盖型描述符也叫 强制描述符。非覆盖型描述符也叫 非数据描述符 或 遮盖型描述符。
覆盖型描述符
实现 __set__ 方法的描述符属于覆盖型描述符,因为虽然描述符是类属性,但是实现 __set__ 方法的话,描述符将覆盖对实例属性的赋值操作。特性也是覆盖型描述符:如果没有提供设值函数,那么 property 类中的 __set__ 方法就会抛出 AttributeError 异常,表明那个属性是只读的。
1 | from managed import Managed |
obj.over触发描述符的__get__方法,传入的第二个参数是托管实例 objManaged.over触发描述符的__get__方法,传入的第二个参数(instance)是 None- 为
obj.over赋值,触发描述符的__set__方法,传入的最后一个参数是 7 - 读取
obj.over仍会调用描述符的__get__方法 - 可以绕过描述符,直接通过
obj.__dict__属性设值 - 然而,即使是名为
over的实例属性,Managed.over 描述符仍会覆盖读取 obj.over 操作
另外需要注意,因为一切设置实例属性的操作均会被 __setattr__ 方法截获了,即使存在诸如覆盖型描述符。因此如果 Managed 中增加了如下代码,由于这个 __setattr__ 实现里并没有自己处理描述符相关的逻辑,,因此覆盖型描述符不会起作用:
1 | def __setattr__(self, name: str, value: Any) -> None: |
1 | obj.over =1 |
如果 __setattr__ 是这样实现的,则可以正常工作:
1 | def __setattr__(self, name: str, value: Any) -> None: |
1 | obj.over = 1 |
没有 __get__ 方法的覆盖型描述符
特性和其他覆盖型描述符(例如Django模型字段)既实现 __set__ 方法,也实现 __get__ 方法,不过也可以只实现 __set__ 方法。此时:
- 只有写操作由描述符处理
- 通过实例读取描述符会返回描述符对象本身,因为没有处理读操作的
__get__方法 - 如果直接通过实例的
__dict__属性创建同名实例属性,那么以后再设置那个属性时,仍由__set__方法插手接管,但是读取那个属性的话,会直接从实例中返回新赋予的值,而不返回描述符对象
也就是说,实例属性会遮盖描述符,不过只有读操作是如此。
1 | obj.over_no_get |
非覆盖性描述符
没有实现 __set__ 方法的描述符是非覆盖型描述符。如果设置了同名的实例属性,那么描述符就会被遮盖,致使其无法处理那个实例的那个属性。所有方法和 @functools.cached_property 是以非覆盖型描述符实现的。
1 | obj.non_over |
obj.non_over触发描述符的__get__方法,传入的第二个参数是 objManaged.non_over是非覆盖型描述符,没有干涉赋值操作的__set__方法- 当 obj 有个名为
non_over的实例属性,会遮盖Managed类的同名描述符属性 - 但
Managed.non_over描述符依然存在,可以通过类截获这次访问 - 如果把
non_over实例属性删除了,那么读取obj.non_over时会触发类中描述符的__get__方法。但要注意,第二个参数的值是托管实例
我们为几个与描述符同名的实例属性赋了值,结果依描述符是否有 __set__ 方法而有所不同。
需要注意,依附在类上的描述符无法控制为类属性赋值的操作。其实,这意味着为类属性赋值能覆盖描述符属性。
覆盖类中描述符
不管描述符是不是覆盖型,为类属性赋值都能覆盖描述符。这是一种猴子补丁技术:
1 | obj = Managed() |
这也揭示了读写属性的另一种不对等:类属性读取操作可以由依附在托管类上定义有 __get__ 方法的描述符处理,但是类属性写入操作不由依附在托管类上定义有 __set__ 方法的描述符处理。
要想控制设置类属性的操作,需要把描述符依附在类的类上,即依附在元类上。默认情况下,对于用户定义的类,元类是 type,不能向 type 添加属性。
方法是描述符
在类中定义的函数,如果在实例上调用,就会变成绑定方法,因为用户定义的函数都有 __get__ 方法,在依附到类上后,就相当于描述符。而且,方法相当于是非覆盖型描述符:
1 | obj = Managed() |
obj.spam获取一个绑定方法对象- 但是
Managed.spam获取的是一个函数 - 为
obj.spam赋值,遮盖类属性,导致无法通过 obj 实例访问 spam 方法
函数没有实现 __set__ 方法,因此是非覆盖型描述符。这也解释了上述代码为 obj.spam 赋值后的行为。
obj.spam 和 Managed.spam 获取的是不同的对象:
- 与描述符一样,通过托管类访问时,函数的
__get__方法返回自身的引用 - 但是,通过实例访问时,函数的
__get__方法返回的是绑定方法对象:一种可调用对象,里面包装着函数,并把托管实例(例如 obj)绑定给函数的第一个参数(self),这与functools.partial函数的行为一致
来看一个具体例子,
1 | import collections |
1 | word = Text("forward") |
1 | def t(): |
- 在类上调用方法相当于调用函数
- 注意类型是不同的,
Text.reverse是function,word.reverse是 method Text.reverse相当于函数,甚至可以处理 Text 实例之外的其他对象。- 函数都是非覆盖型描述符。如果在函数上调用
__get__方法时传入实例,则得到的是绑定到那个实例上的方法 - 调用函数的
__get__方法时,如果 instance 参数的值是 None,那么得到的是函数本身 word.reverse表达式其实会调用Text.reverse.__get__(word)返回对应的绑定方法- 绑定方法对象有一个
__self__属性,其值是调用该方法的实例引用 - 绑定方法的
__func__属性是依附在托管类上那个原始函数的引用
绑定方法对象还有 __call__ 方法,用于处理实际调用过程。这个方法会调用 __func__ 属性引用的原始函数,传入的第一个参数是方法的 __self__ 属性。这就是形参self的隐式绑定方式。
函数会变成绑定方法,这是 Python 语言底层使用描述符的最好例证。
描述符用法建议
下面根据刚刚探讨的描述符特征给出一些实用的结论:
-
使用 property 以保持简单:内置的 property 类创建的其实是实现了
__set__方法和__get__方法的覆盖型描述符,即使没有定义设值方法,特性的__set__方法会默认抛出AttributeError: can't set attribute,因此创建只读属性最简单的方式是使用特性 -
只读描述符必须有
__set__方法:使用描述符类实现只读属性时要记住,__get__和__set__这两个方法必须都定义,否则,实例的同名属性会遮盖描述符。只读属性的__set__方法只需抛出AttributeError 异常,并提供合适的错误消息 -
用于验证的描述符可以只有
__set__方法:在仅用于验证的描述符中,__set__方法应该检查value参数获得的值,如果有效,就使用描述符实例的名称作为键,直接在实例的__dict__属性中设置。这样,从实例中读取同名属性的速度很快,因为不用经过__get__方法处理 -
仅有
__get__方法的描述符可以实现高效缓存:如果只编写了__get__方法,那么得到的是非覆盖型描述符。这种描述符可用于执行某些耗费资源的计算,然后为实例设置同名属性,缓存结果。同名实例属性会遮盖描述符,因此后续访问直接从实例的__dict__属性中获取值,不再触发描述符的__get__方法。@functools.cached_property装饰器创建的其实就是非覆盖型描述符 -
非特殊的方法可以被实例属性遮盖:函数和方法只实现了
__get__方法,属于非覆盖型描述符:- 因此 像
my_obj.the_method =7这样简单赋值之后,后续通过该实例访问the_method,得到的是数值 7,但是不影响类或其他实例 - 解释器只在类中寻找特殊方法,也就是说,
repr(x)执行的其实是x.__class__.__repr__(x),因此 x 的repr属性对repr(x)` 方法调用没有影响 - 出于同样的原因,实例的
__getattr__属性不会破坏常规的属性访问规则
- 因此 像
实例的非特殊方法可以轻易被覆盖,这听起来不太可靠且容易出错,但是一般不需要担心被困扰。然而,如果要创建大量动态属性,且属性名称从不受自己控制的数据中获取,那么就应该知道这种行为。或许可以实现某种机制,筛选或转义动态属性的名称,以维持数据的健全性。而且,只要通过类访问,类方法就是安全的。
描述符的文档字符串和覆盖删除操作
描述符类的文档字符串也用作托管类中各个描述符实例的文档。
我们在讨论特性时还讲了一个细节,而描述符还没有涉及,那就是对删除托管属性的处理。在描述符类中,除了实现常规的 __get__ 方法和 __set__ 方法,还可以实现 __delete__ 方法,或者只实现 __delete__ 方法,处理删除托管属性操作。但是现实中一般很少使用。