资讯动态

Python 3.12中dir()排序变化:__dir__魔术方法的底层原理与实战指南

发布时间:2026/10/16 15:12:32 来源:尧图企业网站定制
先把结论放在前面__dir__这个魔术方法在Python整个对象模型里存在感一直不算高但它几乎是所有“探索对象属性”的工具的底层依赖。你平时用的dir()、IDE的自动补全、调试器的变量面板、甚至有些序列化库在收集字段时的内部逻辑最终都会落到__dir__的返回值上。而Python 3.12里dir()的行为悄悄发生了一个不大不小的变化这个变化对大多数人来说可能无感却实实在在影响了一批依赖“列表顺序”的代码。这篇就专门聊聊__dir__我会从3.12的顺序变化讲起再带你把自定义__dir__能做的、不能做的事都捋一遍最后给一些实战里容易踩的坑。1. 一次真实的“顺序翻车”事件几个月前我接手了一个内部工具功能是生成对象的属性清单并打印成表格。代码很简单核心就是for name in dir(obj)按顺序取前几个字段作为表格的固定列。这套逻辑在Python 3.11里跑得好好的但一升级到3.12列顺序突然乱了表格的表头和内容错位。排查了很久最后定位到根因Python 3.12里dir()返回的列表不再保证按字母排序。我当时第一反应是“是不是我代码里有别的地方改了顺序”但反复验证后发现这就是3.12的官方变更。在3.11及更早版本里dir()无论对内置对象还是自定义对象基本都返回一个排好序的列表。3.12起CPython对这个行为做了调整object.__dir__的默认实现不再对结果排序dir()整体上的输出顺序变成“未定义”。依赖排序的代码从此变得不可靠正确做法是自己在外部补一句sorted(dir(obj))。这件事让我意识到很多人写代码时把dir()的排序当成默认契约包括我自己之前也是。但语言版本一升级“隐藏契约”就显形了。所以这篇顺带也想提醒大家尽量不要依赖任何“文档里没写死”的行为。1.1 3.12这次改动到底改了什么确切说3.12的改动不是新增__dir__而是调整了object.__dir__和type.__dir__这两个内置默认实现的内部逻辑。以前它们在返回名字列表之前会对所有属性名做一次排序现在这个排序步骤被移除了。这样做的主要目的是性能优化。你可能会觉得一个排序能有多大开销。但在某些场景里dir()的调用频率是远超你想象的。比如动态代理对象、调试器、自动补全引擎、模板引擎的属性收集这些组件在运行时会频繁反射对象结构。一个大型对象可能有几百个属性每次dir()都做一次O(n log n)的排序累计起来就是可观的CPU消耗。3.12把这个排序砍掉后只返回“自然顺序”的属性名集合配合上内部去重逻辑的优化整体开销明显下降。对普通开发者而言最直观的感受就是如果你写print(dir(obj))3.11是一个整洁的字母排列清单3.12则可能看起来有点“乱”。这不是bug也不代表属性缺失只是官方决定不再替你排序。所有依赖dir()排序的逻辑升级前都应该主动检查。1.2 官方文档里的说法Python官方文档对dir()的说明里有一句关键的话如果对象提供了__dir__()方法它就一定会被调用如果对象没有提供__dir__()方法那么dir()会尽量从对象的__dict__和类型信息中收集属性名。而且文档还明确说“除了已知的模块、类型以外返回的列表不保证排序。”这句话在旧版本里其实有点“名不副实”因为默认实现确实排序了到了3.12文档才真正和实现对齐了。换句话说__dir__的优先级非常高。你只要在类里定义了这个方法dir(实例)的输出就完全由你来定默认的对象属性收集逻辑会被你的自定义结果取代。这既是自由也是责任。2.__dir__的底层原理它到底从哪里来、到哪里去既然__dir__这么重要我就把它的运行机制完整拆开讲一遍。2.1 dir()无参数与有参数时的两条路径先看无参数的情况。直接调用dir()注意是括号里什么都不传Python返回的是当前作用域里的名字列表包括局部变量、全局变量和导入的名字。这个行为不涉及任何__dir__走的是另一套纯作用域收集逻辑。一旦dir()接收了参数比如dir(obj)事情就交给了对象本身。CPython内部执行时会做这样几件事检查对象有没有__dir__属性。有的话直接调用它把返回的结果转成列表。没有的话走默认的收集流程。对绝大多数自定义类对象来说它从object那里继承到了一个默认的__dir__所以第3步实际很少走到。但默认__dir__具体收集什么很多人可能没仔细想过。2.2 默认object.__dir__收集属性时的几个来源默认实现下一个实例对象的dir()结果大致是这些名字的并集实例自身的__dict__里的所有键也就是self.xxx ...设置的那些实例属性。实例的类以及所有基类沿MRO向上的__dict__里的键包括方法名、类属性、描述器。一些隐含属性比如__class__、__dict__、__weakref__、__doc__等。如果类定义了__slots__插槽属性名也会被纳入。这些名字收集完成后旧版本会去重并按字母排序3.12后去重逻辑还在但排序被移除了。所以3.12下看到的顺序更像是“先实例属性、再类属性、再内置属性”的插入顺序但也不是严格保证因为内部实现是几个列表拼接后的产物。对于类对象本身比如dir(SomeClass)走的是type.__dir__它收集的是类自身的__dict__加上所有基类的__dict__类似于dir一个元类实例。对于模块对象dir(module)返回的是模块命名空间module.__dict__里的键。这些同样不保证排序。2.3 自定义__dir__是如何被调用的当你在类里定义def __dir__(self): ...后所有对dir(实例)的调用都会先走到你的方法。返回值需要是序列官方推荐list实际上tuple、set也没问题dir()内部会尝试做list()转换。如果返回的不是可迭代对象会抛出TypeError。有一点值得注意__dir__是一个实例方法所以你可以在里面访问self的任意状态根据对象当前的实际内容动态生成名字列表。这正是它比__dict__更灵活的地方——你不仅可以列出真实存在的属性还可以列出“希望外界看到”的虚拟属性名。3. 定义__dir__的正确姿势写一个自定义__dir__真的不难但想要写对、写稳、不坑后续维护者还是有一些门道。3.1 最简实现和返回类型陷阱先看一个最简版本class Demo: def __init__(self): self.name demo self._secret hidden def __dir__(self): return [name, extra_virtual]这个类的实例调用dir()只会看到name和extra_virtual两个名字_secret不见了__dict__、__class__等内置属性也不见了。这种写法适合做“白名单模式”。但立刻就有个问题__dir__返回的列表里如果塞了一个实际不存在的属性名dir()不会报错它只会把这个名字当作一个普通字符串返回。后续如果你用hasattr(obj, extra_virtual)去检查只要没有自定义__getattr__结果会是False。也就是说dir()的返回结果和属性是否真实存在之间并没有强制绑定关系这给了我们很大的控制空间但也容易让调用方困惑。再说返回类型。虽然set和tuple都能凑合用但我强烈建议始终返回list原因有两个一是兼容性有些第三方库会把dir()的结果直接当作list使用往里追加元素二是可控性list的顺序是你自己写死的顺序而set的迭代顺序在Python里虽然稳定但不够直观团队其他成员不容易一眼看懂。3.2 先拿默认结果再加工super().dir()最常见也最稳妥的做法不是从零构造列表而是先调用默认实现拿到完整属性集合再做过滤、追加和排序。class ConfigView: def __init__(self, data: dict): self.data data def __dir__(self): base_names super().__dir__() public_names [n for n in base_names if not n.startswith(_)] extra_fields [ffield_{key} for key in self.data.keys()] return sorted(set(public_names) | set(extra_fields))这里有个细节super().__dir__()调用的是什么在当前类的MRO里如果父类没有自定义__dir__最终会落到object.__dir__返回完整的默认属性名集合。拿到这个集合后你可以过滤掉私有属性也可以追加自己需要的虚拟字段。最后用sorted(set(...))整理一下确保输出稳定、不重复、顺序可预期。我特别推荐在自定义__dir__里做“去重排序”这两步收尾工作。原因很简单Python 3.12后默认实现不再保证排序了但你的自定义实现仍然可以给调用方一个稳定的顺序。这样做既不依赖语言版本的行为差异也让IDE的自动补全体验更友好。3.3 属性可见性过滤与安全边界自定义__dir__经常被用来“隐藏”某些内部属性。比如一个ORM模型的实例dir()里如果混入一堆_sa_instance_state、_internal_cache之类的私有字段对使用方来说就是噪音。通过__dir__过滤掉下划线开头的属性是提升API可读性的有效手段。但这里必须说清楚__dir__不是安全机制它只是“展示层”的过滤器。属性只要真实存在用户依然可以通过obj._secret直接访问也可以通过vars(obj)拿到完整的__dict__。如果你真的想限制外部访问需要用__getattr__配合__setattr__做更严格的控制。__dir__的定位应该是“告诉别人我对外暴露哪些接口”而不是“阻止别人拿到内部数据”。3.4 与__getattr__、__getattribute__的协同动态属性的完整拼图其实是__dir__加上__getattr__。__getattr__负责“当常规属性查找失败时兜底返回动态值”__dir__负责“把动态值可能对应的名字告诉外界”。class DynamicNamespace: def __init__(self): self._storage {} def __getattr__(self, name): if name in self._storage: return self._storage[name] raise AttributeError(name) def __dir__(self): return sorted(set(super().__dir__()) | set(self._storage.keys()))这里最经典的坑就是如果你只实现__getattr__而不实现__dir__那么dir()的结果里永远看不到那些动态属性。很多人通过getattr(obj, xxx)调用动态属性时一切正常但IDE补全却一个都不提示就是在__dir__上漏掉了。反过来如果你实现了__dir__但忘记__getattr__dir()里列出的名字通过hasattr检查就可能为False造成“看起来有摸起来没有”的诡异现象。正确姿势是两者配套。4. 实战场景三个我认为最有价值的__dir__用法理论讲再多不如看几个真实能落地的场景。4.1 场景一调试工具里的对象状态导出我做一个内部调试面板时需要在面板上列出某个复杂对象的所有关键状态字段并按照“重要程度”排序而不是字母顺序。默认的dir()做不到这一点但我可以用__dir__精确控制展示顺序。class ServiceStatus: def __init__(self): self.healthy True self.connected_workers 0 self.last_error self._metrics {} def __dir__(self): priority [healthy, connected_workers, last_error] extras [name for name in super().__dir__() if name not in set(priority)] return priority sorted(extras)调试面板拿到dir(service)后前三个字段永远是健康状态、连接数、错误信息后面的扩展字段再按字母排。这对需要快速读取关键信息的运维场景很有用也让面板的列顺序变得可控。4.2 场景二模拟一个动态模块的命名空间有时候你想让一个对象用起来像模块或者做某种动态代理让外部可以通过属性名访问内部的键值。在这类设计里__dir__配合__getattr__能提供一致的“虚拟属性”体验。class SettingProxy: def __init__(self, settings: dict): self._settings settings def __getattr__(self, name): try: return self._settings[name] except KeyError: raise AttributeError(name) from None def __dir__(self): return sorted(set(self._settings.keys()) | {name for name in super().__dir__() if not name.startswith(_)})这样外部调用方可以写proxy.timeout直接拿到配置用dir(proxy)就能看到所有配置项IDE补全也能给出候选。某些配置中心、特性开关的客户端就是这么做的。这个模式在标准库的argparse.Namespace、types.SimpleNamespace上也有类似影子但SimpleNamespace没有动态过滤能力自定义类可以做得更收敛。4.3 场景三让IDE自动补全更友好很多编辑器的Python补全引擎比如PyCharm的补全机制在补全一个对象的属性时会参考dir()的返回结果。因此__dir__可以直接影响“用户输入点号后编辑器弹出哪些候选”。我接手过一个老项目某个类为了兼容各种来源的数据把属性都塞进了__dict__没有固定的类属性声明。用起来没问题但写代码时补全几乎全靠手敲。后来我在类里加了一个稳定的__dir__把项目里最常用的几个字段名固定返回补全立刻好用了起来。这个操作成本极低但对团队开发效率的提升非常实在。不过也要注意别把__dir__当补全清单来堆砌一堆从未实现的属性名那样只会让使用者在补全后踩AttributeError。一致性原则是把__dir__当作“对外承诺”列出来的名字要么真实存在要么__getattr__能兜底返回。5. 常见问题与排查实录结合我自己踩过的坑和帮别人排查过的案例整理几个高频问题。5.1 为什么自定义__dir__后dir()的结果没变化最可能的原因你定义__dir__的类和你实际调用dir()的实例不是同一个类。尤其是涉及继承时如果你在子类实例上调用了dir()而__dir__定义在父类上按MRO规则是能生效的反过来如果子类自己定义了容易被忽略。另一个可能你调用的是dir()无参数形式那当然和对象的__dir__无关。还有一种隐蔽情况对象重写了__getattribute__导致内部获取__dir__时走了别的方法最终拿到的是另一个对象。这种魔改太深建议直接不要用dir()去探测改用vars()或专门的接口。5.2 dir()里看不到动态属性这是最常见的问题根源就是我上面说的“只实现了__getattr__没实现__dir__”。如果对象的所有对外属性都是动态计算的dir()默认结果里自然搜不到。解决方式也简单在__dir__里显式加上动态名字的集合。5.3 依赖dir()排序的代码在3.12挂掉了3.12之前很多人写sorted_dir dir(obj)然后直接按返回顺序取前N个。3.12之后这就是定时炸弹。排查时会发现某些环境没问题、某些环境顺序乱原因是返回顺序和对象的属性构造顺序、内部实现细节绑定随时可能变。正确的迁移方式凡是需要稳定顺序的地方统一改成sorted(dir(obj))。如果有性能顾虑可以只在真正需要排序的时候才做。帮别人排查时我还发现有些代码会用list(dir(obj))转一下假装“安全”其实这毫无帮助转换不能恢复排序。5.4__dir__抛异常了__dir__里如果抛异常dir()会把异常原样抛给调用方。比如你在这个方法里访问了一个不存在的配置项又没有兜底整个调用链就会炸掉。建议__dir__实现做到两个“绝不”绝不依赖外部资源绝不抛出非AttributeError的异常。如果你在方法里做了网络请求或文件读取一旦超时用户的dir()都会跟着超时。把它当成纯内存操作别干重活。5.5 想让dir()排得干净又整齐有个小技巧自定义__dir__返回前用sorted(set(...))处理一遍。这样既去重又排序还能把3.12默认行为带来的顺序不确定性抹平。虽然官方不保证排序但你自己保证了对调用方来说就是最好的契约。6. 最后再分享一个我一直在用的小技巧我以后写“对外暴露属性较多”的类时会习惯性加一段__dir__哪怕只是简单地把默认结果过滤掉私有属性。这会让dir()的输出显得非常干净特别是在REPL里探索对象的时候。步骤就三行第一行拿默认结果第二行过滤下划线开头第三行sorted(set(...))收尾。成本极低收益立竿见影后来维护的人一眼就能看懂这类对象公开了哪些能力。另外如果你在做框架或者库__dir__其实也是一种轻量的“接口文档”通过dir(实例)看到什么用户就会默认这些是可用的API。既然这样不如把它当作API设计的一部分来对待而不是一个可有可无的魔术方法。真正用好了__dir__你的对象在自己和别人的眼里都会更清晰。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