Python 后端开发技术博客专栏 | 第 06 篇 描述符与属性管理 -- 理解 Python 属性访问的底层机制

难度等级: 高级
适合读者: 有 Python 基础的开发者,准备面试的中高级工程师
前置知识: 第 03 篇《面向对象编程进阶》、第 05 篇《Python 数据模型与标准库精选》


导读

你一定写过 @property,但你是否知道它本质上就是一个描述符(Descriptor) ?你是否想过,当你写 obj.attr 时,Python 到底经历了怎样的查找过程?为什么 classmethodstaticmethodproperty 这些"装饰器"能改变属性的访问行为?

在 Python 中,属性访问是一个远比表面复杂的过程。它涉及三个层次的机制:

  1. __getattribute__:每次属性访问的入口,无论属性是否存在都会被调用
  2. 描述符协议 :定义了 __get____set____delete__ 的对象可以"劫持"属性访问
  3. __getattr__:只在常规查找失败时才被调用的"后备方案"

这三者的优先级和交互关系,构成了 Python 属性访问的完整图景。理解它们,你就能理解 Django ORM 的 Field 是如何工作的、SQLAlchemy 的 Column 为什么能自动映射数据库字段、以及 functools.cached_property 的线程安全陷阱。

本文将从 CPython 源码层面剖析属性访问的完整链路,系统讲解描述符协议,并通过 ORM 字段、参数验证、延迟加载等实战案例展示描述符的强大能力。


学习目标

读完本文后,你将能够:

  1. 完整描述 Python 属性访问的查找链路,包括 __getattribute__、描述符、实例字典的优先级关系
  2. 区分数据描述符和非数据描述符,理解它们在属性查找中的不同优先级
  3. 实现自定义描述符,包括类型检查描述符、延迟计算描述符和缓存属性描述符
  4. 深入理解 @property 的底层实现,能用纯 Python 复现其行为
  5. 掌握 functools.cached_property 的使用场景与线程安全注意事项
  6. 在面试中准确回答描述符协议、属性访问机制等高频问题
  7. 能实现类 ORM 框架的字段定义、参数验证等生产级描述符应用

一、Python 属性访问机制

1.1 属性访问的完整链路

当你写 obj.attr 时,Python 的属性查找远非"从对象的字典里取值"这么简单。CPython 中 object.__getattribute__ 的查找逻辑(简化版)如下:

python 复制代码
# 伪代码:object.__getattribute__ 的查找逻辑
def __getattribute__(obj, name):
    # Step 1: 从类(及其 MRO)中查找同名属性
    cls = type(obj)
    cls_attr = None
    for base in cls.__mro__:
        if name in base.__dict__:
            cls_attr = base.__dict__[name]
            break

    # Step 2: 如果类属性是数据描述符(定义了 __get__ + __set__ 或 __delete__),优先调用
    if cls_attr is not None and hasattr(type(cls_attr), '__set__') or hasattr(type(cls_attr), '__delete__'):
        # 数据描述符优先于实例字典
        if hasattr(type(cls_attr), '__get__'):
            return type(cls_attr).__get__(cls_attr, obj, cls)

    # Step 3: 查找实例字典
    if hasattr(obj, '__dict__') and name in obj.__dict__:
        return obj.__dict__[name]

    # Step 4: 如果类属性是非数据描述符(只定义了 __get__),调用它
    if cls_attr is not None:
        if hasattr(type(cls_attr), '__get__'):
            return type(cls_attr).__get__(cls_attr, obj, cls)
        return cls_attr  # 普通类属性

    # Step 5: 都没找到,触发 __getattr__(如果定义了的话)
    raise AttributeError(f"'{cls.__name__}' object has no attribute '{name}'")

关键优先级(这是面试的核心考点):

复制代码
数据描述符 > 实例字典 > 非数据描述符 > 普通类属性 > __getattr__

我们用代码来验证这个优先级:

python 复制代码
class DataDescriptor:
    """数据描述符:定义了 __get__ 和 __set__"""
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return "data_descriptor_value"

    def __set__(self, obj, value):
        pass  # 只要定义了 __set__,就是数据描述符


class NonDataDescriptor:
    """非数据描述符:只定义了 __get__"""
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return "non_data_descriptor_value"


class MyClass:
    data_attr = DataDescriptor()
    non_data_attr = NonDataDescriptor()


obj = MyClass()

# 数据描述符 vs 实例字典
obj.__dict__["data_attr"] = "instance_value"
print(obj.data_attr)       # data_descriptor_value -- 数据描述符胜出!
print(obj.__dict__["data_attr"])  # instance_value -- 实例字典中的值仍然存在

