描述符:从数据、非数据到内置装饰器
一、简介
简单来说,描述符就是 Python 里一种“懂规矩的工具类”——这里的“规矩”就是描述符协议,只要一个类实现了 __get__(取值)、__set__(赋值)、__delete__(删除)这三个特殊方法中的至少一个,它就成了描述符,能帮我们控制属性的访问逻辑。
打个比方,描述符就像属性的“管家”:有的管家能管“拿东西”和“放东西”(数据描述符),有的只能管“拿东西”(非数据描述符),还有些是 Python 自带的“现成管家”(内置装饰器),不用我们自己调教就能用。接下来就分三条线,用具体例子讲清楚这三类“管家”。
二、第一条线:数据描述符(能管“读+写”的全能管家)
数据描述符是“全能管家”,因为它同时实现了 __get__ 和 __set__ 方法,既能控制属性的“取值”,也能控制“赋值”,特别适合需要给属性加“限制条件”的场景。
通俗例子:给“年龄”加个“不能为负”的限制
比如我们要定义一个“Person”类,其中“age”属性必须是正整数,负数不允许赋值。这时候用数据描述符就能轻松实现:
class PositiveInt:# 记录属性名(比如"age")def __set_name__(self, owner, name):self.name = name# 取值:从实例的__dict__里拿属性值def __get__(self, instance, owner):return instance.__dict__.get(self.name, 0) # 没值时默认返回0# 赋值:先验证,符合条件才存def __set__(self, instance, value):if not isinstance(value, int) or value < 0:raise ValueError("年龄必须是正整数!")instance.__dict__[self.name] = value# 用数据描述符定义Person类的age属性
class Person:age = PositiveInt() # age指向数据描述符实例# 测试:符合条件的赋值能成功,不符合的会报错
p = Person()
p.age = 25 # 没问题,存进去了
print(p.age) # 25(触发__get__)p.age = -5 # 报错:ValueError: 年龄必须是正整数!
这里的 PositiveInt 就是数据描述符——它像管家一样,每次给 age 赋值前都会“检查”,确保值是正整数,完美实现了属性的访问控制。
三、第二条线:非数据描述符(只管“读”的半程管家)
非数据描述符是“半程管家”,它只实现了 __get__ 方法,只能控制“取值”逻辑,没法管“赋值”和“删除”。适合做“只读属性”或“每次访问都自动计算”的属性。
通俗例子1:固定的“商品默认折扣”
比如电商系统里,所有商品的默认折扣都是 0.9(九折),这个值不能改,用非数据描述符就很合适:
class FixedDiscount:def __init__(self, discount):self.discount = discount # 初始化固定折扣# 取值:每次访问都返回固定折扣def __get__(self, instance, owner):return self.discount# 用非数据描述符定义商品类的默认折扣
class Goods:default_discount = FixedDiscount(0.9) # 默认九折# 测试:取值能拿到固定值,赋值不生效
apple = Goods()
print(apple.default_discount) # 0.9(触发__get__)# 尝试改折扣,看似没报错,但实际没生效
apple.default_discount = 0.8
print(apple.default_discount) # 还是0.9(非数据描述符没__set__,赋值只存在实例字典里,不影响__get__)
通俗例子2:每次访问都更“新”的当前时间
再比如需要一个“当前时间”属性,每次访问都要拿到最新的时间,而非数据描述符能做到“实时计算”:
import timeclass CurrentTime:# 取值:每次访问都重新获取当前时间def __get__(self, instance, owner):return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())# 用非数据描述符定义“时间工具类”
class TimeTool:now = CurrentTime()# 测试:两次访问时间不一样
tool = TimeTool()
print(tool.now) # 第一次访问:2024-10-01 14:30:00
time.sleep(2) # 等2秒
print(tool.now) # 第二次访问:2024-10-01 14:30:02(时间更新了)
这里的 CurrentTime 就是非数据描述符——它不管赋值,只负责每次取值时“算一次最新时间”,很适合这类“动态计算”的场景。
四、第三条线:内置装饰器(Python自带的“现成管家”)
前面两种描述符都需要我们自己写类、实现 __get__/__set__,但 Python 早就帮我们封装好了一批“现成管家”——就是 @property、@classmethod、@staticmethod 这三个内置装饰器,它们本质都是简化版的描述符,不用我们手动写特殊方法,直接用装饰器就能实现常用功能。
通俗例子1:@property——快速做“计算属性”(含@属性名.setter)
@property 默认是“只读属性”,但通过 @属性名.setter 装饰器,我们能给属性加“赋值逻辑”,实现“可读可写”且带验证的属性,相当于简化版的数据描述符。
比如定义一个“学生”类,“分数”属性需要满足:取值时返回分数,赋值时必须是 0-100 的整数,超出范围就报错:
class Student:def __init__(self, name):self.name = name# 用下划线开头的变量存实际值,避免和@property装饰的属性名冲突self._score = 0# 1. 定义“取值”逻辑:@property装饰的方法是“ getter ”@propertydef score(self):print(f"获取{self.name}的分数")return self._score# 2. 定义“赋值”逻辑:@score.setter装饰的方法是“ setter ”@score.setterdef score(self, value):# 赋值前先验证:必须是整数,且在0-100之间if not isinstance(value, int):raise TypeError("分数必须是整数!")if value < 0 or value > 100:raise ValueError("分数必须在0-100之间!")print(f"给{self.name}设置分数:{value}")self._score = value# 测试:取值和赋值都触发对应的逻辑
s = Student("小明")
# 赋值:触发@score.setter装饰的方法
s.score = 95 # 打印:给小明设置分数:95
# 取值:触发@property装饰的方法
print(s.score) # 打印:获取小明的分数 → 输出95# 测试错误情况
s.score = 105 # 报错:ValueError: 分数必须在0-100之间!
s.score = "优秀" # 报错:TypeError: 分数必须是整数!
这里要注意两个关键点:
- 实际存储值用
_score(下划线开头,约定为“私有变量”),避免和@property装饰的score重名,否则会触发无限递归; @score.setter必须跟在@property后面,名字要和@property装饰的方法一致(都是score),才能绑定到同一个属性上。
本质上,@property + @属性名.setter 就是 Python 帮我们封装了数据描述符的 __get__ 和 __set__ 方法,不用自己写完整的描述符类,就能实现带验证的读写属性。
通俗例子2:@classmethod——“用类调用的方法”
比如需要一个“从字符串创建日期对象”的功能,方法要和“类”绑定,而不是和实例绑定,用 @classmethod 就行:
class Date:def __init__(self, year, month, day):self.year = yearself.month = monthself.day = day# 用@classmethod定义“类方法”,第一个参数是cls(代表类本身)@classmethoddef from_str(cls, date_str):year, month, day = map(int, date_str.split("-"))return cls(year, month, day) # 用cls创建实例# 测试:直接用类调用方法,不用先创建实例
date1 = Date.from_str("2024-10-01")
print(date1.year, date1.month, date1.day) # 2024 10 1
@classmethod 本质是个非数据描述符,它把方法的“调用者”绑定到类上,让方法能直接操作类相关的逻辑。
通俗例子3:@staticmethod——“类里的工具函数”
比如类里需要一个“判断数字是否为偶数”的工具函数,不用访问类或实例的属性,用 @staticmethod 最合适:
class MathTool:# 用@staticmethod定义“静态方法”,没有cls或self参数@staticmethoddef is_even(num):return num % 2 == 0# 测试:类和实例都能调用,不用依赖类/实例属性
print(MathTool.is_even(4)) # True(类调用)
mt = MathTool()
print(mt.is_even(5)) # False(实例调用)
@staticmethod 也是个非数据描述符,它更像“挂在类里的普通函数”,不用绑定类或实例,单纯做工具逻辑。
五、描述符的常见认识误区
很多人刚学描述符时会搞混概念,这里澄清三个最常见的误区:
误区1:描述符是“标识符”
澄清:标识符是变量名、类名这种“名字标签”(比如 age、Person),而描述符是“有功能的工具类/实例”(比如 PositiveInt 类、PositiveInt() 实例)——前者是“名字”,后者是“名字指向的东西”,本质完全不同。
比如 class Person: age = PositiveInt() 中:
age是标识符(给属性起的名字);PositiveInt()是描述符实例(真正控制age访问的“管家”);- 关系是:
age这个“名字”,指向了描述符实例这个“管家”,而不是描述符本身是标识符。
误区2:只有自定义类才是描述符
澄清:前面讲的 @property、@classmethod、@staticmethod 这三个内置装饰器,本质都是 Python 封装好的描述符——它们已经帮我们实现了 __get__ 等方法,只是不用我们手动写而已,属于“现成的描述符”。
比如 @property 装饰的方法,底层会生成一个 property 类的实例,而 property 类本身就实现了 __get__ 和 __set__(当用了 @属性名.setter 时),是标准的数据描述符。
误区3:非数据描述符完全不能“写”属性
澄清:非数据描述符只是没实现 __set__,没法自定义“赋值逻辑”,但不是不能给属性赋值——赋值会直接把值存到实例的 __dict__(实例自己的字典)里,只是不会触发描述符的任何逻辑。
比如前面的 FixedDiscount 例子:
apple = Goods()
apple.default_discount = 0.8 # 赋值不会触发描述符的__set__(因为没有)
print(apple.__dict__) # {'default_discount': 0.8}(值存在实例字典里)
print(apple.default_discount) # 还是0.9(访问时优先用描述符的__get__,忽略实例字典的 value)
简单说:非数据描述符的“不能写”,是“不能按自定义逻辑写”,不是“完全不能赋值”。
六、三者对比(帮你快速选“管家”)
| 类型 | 实现方法要求 | 能控制的操作 | 通俗用例 | 核心优势 |
|---|---|---|---|---|
| 数据描述符 | 必须有__get__+set | 读、写(可加验证) | 年龄(不能负)、工资(有下限) | 能严格控制属性赋值,适合强约束场景 |
| 非数据描述符 | 只有__get__ | 只读(可动态计算) | 固定折扣、实时时间 | 实现简单,适合只读/动态计算场景 |
| 内置装饰器 | 不用自己实现方法 | 按装饰器功能定 | @property+@score.setter控分数、@classmethod创建实例 | 开箱即用,不用写复杂描述符类 |
| (@property可加@属性名.setter) |
简单总结:如果需要强约束(比如验证)且想自定义完整逻辑,选数据描述符;如果只是只读/动态计算,选非数据描述符;如果是日常简单的读写控制(如分数验证)或类方法、工具函数,直接用内置装饰器(@property+@setter、@classmethod、@staticmethod)就够了——三者都是描述符的“不同形态”,核心都是帮我们更好地控制属性和方法的访问。