前言
「Python 的私有属性」这个说法,本身就不太准确。官方教程里写得很干脆:在 Python 中,那种「除非在对象内部,否则无法访问」的私有实例变量是不存在的。
之所以大家还总把它挂在嘴边,是因为 Python 提供了两套约定:单下划线开头(_name)表示「这是内部实现细节,别依赖」;双下划线开头(__name)则会触发编译器层面的名称改写(name mangling),把名字变成_类名__name。前者是君子协定,后者是防止子类意外撞名的技术手段——两者都不是访问控制。
本文只讲可见性约定这一面:Python 为什么没有真正的私有、_x/__x/__x__这几类名字各自的含义、名称改写到底改成了什么、以及它在实战中会怎么坑人。代码按 Python 3.8+ 给出。
一、Python 里没有真正的私有
在 C++ / Java 里,private是编译器强制执行的:从类外部访问会直接编译失败。Python 没有这种机制,原因也很直白——属性查找走的是命名空间字典,只要你知道名字,就能getattr拿到;只要你知道实例,就能vars(obj)看到它的全部属性。
Python 的设计哲学是「我们都是成年人(we are all consenting adults here)」:语言不设路障,而是靠命名约定告诉使用者「这块是内部实现」。所以你在实际项目里会看到两种现象并存:
- 库作者用
_internal命名,文档里明说「不要依赖」; - 使用者如果非要访问,技术上完全做得到,风险自负。
理解了这一点,后面两节的「约定」与「改写」就不会再被误解成「私有性」。
二、单下划线 _x:一种约定
官方教程:一个以单下划线开头的名字(如_spam)应当被当作非公开 API 的一部分(无论它是函数、方法还是数据成员),它属于实现细节,可能在不通知的情况下改变。
约定之外,单下划线还有一个真实的、由解释器执行的效果:在模块里,如果模块没有定义__all__,from 模块 import *只会导入不以单下划线开头的名字。也就是说,_helper这种名字不会被星号导入带出去。
# 适用于 Python 3.8+
class Cache:
def __init__(self):
self._store = {} # 约定:内部存储,外部不要直接依赖
self.hits = 0 # 公开计数
def get(self, key):
if key in self._store:
self.hits += 1
return self._store.get(key)
def put(self, key, value):
self._store[key] = value注意_store依然可以被c._store或c.__dict__["_store"]直接读写。约定保护不了你,它只负责让人「一看就懂这是内部的东西」。
三、双下划线 __x:名称改写
以两个或更多下划线开头、且不以两个或更多下划线结尾的标识符,只要它出现在类定义内部,就会被编译器在生成代码前改写成_类名__标识符。官方规则是:类名去掉前导下划线,前面再补一个下划线,然后拼上标识符本身。
举例:在名为Wallet的类里,self.__balance会被改写成self._Wallet__balance。
# 适用于 Python 3.8+
class Wallet:
def __init__(self, balance):
self.__balance = balance # 编译期改写成 self._Wallet__balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("金额必须为正")
self.__balance += amount
def show(self):
return self.__balance
w = Wallet(100)
w.deposit(50)
print(w.show()) # 推演输出:150
print(w.__dict__) # 推演输出:{'_Wallet__balance': 150}
print(w._Wallet__balance) # 推演输出:150 —— 用改写后的名字照样能访问最后一行是重点:改写不是加密,也不是权限控制。资料里说「__x是私有属性」时,指的只是「这个属性没有出现在类的公开命名里」,而不是「拿不到」。一个vars(w)就把真名暴露了。
名称改写真正的用途是避免子类无意覆盖。因为改写的名字里带了类名,父类和子类各自的__x会落到不同的名字上:
# 适用于 Python 3.8+
class A:
def __init__(self):
self.__v = "A" # 改写为 _A__v
class B(A):
def __init__(self):
super().__init__()
self.__v = "B" # 改写为 _B__v,与 _A__v 互不干扰
b = B()
print(b._A__v, b._B__v) # 推演输出:A B
print(sorted(k for k in b.__dict__ if not k.startswith("__")))
# 推演输出:['_A__v', '_B__v']如果要关注「类名」这一环,还得注意一个细节:改写的规则是「类名去掉前导下划线、再补一个下划线」。所以Foo、_Foo、__Foo三个类名里出现的__spam都会被改写成_Foo__spam——类名前导下划线的数量不影响结果。若类名全部由下划线组成(如_或__),则改写是恒等的,名字保持原样。
四、__x__ 与 x_ 这两类名字
| 名字形式 | 官方性质 | 说明 |
|---|
name | 公开 | 正常 API |
_name | 约定非公开 | 单下划线开头:实现细节;from 模块 import *默认不带它 |
name_ | 约定 | 单下划线结尾:用来避开与关键字的冲突,如class_、import_ |
__name | 名称改写 | 双下划线开头、不以双下划线结尾:改写成_类名__name |
__name__ | 系统保留 | 双下划线包围:属于解释器 / 语言规范,官方建议不要自创这种名字 |
要澄清题面里的一个说法:单下划线开头又单下划线结尾的名字(形如_x_)并没有特殊语义,它既不会被改写,也不属于保留名。真正「保留」的是__x__这一形态——像__init__、__eq__、__slots__都是语言规范占用的名字,自己发明一个__my_thing__只会让人误以为它有特殊作用。
另外补一句版本背景:名称改写是源代码层面的文本变换,规则从 Python 2 到 Python 3 没有变化,所以在 Python 2 里旧式类和新式类的改写结果是一样的。真正随版本变化的是描述符是否生效——官方文档明确写着旧式类不支持描述符,而 Python 2 默认定义的类就是旧式类,写成class C(object):才是新式类。Python 2.7 已于2020 年 1 月 1 日停止维护(PEP 373),现在写类直接class C:即可,Python 3 里所有类都是新式类。
常见坑点
1. 以为__x是真正的私有。
❌ 认为定义self.__x之后外部绝对拿不到,于是把敏感数据(比如密钥)放心地放进去。 ✅__x只是被改写成_类名__x,vars(obj)或obj._类名__x都能读到;要保护数据得靠加密、不落库等真手段。
2. 在子类里「覆盖」父类的__x,期待多态生效。
❌ 父类self.__v、子类也写self.__v,指望子类方法能改到父类的那个值。 ✅ 两者被改写成_父类__v和_子类__v,是两个独立属性;要共享就用单下划线或约定好的公开名字。
3. 用getattr(obj, "__x")去拿改写过的属性。
❌getattr(w, "__balance")抛AttributeError,以为属性丢了。 ✅ 传改写后的名字:getattr(w, "_Wallet__balance");或者干脆查vars(w)。
4. 把单下划线当成「私有」并依赖它。
❌ 在业务代码里直接读写别人库对象的obj._store,库一升级就崩。 ✅ 把单下划线理解为「不保证稳定」的实现细节;需要长期使用的接口应当由库方提供公开方法。
5. 以为下划线开头的名字会被from 模块 import *导出。
❌ 定义了_helper()却期望星号导入能带过来。 ✅ 没有__all__时,import *只导入非下划线开头的名字;确实要导出就在__all__里显式列出。
6. 类名前导下划线带来意外撞名。
❌ 同时存在Foo与_Foo(或__Foo)两个类,各自都有__spam,结果改写名相同、互相干扰。 ✅ 记住Foo、_Foo、__Foo里的__spam都变成_Foo__spam,命名时避免这种巧合。
7. 以为改名能挡住反射和序列化。
❌ 认为__x能躲开vars()、pickle、调试器的眼睛。 ✅ 这些工具看到的都是真实键名(如_Wallet__balance),改名挡不住任何「有意的」访问。
总结
| 需求 | 正确做法 | 是否由解释器强制 |
|---|
| 标记「仅供内部使用」 | 单下划线_name | 否,仅约定(import *除外) |
| 避免子类意外撞名 | 双下划线__name,改为_类名__name | 是,编译期改写 |
| 避开关键字冲突 | 单下划线结尾name_ | 否,仅约定 |
| 使用语言规范接口 | 双下划线包围__name__ | 是,语言保留 |
| 真正保护敏感数据 | 加密 / 不落地 / 服务端校验 | 是(靠密码学而非命名) |
一句话收尾:Python 的「私有」只有名叫_name的约定和名叫__name的改名,没有访问控制;把它们当成「提示」而不是「锁」,代码设计就不会走偏。