社区所有版块导航
Python
python开源   Django   Python   DjangoApp   pycharm  
DATA
docker   Elasticsearch  
aigc
aigc   chatgpt  
WEB开发
linux   MongoDB   Redis   DATABASE   NGINX   其他Web框架   web工具   zookeeper   tornado   NoSql   Bootstrap   js   peewee   Git   bottle   IE   MQ   Jquery  
机器学习
机器学习算法  
Python88.com
反馈   公告   社区推广  
产品
短视频  
印度
印度  
Py学习  »  Python

这个 Python 知识点,90% 的人都得挂~

Python编程时光 • 5 年前 • 864 次点击  

本篇选自 Python黑魔法指南 -> 第四章 -> 第2节

github仓库: https://github.com/iswbm/magic-python

magic-python 目前已经有 600+ 的 star 了,越来越多的人知道了它,从百度网盘的数据来看,已经有 将近 5000 的人下载了这本电子书。

如果本书对你学习 Python 有所帮助,那可以请你帮我 github 上帮我点个 star 嘛?让更多的人知道它。

本篇文章是选自该书的一节,由于昨晚有一位读者留言帮我指正了点小错误,所以我今天对这个知识点的最后一部分内容进行重新梳理,整理一下发出来。希望对你有所帮助。


-- 正文开始--



学习 Python 这么久了,说起 Python 的优雅之处,能让我脱口而出的, Descriptor(描述符)特性可以排得上号。

描述符 是Python 语言独有的特性,它不仅在应用层使用,在语言语法糖的实现上也有使用到(在下面的文章会一一介绍)。

当你点进这篇文章时

  • 你也许没学过描述符,甚至没听过描述符。
  • 或者你对描述符只是一知半解

无论你是哪种,本篇都将带你全面的学习描述符,一起来感受 Python 语言的优雅。

1. 为什么要使用描述符?

假想你正在给学校写一个成绩管理系统,并没有太多编码经验的你,可能会这样子写。

class Student:
    def __init__(self, name, math, chinese, english):
        self.name = name
        self.math = math
        self.chinese = chinese
        self.english = english

    def __repr__(self):
        return "".format(
                self.name, self.math, self.chinese, self.english
            )

看起来一切都很合理

>>> std1 = Student('小明'768768)
>>> std1
76, chinese: 87, english:68>

但是程序并不像人那么智能,不会自动根据使用场景判断数据的合法性,如果老师在录入成绩的时候,不小心录入了将成绩录成了负数,或者超过100,程序是无法感知的。

聪明的你,马上在代码中加入了判断逻辑。




    
class Student:
    def __init__(self, name, math, chinese, english):
        self.name = name
        if 0 <= math <= 100:
            self.math = math
        else:
            raise ValueError("Valid value must be in [0, 100]")
        
        if 0 <= chinese <= 100:
            self.chinese = chinese
        else:
            raise ValueError("Valid value must be in [0, 100]")
      
        if 0 <= chinese <= 100:
            self.english = english
        else:
            raise ValueError("Valid value must be in [0, 100]")
        

    def __repr__(self):
        return "".format(
                self.name, self.math, self.chinese, self.english
            )

这下程序稍微有点人工智能了,能够自己明辨是非了。

程序是智能了,但在__init__里有太多的判断逻辑,很影响代码的可读性。巧的是,你刚好学过 Property 特性,可以很好的应用在这里。于是你将代码修改成如下,代码的可读性瞬间提升了不少

class Student:
    def __init__(self, name, math, chinese, english):
        self.name = name
        self.math = math
        self.chinese = chinese
        self.english = english

    @property
    def math(self):
        return self._math

    @math.setter
    def math(self, value):
        if 0 <= value <= 100:
            self._math = value
        else:
            raise ValueError("Valid value must be in [0, 100]")

    @property
    def chinese(self):
        return self._chinese

    @chinese.setter
    def chinese(self, value):
        if 0 <= value <= 100:
            self._chinese = value
        else:
            raise ValueError("Valid value must be in [0, 100]")

    @property
    def english(self):
        return self._english

    @english.setter
    def english(self, value):
        if 0 <= value <= 100:
            self._english = value
        else:
            raise ValueError("Valid value must be in [0, 100]")

    def __repr__(self):
        return "".format(
                self.name, self.math, self.chinese, self.english
            )

程序还是一样的人工智能,非常好。

你以为你写的代码,已经非常优秀,无懈可击了。

