Python学习之路40-属性描述符

Python学习之路40-属性描述符Python学习之路40-属性描述符

大家好,又见面了,我是你们的朋友全栈君。

《流畅的Python》笔记。

本篇主要讨论Python中的描述符,它是精通Python的关键。

1. 前言

描述符是对多个属性运用相同存取逻辑的一种方式。它是实现了特定协议的类,只要实现了__get____set____delete__三个方法中的任意一个,这个类就是描述符。

特性property类实现了完整的描述符协议,大多数描述符只实现了__get____set__方法,还有很多只实现了其中的一个。

描述符的用法很简单:创建一个实例,作为另一个类的类属性

本篇的内容包括:将上一篇中的特性工厂函数改为描述符类;重构并派生描述符子类;覆盖型描述符和非覆盖型描述符;非覆盖型描述符的典型代表:方法。

2. 描述符

上一篇中,我们用特性工厂函数quantity()实现了特性的抽象,并以此来验证属性。现在我们将quantity()函数改为Quantity描述符类。

2.1 Quantity

Quantity类暂时只实现存值方法,取值方法暂时还用不到:

# 代码2.1
class Quantity:
    def __init__(self, storage_name):
        self.storage_name = storage_name  # 存储描述符对应的属性名?

    def __set__(self, instance, value):
        if value > 0:
            instance.__dict__[self.storage_name] = value   # 注意此处,用的是__dict__
        else:
            raise ValueError("value must be > 0")

class Food:
    weight = Quantity("weight")  # 类属性
    price = Quantity("price")    

    def __init__(self, weight, price):
        self.weight = weight
        self.price = price
复制代码

这段代码并不复杂,weightprice与上一篇一样,被设置成了类属性。但奇怪的是,__set__的参数列表是(self, instance, value),即,传入了一个实例,而不是预想的(self, value)。并且这个方法中,直接操作实例的字典属性instance.__dict__[self.storage_name]来修改值,而不是操作描述符的字典属性self.__dict__[self.storage_name]。暂时不解释,先来看看Food的行为:

# 代码2.2
>>> Food(100,0)
Traceback (most recent call last):
   -- snip --
ValueError: value must be > 0
>>> f = Food(1, 1)
>>> f.weight = -1
Traceback (most recent call last):
   -- snip --
ValueError: value must be > 0
复制代码

当要创建一个price值为0的Food实例时,抛出了异常,行为符合要求。当要给属性weight设置负值时,行为也是正确的。Food类的行为符合要求。

2.2 托管

继续研究描述符,突然蹦出来些奇怪的概念:

  • 描述符类,描述符实例:实现了描述符协议的类叫描述符类,它的实例就是描述符实例(废话,这并不奇怪);
  • 托管类,托管实例:把描述符实例声明为类属性的类,也就是上面的Food类;这种类的对象就称为托管实例,也就是上面的f(这也很好理解);
  • 储存属性:托管实例中存储自身托管属性的属性(这看的是天书?说的这么妖娆?);
  • 托管属性:托管类中有描述符实例处理的公开属性,值存储在储存属性中(已经懵逼了)。

Wait a minute! 怎么就扯上“托管”了?我把什么托管给谁了?为了弄清描述符到底是干什么的,就得弄清这些概念。不过在这之前,先来看看之前我们用到的描述符property

# 代码2.3
>>> class Test:  # 如果按下方注释中的写法,会无限递归,直到强制结束
...     @property
...     def a(self): return self.__a           # 并不是 return self.a
...     @a.setter
...     def a(self, value): self.__a = value   # 也不是 self.a = value
... 
>>> t = Test()
>>> vars(t)
{}   # 空的,并不是{"a": None}
>>> t.a = 1
>>> vars(t)
{
   
   "_Test__a": 1}    # 也不是{"a": 1}
复制代码

当创建Test的实例t时,它的属性列表是空的,可以理解,毕竟没有给它定义实例属性,而a又是类属性,vars函数不会输出它。但当给t.a赋值后,属性列表多了一个属性,值也存到了这个属性中。换句话说,值并没有存到t.a中,而是存到了t.__a(或者说t._Test__a)中。再来看Food的实例f

# 代码2.4
>>> f = Food(1, 1)
>>> vars(f)
{
   
   "weight": 1, "price": 1}
复制代码

f居然有两个和类属性同名的实例属性(实例属性和描述符实例同名)!但在定义Food的时候,一个实例属性都没有定义,那这俩实例属性是从哪来的?不难发现:在Quantity__set__方法中,我们直接操作了实例的__dict__instance.__dict__[self.storage_name],在为self.weightself.price赋值时,创建了这两个实例属性。