# 非数据描述符 vs 实例字典
obj.__dict__["non_data_attr"] = "instance_value"
print(obj.non_data_attr)   # instance_value -- 实例字典胜出!

这就是核心区别 :数据描述符的优先级高于 实例字典,而非数据描述符的优先级低于 实例字典。这个设计让 property(数据描述符)能够拦截所有属性访问,同时让 functools.cached_property(非数据描述符)能够在首次计算后将值写入实例字典,后续直接从实例字典读取。

1.2 __getattr__ vs __getattribute__ vs __get__

这三个方法名字相似,但角色完全不同:

python 复制代码
class TrackedAccess:
    """演示 __getattribute__ 和 __getattr__ 的调用时机"""

    def __init__(self):
        self.existing_attr = "I exist"

    def __getattribute__(self, name):
        """每次属性访问都会调用,无论属性是否存在"""
        print(f"  __getattribute__ called for '{name}'")
        return super().__getattribute__(name)

    def __getattr__(self, name):
        """只在常规查找失败时调用(__getattribute__ 抛出 AttributeError 后)"""
        print(f"  __getattr__ called for '{name}'")
        return f"default_value_for_{name}"


obj = TrackedAccess()

# 访问存在的属性
print("--- 访问 existing_attr ---")
print(obj.existing_attr)
# __getattribute__ called for 'existing_attr'
# I exist

# 访问不存在的属性
print("\n--- 访问 missing_attr ---")
print(obj.missing_attr)
# __getattribute__ called for 'missing_attr'
# __getattr__ called for 'missing_attr'
# default_value_for_missing_attr

三者的角色对比

方法 定义在 调用时机 典型用途
__getattribute__ 实例的类 每次 obj.attr 都调用 拦截所有属性访问(慎用,易递归)
__getattr__ 实例的类 仅在常规查找失败时 动态属性、代理模式、属性默认值
__get__ 描述符类 通过描述符协议触发 控制属性的获取行为

常见陷阱 -- __getattribute__ 中的无限递归

python 复制代码
class BadExample:
    def __init__(self):
        self.data = {"key": "value"}

    def __getattribute__(self, name):
        # 危险!self.data 又触发 __getattribute__,导致无限递归
        # if name in self.data:  # RecursionError!
        #     return self.data[name]

        # 正确做法:用 super().__getattribute__ 或 object.__getattribute__
        return object.__getattribute__(self, name)


# __getattr__ 的实际应用:动态代理
class APIClient:
    """动态代理模式:将方法调用转化为 API 请求"""

    def __init__(self, base_url: str):
        self._base_url = base_url
        self._calls: list = []

    def __getattr__(self, name: str):
        """未定义的属性访问转化为 API 路径段"""
        def method_handler(*args, **kwargs):
            self._calls.append((name, args, kwargs))
            return f"API call: {self._base_url}/{name}"
        return method_handler


client = APIClient("https://api.example.com")
result = client.get_users()
print(result)  # API call: https://api.example.com/get_users
print(client._calls)  # [('get_users', (), {})]

1.3 属性赋值的机制

属性赋值(obj.attr = value)同样受描述符影响:

python 复制代码
class LoggedDescriptor:
    """记录所有赋值操作的描述符"""

    def __init__(self, name: str = ""):
        self.name = name
        self.log: list = []

    def __set_name__(self, owner, name):
        """Python 3.6+:类创建时自动调用,获取属性名"""
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__.get(f"_{self.name}", None)

    def __set__(self, obj, value):
        old = obj.__dict__.get(f"_{self.name}")
        self.log.append({"old": old, "new": value})
        obj.__dict__[f"_{self.name}"] = value

    def __delete__(self, obj):
        obj.__dict__.pop(f"_{self.name}", None)


class Config:
    host = LoggedDescriptor()
    port = LoggedDescriptor()


cfg = Config()
cfg.host = "localhost"
cfg.host = "10.0.0.1"
cfg.port = 5432

print(cfg.host)  # 10.0.0.1
print(cfg.port)  # 5432
print(Config.host.log)   # [{'old': None, 'new': 'localhost'}, {'old': 'localhost', 'new': '10.0.0.1'}]
print(Config.port.log)   # [{'old': None, 'new': 5432}]

__set_name__ 的重要性 (Python 3.6+):在 Python 3.6 之前,描述符无法自动知道自己被赋给了哪个属性名,需要在 __init__ 中显式传入。__set_name__(self, owner, name) 在类创建时由元类自动调用,解决了这个问题。owner 是拥有描述符的类,name 是属性名。


二、描述符协议(Descriptor Protocol)

2.1 数据描述符 vs 非数据描述符

