Python 基础体系 · 第 25/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 描述符与 property:属性访问、绑定方法和 ORM 基础

在 Python 中,obj.name 看起来只是一次字典查找,但实际结果可能来自实例字典、类字典、继承链、描述符协议,甚至数据库或其他外部资源。

描述符正是把“属性访问”变成可编程行为的机制。property、实例方法、classmethodstaticmethod__slots__ 中的成员访问器,以及许多 ORM 的字段对象,都建立在描述符协议之上。Python 3.14 语言参考将描述符定义为实现了 __get__()__set__()__delete__() 之一,并且该对象被放在拥有它的类字典或父类字典中时,属性访问才会触发相应协议。(docs.python.org)


1. 先区分三个对象:属性名、属性值和拥有者

考虑下面的代码:

class User:
    role = "member"

    def __init__(self, name):
        self.name = name


user = User("Alice")

这里有三个不同概念:

  • namerole属性名,本质上是字符串键;
  • "Alice""member" 是属性值;
  • user 是实例,User 是实例的类型。

对于普通实例属性:

user.name

通常可以近似理解为:

user.__dict__["name"]

而对于类属性:

User.role

通常来自:

User.__dict__["role"]

实例访问 user.role 时,如果实例字典中没有 role,Python 会继续从 User 及其父类的字典中查找。

但是,如果找到的类属性值实现了描述符协议,访问行为就不再是“直接取值”,而是由描述符对象控制。例如:

class Constant:
    def __get__(self, instance, owner=None):
        return 42


class Config:
    answer = Constant()


config = Config()

print(config.answer)  # 42

42 并没有存放在 config.__dict__Config.__dict__ 中。Config.__dict__["answer"] 存放的是 Constant 实例,config.answer 会调用它的 __get__() 动态产生结果。描述符必须作为类属性使用;如果把描述符实例放进某个对象的实例字典,它通常只会被当作普通对象,不会自动触发描述符协议。(docs.python.org)

可以用 vars() 观察类字典而不触发描述符:

print(vars(Config)["answer"])
print(config.answer)

前者得到描述符对象,后者得到 __get__() 返回的值。


2. 描述符协议:__get____set____delete__

一个对象只要定义了以下方法中的至少一个,就可以成为描述符:

class Descriptor:
    def __get__(self, instance, owner=None):
        ...

    def __set__(self, instance, value):
        ...

    def __delete__(self, instance):
        ...

这三个方法分别参与:

  • 读取:obj.attr
  • 写入:obj.attr = value
  • 删除:del obj.attr

2.1 __get__

__get__ 的典型签名是:

def __get__(self, instance, owner=None):
    ...

参数含义如下:

参数 含义
self 描述符实例本身
instance 通过哪个实例访问;通过类访问时为 None
owner 拥有该属性的类,通常是 type(instance)

例如:

class DebugDescriptor:
    def __get__(self, instance, owner=None):
        print(f"instance={instance!r}, owner={owner.__name__}")
        return "value"


class Example:
    item = DebugDescriptor()


example = Example()

print(example.item)
print(Example.item)

输出形式类似:

instance=<__main__.Example object at 0x...>, owner=Example
value
instance=None, owner=Example
value

因此:

example.item

大致对应:

Example.__dict__["item"].__get__(example, Example)

而:

Example.item

大致对应:

Example.__dict__["item"].__get__(None, Example)

Python 语言参考明确区分了实例绑定和类绑定:实例访问会把实例作为 __get__() 的第二个参数传入,类访问则传入 None。(docs.python.org)

如果描述符需要同时支持实例访问和类访问,通常这样写:

class Field:
    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        return instance.__dict__[self.name]

instance is None 分支的意义是:访问 Model.field 时,返回字段描述符本身,使框架能够检查字段元数据,而不是尝试读取某个不存在的实例数据。

2.2 __set__

__set__ 拦截赋值:

class PositiveNumber:
    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        return instance.__dict__[self.name]

    def __set__(self, instance, value):
        if value < 0:
            raise ValueError(f"{self.name} must be non-negative")
        instance.__dict__[self.name] = value


class Product:
    price = PositiveNumber()


