python

关注公众号 jb51net

关闭
首页 > 脚本专栏 > python > Python使用描述符

Python如何使用描述符写属性验证框架

作者:Hanniel

本文给大家介绍Python如何使用描述符写属性验证框架,本文结合实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友参考下吧

阅读前提:建议先读上篇。中篇默认你已经理解描述符协议(__get__/__set__/__delete__)、数据描述符 vs 非数据描述符的优先级差异、以及描述符必须作为类变量使用。

一、从日志描述符开始

描述符最常见的实战场景是管理属性访问——在读取或写入属性前后执行自定义逻辑。例如,记录日志。

假设你要审计所有对 age 字段的读写。最笨的做法是在每个 __init__ 和 setter 里手动写 logging.info(...)。如果 age 在 20 个类里被用到,就要写 20 遍。描述符可以把这件事从业务代码中剥离出来:

import logging
logging.basicConfig(level=logging.INFO)
class LoggedAgeAccess:
    def __get__(self, obj, objtype=None):
        value = obj._age
        logging.info('Accessing %r giving %r', 'age', value)
        return value
    def __set__(self, obj, value):
        logging.info('Updating %r to %r', 'age', value)
        obj._age = value
class Person:
    age = LoggedAgeAccess()   # 关键:描述符作为类变量
    def __init__(self, name, age):
        self.name = name        # 普通属性:不走描述符
        self.age = age          # 触发 __set__()!
    def birthday(self):
        self.age += 1           # 触发 __get__() 和 __set__()

self.age = age 执行时,age 是数据描述符,Python 不会直接往 __dict__ 里写,而是调用 LoggedAgeAccess.__set__(self, age)。这个方法记录日志,然后把 30 存进 self._age

vars(mary) 返回 {'name': 'Mary M', '_age': 30} —— 注意里面没有 ageage 是类变量(指向描述符实例),_age 才是实例上真正存储数据的地方。这个设计模式叫"公开属性是描述符,私有属性是真实数据的仓库"。

LoggedAgeAccess 有一个致命缺陷:_age 被硬编码在描述符类里。这意味着同一个描述符类无法管理 nameemail 属性。你没法写 name = LoggedAgeAccess(),因为它的 __get__ 仍然会去读 obj._age

问题的本质是:描述符在"上岗"时不知道自己在类里被叫什么名字。如果它能自动知道"我是 name 的描述符,我应该去管 _name",那么同一个类就可以被无限复用。

Python 3.6 的 __set_name__ 正是解决这个问题的。

二、__set_name__:让描述符知道自己叫什么

__set_name__ 是描述符协议的可选方法。官方文档对它的定义和触发时机说得很精确:

“作为可选项,描述器可以有 __set_name__() 方法。这仅会被用于当描述器需要知道创建它的类或它被分配的类变量名称等场合。”

“当一个新类被创建时,type 元类将扫描新类的字典。如果其中有任何条目是描述器并且它们定义了 __set_name__(),则该方法被调用时将附带两个参数。owner 是使用该描述器的类,而 name 是该描述器被赋值到的变量。”

当你写下:

class Person:
    name = LoggedAccess()    # 类定义时,Python 自动调用 __set_name__(Person, 'name')
    age = LoggedAccess()     # 类定义时,Python 自动调用 __set_name__(Person, 'age')

type 元类在创建 Person 这个类对象时,扫描类字典。看到 name 的值是一个定义了 __set_name__ 的描述符,就调用 LoggedAccess.__set_name__(Person, 'name')。紧接着对 age 做同样的事。

我们用 __set_name__ 重写 LoggedAccess

class LoggedAccess:
    def __set_name__(self, owner, name):
        self.public_name = name           # 'name'
        self.private_name = '_' + name    # '_name'
    def __get__(self, obj, objtype=None):
        value = getattr(obj, self.private_name)
        logging.info('Accessing %r giving %r', self.public_name, value)
        return value
    def __set__(self, obj, value):
        logging.info('Updating %r to %r', self.public_name, value)
        setattr(obj, self.private_name, value)