描述符是实现了 __get____set____delete__ 中至少一个方法的对象。根据实现的方法不同,分为两类:

类型 定义 优先级 典型例子
数据描述符 定义了 __get__ + (__set____delete__) 高于实例字典 property、ORM Field
非数据描述符 只定义了 __get__ 低于实例字典 函数(function)、staticmethodclassmethod
python 复制代码
class DataDesc:
    """数据描述符"""
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__.get("_data_val", "default")

    def __set__(self, obj, value):
        obj.__dict__["_data_val"] = value


class NonDataDesc:
    """非数据描述符"""
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return "computed_value"


class Demo:
    data = DataDesc()
    non_data = NonDataDesc()


d = Demo()

# 数据描述符:__set__ 拦截赋值
d.data = 42
print(d.data)  # 42 -- __get__ 从 __dict__ 取值
print("_data_val" in d.__dict__)  # True

# 非数据描述符:赋值直接写入实例字典,遮盖描述符
d.non_data = "override"
print(d.non_data)  # override -- 实例字典优先
del d.non_data     # 删除实例字典中的值
print(d.non_data)  # computed_value -- 描述符重新生效

2.2 函数也是描述符

Python 的函数对象实现了 __get__ 方法(是非数据描述符),这就是"绑定方法"的底层原理:

python 复制代码
class MyClass:
    def greet(self):
        return f"Hello from {self}"


# 函数对象本身
print(type(MyClass.__dict__["greet"]))  # <class 'function'>

# 通过实例访问时,函数描述符的 __get__ 被调用,返回绑定方法
obj = MyClass()
print(type(obj.greet))  # <class 'method'>

# 手动调用函数的 __get__ 方法
func = MyClass.__dict__["greet"]
bound_method = func.__get__(obj, MyClass)
print(bound_method())  # Hello from <__main__.MyClass object at 0x...>

# 这就是为什么实例方法的第一个参数 self 会被自动填充
# obj.greet() 实际上等价于 MyClass.__dict__["greet"].__get__(obj, MyClass)()

2.3 用纯 Python 实现 property

理解了描述符,我们可以用纯 Python 复现 property 的完整行为:

python 复制代码
class MyProperty:
    """用纯 Python 复现 property 的行为"""

    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        if doc is None and fget is not None:
            doc = fget.__doc__
        self.__doc__ = doc

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self  # 通过类访问时返回描述符本身
        if self.fget is None:
            raise AttributeError("unreadable attribute")
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError("can't set attribute")
        self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError("can't delete attribute")
        self.fdel(obj)

    def getter(self, fget):
        return type(self)(fget, self.fset, self.fdel, self.__doc__)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel, self.__doc__)

    def deleter(self, fdel):
        return type(self)(self.fget, self.fset, fdel, self.__doc__)


# 使用我们自己的 property
class Circle:
    def __init__(self, radius: float):
        self._radius = radius

    @MyProperty
    def radius(self) -> float:
        """The radius of the circle."""
        return self._radius

    @radius.setter
    def radius(self, value: float) -> None:
        if value <= 0:
            raise ValueError("Radius must be positive")
        self._radius = value

    @MyProperty
    def area(self) -> float:
        """Computed area (read-only)."""
        import math
        return math.pi * self._radius ** 2


c = Circle(5.0)
print(c.radius)        # 5.0
c.radius = 10.0
print(c.radius)        # 10.0
print(f"{c.area:.2f}") # 314.16

# 验证 read-only
try:
    c.area = 100
except AttributeError as e:
    print(f"Error: {e}")  # Error: can't set attribute

# 验证校验
try:
    c.radius = -1
except ValueError as e:
    print(f"Error: {e}")  # Error: Radius must be positive

为什么 property 是数据描述符? 因为它同时实现了 __get____set____delete__。即使你没有定义 setter,property.__set__ 仍然存在(只是会抛出 AttributeError)。这保证了数据描述符的优先级 -- 任何对属性的赋值都会被 property 拦截,不会意外写入实例字典。


三、@property 的进阶使用

3.1 getter/setter/deleter 的完整用法

python 复制代码
class User:
    """property 的完整用法示例"""

    def __init__(self, first_name: str, last_name: str, email: str):
        self.first_name = first_name
        self.last_name = last_name
        self.email = email  # 触发 setter 进行校验

    @property
    def email(self) -> str:
        return self._email

    @email.setter
    def email(self, value: str) -> None:
        if "@" not in value:
            raise ValueError(f"Invalid email: {value}")
        self._email = value.lower().strip()

    @email.deleter
    def email(self) -> None:
        self._email = ""

    @property
    def full_name(self) -> str:
        """只读计算属性"""
        return f"{self.first_name} {self.last_name}"