product = Product()
product.price = 10
print(product.price)

product.price = -1

执行结果:

10
ValueError: price must be non-negative

赋值语句:

product.price = 10

并不是把 price 直接写入 product.__dict__,而是调用:

Product.__dict__["price"].__set__(product, 10)

描述符通常把公开属性名和实际存储名分开:

product.__dict__["price"]

或者:

product.__dict__["_price"]

这样可以在读取和写入之间插入校验、转换、日志、缓存、脏数据标记等逻辑。

2.3 __delete__

__delete__ 拦截:

del product.price

例如:

class ManagedValue:
    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        try:
            return instance.__dict__[self.name]
        except KeyError:
            raise AttributeError(self.name) from None

    def __set__(self, instance, value):
        instance.__dict__[self.name] = value

    def __delete__(self, instance):
        try:
            del instance.__dict__[self.name]
        except KeyError:
            raise AttributeError(self.name) from None


class Record:
    value = ManagedValue()


record = Record()
record.value = 3
print(record.value)  # 3

del record.value

try:
    print(record.value)
except AttributeError as exc:
    print(type(exc).__name__, exc)

输出:

3
AttributeError value

3. 数据描述符与非数据描述符

描述符的分类关键不在于它有没有 __get__(),而在于它是否定义了 __set__()__delete__()

3.1 数据描述符

如果描述符定义了 __set__()__delete__(),它就是数据描述符

class DataDescriptor:
    def __get__(self, instance, owner=None):
        return "from descriptor"

    def __set__(self, instance, value):
        print("assignment intercepted")

即使实例字典中有同名属性,数据描述符仍然优先:

class Example:
    value = DataDescriptor()


example = Example()
example.__dict__["value"] = "from instance dict"

print(example.value)

输出:

from descriptor

因为 value 是数据描述符,实例字典中的同名键不能覆盖它。

只读 property 也是数据描述符。即使没有定义 setter,property 仍然拥有写入行为,只是该行为会抛出 AttributeError。Python 3.14 文档明确说明,property 通过数据描述符协议实现;属性实例访问会调用 getter,赋值调用 setter,删除调用 deleter。(docs.python.org)

3.2 非数据描述符

如果描述符只定义 __get__(),它就是非数据描述符

class NonDataDescriptor:
    def __get__(self, instance, owner=None):
        return "from descriptor"


class Example:
    value = NonDataDescriptor()


example = Example()
example.__dict__["value"] = "from instance dict"

print(example.value)

输出:

from instance dict

实例字典覆盖了非数据描述符。

非数据描述符的这一特性非常重要,因为普通函数就是非数据描述符。它既允许函数在类中表现为方法,也允许单个实例通过实例属性覆盖某个方法。


4. 属性查找优先级:为什么顺序不能记错

对于普通对象的属性读取,Python 的核心优先级可以概括为:

  1. 数据描述符;
  2. 实例字典;
  3. 非数据描述符;
  4. 普通类属性;
  5. __getattr__() 兜底。

可以把它写成如下形式。设:

  • obj 是实例;
  • cls = type(obj)
  • name 是属性名;
  • cls_var 是沿 cls.__mro__ 查到的第一个同名类属性;
  • descr_getcls_var 类型上的 __get__ 方法。

则读取 obj.name 的主要逻辑近似为:

def lookup_attribute(obj, name):
    cls = type(obj)
    cls_var = find_first_in_mro(cls, name)

    if cls_var is not MISSING:
        descriptor_type = type(cls_var)

        if (
            hasattr(descriptor_type, "__get__")
            and (
                hasattr(descriptor_type, "__set__")
                or hasattr(descriptor_type, "__delete__")
            )
        ):
            return cls_var.__get__(obj, cls)

    if hasattr(obj, "__dict__") and name in obj.__dict__:
        return obj.__dict__[name]

    if cls_var is not MISSING and hasattr(type(cls_var), "__get__"):
        return cls_var.__get__(obj, cls)

    if cls_var is not MISSING:
        return cls_var

    raise AttributeError(name)