这和我最初的理解相差有点大呀:描述符不是用来管理属性的存取的吗?不保存这些值怎么管理呢?嗯?难道它是个中介?

之所以有这个疑惑,其实是忽略了一个概念:描述符是类属性。一个类的实例有千千万万个,但类属性是唯一的,被所有实例所共有。要是把每个实例的数据都存到类属性中,这不叫“管理”,这叫“制造混乱”。

也就是说,描述符其实是个管理工具,它不是用来存储实例的数据属性的,而是代为管理实例的这些属性。这也解释了为什么有“托管”一说:所有托管实例将某些共同的属性委托给一个描述符实例管理。没使用描述符时,用户获取属性,比如f.weight,这相当于直接调用f.__dict__["weight"],即用户直接操作了__dict__;使用了描述符后,对__dict__的操作由描述符接管:“你自己操作不安全,告诉我(描述符)你要做什么,我来给你操作”。从这个层面讲,描述符更应该被叫做“接管器”。

现在再回过头来看之前给出的那些奇怪概念:

  • 描述符类描述符实例:我们自定义的,实现了描述符接口的Quantity就是描述符类,Food中的weightprice类属性就是描述符实例;
  • 托管类托管实例Food类使用了描述符实例weightprice作为类属性,所以它是托管类;前面用到的f就是托管实例
  • 托管属性:在使用FoodTest的实例时,如果不知道这两个类的定义,那么在调用f.weight或者t.a时,我们只能判断f有个名为weight的属性,t有个名为a的属性,但这两个属性是一般属性还是特性或者描述符,这就无法直观判断了,只知道这俩属性能公开访问(这类属性也叫公开属性)。如果某个公开属性是由描述符管理的,这个公开属性就是托管属性,否则就是一般的属性。但托管属性并不是指与之同名的用作类属性的描述符实例
  • 储存属性:经上述分析可知,描述符不是用来存储托管实例的属性的,而是用来管理的,但这些值总得有个地方存呀。托管实例真正存这些值的属性就叫做储存属性(如果要说得再准确一点,就是前面给出的那个妖娆的定义)。托管属性t.a真正的值存在t._Test__a中,托管属性f.weight真正的值存在f.__dict__["weight"]中,这两个实例属性就叫做储存属性。或者说,与self.storage_name同名的属性就是储存属性。这里也体现了“描述符”为什么叫“描述符”:把一个属性“描述”成另一个属性。可以看出,储存属性和托管属性是可以同名的,或者说,储存属性和描述符实例是可以同名的!一旦同名,大家也应该明白会牵扯到什么问题:覆盖。

上述这些概念也解释了之前的疑惑:

  • 描述符需要知道从托管实例的哪个属性获取值,或者存到哪个属性中,因此Quantity需要定义一个实例属性storage_name,它的值是储存属性的名称;
  • 描述符其实是管理工具,它要操作实例,所以Quantity__set__的参数列表是(self, instance, value),而不是(self, value)
  • 描述符用作类属性,它不是用来存储托管实例的属性的,真正的值依然存储在托管实例中,所以是instance.__dict__[self.storage_name],而不是self.__dict__[self.storage_name]

2.3 重构Quantity

使用上述Quantity,当在Food中定义描述符实例时,同一个单词重复输入了两次,这看着有点别扭,能不能只输入一次呢?比如像这样:

# 代码2.5
class Food:
    weight = Quantity()
复制代码

实现这种功能最好的办法是使用类装饰器或元类,这将在下一篇文章中介绍。本篇介绍一个略显笨拙的方式:既然Food中不指定储存属性的名称,那就自动生成,为每个Quantity实例的storage_name创建一个唯一的字符串。

我们还要实现之前没有实现的__get__方法,而且还想在Food中添加一个description实例,用于描述Food实例。description不能为空,因此也需要使用描述符。由于验证逻辑和weight相似,从头再写一个描述符类并不值得,因此选择继承。

以下是重构后的代码:

# 代码2.6
import abc