u = User("Yu", "Fei", "Test@Example.COM")
print(u.email)       # test@example.com
print(u.full_name)   # Yu Fei

# deleter
del u.email
print(u.email)       # (empty string)

# setter 校验
try:
    u.email = "invalid"
except ValueError as e:
    print(f"Validation: {e}")  # Validation: Invalid email: invalid

3.2 functools.cached_property:缓存计算属性

functools.cached_property(Python 3.8+)是一个非数据描述符,它在首次访问时计算值并将结果写入实例字典。由于非数据描述符的优先级低于实例字典,后续访问会直接从实例字典读取,不再触发计算。

python 复制代码
import functools
import time


class DataAnalyzer:
    """使用 cached_property 缓存昂贵的计算"""

    def __init__(self, data: list):
        self._data = data

    @functools.cached_property
    def statistics(self) -> dict:
        """首次访问时计算,结果缓存在实例字典中"""
        print("  Computing statistics... (expensive operation)")
        time.sleep(0.01)  # 模拟昂贵计算
        n = len(self._data)
        total = sum(self._data)
        mean = total / n if n > 0 else 0
        sorted_data = sorted(self._data)
        median = sorted_data[n // 2] if n > 0 else 0
        return {"count": n, "sum": total, "mean": mean, "median": median}


analyzer = DataAnalyzer([3, 1, 4, 1, 5, 9, 2, 6, 5, 3])

# 第一次访问:触发计算
print(analyzer.statistics)
# Computing statistics... (expensive operation)
# {'count': 10, 'sum': 39, 'mean': 3.9, 'median': 4}

# 第二次访问:直接从实例字典读取,不再计算
print(analyzer.statistics)  # 直接返回结果,无 "Computing..." 输出

# 验证:值确实在实例字典中
print("statistics" in analyzer.__dict__)  # True

# 清除缓存的方式:删除实例字典中的属性
del analyzer.__dict__["statistics"]
# 下次访问会重新计算
print("After cache clear:", "statistics" in analyzer.__dict__)  # False

cached_property 的原理

python 复制代码
# cached_property 的简化实现
class SimpleCachedProperty:
    """非数据描述符:只有 __get__,没有 __set__"""

    def __init__(self, func):
        self.func = func
        self.attrname = func.__name__

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        # 计算值并写入实例字典
        value = self.func(obj)
        obj.__dict__[self.attrname] = value  # 后续访问直接走实例字典
        return value

线程安全注意事项

在 Python 3.12 之前,functools.cached_property 不是线程安全的 。如果多个线程同时首次访问同一个 cached_property,计算函数可能被执行多次。在 Python 3.12 中,cached_property 加入了锁机制来保证线程安全。对于 3.8~3.11,如果需要线程安全的缓存属性,需要自行加锁:

python 复制代码
import threading
import functools


class ThreadSafeCachedProperty:
    """线程安全的缓存属性描述符"""

    _MISSING = object()

    def __init__(self, func):
        self.func = func
        self.attrname = func.__name__
        self.lock = threading.Lock()

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        # 先检查实例字典(无锁,快速路径)
        val = obj.__dict__.get(self.attrname, self._MISSING)
        if val is not self._MISSING:
            return val
        # 未找到,加锁计算
        with self.lock:
            # Double-checked locking
            val = obj.__dict__.get(self.attrname, self._MISSING)
            if val is not self._MISSING:
                return val
            val = self.func(obj)
            obj.__dict__[self.attrname] = val
            return val


class Service:
    @ThreadSafeCachedProperty
    def db_connection(self):
        print("  Creating DB connection...")
        return {"host": "localhost", "port": 5432, "connected": True}


svc = Service()
print(svc.db_connection)  # Creating DB connection...
print(svc.db_connection)  # 直接返回,不再创建

3.3 property vs cached_property 对比

特性 property cached_property
描述符类型 数据描述符 非数据描述符
每次访问是否重新计算 否(首次计算后缓存)
支持 setter
缓存位置 无缓存 实例的 __dict__
__slots__ 兼容 否(需要 __dict__
线程安全(3.8~3.11) 不涉及 不安全
适用场景 需要校验/计算的属性 昂贵的一次性计算

四、实战应用

4.1 类型检查描述符(参数验证)

描述符最经典的应用之一是参数验证。以下是一个可复用的类型+范围检查描述符:

python 复制代码
class Validated:
    """通用验证描述符基类"""

    def __init__(self, **kwargs):
        self.validators = kwargs

    def __set_name__(self, owner, name):
        self.name = name
        self.storage_name = f"_{name}"

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.storage_name, None)

    def __set__(self, obj, value):
        self.validate(value)
        setattr(obj, self.storage_name, value)

    def validate(self, value):
        if "type" in self.validators:
            expected = self.validators["type"]
            if not isinstance(value, expected):
                raise TypeError(
                    f"'{self.name}' must be {expected.__name__}, got {type(value).__name__}"
                )
        if "min_value" in self.validators:
            if value < self.validators["min_value"]:
                raise ValueError(
                    f"'{self.name}' must be >= {self.validators['min_value']}, got {value}"
                )
        if "max_value" in self.validators:
            if value > self.validators["max_value"]:
                raise ValueError(
                    f"'{self.name}' must be <= {self.validators['max_value']}, got {value}"
                )
        if "min_length" in self.validators:
            if len(value) < self.validators["min_length"]:
                raise ValueError(
                    f"'{self.name}' length must be >= {self.validators['min_length']}"
                )
        if "max_length" in self.validators:
            if len(value) > self.validators["max_length"]:
                raise ValueError(
                    f"'{self.name}' length must be <= {self.validators['max_length']}"
                )


class Product:
    name = Validated(type=str, min_length=1, max_length=100)
    price = Validated(type=(int, float), min_value=0)
    quantity = Validated(type=int, min_value=0)

    def __init__(self, name: str, price: float, quantity: int):
        self.name = name
        self.price = price
        self.quantity = quantity

    def __repr__(self) -> str:
        return f"Product(name={self.name!r}, price={self.price}, qty={self.quantity})"


# 正常使用
p = Product("Widget", 9.99, 100)
print(p)  # Product(name='Widget', price=9.99, qty=100)

# 类型检查
try:
    Product(123, 9.99, 100)
except TypeError as e:
    print(f"TypeError: {e}")  # TypeError: 'name' must be str, got int

# 范围检查
try:
    Product("Widget", -1.0, 100)
except ValueError as e:
    print(f"ValueError: {e}")  # ValueError: 'price' must be >= 0, got -1.0

# 长度检查
try:
    Product("", 9.99, 100)
except ValueError as e:
    print(f"ValueError: {e}")  # ValueError: 'name' length must be >= 1

4.2 ORM 字段描述符(类 Django Model Field)

ORM 框架的核心就是描述符。以下简化实现展示了 Django Model Field 的底层原理:

python 复制代码
class Field:
    """ORM 字段基类 -- 描述符实现"""

    field_type = "UNKNOWN"

    def __init__(self, primary_key: bool = False, nullable: bool = False,
                 default=None, max_length: int = None):
        self.primary_key = primary_key
        self.nullable = nullable
        self.default = default
        self.max_length = max_length

    def __set_name__(self, owner, name):
        self.name = name
        self.storage_name = f"_field_{name}"
        # 注册到 owner 类的字段列表中
        if not hasattr(owner, "_fields"):
            owner._fields = {}
        owner._fields[name] = self

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.storage_name, self.default)

    def __set__(self, obj, value):
        if value is None and not self.nullable:
            raise ValueError(f"Field '{self.name}' is not nullable")
        if value is not None:
            value = self.to_python(value)
        setattr(obj, self.storage_name, value)

    def to_python(self, value):
        """子类覆写:类型转换"""
        return value

    def to_sql(self) -> str:
        """生成 SQL 列定义"""
        sql = f"{self.name} {self.field_type}"
        if self.primary_key:
            sql += " PRIMARY KEY"
        if not self.nullable:
            sql += " NOT NULL"
        return sql