class Person:
    name = LoggedAccess()    # 自动命名
    age = LoggedAccess()     # 自动命名
    def __init__(self, name, age):
        self.name = name
        self.age = age
pete = Person('Peter P', 10)
# 日志输出:
# INFO:root:Updating 'name' to 'Peter P'
# INFO:root:Updating 'age' to 10
print(vars(pete))  # {'_name': 'Peter P', '_age': 10}

现在同一个描述符类可以管理任意数量的属性。__set_name__ 让它在类定义阶段自动获取属性名,零人工配置。这就是从"玩具"到"工具"的关键一步。

“由于更新逻辑是在 type.__new__() 中,因此通知仅在类创建时发出。之后如果将描述器添加到类中,则需要手动调用 __set_name__()。”

三、验证器框架:把属性校验从业务代码中剥离

现在有了趁手的工具,可以做一件更有工程价值的事:属性验证

你在写类时,是不是经常在 __init__ 里写一堆 if 判断?

class Product:
    def __init__(self, name, price, category):
        if not isinstance(name, str) or len(name) < 3:
            raise ValueError('name must be a string with at least 3 chars')
        if not isinstance(price, (int, float)) or price < 0:
            raise ValueError('price must be non-negative')
        if category not in ('electronics', 'clothing', 'food'):
            raise ValueError('invalid category')
        self.name = name
        self.price = price
        self.category = category

这种代码有三个问题:

  1. 重复:类型检查、范围检查、枚举检查,每个类都写一遍。
  2. 混杂:业务逻辑(初始化对象)和验证逻辑(参数校验)混在一起。
  3. 脆弱:如果 price 后期允许通过 setter 修改,你得把验证逻辑再复制一份到 setter 里。

描述符可以把验证逻辑彻底从业务类中剥离。理想情况下,我们想写成这样:

class Product:
    name = String(minsize=3, maxsize=50)
    price = Number(minvalue=0)
    category = OneOf('electronics', 'clothing', 'food')

简洁、声明式、无重复。赋值时自动验证,非法数据在对象创建的第一秒就被拦下。这正是官方文档中"完整的实际例子"所展示的核心设计。

四、框架骨架:Validator 抽象基类

我们的目标让所有具体验证器(StringNumberOneOf)共享同一套"托管属性"的基础设施。这套基础设施负责:自动命名、从私有属性读取值、先验证再存储。验证逻辑本身则由子类各自实现。这天然适合用抽象基类来组织:

from abc import ABC, abstractmethod
class Validator(ABC):
    """验证器抽象基类:同时作为数据描述符使用。"""
    def __set_name__(self, owner, name):
        self.private_name = '_' + name
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.private_name)
    def __set__(self, obj, value):
        self.validate(value)           # 先验证
        setattr(obj, self.private_name, value)  # 再存储
    @abstractmethod
    def validate(self, value):
        pass

逐行解析:

官方文档对这个设计的概括是:

“验证器是一个用于托管属性访问的描述器。在存储任何数据之前,它会验证新值是否满足各种类型和范围限制。如果不满足这些限制,它将引发异常,从源头上防止数据损坏。”

“这个 Validator 类既是一个 abstract base class 也是一个被管理的属性描述器。”

五、三种具体验证器

5.1 OneOf:枚举白名单

class OneOf(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}'
            )

5.2 Number:数值类型与范围