class AutoStorage:   # 这个描述符可以作用于一般的属性,并没有进行属性验证
    __counter = 0    # 描述符类内部维护一个计数器,用于创建属性
    
    def __init__(self):        # 不再需要传入储存属性的名称,由描述符类自动生成
        cls = self.__class__   # 名称的格式为 下划线 + 类名 + #号 + 编号
        prefix = cls.__name__  # 类名作为前缀
        index = cls.__counter  # 获取编号
        self.storage_name = "_{}#{}".format(prefix, index)   # 生成类名
        cls.__counter += 1

    def __get__(self, instance, owner):  # 这个方法有一个owner参数,它是托管类的引用
        if instance is None:    # 如果实例为空,此时表示通过托管类而不是托管实例实例来访问属性
            return self         # 返回描述类实例自身
        else:                   # 否则返回托管实例相应的属性
            return getattr(instance, self.storage_name)   # 并没有直接调用__dict__

    def __set__(self, instance, value):   # 这里并没有验证,而是直接赋值
        setattr(instance, self.storage_name, value)

class Validated(abc.ABC, AutoStorage):    # 多重继承,重写了__set__方法,赋值之前进行验证
    def __set__(self, instance, value):   
        value = self.validate(instance, value)   
        super().__set__(instance, value)  # 并没有直接调用__dict__

 @abc.abstractmethod # 将验证的过程单独放到一个函数中
    def validate(self, instance, value): # 并由子类自行实现验证方法
        """return validated value or raise ValueError"""

class Quantity(Validated):  # 值必须大于0
    def validate(self, instance, value):   # 这个描述符类只需重写验证方法
        if value <= 0:
            raise ValueError("value must be > 0!")
        return value

class NonBlank(Validated):  # 值不能是空字符串
    def validate(self, instance, value):
        value = value.strip()
        if len(value) == 0:
            raise ValueError("value cannot be empty or blank")
        return value

class Food:
    description = NonBlank()
    weight = Quantity()
    price = Quantity()

    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight
        self.price = price
复制代码

__get__方法的参数列表中有一个owner参数,它存储的是托管类的引用。它之前还有一个instance参数,如果是通过托管实例访问属性,比如f.weightinstance的值则为f的引用;如果是通过托管类访问属性,比如Food.weightinstance的值则为None

__get____set__方法中,我们并没有直接操作__dict__,因为这里的储存属性描述符实例不会重名,所以不会产生无限递归,可以使用内置的getattr()setattr()函数。

为了自动生成storage_name,这里以_Quantity#或者_NonBlank#为前缀,然后在后面接个数字。然而,形如f._Quantity#0的直接访问在Python中是无效的,因为注释也用的是#号,然而内置的getattrsetattr函数可以使用这种“无效的”标识获取和设置属性,此外也可以直接处理实例属性__dict__,因为井号#被放到了字符串中。

2.4 描述符 vs 特性工厂函数

将描述符的实现和前面的特性工厂函数对比,其实差别并不是想象中的那么大。这两者有以下几点差异:

  • 描述符类可以使用子类扩展;若想重用工厂函数中的代码,除了复制粘贴,很难有其他方法;
  • 如果要像代码2.6中重构后的描述符那样自动创建storage_name,那么工厂函数需要用到函数属性和闭包,这让代码显得不够直观。

3. 覆盖型与非覆盖型描述符

Python存取属性的方式并不是对等的:通过实例读取属性时,通常返回的是实例中定义的属性,如果没有这个属性,再到所属的类中去找;但为实例中的属性赋值时,通常会在实例中创建属性,根本不影响类。

这种不对等也影响到了描述符。根据是否定义__set__方法,描述符被分成了两大类:定义了__set__方法的描述符是覆盖型描述符,否则是费覆盖型描述符。可以分为以下三种情况(再次提醒,描述符是类属性):

  • 如果描述符实现了__get____set__方法,描述符覆盖同名实例属性,即属性的存取值过程都会被描述符接管。这说得通,毕竟两个方法都定义了;
  • 如果描述符只实现了__set__方法,描述符“半覆盖”同名实例属性,即存值过程被接管,而取值过程不会被接管。这也说得通,毕竟没有定义__get__方法;
  • 如果描述符只实现了__get__方法,描述符不会覆盖同名实例属性,即存取值过程都不会被接管!这就蹊跷了,明明定义了__get__方法,但它不起作用。

定义三个描述符和一个类用于演示上述情况:

# 代码3.1 所有的__get__,__set__方法都只是输出操作,没有存取值的操作
class Overriding:        # 两个方法都实现,覆盖型
    def __get__(self, instance, owner):
        print(instance, owner)

    def __set__(self, instance, value):
        print(instance, value)

class OverridingNoGet:   # 只实现__set__,覆盖型
    def __set__(self, instance, value):
        print(instance, value)

class NonOverriding:     # 只实现__get__,非覆盖型
    def __get__(self, instance, owner):
        print(instance, owner)