没想到,人外有天,你的主管看了你的代码后,深深地叹了口气:类里的三个属性,math、chinese、english,都使用了 Property 对属性的合法性进行了有效控制。功能上,没有问题,但就是太啰嗦了,三个变量的合法性逻辑都是一样的,只要大于0,小于100 就可以,代码重复率太高了,这里三个成绩还好,但假设还有地理、生物、历史、化学等十几门的成绩呢,这代码简直没法忍。去了解一下 Python 的描述符吧。

经过主管的指点,你知道了「描述符」这个东西。怀着一颗敬畏之心,你去搜索了下关于 描述符的用法。

其实也很简单,一个实现了 描述符协议 的类就是一个描述符。

什么描述符协议:在类里实现了 __get__()__set__()__delete__() 其中至少一个方法。

  • __get__:用于访问属性。它返回属性的值,若属性不存在、不合法等都可以抛出对应的异常。
  • __set__:将在属性分配操作中调用。不会返回任何内容。
  • __delete__:控制删除操作。不会返回内容。

对描述符有了大概的了解后,你开始重写上面的方法。

如前所述,Score 类是一个描述符,当从 Student 的实例访问 math、chinese、english这三个属性的时候,都会经过 Score 类里的三个特殊的方法。这里的 Score 避免了 使用Property 出现大量的代码无法复用的尴尬。

class Score:
    def __init__(self, default=0):
        self._score = default

    def __set__(self, instance, value):
        if not isinstance(value, int):
            raise TypeError('Score must be integer')
        if not 0 <= value <= 100:
            raise ValueError('Valid value must be in [0, 100]')

        self._score = value

    def __get__(self, instance, owner):
        return self._score

    def __delete__(self):
        del self._score
        
class Student:
    math = Score(0)
    chinese = Score(0)
    english = Score(0)

    def __init__(self, name, math, chinese, english):
        self.name = name
        self.math = math
        self.chinese = chinese
        self.english = english


    def __repr__(self):
        return "".format(
                self.name, self.math, self.chinese, self.english
            )

实现的效果和前面的一样,可以对数据的合法性进行有效控制(字段类型、数值区间等)

以上,我举了下具体的实例,从最原始的编码风格到 Property ,最后引出描述符。由浅入深,一步一步带你感受到描述符的优雅之处。

到这里,你需要记住的只有一点,就是描述符给我们带来的编码上的便利,它在实现 保护属性不受修改属性类型检查 的基本功能,同时有大大提高代码的复用率。

2. 描述符的访问规则

描述符分两种:

  • 数据描述符:实现了__get____set__ 两种方法的描述符
  • 非数据描述符:只实现了__get__ 一种方法的描述符

你一定会问,他们有什么区别呢?网上的讲解,我看过几个,很多都把一个简单的东西讲得复杂了。

其实就一句话,数据描述器和非数据描述器的区别在于:它们相对于实例的字典的优先级不同

如果实例字典中有与描述符同名的属性,如果描述符是数据描述符,优先使用数据描述符,如果是非数据描述符,优先使用字典中的属性。

这边还是以上节的成绩管理的例子来说明,方便你理解。

# 数据描述符
class DataDes:
    def __init__(self, default=0):
        self._score = default

    def __set__(self, instance, value):
        self._score = value

    def __get__(self, instance, owner):
        print("访问数据描述符里的 __get__")
        return self._score

# 非数据描述符
class NoDataDes:
    def __init__(self, default=0):
        self._score = default

    def __get__(self, instance, owner):
        print("访问非数据描述符里的 __get__")
        return self._score


class Student:
    math = DataDes(0)
    chinese = NoDataDes(0)

    def __init__(self, name, math, chinese):
        self.name = name
        self.math = math
        self.chinese = chinese
        
    def __getattribute__(self, item):
        print("调用 __getattribute__")
        return super(Student, self).__getattribute__(item)
     
    def __repr__(self):
        return "".format(
                self.name, self.math, self.chinese)