这不是完整的解释器实现,但准确体现了核心优先级。Python 官方描述符指南也给出了类似的纯 Python 模拟代码。(docs.python.org)

4.1 数据描述符压过实例字典

class PropertyLike:
    def __get__(self, instance, owner=None):
        return "descriptor value"

    def __set__(self, instance, value):
        instance.__dict__["x"] = value


class A:
    x = PropertyLike()


a = A()
a.x = "instance value"

print(a.__dict__)
print(a.x)

输出:

{'x': 'instance value'}
descriptor value

这里赋值确实把数据放进了实例字典,但读取时数据描述符先被调用,因此实例字典中的值不可见。

这也是数据描述符适合实现校验字段、ORM 字段和只读属性的原因:使用者不能绕过描述符的正常访问路径来覆盖属性语义。

4.2 非数据描述符可以被实例覆盖

class A:
    def method(self):
        return "class method"


a = A()
a.method = lambda: "instance function"

print(a.method())

输出:

instance function

普通方法是非数据描述符,所以 a.__dict__["method"] 优先于类中的函数对象。

但是,这种覆盖只影响当前实例:

b = A()

print(a.method())  # instance function
print(b.method())  # class method

4.3 __getattr__() 不是普通查找的第四级

更准确地说,__getattr__() 不是 object.__getattribute__() 内部的一步。点号访问或 getattr()__getattribute__() 抛出 AttributeError 后,才会尝试调用 __getattr__()。直接调用 object.__getattribute__(obj, name) 不会自动触发 __getattr__()。(docs.python.org)

class Fallback:
    def __getattr__(self, name):
        return f"generated: {name}"


obj = Fallback()

print(obj.missing)
print(object.__getattribute__(obj, "missing"))

结果是:

generated: missing
AttributeError: 'Fallback' object has no attribute 'missing'

这一区别在实现代理对象、懒加载对象或 ORM 模型时尤其重要。


5. __set_name__:描述符如何知道自己的字段名

描述符通常需要知道它被绑定到哪个属性名。例如:

class Logged:
    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = "_" + name

    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        value = getattr(instance, self.private_name)
        print(f"读取 {self.public_name} -> {value!r}")
        return value

    def __set__(self, instance, value):
        print(f"写入 {self.public_name} <- {value!r}")
        setattr(instance, self.private_name, value)


class User:
    name = Logged()
    age = Logged()

    def __init__(self, name, age):
        self.name = name
        self.age = age


user = User("Alice", 30)
print(user.name)
print(user.age)
print(vars(user))

输出类似:

写入 name <- 'Alice'
写入 age <- 30
读取 name -> 'Alice'
Alice
读取 age -> 30
30
{'_name': 'Alice', '_age': 30}

在类创建过程中,type.__new__() 会扫描新类的类变量;如果某个对象定义了 __set_name__(),就调用:

descriptor.__set_name__(OwnerClass, "attribute_name")

该机制从 Python 3.6 起提供。(docs.python.org)

5.1 __set_name__ 的调用时机

下面的写法会自动调用 __set_name__()

class Model:
    field = Logged()

但动态添加属性不会自动调用:

class Model:
    pass


descriptor = Logged()
Model.field = descriptor

此时需要手动通知:

descriptor.__set_name__(Model, "field")

官方文档特别指出,__set_name__() 的自动通知发生在类创建时;类创建后再添加描述符,必须手动调用。(docs.python.org)

5.2 描述符实例通常由同一个类对象共享

class User:
    name = Logged()


a = User("Alice", 20)
b = User("Bob", 30)

User.name 对应的是同一个 Logged 描述符实例。描述符本身应该保存“字段定义”,而不应该保存某一个具体用户的数据。

因此:

vars(User)["name"]

是共享的描述符对象,而:

vars(a)
vars(b)

分别保存两个实例的数据。

如果错误地把每个实例的数据存储在描述符对象自身:

class Broken:
    def __set__(self, instance, value):
        self.value = value

    def __get__(self, instance, owner=None):
        return self.value

那么不同实例之间会共享 self.value,后写入的实例会覆盖先写入的实例。这是描述符实现中最常见的状态归属错误之一。


