一个看似矛盾的报错,背后是 Python 描述符协议、
dir()语义与对象初始化路径的经典交织。本文以 Pdb 调试片段为起点,举一反三,梳理同类问题的通用排查与设计模式。
1. 引子:一个"自相矛盾"的报错
在 Pdb 中调试时,你可能见过这样的场景:
python
(Pdb) dir(sub)
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__',
'__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__',
'__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__',
'__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__',
'__str__', '__subclasshook__', '__weakref__', '_arch', '_id', 'arch',
'children', 'download_task', 'id']
(Pdb) print(sub.children)
*** AttributeError: 'KojiBuildArchTask' object has no attribute '_children'
dir(sub) 明明白白列出了 children,但访问 sub.children 却报错说 _children 不存在。初学者容易困惑:既然 children 存在,为什么还会报错?而且报的还不是 children,而是 _children。
这不是 Python 的 bug,而是属性查找机制在告诉你:children 很可能是一个 property,它的 getter 内部依赖了尚未初始化的 _children。
2. 第一反应 vs 真相:dir() 不会告诉你一切
dir(obj) 返回的是对象及其所属类上可发现的属性名列表。它的实现大致是:
- 收集实例
obj.__dict__的键; - 收集类及其 MRO 上所有类的
__dict__的键; - 如果类定义了
__dir__,则调用它; - 排序后返回。
关键在于:dir() 返回的是名字 ,不是值 ,也不保证访问每个名字都能成功。它把类上的 property、方法、描述符、类属性都算进去。所以 children 出现在 dir(sub) 中,只说明"这个类上有一个叫 children 的东西",并不说明 sub.__dict__ 里有 children。
真正决定 sub.children 行为的是 Python 的属性查找协议。
3. 属性查找机制:__getattribute__ 与描述符
当你写 sub.children 时,Python 实际调用的是:
python
type(sub).__getattribute__(sub, 'children')
object.__getattribute__ 的查找顺序大致如下:
- 数据描述符 (定义了
__get__和__set__或__delete__)优先于实例字典; - 然后查实例
sub.__dict__; - 再查类及其 MRO 上的非数据描述符或普通类属性;
- 如果找不到,调用
__getattr__(如果定义了); - 否则抛出
AttributeError。
property 是一个数据描述符 ,它同时定义了 __get__、__set__、__delete__。因此,只要类上定义了 children = property(...),访问 sub.children 就一定会触发 property.__get__,也就是 getter,而不会去实例字典里找 children。
如果 getter 长这样:
python
@property
def children(self):
return self._children
那么它会继续访问 self._children。此时如果 sub.__dict__ 中没有 _children,且类上也没有 _children,就会抛出:
text
AttributeError: 'KojiBuildArchTask' object has no attribute '_children'
所以,报错信息中的 _children 才是真正缺失的属性,而 children 只是一个入口。
4. 如何正确调试:不要用 getattr 触发副作用
在 Pdb 中,如果你想确认 children 到底是实例属性还是类上的 property,直接 print(sub.children) 会触发 getter,可能产生副作用(懒加载、数据库查询、网络请求等)。更安全的做法是使用 inspect.getattr_static:
python
(Pdb) import inspect
(Pdb) inspect.getattr_static(sub, 'children')
<property object at 0x...>
getattr_static 不会触发描述符协议,它直接从实例 __dict__ 和类 MRO 中查找原始对象。你可以借此判断:
- 如果是
property对象,说明是描述符; - 如果是普通值,说明是实例或类属性;
- 如果抛出
AttributeError,说明名字根本不存在。
同时,查看实例字典:
python
(Pdb) pp sub.__dict__
如果里面有 _arch、_id,但没有 _children,基本可以确认初始化不完整。
再看 property 的源码:
python
(Pdb) import inspect
(Pdb) print(inspect.getsource(type(sub).children.fget))
以及类的 __init__:
python
(Pdb) print(inspect.getsource(type(sub).__init__))
这样就能定位到是 __init__ 漏设了 _children,还是对象走了非标准构造路径。
5. 举一反三:同类陷阱的 N 种场景
这个案例不是孤例。只要涉及描述符、动态属性或非标准初始化,都可能出现类似"属性在 dir() 中但访问失败"的现象。以下场景值得警惕。
5.1 懒加载 property
python
class Order:
def __init__(self, order_id):
self.order_id = order_id
self._items = None
@property
def items(self):
if self._items is None:
self._items = self._load_items()
return self._items
如果 __init__ 忘了设 self._items = None,访问 order.items 就会报 AttributeError: 'Order' object has no attribute '_items'。修复方式是在 __init__ 中初始化,或在 getter 中使用 getattr(self, '_items', None) 兜底。
5.2 cached_property
functools.cached_property 也是非数据描述符,它把计算结果写入实例 __dict__。如果类定义了 __slots__ 但没有包含对应字段,或者实例被 __setattr__ 限制,就可能写入失败。典型报错:
text
AttributeError: 'X' object has no attribute 'y'
排查时要检查 __slots__ 和自定义 __setattr__。
5.3 __getattr__ 动态属性
python
class Dynamic:
def __getattr__(self, name):
return self._data[name]
如果 _data 本身未初始化,访问任何未定义属性都会递归触发 __getattr__,最终报 _data 不存在。注意 __getattr__ 只在正常查找失败后调用,不要在内部访问未初始化的 self 属性。
5.4 ORM 延迟加载
Django、SQLAlchemy 等 ORM 常用描述符实现延迟加载。例如 Django 的 ForwardManyToOneDescriptor。如果对象是通过 Model.construct() 或反序列化创建的,可能绕过了 __init__,导致内部缓存字段未设置。访问关系属性时就会报 _state 或 _cached_... 不存在。
5.5 dataclass 的 field(init=False)
python
@dataclass
class Config:
name: str
_cache: dict = field(init=False, default_factory=dict)
如果子类覆盖了 __init__ 却没有调用 super().__init__(),_cache 就不会被设置。访问依赖它的 property 时就会报错。
5.6 多继承与 Mixin 初始化顺序
Mixin 常定义 property,但依赖宿主类初始化某些字段。如果 MRO 中某个 __init__ 没有调用 super().__init__(),链条断裂,字段缺失。这类问题在协作式多继承中尤为隐蔽。
6. 设计建议:让 property 更健壮
作为 Python 开发者,我们可以从设计上减少这类问题:
-
在
__init__中显式初始化所有 property 依赖的私有字段 ,哪怕只是设为None。 -
property getter 使用防御性访问:
python@property def children(self): return getattr(self, '_children', None)如果语义允许,返回空列表或空字典更好。
-
懒加载 property 要区分"未加载"和"已加载但为空",用哨兵值:
python_UNSET = object() class X: def __init__(self): self._children = _UNSET @property def children(self): if self._children is _UNSET: self._children = self._load() return self._children -
避免在
__getattr__中访问未初始化的self属性,可先设置类级默认值。 -
使用
__slots__时确保 property 写入的字段在__slots__中。 -
为反序列化、ORM、复制等非标准构造路径提供
__setstate__、from_db、__reduce__等钩子,确保字段完整。
7. 调试清单与最佳实践
遇到 AttributeError: 'X' object has no attribute '_y',但 dir(X) 中似乎有 y 时,按以下清单排查:
inspect.getattr_static(obj, 'y'):判断y是 property、普通属性还是不存在。pp obj.__dict__:查看实例真正拥有的字段。type(obj).__mro__:确认类继承链。inspect.getsource(type(obj).y.fget):查看 property getter 依赖了哪些私有字段。inspect.getsource(type(obj).__init__):检查初始化是否完整。- 检查对象是否通过
__new__、copy.copy、pickle.loads、ORMconstruct等路径创建。 - 检查
__getattr__、__getattribute__、__setattr__是否被重写。 - 检查
__slots__是否遗漏字段。
在 Pdb 中临时修复可以:
python
(Pdb) !obj._y = None # 或 [] / {}
但正式修复必须回到初始化逻辑或 property 设计。
8. 结语:从现象到模式
dir() 显示有 children,访问却报 _children 不存在------这个现象的本质是 Python 属性查找协议中描述符优先于实例字典 ,而 property 的 getter 又依赖了未初始化的私有字段。
举一反三,任何"属性存在但访问失败"的问题,都可以沿着三条线排查:
- 属性来源 :实例字典、类字典、描述符、
__getattr__; - 初始化路径 :
__init__是否执行、是否完整、是否被绕过; - 描述符行为:property、cached_property、ORM 描述符的副作用与依赖。
理解这些机制后,你不仅能快速定位当前报错,还能在设计和代码审查中提前避免同类陷阱。Python 的动态性是把双刃剑,而描述符协议正是其最强大也最容易踩坑的核心之一。掌握它,你就能从"调试报错"升级为"设计健壮系统"。