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

 更新时间:2026年06月16日 11:30:29   作者: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

逐行解析:

  • __set_name__:自动推导私有属性名。无需人工配置。
  • __get__:读取时透传。if obj is None 时返回 self,这样通过类访问(如 Product.name)返回的是描述符对象本身。
  • __set__框架的心脏。强制"先验证,后存储"。任何写入操作都必须过 validate() 这一关。
  • validate:用 @abstractmethod 标记,逼迫子类必须实现。

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

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

“这个 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

官方文档对此的总结是:

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

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

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

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

  • Email 验证器:用正则表达式检查格式。
  • Regex 验证器:接受一个正则模式,验证字符串是否匹配。
  • ForeignKey 验证器:验证值是否是另一个类实例的 ID。
  • Nullable 包装器:把一个验证器包装成"允许 None 值"的版本。
  • Default 支持:如果实例没有赋值,返回默认值而不是抛 AttributeError

思路完全一样:继承 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使用描述符内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • python tornado开启多进程的几种方法

    python tornado开启多进程的几种方法

    本文主要介绍了python tornado开启多进程的几种方法,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-04-04
  • Python socket模块ftp传输文件过程解析

    Python socket模块ftp传输文件过程解析

    这篇文章主要介绍了Python socket模块ftp传输文件过程解析,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
    2019-11-11
  • Python使用functools模块中的partial函数生成偏函数

    Python使用functools模块中的partial函数生成偏函数

    所谓偏函数即是规定了固定参数的函数,在函数式编程中我们经常可以用到,这里我们就来看一下Python使用functools模块中的partial函数生成偏函数的方法
    2016-07-07
  • 用python实现简单EXCEL数据统计的实例

    用python实现简单EXCEL数据统计的实例

    下面小编就为大家带来一篇用python实现简单EXCEL数据统计的实例。小编觉得挺不错的,现在就分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2017-01-01
  • Python爬取网页返回521状态码的解决方案

    Python爬取网页返回521状态码的解决方案

    该文章分析了Python爬虫在爬取网页详情页时遇到数据长度有限的问题,原因在于频繁爬取触发了反爬机制,解决方案包括更换设备、复制Headers、适当增加访问间隔等,文中还提供了几种代码示例,帮助用户解决反爬问题,需要的朋友可以参考下
    2026-03-03
  • Python入门教程3. 列表基本操作【定义、运算、常用函数】

    Python入门教程3. 列表基本操作【定义、运算、常用函数】

    这篇文章主要介绍了Python列表基本操作,结合实例形式总结分析了Python针对列表的基本定义、判断、运算及各种常用函数与相关使用技巧,需要的朋友可以参考下
    2018-10-10
  • Python中利用sqrt()方法进行平方根计算的教程

    Python中利用sqrt()方法进行平方根计算的教程

    这篇文章主要介绍了Python中利用sqrt()方法进行平方根计算的教程,是Python学习的基础知识,需要的朋友可以参考下
    2015-05-05
  • Python二进制数据结构Struct的具体使用

    Python二进制数据结构Struct的具体使用

    在C/C++语言中,struct被称为结构体。而在Python中,struct是一个专门的库,用于处理字节串与原生Python数据结构类型之间的转换。本文就详细介绍struct的使用方式
    2021-06-06
  • 使用Python将PDF转成Excel的代码实现

    使用Python将PDF转成Excel的代码实现

    在日常工作中,您是否曾被困扰于从复杂的PDF文档中手动提取数据,特别是表格数据,然后逐一录入到Excel,这项任务不仅耗时耗力,还极易引入人为错误,严重影响工作效率,本文将深入探讨如何利用Spire.PDF for Python这一高效库,轻松实现PDF转Excel的需求
    2025-10-10
  • Conda环境离线迁移全过程

    Conda环境离线迁移全过程

    这篇文章主要介绍了Conda环境离线迁移全过程,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教
    2025-10-10

最新评论