class Number(Validator):
    def __init__(self, minvalue=None, maxvalue=None):
        self.minvalue = minvalue
        self.maxvalue = maxvalue
    def validate(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError(f'Expected {value!r} to be an int or float')
        if self.minvalue is not None and value < self.minvalue:
            raise ValueError(f'Expected {value!r} to be at least {self.minvalue!r}')
        if self.maxvalue is not None and value > self.maxvalue:
            raise ValueError(f'Expected {value!r} to be no more than {self.maxvalue!r}')

5.3 String:字符串类型与长度,外加自定义条件

class String(Validator):
    def __init__(self, minsize=None, maxsize=None, predicate=None):
        self.minsize = minsize
        self.maxsize = maxsize
        self.predicate = predicate
    def validate(self, value):
        if not isinstance(value, str):
            raise TypeError(f'Expected {value!r} to be a str')
        if self.minsize is not None and len(value) < self.minsize:
            raise ValueError(f'Expected {value!r} to be no smaller than {self.minsize!r}')
        if self.maxsize is not None and len(value) > self.maxsize:
            raise ValueError(f'Expected {value!r} to be no bigger than {self.maxsize!r}')
        if self.predicate is not None and not self.predicate(value):
            raise ValueError(f'Expected {self.predicate} to be true for {value!r}')

三个验证器只关心规则,完全不关心数据存在哪里、属性叫什么。Validator 基类已经帮它们处理好了所有"描述符基础设施"的工作。

六、实战:Component 类

class Component:
    name = String(minsize=3, maxsize=10, predicate=str.isupper)
    kind = OneOf('wood', 'metal', 'plastic')
    quantity = Number(minvalue=0)
    def __init__(self, name, kind, quantity):
        self.name = name
        self.kind = kind
        self.quantity = quantity

__init__ 里没有任何 if 判断!因为 self.name = name 会触发 String.__set__,而 String.__set__ 会先调用 validate()。如果数据不合法,异常会在 Component(...) 创建的瞬间抛出,根本来不及产生一个"带病"的实例。

验证一下验证器是否真的在"守门":

Component('Widget', 'metal', 5)
# ValueError: Expected <method 'isupper' of 'str' objects> to be true for 'Widget'
Component('WIDGET', 'metle', 5)
# ValueError: Expected 'metle' to be one of {'metal', 'plastic', 'wood'}
Component('WIDGET', 'metal', -5)
# ValueError: Expected -5 to be at least 0
Component('WIDGET', 'metal', 'V')
# TypeError: Expected 'V' to be an int or float
c = Component('WIDGET', 'metal', 5)
print(c.name, c.kind, c.quantity)   # WIDGET metal 5
print(vars(c))  # {'_name': 'WIDGET', '_kind': 'metal', '_quantity': 5}

全部通过!而且你可以放心地修改属性,验证器会持续工作:

c.quantity = 10     # 合法
c.quantity = -1     # ValueError: Expected -1 to be at least 0

官方文档对此的总结是:

“描述器阻止无效实例的创建。”

这句话的意义是防御性编程的精髓:在数据进入对象的第一道关卡就拦住它,而不是等到对象被到处传递后才发现数据是坏的。

七、这个框架还能扩展什么?

到这里,你已经拥有了一个可用的验证器框架。但它的潜力远不止于此:

思路完全一样:继承 Validator,实现 validate(),其余的基础设施(命名、存储、拦截)由基类负责。Django 的 CharField()、SQLAlchemy 的 Column(Integer()) 本质上都是这种"声明式验证器"思路的不同变体。它们在你写下类定义的那一刻,就已经把规则预埋好了。

八、总结:从"知其然"到"知其所以然"

中篇结束时,我们手上有了两件工具:

  1. 一个可复用的日志描述符LoggedAccess),利用 __set_name__ 实现了零配置复用。
  2. 一个可扩展的验证器框架ValidatorOneOf/Number/String),把属性校验从业务代码中彻底剥离,用声明式语法替代了 imperative 的 if 堆砌。

这两个工具共同证明了一件事:描述符不是花拳绣腿,而是工程级的代码组织手段。

但还有一个问题悬而未决:我们写了自己的 Validator,也天天在用 Python 内置的 propertystaticmethodclassmethod。它们之间到底是什么关系?

下篇会揭开这个答案:它们共享着完全相同的底层协议。区别只在于——我们用 Python 代码写的,官方用 C 语言写的。我们会亲手重写 property(),拆开函数绑定方法的内部机制,逐行解释 object.__getattribute__ 的查找逻辑,让你亲眼看到每天使用的"魔法"本质和自己写的描述符一模一样。

到此这篇关于Python如何使用描述符写属性验证框架的文章就介绍到这了,更多相关Python使用描述符内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文