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. boolint 的子类

在 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

当通过类访问字段时,instanceNone。通常应该返回字段对象本身:

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)

你的代码就不再只是“能运行”,而是开始表达业务意图。好的代码不只是告诉机器怎么做,也是在告诉后来的开发者:这里为什么这样做。


十三、总结:描述符让字段自己学会“守门”

用描述符实现字段类型校验,核心思路并不复杂:

  1. 在字段类中实现 __get__()__set__()
  2. __set_name__() 获取字段名;
  3. __set__() 中执行类型检查和业务校验;
  4. 把值存到实例对象中,而不是描述符对象中;
  5. 用字段子类和 validators 扩展不同规则;
  6. 用模型基类收集字段,实现序列化、初始化和 ORM 雏形。

描述符像一位安静的守门人。它不打扰你写自然的 Python 代码,却在关键时刻挡住错误数据、保护系统边界、维护业务秩序。

对于初学者来说,理解描述符能让你真正跨过“语法使用者”到“语言机制理解者”的门槛。对于资深开发者来说,描述符是设计 ORM、配置系统、表单系统、缓存属性和领域模型的强大工具。

如果你正在写 Python教程、Python实战项目,或者正在打磨团队内部的 Python最佳实践,不妨从一个小小的字段校验描述符开始。很多高级架构能力,都是从认真理解一行普通赋值开始的。

最后留两个问题给你:

你在项目中遇到过哪些因为字段类型不正确导致的线上问题?

如果让你设计一个轻量级 ORM,你会把哪些校验规则放进描述符,又会把哪些规则交给业务服务层?

更多推荐