
本文详解如何用自定义 descriptor 正确实现真正可初始化、不可篡改的只读属性,并解决嵌套可变对象(如列表)的深层修改问题。
本文详解如何用自定义 descriptor 正确实现真正可初始化、不可篡改的只读属性,并解决嵌套可变对象(如列表)的深层修改问题。
在 Python 中,@property 是实现只读属性最常用的方式,但当项目中存在大量类似逻辑(如多个类需统一管理只读字段)时,重复编写 @property 显得冗余且难以维护。此时,使用 descriptor 封装只读行为是更优雅、可复用的方案。然而,直接照搬 property 的语义到 descriptor 并不简单——常见误区是让 __set__ 无条件抛出异常,导致 __init__ 初始化失败。
✅ 正确的只读 descriptor 实现
核心原则:允许首次赋值(即初始化),禁止后续修改。这要求 __set__ 方法区分“首次设置”与“重复赋值”:
class ReadOnly:
def __set_name__(self, owner, name):
# 使用私有名称存储实际值,避免 descriptor 自身被实例属性覆盖
self._name = f"_{name}"
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self._name, None)
def __set__(self, instance, value):
# 若私有属性已存在,说明已被初始化过 → 禁止修改
if hasattr(instance, self._name):
raise AttributeError(f"Cannot modify read-only attribute '{self._name[1:]}'")
# 否则允许初始化(如 __init__ 中首次赋值)
setattr(instance, self._name, value)
def __delete__(self, instance):
raise AttributeError(f"Cannot delete read-only attribute '{self._name[1:]}'")
? 关键点:
__set_name__中将底层存储名设为_name(如top→_top),确保 descriptor 不与实例属性同名冲突;__set__通过hasattr(instance, self._name)判断是否已初始化,而非检查instance.__dict__(更健壮,兼容__slots__类)。
使用示例:
class A:
n = ReadOnly()
top = ReadOnly()
def __init__(self, n):
self.n = n # ✅ 首次赋值成功
self.top = -1 # ✅ 首次赋值成功
def increase(self):
# 注意:此处 self.top += 1 实际触发 __get__ + __set__ → 会报错!
# 正确做法是直接操作底层属性:
self._top += 1 # ✅ 允许内部修改
a = A(7)
print(a.top) # → -1
a.increase()
print(a.top) # → 0
a.top = 100 # ❌ AttributeError: Cannot modify read-only attribute 'top'
⚠️ 注意:self.top += 1 等价于 self.top = self.top + 1,会触发 __set__ → 报错。因此,只读 descriptor 仅约束外部访问,内部逻辑仍需直接操作底层私有属性(如 self._top)。
?️ 进阶:防止可变对象(如 list)的深层修改
只读 descriptor 能阻止 s.S = [...],但无法阻止 s.S[0] = x —— 因为 s.S 返回的是原列表对象引用,其内部修改不经过 descriptor 的 __set__。
若需彻底冻结可变对象,有以下两种策略:
方案一:返回不可变代理(推荐)
使用 types.MappingProxyType(仅适用于 dict)或自定义只读包装器。对列表,可返回 tuple 或封装为只读视图:
from collections.abc import Sequence
class ReadOnlyList(Sequence):
def __init__(self, data):
self._data = list(data) # 深拷贝防御原始引用
def __getitem__(self, index):
return self._data[index]
def __len__(self):
return len(self._data)
def __repr__(self):
return f"ReadOnlyList({self._data!r})"
# 在 descriptor 中返回只读视图
class ReadOnly:
# ... __get__, __set__ 同上 ...
def __get__(self, instance, owner):
if instance is None:
return self
value = getattr(instance, self._name, None)
# 对 list 类型自动包装为只读视图
if isinstance(value, list):
return ReadOnlyList(value)
return value
方案二:深度冻结(谨慎使用)
初始化时对可变对象做深拷贝并转为不可变类型(如 tuple),适合数据量小、结构简单的场景:
import copy
def __set__(self, instance, value):
if hasattr(instance, self._name):
raise AttributeError(f"Cannot modify read-only attribute '{self._name[1:]}'")
# 若是 list/dict,存为不可变副本
if isinstance(value, list):
value = tuple(value)
elif isinstance(value, dict):
value = frozenset(value.items())
setattr(instance, self._name, value)
? 提示:方案一更灵活,保留原始类型接口;方案二更彻底但牺牲了可变性,需按业务权衡。
✅ 总结
- 只读 descriptor 的
__set__必须区分 初始化 与 重赋值,利用hasattr(instance, private_name)判断; - 底层存储名应与 descriptor 名不同(如
_nvsn),避免命名冲突; -
__get__应支持None实例(类访问)和正常实例访问; - 对可变对象的“只读”,需额外封装(如只读代理)或冻结(如转
tuple),descriptor 本身无法拦截其内部操作; - 最终目标:对外提供安全的只读接口,对内保留必要的可变能力,兼顾安全性与实用性。
通过以上设计,你不仅能替代 @property 实现统一的只读逻辑,还能构建出可扩展、易维护的属性管控体系。
Python免费学习笔记(深入):立即使用
在学习笔记中,你将探索 Python 的核心概念和高级技巧!