class Managed:
    over = Overriding()
    over_no_get = OverridingNoGet()
    non_over = NonOverriding()

    def spam(self):  # 这个方法后面会用到
        pass
复制代码

下面我们通过一些例子来展示覆盖的情况。

3.1 覆盖型描述符

此处展示实现了__get____set__方法的描述符的覆盖情况:

# 代码3.2
>>> obj = Managed()  # 此时没有实例属性
>>> obj.over      # 取值过程被接管,注意输出,形参instance指向obj
<a.Managed object at 0x000001A616C13EB8> <class 'a.Managed'>
>>> Managed.over  # 通过托管类读值,依然被描述符接管,instance为空
None <class 'a.Managed'>
>>> obj.over = 7  # 存值过程也被接管,值传给了__set__的形参value
<a.Managed object at 0x000001A616C13EB8> 7   # 注意这里的输出
>>> vars(obj)     # 由于存值过程被接管,所以依然没有实例属性
{}
>>> obj.__dict__["over"] = 8   # 绕过描述符,直接存值
>>> vars(obj)     # 有了和描述符同名的实例属性
{'over': 8}
>>> obj.over      # 描述符覆盖了实例属性(被接管),读不到实例属性的值
<a.Managed object at 0x000001A616C13EB8> <class 'a.Managed'>
复制代码

这里不光验证了前面的说法,还发现,通过类访问描述符(Managed.over),依然会调用__get__方法。而对于普通的类属性,(如果没有定义重写__repr__方法)则会直接返回类属性在内存中的信息,比如<a.OtherClass object at 0x...>。也就是说,通过托管类访问描述符依然会被接管。

3.2 无 __get__ 方法描述符

# 代码3.3
>>> obj = Managed()
>>> obj.over_no_get
<a.OverridingNoGet object at 0x0000019FF17C7E48>
>>> Managed.over_no_get
<a.OverridingNoGet object at 0x0000019FF17C7E48>
>>> obj.over_no_get = 7
<a.Managed object at 0x0000019FF1F88748> 7
>>> vars(obj)
{}
>>> obj.__dict__["over_no_get"] = 8
>>> vars(obj)
{
   
   "over_no_get": 8}
>>> obj.over_no_get
8
复制代码

可以看到,读值过程没有被接管。在没有实例属性over_no_get之前,obj.over_no_getManaged.over_no_get都返回的是描述符实例over_no_get在内存中的信息。

3.3 非覆盖型描述符

# 代码3.4
>>> obj = Managed()
>>> obj.non_over
<a.Managed object at 0x0000019FF1F88710> <class 'a.Managed'>
>>> Managed.non_over
None <class 'a.Managed'>
>>> vars(obj)
{}
>>> obj.non_over = 7
>>> obj.non_over
7
>>> vars(obj)
{"non_over": 7}
>>> Managed.non_over
None <class 'a.Managed'>
>>> del obj.non_over
>>> obj.non_over
<a.Managed object at 0x0000019FF1F88710> <class 'a.Managed'>
复制代码

可以看到,未赋值前,obj.non_overManaged.non_over都被描述符接管,此时obj中也没有实例属性。在赋值过后,obj中有了实例属性non_over,并且它覆盖了描述符,读值过程没有被接管。删除了实例属性后,描述符不再被覆盖。非覆盖型描述符可以实现缓存。

4. 方法是描述符

在类中定义的函数属于绑定方法(bound method),简称方法,而用户定义的函数都有__get__方法,所以方法其实是非覆盖型描述符。这也是非覆盖型描述符的一个具体类型,同时,这也说明了,Python语言的底层就用到了描述符类。下面是之前定义的spam方法的例子:

# 代码4.1
>>> obj = Managed()
>>> obj.spam
<bound method Managed.spam of <a.Managed object at 0x0000019FF17E1E80>>
>>> Managed.spam
<function Managed.spam at 0x0000019FF1F7D7B8>
>>> obj.spam = 1
>>> obj.spam
1
>>> vars(obj)
{
   
   "spam": 1}
>>> del obj.spam
>>> obj.spam
<bound method Managed.spam of <a.Managed object at 0x0000019FF17E1E80>>
>>> obj.spam.__self__
<a.Managed object at 0x0000019FF17E1E80>
>>> obj.spam.__func__ is Managed.spam
True
>>> obj.spam.__get__
<method-wrapper '__get__' of method object at 0x00000261B11B3908>
>>> Managed.spam((1, 2, 3))
(3, 2, 1)
复制代码