class IntegerField(Field):
    field_type = "INTEGER"

    def to_python(self, value):
        if not isinstance(value, int):
            try:
                return int(value)
            except (ValueError, TypeError):
                raise ValueError(f"Cannot convert {value!r} to integer")
        return value


class CharField(Field):
    field_type = "VARCHAR"

    def to_python(self, value):
        value = str(value)
        if self.max_length and len(value) > self.max_length:
            raise ValueError(
                f"Field '{self.name}' max length is {self.max_length}, got {len(value)}"
            )
        return value

    def to_sql(self) -> str:
        base = f"{self.name} {self.field_type}"
        if self.max_length:
            base = f"{self.name} {self.field_type}({self.max_length})"
        if self.primary_key:
            base += " PRIMARY KEY"
        if not self.nullable:
            base += " NOT NULL"
        return base


class FloatField(Field):
    field_type = "REAL"

    def to_python(self, value):
        if not isinstance(value, (int, float)):
            try:
                return float(value)
            except (ValueError, TypeError):
                raise ValueError(f"Cannot convert {value!r} to float")
        return float(value)


class Model:
    """ORM Model 基类"""

    _fields: dict

    def __init__(self, **kwargs):
        for name, field in self.__class__._fields.items():
            if name in kwargs:
                setattr(self, name, kwargs[name])
            elif field.default is not None:
                setattr(self, name, field.default)

    @classmethod
    def create_table_sql(cls) -> str:
        columns = []
        for name, f in cls._fields.items():
            columns.append(f.to_sql())
        return f"CREATE TABLE {cls.__name__.lower()} (\n  " + ",\n  ".join(columns) + "\n);"

    def __repr__(self) -> str:
        fields = ", ".join(
            f"{name}={getattr(self, name)!r}" for name in self.__class__._fields
        )
        return f"{self.__class__.__name__}({fields})"