需要注意的是,math 是数据描述符,而 chinese 是非数据描述符。从下面的验证中,可以看出,当实例属性和数据描述符同名时,会优先访问数据描述符(如下面的math),而当实例属性和非数据描述符同名时,会优先访问实例属性(__getattribute__

>>> std = Student('xm'8899)
>>> 
>>> std.math
调用 __getattribute__
访问数据描述符里的 __get__
88
>>> std.chinese
调用 __getattribute__
99

讲完了数据描述符和非数据描述符,我们还需要了解的对象属性的查找规律。

当我们对一个实例属性进行访问时,Python 会按 obj.__dict__type(obj).__dict__type(obj)的父类.__dict__ 顺序进行查找,如果查找到目标属性并发现是一个描述符,Python 会调用描述符协议来改变默认的控制行为。

3. 基于描述符如何实现property

经过上面的讲解,我们已经知道如何定义描述符,且明白了描述符是如何工作的。

正常人所见过的描述符的用法就是上面提到的那些,我想说的是那只是描述符协议最常见的应用之一,或许你还不知道,其实有很多 Python 的特性的底层实现机制都是基于 描述符协议 的,比如我们熟悉的@property@classmethod@staticmethodsuper 等。

先来说说 property 吧。

有了前面的基础,我们知道了 property 的基本用法。这里我直接切入主题,从第一篇的例子里精简了一下。




    
class Student:
    def __init__(self, name):
        self.name = name

    @property
    def math(self):
        return self._math

    @math.setter
    def math(self, value):
        if 0 <= value <= 100:
            self._math = value
        else:
            raise ValueError("Valid value must be in [0, 100]")

不防再简单回顾一下它的用法,通过property装饰的函数,如例子中的 math 会变成 Student 实例的属性。而对 math 属性赋值会进入 使用 math.setter 装饰函数的逻辑代码块。

为什么说 property 底层是基于描述符协议的呢?通过 PyCharm 点击进入 property 的源码,很可惜,只是一份类似文档一样的伪源码,并没有其具体的实现逻辑。

不过,从这份伪源码的魔法函数结构组成,可以大体知道其实现逻辑。

这里我自己通过模仿其函数结构,结合「描述符协议」来自己实现类 property 特性。

代码如下:

class TestProperty(object):

    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        self.__doc__ = doc

    def __get__(self, obj, objtype=None):
        print("in __get__")
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError
        return self.fget(obj)

    def __set__(self, obj, value):
        print("in __set__")
        if self.fset is None:
            raise AttributeError
        self.fset(obj, value)

    def __delete__(self, obj):
        print("in __delete__")
        if self.fdel is None:
            raise AttributeError
        self.fdel(obj)


    def getter(self, fget):
        print("in getter")
        return type(self)(fget, self.fset, self.fdel, self.__doc__)

    def setter(self, fset):
        print("in setter")
        return type(self)(self.fget, fset, self.fdel, self.__doc__)

    def deleter(self, fdel):
        print("in deleter")
        return type(self)(self.fget, self.fset, fdel, self.__doc__)

然后 Student 类,我们也相应改成如下

class Student:
    def __init__(self, name):
        self.name = name

    # 其实只有这里改变
    @TestProperty
    def math(self):
        return self._math

    @math.setter
    def math(self, value):
        if 0 <= value <= 100:
            self._math = value
        else:
            raise ValueError("Valid value must be in [0, 100]")

为了尽量让你少产生一点疑惑,我这里做两点说明:

  1. 使用TestProperty装饰后,math 不再是一个函数,而是TestProperty 类的一个实例。所以第二个math函数可以使用 math.setter 来装饰,本质是调用TestProperty.setter 来产生一个新的 TestProperty 实例赋值给第二个math

  2. 第一个 math 和第二个 math 是两个不同 TestProperty 实例。但他们都属于同一个描述符类(TestProperty),当对 math 对于赋值时,就会进入 TestProperty.__set__,当对math 进行取值里,就会进入 TestProperty.__get__。仔细一看,其实最终访问的还是Student实例的 _math 属性。

说了这么多,还是运行一下,更加直观一点。

# 运行后,会直接打印这一行,这是在实例化 TestProperty 并赋值给第二个math
in setter
>>>
>>> s1.math = 90
in __set__
>>> s1.math
in __get__
90

对于以上理解 property 的运行原理有困难的同学,请务必参照我上面写的两点说明。如有其他疑问,可以加微信与我进行探讨。

4. 基于描述符如何实现staticmethod

说完了 property ,这里再来讲讲  @classmethod@staticmethod 的实现原理。

我这里定义了一个类,用了两种方式来实现静态方法。

class Test:
    @staticmethod
    def myfunc():
        print("hello")

# 上下两种写法等价

class Test:
    def myfunc():
        print("hello")
    # 重点:这就是描述符的体现
    myfunc = staticmethod(myfunc)

这两种写法是等价的,就好像在 property 一样,其实以下两种写法也是等价的。

@TestProperty
def math(self):
    return self._math
  
math = TestProperty(fget=math)

话题还是转回到 staticmethod 这边来吧。

由上面的注释,可以看出 staticmethod 其实就相当于一个描述符类,而myfunc 在此刻变成了一个描述符。关于 staticmethod 的实现,你可以参照下面这段我自己写的代码,加以理解。

调用这个方法可以知道,每调用一次,它都会经过描述符类的 __get__

>>> Test.myfunc()
in staticmethod __get__
hello
>>> Test().myfunc()
in staticmethod __get__
hello

5. 基于描述符如何实现classmethod

同样的 classmethod 也是一样。

class classmethod(object):
    def __init__(self, f):
        self.f = f

    def __get__(self, instance, owner=None):
        print("in classmethod __get__")
        
        def newfunc(*args):
            return self.f(owner, *args)
        return newfunc

class Test:
    def myfunc(cls):
        print("hello")
        
    # 重点:这就是描述符的体现
    myfunc = classmethod(myfunc)

验证结果如下

>>> Test.myfunc()
in classmethod __get__
hello
>>> Test().myfunc()
in classmethod __get__
hello

讲完了 propertystaticmethodclassmethod 与 描述符的关系。我想你应该对描述符在 Python 中的应用有了更深的理解。对于 super 的实现原理,就交由你来自己完成。

6. 所有实例共享描述符

通过以上内容的学习,你是不是觉得自己已经对描述符足够了解了呢?

可在这里,我想说以上的描述符代码都有问题。

问题在哪里呢?请看下面这个例子。

class Score:
    def __init__(self, default=0):
        self._value = default

    def __get__(self, instance, owner):
        return self._value

    def __set__(self, instance, value):
        if 0 <= value <= 100:
            self._value = value
        else:
            raise ValueError


class Student:
    math = Score(0)
    chinese = Score(0)
    english = Score(0)

    def __repr__(self):
        return "".format(self.math, self.chinese, self.english)

Student 里没有像前面那样写了构造函数,但是关键不在这儿,没写只是因为没必要写。

然后来看一下会出现什么样的问题呢

>>> std1 = Student()
>>> std1
0, chinese:0, english:0>
>>> std1.math = 85
>>> std1
85, chinese:0, english:0>
>>> std2 = Student()
>>> std2 # std2 居然共享了std1 的属性值
85, chinese:0, english:0>
>>> std2.math = 100
>>> std1 # std2 也会改变std1 的属性值
100, chinese:0, english:0>

从结果上来看,std2 居然共享了 std1 的属性值,只要其中一个实例的变量发生改变,另一个实例的变量也会跟着改变。

探其根因,是由于此时 math,chinese,english 三个全部是类变量,导致 std2 和 std1 在访问 math,chinese,english 这三个变量时,其实都是访问类变量。

问题是不是来了?小明和小强的分数怎么可能是绑定的呢?这很明显与实际业务不符。

使用描述符给我们制造了便利,却无形中给我们带来了麻烦,难道这也是描述符的特性吗?

描述符是个很好用的特性,会出现这个问题,是由于我们之前写的描述符代码都是错误的。

描述符的机制,在我看来,只是抢占了访问顺序,而具体的逻辑却要因地制宜,视情况而定。

如果要把 math,chinese,english  这三个变量变成实例之间相互隔离的属性,应该这么写。

class Score:
    def __init__(self, subject):
        self.name = subject

    def __get__(self, instance, owner):
        return instance.__dict__[self.name]

    def __set__(self, instance, value):
        if 0 <= value <= 100:
            instance.__dict__[self.name] = value
        else:
            raise ValueError


class Student:
    math = Score("math")
    chinese = Score("chinese")
    english = Score("english")

    def __init__(self, math, chinese, english):
        self.math = math
        self.chinese = chinese
        self.english = english

    def __repr__(self):
        return "".format(self.math, self.chinese, self.english)

引导程序逻辑进入描述符之后,不管你是获取属性,还是设置属性,都是直接作用于 instance 的。

这段代码,你可以仔细和前面的对比一下。

不难看出:

  • 之前的错误代码,更像是把描述符当做了存储节点。
  • 之后的正确代码,则是把描述符直接当做代理,本身不存储值。

以上便是我对描述符的全部分享,希望能对你有所帮助。





推荐阅读


《Python黑魔法指南》全新版本 v2.0 上线发布

最全总结:把模块当做脚本来执行的 7 种案例及其原理

字典访问不存在的key 时,如何才能不报错?

那些被低估了的 Python 库,看看你用过几个?

50 款数据可视化分析工具大集合,总有一款适合你

Selenium自动登录淘宝,我无意间发现了登录漏洞!

黑魔法:执行任意代码都会自动念上一段 『平安经』



长按下图  ➡   关注博主

(按左边关注 Python, 按右边关注 Goalng


Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/72416
 
864 次点击