在做代码审查时,我经常遇到一类争论:新同学把类里的属性全部写成双下划线开头,理由是“这是私有变量,不能让人随便改”;老同学则坚持用单下划线,说Python里根本没有真正的私有变量。两边都有道理,但都不全面。
这里面的核心问题,就是Python类中的私有变量和公有变量到底该怎么用。如果你也在纠结“什么时候加下划线”“加几个下划线”“能不能直接访问”,那这篇就是为你准备的。我会把名称修饰机制的真实规则、单下划线与双下划线的边界、property封装的最佳实践,以及我在实际项目里踩过的坑,一次性聊透。无论你是刚接触面向对象编程的初学者,还是要负责团队代码规范的开发者,都能从里面拿到一套可以直接落地的判断标准。
1. 先搞清楚:Python里面有真正的私有变量吗
1.1 一个类,三种写法,几种“私有”
在Java或C++里,private是语言层面的强制约束,编译器直接禁止外部访问。Python没有这种关键字,所以很多人会告诉你“Python里没有私有变量”。这句话方向对,但不够准确。Python用命名约定和名称修饰机制,提供了一种“约定上私有、物理上受限”的方案。实际开发中,你会遇到三种属性写法:
class Demo: def __init__(self): self.public = 1 # 公有变量:谁都能访问 self._protected = 2 # 单下划线:约定内部使用 self.__private = 3 # 双下划线:触发名称修饰 def show(self): print(self.public, self._protected, self.__private) d = Demo() d.show() print(d.public) # 1 print(d._protected) # 2 print(d.__private) # AttributeError: 'Demo' object has no attribute '__private' print(d._Demo__private) # 3看到区别了吗?public和_protected都能从外部直接访问,__private在外部用原名访问会报错,但把名字改成_Demo__private之后又能拿到。这说明双下划线做的事情,并不是“禁止访问”,而是“把属性改了个难猜的名字”,专业说法叫名称修饰(name mangling)。
我常用的类比是这样的:公有变量是敞开的大门,单下划线是门口挂个牌子写着“内部员工请走侧门”,双下划线才是真装了一把锁。但这把锁是防君子的,不防那些知道开锁方式的人。你拦得住粗心大意的调用方,拦不住一个有经验、有心去看源码的开发者。
1.2 从其他语言过来的程序员为什么会问这个问题
我有段时间经常帮新人做代码评审,发现但凡从Java转过来的同事,第一反应都是把类属性全写成私有,然后提供一堆getter/setter。他们问得最多的就是:“Python这语言也太不安全了,private都没有,变量随便就能改,出问题怎么办?”
这个疑问我可以理解。Java用编译期强制,是为了在大型工程里建立可靠的边界。但Python选择了一条不同的路:它信任开发者,相信大家都明白自己在做什么。Python社区有句名言叫“我们都是成年人”,意思是语言提供足够的灵活度,具体怎么用由人自己把握。所以Python用命名约定划分边界:看到单下划线你就知道不该从外部碰它,看到双下划线你就知道这是类内部“比较私密”的东西。
这个设计的优点是灵活和高效。你不需要为了访问一个属性绕一大圈,写一堆样板代码。代价也有,就是约束力完全依赖团队的自律和代码审查。换句话说,Python把“强制”换成了“提醒”,把安全责任从语言层面转移到了开发规范层面。
2. 名称修饰机制拆解:双下划线到底给变量改名成什么
2.1 精确规则:_ClassName__attr是怎么来的,边界条件是什么
名称修饰机制值得单独拆开讲,因为它有很多细节反直觉。
规则本身很简单:在类的定义内部,凡是以两个下划线开头、但末尾没有两个下划线的属性名,都会被自动改写成_类名__属性名。比如Demo类里的self.__private,编译后实际变成了self._Demo__private。所以你在类外部写d.__private,解释器会去找Demo实例上有没有名为__private的属性,找不到就报错;但如果你写d._Demo__private,就能直接命中。
验证方式非常简单:
d = Demo() print([name for name in dir(d) if "private" in name]) # ['_Demo__private']dir()会把实例的所有属性名列出来,修饰后的名字就藏在这里。我建议初学者都亲手执行一次,比看十篇文章都管用。另外两个容易忽略的边界条件:
- 名称修饰只发生在类的定义内部,而且发生在编译阶段,不是运行时拦截。也就是说,
self.__private这一段在你保存代码时,符号就已经被替换了。 - 如果属性名以双下划线开头并且以双下划线结尾(比如
__magic__),则不会被修饰。这类名字是Python保留给特殊方法和特殊属性的区域,比如__init__、__dict__,你在自定义时最好也别碰这个命名区间。 - 如果在类代码块外部给实例动态添加双下划线开头的属性,比如
d.__new_attr = 5,这个属性不会被修饰,它就是字面上的__new_attr,可以正常访问。
第三条特别容易踩坑。有人以为类外面动态添加obj.__something也会被隐藏,结果打开obj.__dict__一看,属性好端端地躺在那里。
2.2 继承场景里的价值:防子类意外覆盖父类内部状态
名称修饰看起来多此一举,但它在继承体系里有一个实实在在的价值:防止子类意外覆盖父类的内部属性。这点和Java的private目的类似,只是实现方式不同。
class Base: def __init__(self): self.__secret = "base" def get_secret(self): return self.__secret class Child(Base): def __init__(self): super().__init__() self.__secret = "child" c = Child() print(c.get_secret()) # base,父类内部数据没有被覆盖 print(c.__dict__) # {'_Base__secret': 'base', '_Child__secret': 'child'}注意Child里的self.__secret也被名称修饰成了_Child__secret,和父类的_Base__secret互不干扰。get_secret()访问的是父类自己的_Base__secret,所以结果依然是"base"。
如果父类用的是self._secret,情况就完全不同。子类里写一句self._secret = "child",直接把父类的数据覆盖了,get_secret()会返回"child"。这种bug在大型继承体系里非常隐蔽,往往等到某个模块异常了才被追查出来。所以双下划线在“父类需要保护内部状态,不让子类误碰”的场景里,确实不可替代。代价也很明显:调试时你会看到一堆_Base__secret、_Child__secret这种带类名前缀的名字,刚开始会有种“代码怎么变样了”的错乱感,习惯就好。
再说清楚一个关系:**名称修饰不是为了安全加密,而是为了减少命名冲突。**它的目标是让子类和父类的内部属性各归各,而不是防止黑客读取数据。明白了这个设计意图,你在使用双下划线时就不会再纠结“别人能不能看到”,而是会想“这里该不该让子类碰”。
3. 公有变量与单下划线约定:Python的默认风格
3.1 公有变量才是默认:适合什么场景
很多来自其他语言的人有个误解,以为Python面向对象编程就该把属性全藏起来。事实恰恰相反,Python的默认风格是能直接用属性就直接用属性。一个类如果没有特殊约束,最自然的写法就是公开属性:
class Point: def __init__(self, x, y): self.x = x self.y = y p = Point(3, 4) print(p.x, p.y) # 3 4 p.x = 10这种写法简洁、直观、性能好,完全符合Python的哲学。它有没有风险?当然有,调用方可能把x赋成一个字符串,或者设置一个超出合理范围的值。但Python的选择是:你可以在属性赋值时做校验,也可以信任调用方,等真正出问题时再暴露问题。对于很多内部工具、快速原型、数据类来说,这种信任换来的是极高的开发和迭代效率。
dataclasses的出现进一步说明了Python的态度。它专门用于创建以数据为主要内容的类,字段直接声明为公开属性:
from dataclasses import dataclass @dataclass class User: name: str age: int u = User("Alice", 30) print(u.age) # 30dataclass生成的类里,字段默认是公有变量,这没有任何问题。什么时候适合用公有变量?我总结了四条经验:
- 属性就是纯粹的数据载体,没有读写时的额外逻辑。
- 类的使用者就是本模块或同团队的人,彼此信任。
- 你不打算在短期内对属性增加校验、计算或日志逻辑。
- 这是配置类、DTO(数据传输对象)、实体类等“堆数据”的场景。
3.2 单下划线不是private,而是“内部使用”的君子协定
单下划线开头的_name是Python里最容易被低估的约定。它在语法层面什么事情都没做,只是表示“这是内部使用的名字,外部代码请别直接碰”。但它有几个实际效果,值得展开说。
首先是模块导入层面的行为。如果你在模块里定义了一个_private_var,那么from module import *默认不会导入它:
# my_module.py _private_var = 1 public_var = 2 # 另一个文件 from my_module import * print(public_var) # 2 # print(_private_var) # NameError这个机制同样适用于类中的方法名和属性名。IDE和代码检查工具也会把单下划线开头的成员标记为“受保护”,在自动补全时放在靠后位置,提醒你“这个不推荐外部用”。
那它和双下划线怎么分工?我的实践准则是这样:
_name:内部实现细节,希望外部“自觉”不要碰,但不担心子类重名覆盖。同一个类内部、模块内部随便用。最典型的就是配合property使用的存储字段,比如self._balance,外部通过属性接口访问,内部直接操作存储字段。__name:父类内部状态希望和子类彻底隔离开,或者你明确就想让外部访问时要费点功夫,那就双下划线。name:没有特殊约束,公开就是公开,访问它不需要任何心理负担。
单下划线的场景远比双下划线多。我见过不少团队把内部变量一律写成_xxx,收到效果不错:代码的意图一目了然,又不影响调试,也不像双下划线那样让属性名带上类名前缀。真正需要双下划线的地方,是遇到继承和多层封装时,再针对性使用。
4. 封装落地:用property和单下划线写出优雅的类
4.1 为什么要用property:外松内紧的访问控制
前面一直在说“约定”,但实际封装时还需要一个工具把约定变成可控的接口,这就是property装饰器。property的核心作用是:外部依然用属性访问语法,但内部可以挂接读取、设置和删除逻辑。
class Account: def __init__(self, balance): self._balance = balance @property def balance(self): return self._balance @balance.setter def balance(self, value): if value < 0: raise ValueError("余额不能为负") self._balance = value @balance.deleter def balance(self): self._balance = 0 acc = Account(100) print(acc.balance) # 100,其实调用了balance的getter acc.balance = 200 # 实际调用了setter,并且通过了校验 # acc.balance = -1 # ValueError: 余额不能为负注意到这里的存储字段是_balance,对外暴露的是balance属性。外部代码写起来和访问普通公有变量没有区别,但赋值时自动经过校验。这就是外松内紧:接口层面简单,内部逻辑严密。
需要提醒的是,deleter只有在明确知道“删除这个属性代表什么”时才需要定义。大多数业务类根本不需要删除属性,不写deleter时用del acc.balance会直接报错,这反而帮你挡住了无意义的操作。
4.2 一个完整的业务类示例:从存储字段到对外属性
为了让你看到一个更完整的封装形态,我写一个温度传感器类,把私有变量、公有变量、property组合起来:
class TemperatureSensor: def __init__(self, celsius): self._celsius = celsius @property def celsius(self): return self._celsius @celsius.setter def celsius(self, value): if not -273.15 <= value <= 10000: raise ValueError("温度超出合理范围") self._celsius = value @property def fahrenheit(self): return self._celsius * 9 / 5 + 32 def __str__(self): return f"TemperatureSensor({self.celsius:.2f}°C)"这里的字段设计是:内部存储_celsius,对外提供可读写的celsius属性和只读的fahrenheit属性。你无法直接给fahrenheit赋值,因为它只有getter,没有setter,这是“只读属性”的标准写法。这样做的好处是:摄氏度和华氏度始终由同一个底层数值推导而来,不存在“华氏度被外部改掉、和摄氏度失去同步”的问题。
从外部使用者视角看,代码非常友好:
s = TemperatureSensor(25) print(s.celsius) # 25 print(s.fahrenheit) # 77.0 s.celsius = 30 print(s.fahrenheit) # 86.0如果你在setter里再加一行日志记录,就能追踪每次温度变化,而不影响调用方的任何代码。这种能力是直接暴露公有变量做不到的。
4.3 重构平滑过渡:从公有属性到property的关键技巧
这一节是我很想分享的实战技巧,也是property在Python里表现最惊艳的地方。
设想你第一版代码写得很爽快,直接用公有变量:
class Person: def __init__(self, age): self.age = age项目跑了几个月,调用方到处都是person.age = xx。这时候产品提了个新需求,年龄必须限制在0到150之间。如果按Java思维,你会把age改成私有字段,再提供getAge()和setAge(),然后把所有调用点全部改一遍。在Python里,你只需要做一件事:把公有变量改写成property,外部的person.age语法完全不用变。
class Person: def __init__(self, age): self._age = age @property def age(self): return self._age @age.setter def age(self, value): if not 0 <= value <= 150: raise ValueError("年龄必须在0到150之间") self._age = value这就是我常说的“平滑重构”:对外零破坏,对内加了约束。“为什么Python没有强制私有变量?”很大一个原因就是,Python选择了信任与约定,换来的是这种灵活演化的能力。
但这里有一个必须记住的禁忌:getter和setter里面不能出现和property同名的直接访问,否则无限递归。
class BadExample: def __init__(self, age): self.age = age # 这其实调用了下面的setter,然后setter里又执行self.age = ...如果你在__init__里写了self.age = value,它确实会走setter做校验,这本身是有用的特性;但如果在setter内部又写self.age = value,就会不断调用自己,最终抛出RecursionError。正确姿势是:内部一律操作存储字段self._age,外部才通过self.age访问。
我推荐的最佳实践是:property负责对外接口和校验逻辑,_字段名负责存储,二者分工明确,不要混为一谈。
5. 常见误区与代码审查清单
5.1 高频翻车现场汇总
先列几个我经常在别人代码里看到的问题,也是你可能会遇到的坑。
第一个误区:以为双下划线的属性彻底访问不到。实际上,obj._类名__属性名可以拿到,obj.__dict__也能看到。前面已经演示过,名称修饰是给名字加了前缀,不是权限控制。把敏感数据放在双下划线属性里并不能保障安全性,如果有这个需求,应该考虑加密或绝对不要输出到内存里。
第二个误区:在类外部给实例添加双下划线属性,以为也会被隐藏。前面提过,名称修饰只在类定义内部生效,在类定义外动态创建的__attr就是普通属性。有人会在__init__里给实例动态添加属性,而__init__本身是类定义的一部分,所以修饰会生效;但在其它位置动态添加就不生效了。边界一定要记清楚。
第三个误区:把所有属性都加上双下划线。这类代码的典型特征是,__dict__里全是_ClassName__xxx,不仅可读性差,继承场景下更是繁琐。双下划线应当用于“父类或基类需要保护内部状态”或“类的外围调用方很多、希望显著隔开命名空间”的情况,日常业务类用单下划线加property就够了。
第四个误区:使用property时命名冲突。内部字段_age和对外属性age要分开。有些人写setter时习惯把所有逻辑堆进去,包括耗时操作,导致一次简单的属性赋值卡顿明显。属性访问在直觉上应该是轻量操作,重活应该交给方法。
第五个误区:混淆“约定”和“语法”。单下划线是纯约定,没有任何解释器强制力;双下划线是语法层面的名称修饰。你在代码审查时如果用“单下划线不能访问”去要求别人,那是理解偏了,应该是“单下划线表示别人不该访问”。
5.2 我的取舍标准与审查要点
代码审查时,我会对属性声明方式做这样一张快速判断表:
| 场景 | 推荐写法 | 理由 |
|---|---|---|
| 纯数据容器、DTO、配置项 | 公有变量name | 无校验逻辑,直接访问最省事 |
| 有内部状态但不担心子类同名覆盖 | 单下划线_name | 表达“内部使用”,调试方便 |
| 继承体系中父类要隔离内部状态 | 双下划线__name | 防止子类意外覆盖 |
| 对外属性需要校验或计算附加值 | property+ 单下划线存储 | 外松内紧,接口不破坏 |
| 魔法方法、特殊属性 | 双下划线前后缀__name__ | Python保留区域,定义协议接口 |
审查清单方面,我固定会看几个点:
- 有没有该用公有变量却硬封装的地方?如果一个属性只是普通数据,没有约束、没有计算、没有日志,那直接在
__init__里赋值即可,不必为封装而封装。过度封装会让类变得臃肿,代码量成倍增长。 - 双下划线用在了哪些地方?每个双下划线属性都应该能回答“我为什么怕子类覆盖”。
property的setter里有没有做重活?正常校验和简单类型转换可以,IO操作、网络请求、复杂计算都不该出现在里面。- 有没有把内部实现和对外接口混在一个名字里?比如用
self._age存储,再用@property定义age,两者在命名上明确区分是最稳的。
我做审查时还特别关注一点:团队是否就命名约定达成了一致。Python的灵活是一把双刃剑,有人习惯全公有,有人习惯全下划线,如果没有统一标准,代码库很快就会风格分裂。我见过一个项目里,同一个模块,有的人写self.user_id,有的人写self._user_id,还有人写self.__user_id,每个开发者都得先去源码里看半天才知道该不该动这个属性。这种事最消耗团队的协作效率,必须在规范层面解决。
最后再分享一点我自己的体会
起初我也有段时间喜欢把类属性全部写成双下划线,觉得这样才像“真正的封装”。后来在一次重构里被_ClassName__attr这种调试时完全看不懂的属性名教训过一次,从此就收敛了:Python的封装应该用约定和property来承载,双下划线只在真正需要隔离继承命名空间时使用。
还有一个小经验供你参考:如果你正在设计一个会被外部依赖的类,先把属性做成公有变量,等确实出现了校验、计算、日志之类的需求,再原地改成property。这个技巧的好处是,类对外呈现的接口从头到尾一致,调用方完全无感,而你随时能收紧控制。这种“先信任,后约束,再演化为隔离”的路径,是我觉得Python面向对象设计里最符合直觉、也最不容易踩坑的思路。