6. function 到 bound method:方法绑定到底发生了什么

定义类方法时:

class Greeter:
    def hello(self, name):
        return f"Hello, {name}"

在类字典中,hello 存放的是一个函数对象:

greeter_function = vars(Greeter)["hello"]

print(type(greeter_function))
print(greeter_function)

函数对象本身实现了非数据描述符协议。访问类属性:

Greeter.hello

大致得到原始函数:

vars(Greeter)["hello"].__get__(None, Greeter)

访问实例属性:

greeter = Greeter()
greeter.hello

则大致得到:

vars(Greeter)["hello"].__get__(greeter, Greeter)

返回值是一个绑定方法,也就是把 greeter 预先绑定为第一个参数的可调用对象。官方描述符指南给出的等价模型包含两个关键引用:

class Method:
    def __init__(self, function, instance):
        self.__func__ = function
        self.__self__ = instance

    def __call__(self, *args, **kwargs):
        return self.__func__(
            self.__self__,
            *args,
            **kwargs
        )

因此:

greeter.hello("Alice")

等价于:

Greeter.hello(greeter, "Alice")

或者更底层地理解为:

vars(Greeter)["hello"].__get__(greeter, Greeter)("Alice")

Python 方法和普通函数的核心差别,是实例会被自动放到参数列表前面。(docs.python.org)

可以直接验证:

class Greeter:
    def hello(self, name):
        return f"Hello, {name}"


greeter = Greeter()

bound = greeter.hello
unbound_like = Greeter.hello

print(bound.__self__ is greeter)       # True
print(bound.__func__ is unbound_like)  # True

print(bound("Alice"))
print(unbound_like(greeter, "Alice"))

输出:

True
True
Hello, Alice
Hello, Alice

这里常说的“未绑定方法”需要谨慎理解。在现代 Python 中,Greeter.hello 返回的是函数对象,而不是 Python 2 时代意义上的特殊未绑定方法对象;真正的绑定发生在通过实例访问时。

6.1 方法可以被实例属性覆盖

因为函数是非数据描述符,所以:

greeter.hello = lambda name: f"Intercepted {name}"

print(greeter.hello("Alice"))

输出:

Intercepted Alice

此时实例字典中的 hello 覆盖了类字典中的函数描述符。

这也解释了为什么把实例方法替换成测试替身是可行的:

service.fetch = lambda: []

但它同时意味着实例状态可能改变对象行为,不能把“类中定义了方法”理解为“实例一定只能使用这个方法”。


7. property:把方法接口伪装成属性接口

property 是内置的数据描述符。它允许我们把方法调用表达成属性访问:

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("temperature below absolute zero")
        self._celsius = value

    @property
    def fahrenheit(self):
        return self._celsius * 9 / 5 + 32

使用时:

temperature = Temperature(20)

print(temperature.celsius)
print(temperature.fahrenheit)

temperature.celsius = 30
print(temperature.fahrenheit)

输出:

20
68.0
86.0

调用关系是:

temperature.celsius

触发:

Temperature.__dict__["celsius"].__get__(temperature, Temperature)

最终调用 getter:

Temperature.celsius.fget(temperature)

而:

temperature.celsius = 30

触发:

Temperature.__dict__["celsius"].__set__(temperature, 30)

最终调用 setter。

7.1 装饰器语法的展开

下面两种写法在语义上等价:

class Account:
    def get_balance(self):
        return self._balance

    def set_balance(self, value):
        if value < 0:
            raise ValueError("balance cannot be negative")
        self._balance = value

    balance = property(get_balance, set_balance)

以及:

class Account:
    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("balance cannot be negative")
        self._balance = value

property() 的构造参数是:

property(fget=None, fset=None, fdel=None, doc=None)

其中:

  • fget 负责读取;
  • fset 负责赋值;
  • fdel 负责删除;
  • doc 是属性文档字符串。

如果没有显式传入 doc,通常会使用 getter 的文档字符串。(docs.python.org)

7.2 只读 property 为什么不能被实例字典绕过

class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    def area(self):
        return 3.141592653589793 * self.radius ** 2


circle = Circle(2)