class UserModel(Model):
    id = IntegerField(primary_key=True)
    name = CharField(max_length=50)
    email = CharField(max_length=100, nullable=True)
    score = FloatField(default=0.0)


# 使用
user = UserModel(id=1, name="Alice", email="alice@example.com", score=95.5)
print(user)  # UserModel(id=1, name='Alice', email='alice@example.com', score=95.5)

# 自动类型转换
user.score = "88.5"  # 字符串自动转 float
print(user.score)    # 88.5
print(type(user.score))  # <class 'float'>

# 生成建表 SQL
print(UserModel.create_table_sql())
# CREATE TABLE usermodel (
#   id INTEGER PRIMARY KEY NOT NULL,
#   name VARCHAR(50) NOT NULL,
#   email VARCHAR(100),
#   score REAL NOT NULL
# );

# nullable 校验
try:
    user.name = None
except ValueError as e:
    print(f"Nullable check: {e}")  # Nullable check: Field 'name' is not nullable

4.3 延迟加载(Lazy Loading)描述符

在 Web 应用中,延迟加载可以避免不必要的数据库查询或 API 调用:

python 复制代码
class LazyLoad:
    """延迟加载描述符:首次访问时才执行加载函数"""

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

    def __set_name__(self, owner, name):
        self.name = name
        self.storage_name = f"_lazy_{name}"

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        # 检查是否已加载
        if not hasattr(obj, self.storage_name):
            # 首次访问:执行加载
            value = self.loader_func(obj)
            setattr(obj, self.storage_name, value)
        return getattr(obj, self.storage_name)

    def __set__(self, obj, value):
        """允许手动设置值(覆盖延迟加载)"""
        setattr(obj, self.storage_name, value)

    def __delete__(self, obj):
        """删除缓存,下次访问重新加载"""
        if hasattr(obj, self.storage_name):
            delattr(obj, self.storage_name)


def _load_profile(user):
    """模拟从数据库加载用户 profile"""
    print(f"  Loading profile for user {user.user_id}...")
    return {"avatar": "default.png", "bio": "Hello!", "user_id": user.user_id}


def _load_permissions(user):
    """模拟从缓存/数据库加载权限"""
    print(f"  Loading permissions for user {user.user_id}...")
    return ["read", "write", "admin"]


class UserEntity:
    profile = LazyLoad(_load_profile)
    permissions = LazyLoad(_load_permissions)

    def __init__(self, user_id: int, name: str):
        self.user_id = user_id
        self.name = name


# 创建用户不会触发任何加载
user = UserEntity(1001, "Alice")
print(f"User created: {user.name}")

# 首次访问 profile 触发加载
print(user.profile)
# Loading profile for user 1001...
# {'avatar': 'default.png', 'bio': 'Hello!', 'user_id': 1001}

# 再次访问不会重新加载
print(user.profile["avatar"])  # default.png(无 "Loading..." 输出)

# 首次访问 permissions 触发加载
print(user.permissions)
# Loading permissions for user 1001...
# ['read', 'write', 'admin']

# 可以手动设置值
user.profile = {"avatar": "custom.png", "bio": "Updated"}
print(user.profile["avatar"])  # custom.png

# 删除缓存,下次访问重新加载
del user.profile
print(user.profile)
# Loading profile for user 1001...
# {'avatar': 'default.png', 'bio': 'Hello!', 'user_id': 1001}

4.4 描述符的高级模式:链式验证

