Python 里 __x 到底被改名成了什么:拆开 name mangling、继承里的 _Cls__x 和「其实根本不私有」的真相
写类的时候,self.__x 几乎被当成「私有属性」的同义词。但真去 obj.__x 取,会得到一个 AttributeError;obj._Cls__x 却能拿到值。这中间的 _Cls__ 是谁加的、什么时候加的、继承后又会变成什么样?
一句话结论:Python 没有私有属性。类体里以两个下划线开头、且不以两个及以上下划线结尾的标识符,会在编译期被无条件改写成 _类名 + 原标识符;这纯粹是避免子类意外覆盖父类内部名字的命名约定,任何人在类外手写 _Cls__x 都能直接访问。
下面数据全部来自本机 CPython 3.13.14 实测(d55-probe.py)。
一、复现:__secret 在 dict 里叫什么
class Base:
__secret = 'BASE'
__dunder__ = 'kept'
__tail_ = 'tail'
def who(self):
return self.__secret
@classmethod
def cls_secret(cls):
return cls.__secret
print([k for k in Base.__dict__ if 'secret' in k or 'dunder' in k or 'tail' in k])
print(Base._Base__secret, Base().who(), Base.cls_secret())
实测输出:
['_Base__secret', '__dunder__', '_Base__tail_', 'cls_secret']
BASE BASE BASE
三点一眼可见:
__secret在__dict__里的真名是_Base__secret;__dunder__原样保留,没被改写;__tail_(尾部一个下划线)照样被改成了_Base__tail_。
而且不管是实例方法 self.__secret 还是类方法 cls.__secret,读到的都是改写后的 _Base__secret——类方法里没有「实例」概念,照样改写,说明这件事跟 self 无关。
二、改写规则,其实只有三条
| 写法 | 是否改写 | 结果 |
|---|---|---|
__x(双下划线开头,非双下划线结尾) |
✅ | _Cls__x |
__x_(尾部一个下划线) |
✅ | _Cls__x_ |
__x__(前后都双下划线,magic / dunder) |
❌ | 原样保留 |
_x(单下划线) |
❌ | 原样,纯约定 |
模块级 / 类体外的 __x |
❌ | 原样 |
判定条件精确说是:以至少两个下划线开头,且不以两个及以上下划线结尾。所以 __dunder__ 因为结尾也是双下划线被豁免——否则 __init__、__new__ 全会被改名,语言就崩了。
顺带一提,类名若以下划线开头,改写时前导下划线会被剥掉(class _C: __x → _C__x 而非 __C__x),这条比较冷门,但读第三方库源码时会遇到。
三、发生在编译期,不是运行期
这一点最容易被讲错。很多人以为是「属性访问时动态改名」,用 dis 看一眼就清楚了:
def probe(obj):
return obj.__secret # 顶层函数,无类上下文
class Probe:
__hidden = 42
def f(self):
return self.__hidden # 类体内
实测反汇编:
B10 dis LOAD_ATTR (顶层函数, 无类上下文,不改写):
LOAD_ATTR __secret
B11 dis inside class -> LOAD_ATTR name:
LOAD_ATTR _Probe__hidden
同一个 __xxx 写法,顶层函数里字节码常量是 __secret,类体内已经变成了 _Probe__hidden。改名在编译 .py → 字节码时就完成了,运行时根本没有「判断要不要改名」这一步。这也解释了为什么 getattr(obj, '__x') 拿不到、而 getattr(obj, '_Cls__x') 能拿到——字符串是运行期拼的,编译器够不着。
模块级也印证了这点:
__outside = 'module'
print('__outside' in globals()) # True
globals() 里就是 __outside,没被改写,因为它不在任何类体内。
四、继承:父类方法永远读 _Base__secret
class Sub(Base):
__secret = 'SUB'
def who2(self):
return self.__secret
s2 = Sub()
print([k for k in Sub.__dict__ if 'secret' in k])
print(Base.who(s2), s2.who2())
print(s2._Base__secret, s2._Sub__secret)
实测输出:
['_Sub__secret']
BASE SUB
BASE / SUB
Sub 里那个 __secret 被改成了 _Sub__secret,和父类的 _Base__secret 互不相干,同一个实例上并存两份:
Base.who(s2)虽然作用在Sub实例上,但这段代码编译在Base类体内,读的是_Base__secret→BASE;s2.who2()编译在Sub类体内,读_Sub__secret→SUB。
改写用的是「代码所在的类名」,不是「运行时的实例类型」。 这正是 name mangling 存在的唯一理由:子类里随手写了个同名的 __secret,不会把父类内部状态覆盖掉。它不是访问控制,是防误撞。
五、「私有」是假的
b = Base()
try:
b.__secret
except AttributeError as e:
print(e)
print(b._Base__secret)
实测输出:
'Base' object has no attribute '__secret'
BASE
b.__secret 报错,是因为类外这行代码没被改写、去找了一个根本不存在的 __secret 属性;而 _Base__secret 一直好好躺在实例字典里,谁都能读、能改:
p = Probe()
setattr(p, '_Probe__hidden', 99)
print(p.f()) # 99
所以 Python 的「私有」只是名字不好看,不是访问不到。真正需要约束时,靠的是单下划线约定 + 文档 + property,或者 __slots__ 限制属性集合,而不是双下划线。
六、几个容易踩的坑
- 动态访问(
getattr/setattr/hasattr/ 序列化字段名 / 配置映射)里写'__x'一律失效,必须手写'_Cls__x'。ORM、dataclasses 的字段名、测试里 mock 属性时最容易中招。 __slots__同理:写__slots__ = ('__v',),槽的真名是_Cls__v。用copy/pickle走__reduce_ex__重建时,槽名对不上就会丢属性。- 别拿它藏敏感信息。
_Cls__x是可读可写的普通属性,vars(obj)一眼看光。 from module import *不受影响。那个规则只看单下划线前缀和__all__,跟 mangling 是两套机制。- 子类想访问父类「私有」属性,得写
self._Base__x(或由父类提供受保护的接口),写self.__x只会新建一个_Sub__x。 - 调试器 / 日志里看到的键名是改写后的,别以为自己定义了一个
__x属性却在__dict__里消失了。
七、一张对照表
| 问题 | 答案 |
|---|---|
| 什么时候改名 | 编译期,且只针对类体内的标识符 |
| 改成什么 | _ +(去掉前导下划线的)类名 + 原标识符 |
| 谁决定用哪个类名 | 代码所在的类,不是实例的运行时类型 |
__init__ 为什么没事 |
前后双下划线结尾,豁免 |
| 能挡住外部访问吗 | 不能,obj._Cls__x 直接读写 |
| 设计目的 | 防止子类意外覆盖父类内部名字 |
和 import * 有关系吗 |
没有 |
八、六条排错清单
AttributeError: 'X' object has no attribute '__y'→ 改成obj._X__y,或给类加property。- 子类改了
__y但父类行为没变 → 预期行为,父类方法读的是_Base__y。 getattr(obj, '__y', None)永远返回 None → 字符串要写'_Cls__y'。pickle/copy后某个属性丢了 → 检查是不是__slots__里的__y与还原时的名字不一致。- 想在子类复用父类内部状态 → 用单下划线
_y(约定保护)+ 文档,别用双下划线。 - 代码里出现
_Cls__y硬编码 → 说明在依赖实现细节,重构类名时会一起断,建议改成受保护方法。
小结
name mangling 是解释器的一个编译期文本替换,不是访问控制,也不涉及 self。理解它只需要记住两件事:改名依据的是「代码写在哪个类里」,以及改名后的名字依然是个普通属性。想清楚这两点,__x 到底是私有还是障眼法、继承里为什么两套名字并存,就都不用背了。
实测环境:本机 CPython 3.13.14,脚本 G:/work/netease-hao/d55-probe.py,全部输出为该脚本原样打印(dis 部分为 3.13 字节码)。

浙公网安备 33010602011771号