从上面的例子可以看到一个重要的信息:obj.spamManaged.spam获取的是不同的对象,这和前面三种情况的描述符很不一样。Managed.spam得到的是function对象,而obj.spam得到的是bound method对象:

  • 绑定方法对象是一种可调用的对象,里面包装着函数,并把托管实例绑定给函数的第一个参数;
  • 绑定方法对象有一个__self__属性,其值是调用这个方法的实例的引用,比如obj.spam.__self__就是obj自身;
  • 绑定方法对象还有个__func__属性,它的值是依附在托管类上的那个原始函数的引用;通过托管类访问方法也访问的是那个原始函数(Managed.spam),换句话说,如果通过托管类访问方法,这个方法就只是一个普通函数,此时传入的第一个参数会赋值给形参selfself不再自动指向任何类的实例。比如上述的Managed.spam((1, 2, 3))self参数存的是元组(1, 2, 3)的引用。
  • 绑定方法对象还有个__call__方法,用于处理真正的调用过程:它会调用__func__引用的原始函数,并把__self__的引用传给函数的第一个参数,也就是self。这也正是self的隐式绑定过程。

也就是说,function对象只要一个,但bound method对象会随实例的不同而不同;与描述符接管属性的存取过程类似,实例调用方法时也会被接管,由bound method去调用真正的function

5. 总结

本篇首先将讲特性工厂函数换成了描述符类,介绍了描述符的基本用法;然后介绍了众多与描述符相关的概念(“托管”);随后我们将Quantity重构,实现了描述符的派生,以及去掉了之前声明Quantity描述符所需的storage_name参数;接着介绍了覆盖型与非覆盖型描述符;最后介绍了非覆盖型描述符的一个典型类型:方法。

迎大家关注我的微信公众号”代码港” & 个人网站 www.vpointer.net ~

转载于:https://juejin.im/post/5b2fac926fb9a00e67149d92

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

发布者:全栈程序员-用户IM,转载请注明出处:https://javaforall.cn/107346.html原文链接:https://javaforall.cn

【正版授权,激活自己账号】: Jetbrains全家桶Ide使用,1年售后保障,每天仅需1毛

【官方授权 正版激活】: 官方授权 正版激活 支持Jetbrains家族下所有IDE 使用个人JB账号...

(0)


相关推荐

  • 基于matlab的傅里叶变换「建议收藏」

    基于matlab的傅里叶变换「建议收藏」原文出处例子1作用:使用傅里叶变换找出隐藏在噪声中的信号的频率成分。(指定信号的参数,采样频率为1kHz,信号持续时间为1秒。)由上图可知:从时域中我们很难观察到信号的频率成分。怎么办呢?当然

  • 动态调用function

    动态调用function

  • 2020版PS快捷键_ps应用快捷键大全

    2020版PS快捷键_ps应用快捷键大全说明:为避免篇幅过大,本文快捷键是基于Windows系统下Photoshop2020版本的。Mac系统下的快捷键可按以下方式进行对应:Ctrl→Command,Alt→Option。有…

  • 剑指Offer面试题:9.打印1到最大的n位数

    一题目:打印1到最大的n位数二不考虑大数解法三字符串模拟算法解法解决这个问题需要表达一个大数。最常用也是最容易的方法是用字符串或者数组表达大数。该算法的步骤如下:Step1.把字符串中的

    2021年12月19日
  • Python批量修改文件名,文件再多也只要一秒,省时又不闹心

    Python批量修改文件名,文件再多也只要一秒,省时又不闹心前言嗨喽!大家好,这里是魔王对于电脑中的文件夹啊,我们那是新建一个又一个啊,有时候,我们整理资料的时候就会发现,文件夹那是一个杂乱无章,一个一个的去修改太浪费时间,咋今天就来分享一个小技巧:批量修改文件名一、在原有的名字前中后批量加字随意一点,这是我刚刚新建的文件夹和我存放的路径。我们来看看代码,我都详细注释了。importos#导入模块filename=’C:\\Users\\Administrator\\Desktop\\123’#文件地址list_path=os.l

    2022年10月28日
  • centos 7.5 内核版本_内核版本多少算好手机

    centos 7.5 内核版本_内核版本多少算好手机实验环境CentOS-7-x86_64-Minimal-1708.isoCentOSLinuxrelease7.4.1708(Core)Kernel3.10.0-693.el7.x86_64方案一:小版本升级连接并同步CentOS自带yum源,更新内核版本。此方法适用于更新内核补丁。具体实验步骤:sudoyumlistkernelsudoyumupdate-yke…

发表回复

您的电子邮箱地址不会被公开。

关注全栈程序员社区公众号