python 复制代码
class ValidatorChain:
    """支持链式验证的描述符"""

    def __init__(self):
        self._validators: list = []

    def __set_name__(self, owner, name):
        self.name = name
        self.storage_name = f"_{name}"

    def type_check(self, expected_type):
        """添加类型检查"""
        def validator(value):
            if not isinstance(value, expected_type):
                raise TypeError(
                    f"'{self.name}' expects {expected_type.__name__}, "
                    f"got {type(value).__name__}"
                )
        self._validators.append(validator)
        return self

    def range_check(self, min_val=None, max_val=None):
        """添加范围检查"""
        def validator(value):
            if min_val is not None and value < min_val:
                raise ValueError(f"'{self.name}' must be >= {min_val}")
            if max_val is not None and value > max_val:
                raise ValueError(f"'{self.name}' must be <= {max_val}")
        self._validators.append(validator)
        return self

    def custom(self, func):
        """添加自定义验证"""
        self._validators.append(func)
        return self

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__.get(self.storage_name)

    def __set__(self, obj, value):
        for validator in self._validators:
            validator(value)
        obj.__dict__[self.storage_name] = value


class Order:
    amount = ValidatorChain().type_check((int, float)).range_check(min_val=0)
    status = ValidatorChain().type_check(str).custom(
        lambda v: None if v in ("pending", "paid", "shipped", "delivered")
        else (_ for _ in ()).throw(ValueError(f"Invalid status: {v}"))
    )

    def __init__(self, amount: float, status: str = "pending"):
        self.amount = amount
        self.status = status

    def __repr__(self) -> str:
        return f"Order(amount={self.amount}, status={self.status!r})"

上面的 status 验证器使用了 lambda 技巧来抛出异常,这在实际代码中可读性不佳。让我们用更清晰的方式实现:

python 复制代码
class StatusValidator:
    """更清晰的链式验证方式"""

    VALID_STATUSES = ("pending", "paid", "shipped", "delivered")

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

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__.get(f"_{self.name}")

    def __set__(self, obj, value):
        if not isinstance(value, str):
            raise TypeError(f"Status must be str, got {type(value).__name__}")
        if value not in self.VALID_STATUSES:
            raise ValueError(f"Invalid status '{value}', must be one of {self.VALID_STATUSES}")
        obj.__dict__[f"_{self.name}"] = value


class OrderV2:
    amount = Validated(type=(int, float), min_value=0)
    status = StatusValidator()

    def __init__(self, amount: float, status: str = "pending"):
        self.amount = amount
        self.status = status

    def __repr__(self) -> str:
        return f"OrderV2(amount={self.amount}, status={self.status!r})"


order = OrderV2(99.99, "pending")
print(order)  # OrderV2(amount=99.99, status='pending')

order.status = "paid"
print(order.status)  # paid

try:
    order.status = "cancelled"
except ValueError as e:
    print(f"Status validation: {e}")
    # Invalid status 'cancelled', must be one of ('pending', 'paid', 'shipped', 'delivered')

try:
    order.amount = -10
except ValueError as e:
    print(f"Amount validation: {e}")  # 'amount' must be >= 0, got -10

五、面试高频题汇总

Q1:描述符协议的工作原理是什么?

A:描述符是实现了 __get____set____delete__ 中至少一个方法的对象。当一个类的类属性是描述符实例时,通过该类的实例访问这个属性会触发描述符协议,而非普通的字典查找。

描述符分两种:

  • 数据描述符 :同时实现了 __get____set__(或 __delete__),优先级高于实例字典
  • 非数据描述符 :只实现了 __get__,优先级低于实例字典

完整的属性查找优先级是:数据描述符 > 实例 __dict__ > 非数据描述符 / 类属性 > __getattr__

__set_name__(self, owner, name) 是 Python 3.6 新增的钩子,在类创建时自动调用,让描述符知道自己被绑定到了哪个属性名上。

Q2:@property 的底层实现机制是怎样的?

A:property 是一个数据描述符 ,它同时实现了 __get____set____delete__

  • __get__ 调用 fget 函数(getter)
  • __set__ 调用 fset 函数(setter),如果没有定义 setter 则抛出 AttributeError
  • __delete__ 调用 fdel 函数

@property 是语法糖,@prop.setter@prop.deleter 分别返回一个新的 property 对象(替换了对应的函数)。因为 property 是数据描述符,它的优先级高于实例字典,所以即使实例的 __dict__ 中有同名键,访问时仍然走 property__get__

Q3:数据描述符和非数据描述符的优先级差异?

A:属性查找时,数据描述符的优先级高于实例字典,而非数据描述符的优先级低于实例字典

这个设计有深远的实际影响:

  • property 是数据描述符,所以它能可靠地拦截所有的属性访问和赋值
  • functools.cached_property 是非数据描述符,所以首次计算后将值写入实例字典,后续访问直接走实例字典(绕过描述符),实现了缓存效果
  • 普通函数也是非数据描述符(实现了 __get__),这就是为什么实例方法通过 obj.method 访问时会变成绑定方法

Q4:__getattr____getattribute__ 的区别?

