AIGC标识 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__ 限制属性集合,而不是双下划线。

六、几个容易踩的坑

  1. 动态访问(getattr / setattr / hasattr / 序列化字段名 / 配置映射)里写 '__x' 一律失效,必须手写 '_Cls__x'。ORM、dataclasses 的字段名、测试里 mock 属性时最容易中招。
  2. __slots__ 同理:写 __slots__ = ('__v',),槽的真名是 _Cls__v。用 copy/pickle 走 __reduce_ex__ 重建时,槽名对不上就会丢属性。
  3. 别拿它藏敏感信息。_Cls__x 是可读可写的普通属性,vars(obj) 一眼看光。
  4. from module import * 不受影响。那个规则只看单下划线前缀和 __all__,跟 mangling 是两套机制。
  5. 子类想访问父类「私有」属性,得写 self._Base__x(或由父类提供受保护的接口),写 self.__x 只会新建一个 _Sub__x。
  6. 调试器 / 日志里看到的键名是改写后的,别以为自己定义了一个 __x 属性却在 __dict__ 里消失了。

七、一张对照表

问题 答案
什么时候改名 编译期,且只针对类体内的标识符
改成什么 _ +(去掉前导下划线的)类名 + 原标识符
谁决定用哪个类名 代码所在的类,不是实例的运行时类型
__init__ 为什么没事 前后双下划线结尾,豁免
能挡住外部访问吗 不能,obj._Cls__x 直接读写
设计目的 防止子类意外覆盖父类内部名字
和 import * 有关系吗 没有

八、六条排错清单

  1. AttributeError: 'X' object has no attribute '__y' → 改成 obj._X__y,或给类加 property。
  2. 子类改了 __y 但父类行为没变 → 预期行为,父类方法读的是 _Base__y。
  3. getattr(obj, '__y', None) 永远返回 None → 字符串要写 '_Cls__y'。
  4. pickle / copy 后某个属性丢了 → 检查是不是 __slots__ 里的 __y 与还原时的名字不一致。
  5. 想在子类复用父类内部状态 → 用单下划线 _y(约定保护)+ 文档,别用双下划线。
  6. 代码里出现 _Cls__y 硬编码 → 说明在依赖实现细节,重构类名时会一起断,建议改成受保护方法。

小结

name mangling 是解释器的一个编译期文本替换,不是访问控制,也不涉及 self。理解它只需要记住两件事:改名依据的是「代码写在哪个类里」,以及改名后的名字依然是个普通属性。想清楚这两点,__x 到底是私有还是障眼法、继承里为什么两套名字并存,就都不用背了。


实测环境:本机 CPython 3.13.14,脚本 G:/work/netease-hao/d55-probe.py,全部输出为该脚本原样打印(dis 部分为 3.13 字节码)。

posted @ 2026-11-09 09:54  小小张说故事  阅读(2)  评论(0)    收藏  举报