Python 描述符实战:从字段类型校验到迷你 ORM 设计
Python 描述符实战:从字段类型校验到迷你 ORM 设计
很多人学习 Python 时,最先被它吸引的往往是简洁:列表、字典、函数、类,几行代码就能完成一个小工具。可当你继续深入,会发现 Python 真正迷人的地方并不只是“简单”,而是它允许我们用优雅的语法承载复杂的设计。
比如你在 ORM 中经常看到这样的代码:
class User:
name = StringField(max_length=20)
age = IntegerField(min_value=0)
user = User()
user.name = "Alice"
user.age = 18
看起来只是普通赋值,但背后可能已经完成了类型检查、长度校验、默认值处理、错误提示和字段元数据收集。这种“看起来普通、实际上很强大”的能力,很大程度上来自 Python 的描述符机制。
Python 官方描述符文档指出,只要一个对象定义了 __get__()、__set__() 或 __delete__() 中的任意方法,它就可以参与描述符协议,从而控制属性访问、赋值和删除行为。(Python documentation) 这篇文章就从一个真实问题出发:如何用描述符实现字段类型校验?
一、为什么字段类型校验值得认真设计?
Python 是动态类型语言,这让它非常灵活。你可以快速写出原型,也可以用它做 Web 开发、自动化、数据分析、AI 应用和工程脚本。Python 官方也强调,它适合快速开发,并能高效集成系统。(Python.org)
但动态类型也带来一个现实问题:错误可能在运行时才暴露。
user.age = "18"
这行代码语法完全合法,但如果 age 最终要写入数据库整数列、参与数学计算或用于风控规则,就可能埋下隐患。
当然,我们可以到处写 if not isinstance(...):
if not isinstance(age, int):
raise TypeError("age must be int")
问题是,这种写法很容易散落在业务代码中,久而久之形成重复、混乱和难以维护的校验逻辑。
更好的方式是把校验规则封装到字段本身:
class User:
age = IntegerField()
以后只要给 age 赋值,字段自己负责判断是否合法。这正是描述符的典型用武之地。
二、描述符的最小模型:拦截属性赋值
先看一个最简单的描述符:
class IntegerField:
def __set_name__(self, owner, name):
self.name = name
self.private_name = f"_{name}"
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.private_name, None)
def __set__(self, instance, value):
if not isinstance(value, int):
raise TypeError(f"{self.name} 必须是 int 类型")
setattr(instance, self.private_name, value)
使用它:
class User:
age = IntegerField()
user = User()
user.age = 18
print(user.age)
user.age = "18"
最后一行会抛出异常:
TypeError: age 必须是 int 类型
这里的核心是 __set__()。当你执行:
user.age = 18
Python 并不会简单地把 18 放进 user.__dict__["age"],而是调用 IntegerField.__set__(user, 18)。
__set_name__() 也很关键。它会在类创建时自动接收字段所属的类和字段名。也就是说,当 User 类被创建时,Python 会告诉 IntegerField:“你现在绑定到了 User.age 上。”
这个机制非常适合字段系统,因为字段对象终于知道了自己的名字。
三、不要把值存在描述符对象自己身上
初学者经常写出这样的代码:
class BadIntegerField:
def __init__(self):
self.value = None
def __get__(self, instance, owner):
return self.value
def __set__(self, instance, value):
if not isinstance(value, int):
raise TypeError("必须是 int")
self.value = value
看似没问题,实际上有严重 bug:
class User:
age = BadIntegerField()
u1 = User()
u2 = User()
u1.age = 18
u2.age = 30
print(u1.age)
print(u2.age)
你可能以为输出是:
18
30
实际两个对象会共享同一个描述符实例,结果很可能都是:
30
30
因为 age = BadIntegerField() 是类属性,整个 User 类只有一个 BadIntegerField 对象。
正确做法是:描述符负责规则,实例负责数据。
也就是说,值应该存到实例对象上,例如:
setattr(instance, self.private_name, value)
这也是描述符设计中最重要的实践经验之一。
四、从单一字段到通用 Field 基类
只支持整数远远不够。真实项目中,我们需要字符串、浮点数、布尔值、日期、邮箱、枚举等字段。
可以先抽象一个通用字段基类:
class ValidationError(Exception):
pass
class Field:
expected_type = object
def __init__(self, *, required=False, default=None):
self.required = required
self.default = default
self.name = None
self.private_name = None
def __set_name__(self, owner, name):
self.name = name
self.private_name = f"_{name}"
def validate(self, value):
if value is None:
if self.required:
raise ValidationError(f"{self.name} 是必填字段")
return value
if self.expected_type is not object and not isinstance(value, self.expected_type):
raise ValidationError(
f"{self.name} 必须是 {self.expected_type.__name__} 类型,"
f"实际收到 {type(value).__name__}"
)
return value
def __get__(self, instance, owner):
if instance is None:
return self
if hasattr(instance, self.private_name):
return getattr(instance, self.private_name)
return self.default
def __set__(self, instance, value):
value = self.validate(value)
setattr(instance, self.private_name, value)
然后定义具体字段:
class IntegerField(Field):
expected_type = int
class StringField(Field):
expected_type = str
class FloatField(Field):
expected_type = float
使用方式:
class Product:
name = StringField(required=True)
stock = IntegerField(default=0)
price = FloatField(required=True)
p = Product()
p.name = "Keyboard"
p.stock = 100
p.price = 299.0
print(p.name, p.stock, p.price)
如果赋错类型:
p.stock = "100"
会得到清晰错误:
ValidationError: stock 必须是 int 类型,实际收到 str
到这里,我们已经实现了一个可复用字段校验系统。
五、增加业务规则:长度、范围、空值
字段类型校验只是第一步。真实业务中,更常见的是组合规则:
- 用户名必须是字符串;
- 长度不能超过 20;
- 年龄必须是整数;
- 年龄不能小于 0;
- 价格必须大于等于 0。
可以让子类扩展 validate():
class StringField(Field):
expected_type = str
def __init__(self, *, min_length=None, max_length=None, **kwargs):
super().__init__(**kwargs)
self.min_length = min_length
self.max_length = max_length
def validate(self, value):
value = super().validate(value)
if value is None:
return value
if self.min_length is not None and len(value) < self.min_length:
raise ValidationError(f"{self.name} 长度不能小于 {self.min_length}")
if self.max_length is not None and len(value) > self.max_length:
raise ValidationError(f"{self.name} 长度不能超过 {self.max_length}")
return value
class IntegerField(Field):
expected_type = int
def __init__(self, *, min_value=None, max_value=None, **kwargs):
super().__init__(**kwargs)
self.min_value = min_value
self.max_value = max_value
def validate(self, value):
value = super().validate(value)
if value is None:
return value
if self.min_value is not None and value < self.min_value:
raise ValidationError(f"{self.name} 不能小于 {self.min_value}")
if self.max_value is not None and value > self.max_value:
raise ValidationError(f"{self.name} 不能大于 {self.max_value}")
return value
现在模型可以这样写:
class User:
username = StringField(required=True, min_length=3, max_length=20)
age = IntegerField(required=True, min_value=0, max_value=150)
user = User()
user.username = "Tom"
user.age = 20
错误示例:
user.username = "A"
会抛出:
ValidationError: username 长度不能小于 3
这种设计的好处是:字段规则被声明在类定义处,业务含义一目了然。
六、支持默认值工厂,避免可变默认值陷阱
默认值看似简单,但 Python 中有一个经典陷阱:不要把可变对象直接作为默认值。
错误示例:
class Article:
tags = Field(default=[])
如果多个实例共享同一个列表,问题会非常隐蔽。更安全的方式是支持 default_factory:
class Field:
expected_type = object
def __init__(self, *, required=False, default=None, default_factory=None):
if default is not None and default_factory is not None:
raise ValueError("default 和 default_factory 不能同时设置")
self.required = required
self.default = default
self.default_factory = default_factory
self.name = None
self.private_name = None
def get_default(self):
if self.default_factory is not None:
return self.default_factory()
return self.default
def __set_name__(self, owner, name):
self.name = name
self.private_name = f"_{name}"
def validate(self, value):
if value is None:
if self.required:
raise ValidationError(f"{self.name} 是必填字段")
return value
if self.expected_type is not object and not isinstance(value, self.expected_type):
raise ValidationError(f"{self.name} 类型错误")
return value
def __get__(self, instance, owner):
if instance is None:
return self
if not hasattr(instance, self.private_name):
setattr(instance, self.private_name, self.get_default())
return getattr(instance, self.private_name)
def __set__(self, instance, value):
setattr(instance, self.private_name, self.validate(value))
这样就可以安全地写:
class ListField(Field):
expected_type = list
class Article:
tags = ListField(default_factory=list)
a1 = Article()
a2 = Article()
a1.tags.append("Python")
print(a1.tags)
print(a2.tags)
输出:
['Python']
[]
这是工程实践中非常重要的细节。字段系统不是只要“能跑”就够了,还要尽量避免那些半年后才爆炸的坑。
七、做成迷你 ORM:字段收集与对象初始化
在 ORM 中,字段通常不只是校验值,还要被框架收集起来,用于生成表结构、序列化数据、生成 SQL 或 API Schema。
我们可以用一个简单的基类来收集字段:
class Model:
def __init_subclass__(cls):
super().__init_subclass__()
fields = {}
for name, value in cls.__dict__.items():
if isinstance(value, Field):
fields[name] = value
cls.__fields__ = fields
def __init__(self, **kwargs):
for name, field in self.__fields__.items():
value = kwargs.get(name, field.get_default())
setattr(self, name, value)
def to_dict(self):
return {
name: getattr(self, name)
for name in self.__fields__
}
定义模型:
class User(Model):
username = StringField(required=True, min_length=3, max_length=20)
age = IntegerField(required=True, min_value=0)
使用:
user = User(username="Alice", age=18)
print(user.to_dict())
输出:
{'username': 'Alice', 'age': 18}
如果传入非法数据:
user = User(username="Al", age=-1)
会在初始化阶段直接失败。
这就是 ORM、数据模型层、配置系统和表单校验系统常用的套路:
类属性声明字段
↓
描述符拦截读写
↓
字段对象执行校验
↓
模型基类收集字段
↓
统一完成初始化、序列化、存储或查询构建
八、进一步增强:自定义校验器
有时字段规则不是简单类型或范围能表达的。例如邮箱、手机号、订单号、业务编码等。
可以支持外部校验函数:
class Field:
expected_type = object
def __init__(
self,
*,
required=False,
default=None,
default_factory=None,
validators=None
):
self.required = required
self.default = default
self.default_factory = default_factory
self.validators = validators or []
self.name = None
self.private_name = None
def get_default(self):
if self.default_factory is not None:
return self.default_factory()
return self.default
def __set_name__(self, owner, name):
self.name = name
self.private_name = f"_{name}"
def validate(self, value):
if value is None:
if self.required:
raise ValidationError(f"{self.name} 是必填字段")
return value
if self.expected_type is not object and not isinstance(value, self.expected_type):
raise ValidationError(f"{self.name} 类型错误")
for validator in self.validators:
validator(self.name, value)
return value
def __get__(self, instance, owner):
if instance is None:
return self
if not hasattr(instance, self.private_name):
setattr(instance, self.private_name, self.get_default())
return getattr(instance, self.private_name)
def __set__(self, instance, value):
setattr(instance, self.private_name, self.validate(value))
定义邮箱校验器:
def email_validator(name, value):
if "@" not in value:
raise ValidationError(f"{name} 不是合法邮箱地址")
class Customer(Model):
email = StringField(required=True, validators=[email_validator])
使用:
customer = Customer(email="alice@example.com")
print(customer.to_dict())
customer.email = "invalid-email"
这种设计很适合中大型项目,因为它允许你把通用字段规则和业务校验规则分开,既保持字段类简洁,又保留扩展能力。
九、实践案例:用户注册数据模型
现在我们构建一个接近真实项目的用户注册模型。
def password_strength_validator(name, value):
if len(value) < 8:
raise ValidationError(f"{name} 长度至少为 8 位")
if value.isdigit() or value.isalpha():
raise ValidationError(f"{name} 不能只包含数字或字母")
class UserRegistration(Model):
username = StringField(required=True, min_length=3, max_length=20)
email = StringField(required=True, validators=[email_validator])
password = StringField(required=True, validators=[password_strength_validator])
age = IntegerField(required=False, min_value=0, max_value=150)
业务代码变得非常干净:
def register_user(payload):
user = UserRegistration(**payload)
# 到这里说明基础数据已经通过校验
data = user.to_dict()
# 继续执行加密、入库、发送欢迎邮件等逻辑
return data
payload = {
"username": "pythonista",
"email": "hello@example.com",
"password": "py2026Good",
"age": 18,
}
print(register_user(payload))
如果用户输入错误:
payload = {
"username": "py",
"email": "hello",
"password": "123456",
}
程序会在模型创建时给出明确异常。你还可以进一步改造错误收集机制,把多个字段错误一次性返回给前端。
十、描述符类型校验的常见坑
1. bool 是 int 的子类
在 Python 中:
isinstance(True, int)
结果是:
True
如果你不希望 True 被当作整数,可以改用严格类型判断:
if type(value) is not int:
raise ValidationError("必须是严格 int 类型")
可以给字段增加参数:
class IntegerField(Field):
expected_type = int
def __init__(self, *, strict=False, **kwargs):
super().__init__(**kwargs)
self.strict = strict
def validate(self, value):
if value is None:
return super().validate(value)
if self.strict:
if type(value) is not int:
raise ValidationError(f"{self.name} 必须是严格 int 类型")
else:
super().validate(value)
return value
2. 不要让属性名和存储名冲突
如果在 __set__() 里写:
setattr(instance, self.name, value)
会再次触发描述符,导致无限递归。
正确做法是使用私有存储名:
setattr(instance, self.private_name, value)
或者直接操作:
instance.__dict__[self.private_name] = value
3. 类访问和实例访问要分开
描述符的 __get__() 需要处理这种情况:
User.username
当通过类访问字段时,instance 是 None。通常应该返回字段对象本身:
def __get__(self, instance, owner):
if instance is None:
return self
这样 ORM 才能在类层面拿到字段元数据,甚至构建查询表达式。
十一、最佳实践:让字段系统既优雅又可靠
第一,错误信息要面向使用者,而不是面向框架作者。
不要只写“invalid value”,要告诉开发者哪个字段错了、期望什么、实际是什么。
第二,字段类要小而清晰。StringField 负责字符串规则,IntegerField 负责整数规则,复杂业务规则交给 validators。
第三,测试要覆盖边界条件。
比如 None、空字符串、超长字符串、负数、True、默认值、多个实例是否共享状态等。
def test_integer_field_rejects_string():
class User:
age = IntegerField()
user = User()
try:
user.age = "18"
except ValidationError:
pass
else:
raise AssertionError("应该抛出 ValidationError")
第四,遵循一致的代码风格。
PEP 8 是 Python 官方代码风格指南,它为命名、布局、可读性等提供了约定。(Python Enhancement Proposals (PEPs)) 字段系统越底层,越应该保持清晰、克制和可维护。
第五,理解描述符和现代框架的关系。
FastAPI、Pydantic、Django ORM、SQLAlchemy 等工具都在不同层面处理“字段声明、类型约束、数据转换、模型元数据”这些问题。你未必每天都要自己写描述符,但理解它,会让你更容易读懂这些框架的设计思想。
十二、前沿视角:类型提示、数据模型与工程质量
今天的 Python 编程正在越来越重视工程质量。类型提示、数据校验、异步 I/O、自动化测试、CI/CD、API Schema 生成,正在成为中大型项目的标配。asyncio 官方文档也说明,它是使用 async/await 编写并发代码的库,并且是许多异步框架的基础。(Python documentation)
描述符虽然是一个相对底层的语言特性,却和这些现代工程实践有天然联系。因为它解决的是一个基础问题:如何把规则绑定到数据上?
当你能把字段规则写成:
class Order(Model):
order_id = StringField(required=True)
amount = IntegerField(required=True, min_value=1)
你的代码就不再只是“能运行”,而是开始表达业务意图。好的代码不只是告诉机器怎么做,也是在告诉后来的开发者:这里为什么这样做。
十三、总结:描述符让字段自己学会“守门”
用描述符实现字段类型校验,核心思路并不复杂:
- 在字段类中实现
__get__()和__set__(); - 用
__set_name__()获取字段名; - 在
__set__()中执行类型检查和业务校验; - 把值存到实例对象中,而不是描述符对象中;
- 用字段子类和 validators 扩展不同规则;
- 用模型基类收集字段,实现序列化、初始化和 ORM 雏形。
描述符像一位安静的守门人。它不打扰你写自然的 Python 代码,却在关键时刻挡住错误数据、保护系统边界、维护业务秩序。
对于初学者来说,理解描述符能让你真正跨过“语法使用者”到“语言机制理解者”的门槛。对于资深开发者来说,描述符是设计 ORM、配置系统、表单系统、缓存属性和领域模型的强大工具。
如果你正在写 Python教程、Python实战项目,或者正在打磨团队内部的 Python最佳实践,不妨从一个小小的字段校验描述符开始。很多高级架构能力,都是从认真理解一行普通赋值开始的。
最后留两个问题给你:
你在项目中遇到过哪些因为字段类型不正确导致的线上问题?
如果让你设计一个轻量级 ORM,你会把哪些校验规则放进描述符,又会把哪些规则交给业务服务层?
更多推荐



所有评论(0)