circle.__dict__["area"] = "fake"
print(circle.area)

结果仍然是:

12.566370614359172

因为 area 是数据描述符,数据描述符优先于实例字典。

如果尝试:

circle.area = 100

会抛出:

AttributeError: property 'area' of 'Circle' object has no setter

这里的只读并不是“没有 __set__()”,而是 property.__set__() 存在,但在没有 setter 时主动抛出异常。因此它仍然属于数据描述符。

7.3 property 不等于缓存

下面的 getter 每次访问都会重新计算:

class Report:
    @property
    def total(self):
        print("calculating")
        return sum(range(100))


report = Report()

print(report.total)
print(report.total)

输出会包含两次:

calculating

property 只定义访问协议,不自动缓存结果。如果需要缓存,应明确使用实例字段、functools.cached_property 或其他缓存机制,并考虑失效策略。描述符指南将 propertycached_property 都列为建立在描述符基础上的工具,但二者的存储和生命周期语义不同。(docs.python.org)


8. 自定义描述符:从校验字段到可复用组件

property 适合少量、单独定义的属性;当多个字段共享相同的访问规则时,自定义描述符更合适。

例如实现类型校验:

class TypedField:
    def __init__(self, expected_type):
        self.expected_type = expected_type

    def __set_name__(self, owner, name):
        self.name = name
        self.storage_name = "_" + name

    def __get__(self, instance, owner=None):
        if instance is None:
            return self

        try:
            return getattr(instance, self.storage_name)
        except AttributeError:
            raise AttributeError(
                f"{owner.__name__}.{self.name} has not been initialized"
            ) from None

    def __set__(self, instance, value):
        if not isinstance(value, self.expected_type):
            raise TypeError(
                f"{self.name} requires {self.expected_type.__name__}, "
                f"got {type(value).__name__}"
            )
        setattr(instance, self.storage_name, value)


class User:
    name = TypedField(str)
    age = TypedField(int)

    def __init__(self, name, age):
        self.name = name
        self.age = age


user = User("Alice", 30)
print(user.name, user.age)

user.age = "thirty"

赋值 user.age = "thirty" 时,数据流是:

  1. 根据 User 的 MRO 找到 age
  2. 发现 age 是数据描述符;
  3. 调用 TypedField.__set__(user, "thirty")
  4. isinstance() 检查失败;
  5. 抛出 TypeError
  6. _age 不会被写入。

这种设计把验证规则放到了字段定义处,而不是分散到每个构造函数、更新方法和反序列化入口中。

不过,描述符并不能阻止所有绕过行为:

user.__dict__["_age"] = "thirty"

这里直接写入的是私有存储字段,不经过 age.__set__()。因此“描述符保证字段不变量”成立的前提是:所有代码都通过公开属性或明确的受控接口访问数据。Python 的访问控制主要依赖约定,而不是强制的内存隔离。


9. ORM 字段:类属性描述数据库,实例属性访问数据

ORM,即 Object-Relational Mapping,对象关系映射,试图把:

  • 数据库中的表映射成 Python 类;
  • 表中的列映射成类属性;
  • 行映射成 Python 实例;
  • SQL 查询和更新映射成属性读取与赋值。

描述符天然适合这个任务,因为它可以让类属性保存“字段定义”,而把真正的数据访问延迟到实例属性读取或写入时。

9.1 一个可运行的 SQLite 示例

下面的例子只演示描述符机制,不试图实现完整 ORM。运行环境是 Python 3.14 标准库中的 sqlite3

import sqlite3


class Field:
    def __init__(self, column=None):
        self.column = column

    def __set_name__(self, owner, name):
        self.name = name
        if self.column is None:
            self.column = name

    def __get__(self, instance, owner=None):
        if instance is None:
            return self

        sql = (
            f"SELECT {self.column} "
            f"FROM {owner.table_name} "
            f"WHERE {owner.primary_key} = ?"
        )

        row = instance.connection.execute(
            sql,
            (instance.key,),
        ).fetchone()

        if row is None:
            raise LookupError(
                f"{owner.__name__} object with key {instance.key!r} "
                "does not exist"
            )

        return row[0]

    def __set__(self, instance, value):
        sql = (
            f"UPDATE {type(instance).table_name} "
            f"SET {self.column} = ? "
            f"WHERE {type(instance).primary_key} = ?"
        )

        cursor = instance.connection.execute(
            sql,
            (value, instance.key),
        )
        instance.connection.commit()

        if cursor.rowcount == 0:
            raise LookupError(
                f"cannot update missing object with key {instance.key!r}"
            )


