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} —— 注意里面没有 age。age 是类变量(指向描述符实例),_age 才是实例上真正存储数据的地方。这个设计模式叫"公开属性是描述符,私有属性是真实数据的仓库"。
但 LoggedAgeAccess 有一个致命缺陷:_age 被硬编码在描述符类里。这意味着同一个描述符类无法管理 name 或 email 属性。你没法写 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这种代码有三个问题:
- 重复:类型检查、范围检查、枚举检查,每个类都写一遍。
- 混杂:业务逻辑(初始化对象)和验证逻辑(参数校验)混在一起。
- 脆弱:如果
price后期允许通过 setter 修改,你得把验证逻辑再复制一份到 setter 里。
描述符可以把验证逻辑彻底从业务类中剥离。理想情况下,我们想写成这样:
class Product:
name = String(minsize=3, maxsize=50)
price = Number(minvalue=0)
category = OneOf('electronics', 'clothing', 'food')
简洁、声明式、无重复。赋值时自动验证,非法数据在对象创建的第一秒就被拦下。这正是官方文档中"完整的实际例子"所展示的核心设计。
四、框架骨架:Validator 抽象基类
我们的目标让所有具体验证器(String、Number、OneOf)共享同一套"托管属性"的基础设施。这套基础设施负责:自动命名、从私有属性读取值、先验证再存储。验证逻辑本身则由子类各自实现。这天然适合用抽象基类来组织:
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()) 本质上都是这种"声明式验证器"思路的不同变体。它们在你写下类定义的那一刻,就已经把规则预埋好了。
八、总结:从"知其然"到"知其所以然"
中篇结束时,我们手上有了两件工具:
- 一个可复用的日志描述符(
LoggedAccess),利用__set_name__实现了零配置复用。 - 一个可扩展的验证器框架(
Validator→OneOf/Number/String),把属性校验从业务代码中彻底剥离,用声明式语法替代了 imperative 的if堆砌。
这两个工具共同证明了一件事:描述符不是花拳绣腿,而是工程级的代码组织手段。
但还有一个问题悬而未决:我们写了自己的 Validator,也天天在用 Python 内置的 property、staticmethod、classmethod。它们之间到底是什么关系?
下篇会揭开这个答案:它们共享着完全相同的底层协议。区别只在于——我们用 Python 代码写的,官方用 C 语言写的。我们会亲手重写 property(),拆开函数绑定方法的内部机制,逐行解释 object.__getattribute__ 的查找逻辑,让你亲眼看到每天使用的"魔法"本质和自己写的描述符一模一样。
到此这篇关于Python如何使用描述符写属性验证框架的文章就介绍到这了,更多相关Python使用描述符内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
相关文章
Python使用functools模块中的partial函数生成偏函数
所谓偏函数即是规定了固定参数的函数,在函数式编程中我们经常可以用到,这里我们就来看一下Python使用functools模块中的partial函数生成偏函数的方法2016-07-07
Python入门教程3. 列表基本操作【定义、运算、常用函数】
这篇文章主要介绍了Python列表基本操作,结合实例形式总结分析了Python针对列表的基本定义、判断、运算及各种常用函数与相关使用技巧,需要的朋友可以参考下2018-10-10


最新评论