☰
Python 3.12中dir()排序变化:__dir__魔术方法的底层原理与实战指南
2026/10/16 15:12:16 网站建设 项目流程

先把结论放在前面:__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内部执行时会做这样几件事:

  1. 检查对象有没有__dir__属性。
  2. 有的话,直接调用它,把返回的结果转成列表。
  3. 没有的话,走默认的收集流程。

对绝大多数自定义类对象来说,它从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 = [f"field_{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__,你的对象在自己和别人的眼里都会更清晰。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询