class Movie:
    table_name = "movies"
    primary_key = "title"

    director = Field()
    year = Field()

    def __init__(self, connection, title):
        self.connection = connection
        self.key = title


def main():
    connection = sqlite3.connect(":memory:")

    connection.execute(
        """
        CREATE TABLE movies (
            title TEXT PRIMARY KEY,
            director TEXT NOT NULL,
            year INTEGER NOT NULL
        )
        """
    )

    connection.execute(
        """
        INSERT INTO movies(title, director, year)
        VALUES (?, ?, ?)
        """,
        ("Jaws", "Steven Spielberg", 1975),
    )
    connection.commit()

    jaws = Movie(connection, "Jaws")

    print(jaws.director)
    print(jaws.year)

    jaws.year = 1976

    print(jaws.year)

    connection.close()


if __name__ == "__main__":
    main()

预期输出:

Steven Spielberg
1975
1976

9.2 读取路径

执行:

jaws.director

具体过程如下:

  1. Movie.__dict__["director"] 找到 Field 描述符;

  2. Field 定义了 __set__(),因此它是数据描述符;

  3. 数据描述符优先于 jaws.__dict__

  4. 调用:

    Field.__get__(jaws, Movie)
    
  5. __get__()jaws.key 得到主键 "Jaws"

  6. 构造查询并使用参数 (jaws.key,)

  7. 数据库返回一行;

  8. 返回该行的第一列。

实例 jaws 并没有保存 director 的实际值。它只保存了:

{
    "connection": connection,
    "key": "Jaws",
}

因此,属性读取是一次潜在的数据库 I/O,而不是普通内存读取。

9.3 写入路径

执行:

jaws.year = 1976

会经历:

  1. 找到 Movie.yearField
  2. 调用 Field.__set__(jaws, 1976)
  3. 生成 UPDATE
  4. 使用 ? 参数绑定主键和值;
  5. 提交事务;
  6. 如果没有匹配行,则抛出 LookupError

这里使用参数化 SQL 而不是字符串插值,是为了避免把数据值直接拼接到 SQL 中。字段名和表名则不是普通参数值,SQLite 参数绑定不能替换标识符,因此示例假设它们来自类定义,而不是不可信用户输入。

9.4 ORM 描述符的状态关系

可以用下面的关系理解一个简单 ORM:

sequenceDiagram
    participant C as 调用方
    participant O as Movie 实例
    participant D as Field 描述符
    participant DB as SQLite 数据库

    C->>O: movie.director
    O->>D: __get__(movie, Movie)
    D->>O: 读取 key 和 connection
    D->>DB: SELECT director ... WHERE title = ?
    DB-->>D: 返回一行
    D-->>O: 返回 director
    O-->>C: "Steven Spielberg"

    C->>O: movie.year = 1976
    O->>D: __set__(movie, 1976)
    D->>DB: UPDATE movies SET year = ? WHERE title = ?
    DB-->>D: rowcount
    D->>DB: COMMIT
    D-->>C: 完成或抛出异常

这里至少有三种状态:

  • 模型状态:实例中的 key、数据库连接、加载标记;
  • 描述符状态:字段名、列名等类级元数据;
  • 数据库状态:表中的真实值和事务状态。

描述符本身不应保存某个实例的 year 值,因为同一个 Field 会被所有 Movie 实例共享。实例标识和数据库连接属于实例状态,列名和表名属于字段定义状态,真实字段值属于数据库状态。


10. ORM 示例中的真实边界

10.1 每次读取都可能产生 N+1 查询

下面的代码:

for movie in movies:
    print(movie.director)

如果 movie.director 每次都访问数据库,就可能产生:

  • 1 次获取电影列表的查询;
  • N 次获取导演的查询。