A:__getattribute__每次 属性访问时都会被调用,它是属性访问的入口。__getattr__ 只在常规查找失败 时才会被调用(即 __getattribute__ 抛出 AttributeError 后才触发)。

使用 __getattribute__ 要极其小心,因为在其内部访问 self 的任何属性都会再次触发 __getattribute__,很容易导致无限递归。正确做法是使用 object.__getattribute__(self, name) 来避免递归。

__getattr__ 更安全也更常用,适合实现动态属性、代理模式、属性默认值等场景。

Q5:functools.cached_property 和 property 有什么区别?使用时需要注意什么?

A:核心区别在于描述符类型:property 是数据描述符(每次访问都重新计算),cached_property 是非数据描述符(首次计算后写入实例字典缓存)。

使用 cached_property 需要注意三点:

  1. 不兼容 __slots__ :它需要实例有 __dict__ 来存储缓存值
  2. 线程安全:Python 3.8~3.11 中不是线程安全的,多线程可能导致重复计算。3.12 之后加入了锁
  3. 缓存失效 :只能通过 del obj.__dict__['attr_name'] 手动清除缓存

本章总结

本文从 CPython 实现层面,系统性地剖析了 Python 属性访问的完整机制:

  1. 属性访问链路obj.attr 的查找顺序是 -- 数据描述符 > 实例 __dict__ > 非数据描述符/类属性 > __getattr____getattribute__ 是整个流程的入口,每次属性访问都会经过它。理解这个优先级链是掌握描述符的关键。

  2. 描述符协议 :描述符是实现了 __get__/__set__/__delete__ 的对象。数据描述符(有 __set____delete__)优先于实例字典,非数据描述符(只有 __get__)低于实例字典。这个设计让 property 能拦截所有属性操作,让 cached_property 能实现缓存机制。

  3. property 进阶property 本质上是一个数据描述符,可以用纯 Python 复现其实现。functools.cached_property 是非数据描述符,适合缓存昂贵计算,但在 Python 3.12 之前不是线程安全的。

  4. 实战应用 :描述符在 ORM 字段定义(Django Model Field)、参数验证、延迟加载等场景中有广泛应用。通过 __set_name__(Python 3.6+)自动获取属性名,描述符的定义变得更加简洁。

核心原则 :描述符是 Python 中 propertyclassmethodstaticmethodsuper 的底层实现机制,也是理解 ORM、表单验证等框架的关键。掌握描述符协议,你就理解了 Python 对象模型最深层的运作方式。


下一篇预告

第 07 篇:元类与类的创建过程 -- Python 最深层的魔法

下一篇文章将进入 Python 元编程的核心地带。你将了解:

  • 类的创建过程type 是所有类的元类,type(name, bases, namespace) 动态创建类的完整流程
  • 元类(Metaclass)metaclass= 参数的传播规则,__init_subclass__ 作为轻量替代
  • 类装饰器 vs 元类:各自的适用场景对比与选型建议
  • abc.ABCMeta 与抽象基类 :抽象方法、虚拟子类注册,以及 Protocol 的结构化子类型

如果说描述符控制的是"属性的行为",那么元类控制的是"类的行为"。理解元类,你就站在了 Python 对象模型的最高点。


Python 后端开发技术博客专栏 | 作者:耿雨飞

本文为专栏第 06 篇,共 25 篇。完整目录请参阅《Python技术博客专栏大纲》。

相关推荐
松☆1 小时前
C++ 算法竞赛题解:P13569 [CCPC 2024 重庆站] osu!mania —— 浮点数精度陷阱与 `eps` 的深度解析
开发语言·c++·算法
weixin_458580122 小时前
如何修改AWR保留时间_将默认8天保留期延长至30天的设置
jvm·数据库·python
耿雨飞2 小时前
Python 后端开发技术博客专栏 | 第 08 篇 上下文管理器与类型系统 -- 资源管理与代码健壮性
开发语言·python
qq_654366982 小时前
C#怎么实现OAuth2.0授权_C#如何对接第三方快捷登录【核心】
jvm·数据库·python
justjinji2 小时前
如何用 CSS 变量配合 JS setProperty 实现动态换肤功能
jvm·数据库·python
老王以为2 小时前
前端重生之 - 前端视角下的 Python
前端·后端·python
2601_949194262 小时前
Python爬虫完整代码拿走不谢
开发语言·爬虫·python
2301_803875612 小时前
C#怎么使用TopLevel顶级语句 C#顶级语句怎么写如何省略Main方法简化控制台程序【语法】
jvm·数据库·python
baidu_340998822 小时前
SQL多维度数据聚合技巧_利用GROUP BY WITH ROLLUP实现
jvm·数据库·python