这就是常说的 N+1 查询问题。描述符只是把查询隐藏在属性访问后面,并没有自动消除数据库往返。

工程上通常需要在以下方案之间取舍:

  • 一次性预加载字段;
  • 在实例中缓存已读取值;
  • 批量查询;
  • 显式区分懒加载属性和已加载属性;
  • 使用 ORM 自己的查询规划能力。

关键点是:movie.director 语法看起来像内存访问,但其真实成本由 __get__() 决定。

10.2 并发下的读写一致性

如果一个线程读取:

value = movie.year

另一个线程或进程同时更新数据库,第一次读取和第二次读取可能得到不同结果:

print(movie.year)
# 另一个事务更新 year
print(movie.year)

描述符不会自动提供快照隔离、锁、事务边界或缓存一致性。它只负责把属性访问转发到某个实现。事务隔离和并发控制仍然由数据库连接、事务配置和应用层策略决定。

10.3 写入成功不一定代表业务操作完成

示例中的 __set__() 直接执行并提交:

movie.year = 1976

这意味着赋值语句包含数据库 I/O 和事务提交。生产系统可能更希望:

movie.year = 1976
session.commit()

此时 __set__() 只修改内存中的待提交状态,或者记录脏字段;真正提交由工作单元统一完成。两种模型都可以使用描述符,但生命周期不同:

  • 立即写入:实现简单,赋值后数据库通常立即可见,但难以组合多个修改;
  • 延迟写入:事务边界更清晰,支持批量提交和回滚,但需要维护脏状态。

不能因为语法是 obj.field = value,就假设其事务语义等同于普通对象赋值。

10.4 删除、缺失行和空值必须区分

数据库查询可能出现三种情况:

  1. 找到一行,列值为普通值;
  2. 找到一行,列值为 NULL,对应 Python 的 None
  3. 找不到主键对应的行。

因此不能简单写:

return row[0] if row else None

因为这样会把“记录不存在”和“记录存在但列值为 NULL”混为一谈。示例使用:

if row is None:
    raise LookupError(...)
return row[0]

显式区分了两种状态。


11. 常见误解与诊断方法

11.1 “实现了 __get__ 就一定能拦截赋值”

错误。

只实现 __get__() 的是非数据描述符,只能参与读取;赋值时,实例通常可以直接写入同名实例属性。

class ReadOnlyLookup:
    def __get__(self, instance, owner=None):
        return "descriptor"


class Example:
    value = ReadOnlyLookup()


example = Example()
example.value = "instance"

print(example.value)

输出:

instance

如果希望赋值也被拦截,必须实现 __set__()__delete__(),哪怕 __set__() 只负责抛出异常。(docs.python.org)

11.2 “描述符放在实例中也会触发”

通常错误。

class Descriptor:
    def __get__(self, instance, owner=None):
        return 42


class Example:
    def __init__(self):
        self.value = Descriptor()


example = Example()

print(example.value)

输出是一个 Descriptor 实例,而不是 42。描述符协议由属性查找机制针对类字典中的对象触发;实例字典中的对象不会因为自己实现了 __get__() 就自动变成描述符。

11.3 “访问 Class.attr 一定返回原始描述符”

不一定。

class Descriptor:
    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        return "instance value"


class Example:
    value = Descriptor()


print(Example.value)

这个例子中因为 __get__() 主动返回 self,所以得到描述符本身。但描述符也可以在类访问时返回其他值:

class ClassAware:
    def __get__(self, instance, owner=None):
        if instance is None:
            return f"defined on {owner.__name__}"
        return "instance value"

类访问时传入 instance=None,描述符可以据此决定返回元数据、字段对象或其他类级结果。(docs.python.org)

11.4 如何绕过描述符观察原始对象

使用:

vars(Example)["value"]

而不是:

Example.value

因为 Example.value 会经过 type.__getattribute__(),可能触发类绑定;vars(Example) 返回类字典映射,可以直接查看原始条目。描述符指南也强调,通过 vars(some_class)[name] 间接访问时不会触发描述符。(docs.python.org)

11.5 重写 __getattribute__() 会改变描述符行为

描述符自动调用逻辑位于 object.__getattribute__()type.__getattribute__()super() 相关的属性访问机制中。如果自定义 __getattribute__() 时完全不调用父类实现,可能绕过描述符:

class Broken:
    value = property(lambda self: 42)

    def __getattribute__(self, name):
        if name == "value":
            return "bypassed"
        return object.__getattribute__(self, name)


print(Broken().value)

输出:

bypassed

更危险的情况是递归:

class Recursive:
    def __getattribute__(self, name):
        return self.__dict__[name]  # 访问 self.__dict__ 会再次进入这里

通常需要使用:

object.__getattribute__(self, "__dict__")

或者:

super().__getattribute__(name)

来避免不必要的递归,并保留标准属性查找行为。


12. 描述符、property 与普通方法的统一模型

可以把三者放在同一个抽象框架中:

语言特性 类字典中的对象 描述符类型 实例访问效果
普通类属性 42 不是描述符 返回 42
普通方法 函数对象 非数据描述符 产生绑定方法
property property 对象 数据描述符 调用 getter/setter
ORM 字段 自定义字段对象 通常是数据描述符 访问外部数据源
classmethod 类方法描述符 非数据描述符 绑定类
staticmethod 静态方法描述符 非数据描述符 不绑定实例

它们的共同点是:类字典里保存的不是最终结果,而是一个可以参与属性访问的对象。

区别在于:

  • 普通类属性直接返回;
  • 函数根据访问者绑定实例;
  • property 把访问转发给 getter/setter;
  • ORM 字段把访问转发给数据库或会话;
  • classmethod 把类作为第一个参数;
  • staticmethod 不自动添加实例或类参数。

描述符因此不是某个孤立的元编程技巧,而是 Python 对象模型中“类属性如何解释”的统一协议。(docs.python.org)


13. 什么时候使用 property,什么时候使用自定义描述符

如果只有一个类中的一个或几个属性需要封装逻辑,property 通常更直接:

class User:
    @property
    def display_name(self):
        return f"{self.first_name} {self.last_name}"

如果多个类、多个字段需要共享规则,自定义描述符更适合:

class Positive:
    ...

然后复用:

class Product:
    price = Positive()
    stock = Positive()


class Order:
    quantity = Positive()

选择时应先判断数据来源:

  • 数据来自实例内存,适合 property 或本地描述符;
  • 数据需要统一校验,适合可复用的数据描述符;
  • 数据来自数据库、缓存或远程服务,可以使用 ORM 字段式描述符,但必须显式评估 I/O、事务、缓存和并发语义;
  • 数据访问需要复杂查询规划时,单纯隐藏在属性语法后面可能降低可观察性,此时应考虑显式查询接口。

描述符能隐藏实现细节,但不能消除实现成本。尤其是 ORM 场景,属性访问可能触发异常、网络请求、数据库查询或事务提交;接口形式简单,不代表执行语义简单。


结语

理解描述符需要同时掌握四个层次:

  1. 协议层__get__()__set__()__delete__() 定义了属性访问钩子;
  2. 优先级层:数据描述符、实例字典、非数据描述符和普通类属性有严格顺序;
  3. 绑定层:函数作为非数据描述符,把 function 转换成带有 __self__ 的 bound method;
  4. 抽象层property 和 ORM 字段把方法调用、校验逻辑、缓存或数据库操作包装成属性访问。

其中最容易混淆的结论是:

  • 只实现 __get__() 的是非数据描述符,可能被实例字典覆盖;
  • 实现 __set__()__delete__() 的是数据描述符,会压过实例字典;
  • property 即使没有 setter,仍然是数据描述符;
  • __set_name__() 只会在类创建时自动调用;
  • 描述符对象通常由类实例共享,实例数据不能存储在描述符自身;
  • obj.attr 的低成本外观不能证明它只是内存读取,ORM 中它完全可能是一次数据库操作。

掌握这些规则后,property、方法绑定、__slots__、字段校验器和 ORM 字段就不再是彼此孤立的特性,而是同一套属性访问机制在不同场景下的具体应用。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。