不管你是从Java、C++转过来,还是Python入门后就直奔面向对象,第一个让你停下来犹豫的概念八成是封装。而谈到封装,私有属性、私有方法和@property这三个词几乎是绕不过去的一道坎。我第一次用Python写面向对象程序的时候,第一反应是:这封装是不是有点“假”?明明写了个__username,结果换一个名字照样能访问,那私有还有什么意义?后来在项目里摸爬滚打多了才明白,Python的私有从来不是用来防“黑客”的,而是用来约束团队协作和代码维护的一纸协议。今天就把这块彻底聊透,从私有属性、私有方法到@property的优雅访问,一次理清它们各自解决什么问题、什么时候用、怎么用才不会踩坑。
很多同学在网上找“python免费源码大全”,下载了一份号称“企业级”的项目,打开一看却不知道从哪儿下手,原因往往就是封装做得不好——所有状态全裸奔,内部逻辑散落在各层调用里。反过来,封装做得好的代码,外部只需要盯住几个入口,其他全是黑盒,阅读成本和维护成本都低得多。这篇文章写的,就是帮助你从“能写出类”升级到“能把类设计得让别人用得舒服、改得安心”的那个阶段。
1. 封装到底在“封”什么:动手之前要看清的问题
很多教程上来就给结论:封装就是把属性设为私有,然后提供公有方法访问。这话没错,但没说到根子上。我觉得学封装之前,得先想清楚一个问题:我们到底在防谁?
1.1 状态失控带来的连锁灾难
如果你写过一个余额可以随意被改成负数的银行账户类,你大概就能理解封装的第一层价值。假设现在有这样一个类:
python复制class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
acc = BankAccount("张三", 10000)
acc.balance = -100000 # 没有任何东西阻止这行代码
balance被直接改成-100000,转入、转出、利息计算全乱套。更要命的是,这个问题在代码审查里非常难被发现,因为acc.balance = -100000这行代码看起来太正常了,和给普通变量赋值没什么区别。一旦业务逻辑复杂起来,你根本不知道是谁在什么时间点改了这个状态。
我自己在项目里就碰到过类似的情况:订单的状态字段被外部直接改成了“已关闭”,但支付流程、库存释放、通知回调这些真正的业务逻辑压根没走,结果后台数据对不上,排查了两天才发现是有个同事在某个接口里顺手写了order.status = "closed"。这就是状态不设防的代价。
1.2 封装的三个层次:状态保护、接口稳定、变更隔离
如果把封装拆开来看,我认为核心价值有三个层次。
第一层是状态保护。内部数据不能因为外部误操作而进入非法状态。这一层依赖的是我们对属性访问的控制,比如在赋值前做校验、做类型检查、做取值范围检查。
第二层是接口稳定。对外暴露的方法和属性是稳定的“合同”,内部实现细节可以随时换。今天列表存储,明天换数据库,只要接口不变,调用方就无感知。很多同学搜“接口封装”,本质就是干这个事——把一堆底层细节藏在稳定的接口后面。
第三层是变更隔离。内部私有方法改了名字、拆了逻辑、重构了实现,不影响任何外部调用。这个价值在项目迭代后期才会真正显现出来,前期代码量小的时候完全体会不到,等代码上万行,你就知道变更隔离有多重要了。
1.3 Python语境下的“私有”到底是什么意思
这里必须说清楚一个观念:Python里的私有,和Java、C++里的private完全是两回事。
Java的private是编译器强制执行的,你写了private String name;,在类外面就是访问不了,想都别想。Python则不同,它没有访问控制关键字,私有的实现靠两样东西:单下划线_name的约定,和双下划线__name的名称改写机制。
所以学Python封装,第一步是扭转观念:Python的私有不是“锁”,而是“告示牌”。它告诉你“这里不该动”,但如果你非要动,解释器不会拦你,顶多就是你破坏了约定之后,后果自负。理解了这一点,后面所有细节都好说了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 单下划线与双下划线:私有属性的“约定”与“伪装”
这一章是Python封装的核心机制,也是一大堆新手最容易混淆的地方。_x和__x虽然只差一个下划线,含义和使用场景却完全不同。
2.1 单下划线_name:写在PEP8里的受保护约定
单下划线开头的属性名,比如self._balance,在Python社区里代表的是:“这是一个内部属性,请外部不要直接访问,但我不强制禁止。”
它背后的权威依据是PEP8。PEP8中明确提到,_name这种单下划线前缀的命名方式,用来表示“受保护的成员”,含义是“仅内部使用,子类可以访问,但外部模块不要直接碰”。
注意,这里有一个很多人忽略的点:单下划线属性在子类中是可以访问的。也就是说,如果你写一个基类,里面有self._data,子类可以直接用self._data来读写,这在框架设计里非常常见。它表示的是“内部使用,但允许继承者扩展”。
我的习惯是:凡是需要保护、但又可能被子类重写或访问的属性,一律用单下划线。举个例子,写一个配置解析类,内部缓存 _config_dict,子类可能需要读取,我就用单下划线。这样既表明了态度,又没有把路堵死。
2.2 双下划线__name:解释器背后的名称改写机制
双下划线开头且结尾不是双下划线的属性名,比如self.__balance,触发的机制叫做name mangling(名称改写)。它不是简单的约定,而是解释器在编译阶段实实在在做了手脚。
具体来说,当你在类的内部写成self.__balance的时候,Python解释器会把它悄悄改写成self._ClassName__balance。注意,是类名前面加一个下划线,再拼上原来的属性名。
看一下实际效果:
python复制class Account:
def __init__(self, owner):
self.__balance = 0 # 被改写成 self._Account__balance
self.owner = owner
a = Account("Alice")
print(a.__dict__)
# 输出:{'owner': 'Alice', '_Account__balance': 0}
print(a.__balance)
# 报错:AttributeError: 'Account' object has no attribute '__balance'
print(a._Account__balance)
# 输出:0
看到没有?a.__balance直接访问会报错,但a._Account__balance可以正常访问。换句话说,双下划线不是真的“访问不了”,而是换了名字让你找不到。我把这个机制称为“伪装”,它确实比单下划线多了一层保护,但这层保护极其脆弱。
很多初学者看到这里会问:那这还有什么用?安全吗?答案是:这不是安全机制,是防误用机制。它真正目的是防止你手滑访问到内部属性,同时防止子类和基类的同名属性互相覆盖,这一点下面会细说。
2.3 双下划线在继承中的真正价值:防止属性覆盖
name mangling机制的设计初衷,我认为最重要的反而不是“防外部访问”,而是防止继承体系中父子类同名属性的覆盖冲突。
来看这个例子:
python复制class A:
def __init__(self):
self.__x = 1 # 实际存储为 self._A__x
class B(A):
def __init__(self):
super().__init__() # 先初始化 A,生成 _A__x
self.__x = 2 # 实际存储为 self._B__x
b = B()
print(b.__dict__)
# 输出:{'_A__x': 1, '_B__x': 2}
如果没有name mangling,B类的self.__x = 2会直接把A类设置的x覆盖掉。但在name mangling机制下,A类的__x变成了_A__x,B类的__x变成了_B__x,两个属性互不干扰。
这在大型框架、多级继承中非常有用。你可能写了一个基础类,里面用__internal_state保存核心状态,别人继承你的类时,完全不知道你的内部实现,他如果也写了一个__internal_state变量,在Java里这就是纯粹的逻辑错误,而在Python里因为有name mangling,两个互不影响。
2.4 什么情况用单下划线,什么情况用双下划线
这个判断标准我整理过多次,现在基本固化下来了。
| 场景 | 推荐写法 | 理由 |
|---|---|---|
| 内部属性,子类可能访问或重写 | _name |
表示受保护,留给子类扩展空间 |
| 内部属性,担心被子类同名覆盖 | __name |
name mangling隔离父子类命名空间 |
| 外部应该通过方法读写的属性 | _name + @property |
属性存储私有,访问走property |
| 特殊方法(如构造函数、运算符重载) | __name__ |
Python规定,不是私有 |
| 模块内部函数或变量 | _func |
模块级约定,from module import * 默认不导入 |
这里特别提醒一下关于前后双下划线__xxx__的情况。__init__、__str__、__repr__这些是Python的特殊方法,也叫魔术方法,它有特殊含义,不是私有的。千万不要出于“让它私有化”的想法去定义__balance__,那属于自找麻烦,Python不会对它做name mangling,而且很容易和未来的特殊方法撞车。
我自己的经验是:默认用单下划线,双下划线只在写框架、写基础库、担心对方子类里撞名字的时候用。这样既保持了代码可读性,又能在关键地方获得name mangling的隔离效果。
3. 私有方法:把内部动作藏起来,让外部只看到结果
属性要封装,方法同样要封装。私有方法在实际项目中用得非常多,但很多新手不知道怎么用、什么时候用,容易要么完全不用,要么把不该藏的方法也藏起来。
3.1 为什么需要私有方法
一个类对外提供的公开方法,代表的是这个类的能力;私有方法代表的是这个能力内部实现步骤。外部调用者只关心“做什么”,不关心“怎么做”。
举个例子,一个订单支付流程可能是这样的:
python复制class Order:
def __init__(self, order_id, total_price):
self.order_id = order_id
self.total_price = total_price
self.status = "unpaid"
def pay(self):
# 1. 校验库存
# 2. 计算最终价格
# 3. 更新库存
# 4. 调用第三方支付网关
# 5. 更新订单状态
pass
如果pay()方法里直接写200行业务逻辑,这个方法就变得又臭又长,而且——关键问题来了——很多步骤你是不希望外部单独调用的。比如“调用第三方支付网关”这一步,如果被人单独调用,就会产生一个没有订单支撑的支付请求,这是灾难。
正确的做法是:对外只暴露pay(),内部的校验、计算、库存更新、支付请求全部封装成私有方法。
3.2 私有方法的命名与实现细节
私有方法的命名规则和私有属性完全一样:方法名前加双下划线,触发name mangling。
python复制class Order:
def __init__(self, order_id, total_price):
self.order_id = order_id
self.total_price = total_price
self.status = "unpaid"
self.__stock_locked = False
def pay(self):
if not self.__check_stock():
raise ValueError("库存不足")
final_price = self.__calc_final_price()
self.__deduct_stock()
self.__call_payment_gateway(final_price)
self.status = "paid"
def __check_stock(self):
# 内部校验逻辑,不对外开放
return True
def __calc_final_price(self):
# 内部计算逻辑,不对外开放
return self.total_price
def __deduct_stock(self):
# 内部扣库存逻辑,不对外开放
self.__stock_locked = True
def __call_payment_gateway(self, price):
# 内部支付网关调用逻辑,不对外开放
print(f"调用支付网关,金额:{price}")
这里要注意一个细节:私有方法在类内部调用时,直接写self.__check_stock(),解释器会在编译期自动改写为self._Order__check_stock(),所以内部调用没有任何问题。但如果你在外部尝试调用order.__check_stock(),就会报AttributeError。
和私有属性一样,私有方法也可以通过order._Order__check_stock()来访问。再次强调:这是隐藏,不是禁止。你在项目里看到这种写法,基本都是调试代码或者强行绕过接口的临时方案,不应该出现在正式业务代码里。
3.3 私有方法最容易踩的“继承子类调用”坑
这个坑我见过不下十次,而且几乎每个新手都会踩一次。
场景是这样的:你写了一个基类,内部有个私有方法__validate(),子类继承了基类之后,想在子类的方法里调用这个__validate(),然后报错了AttributeError。
原因还是name mangling。基类里的__validate()被改写成了_BaseClass__validate(),子类里写self.__validate(),会被改写成_ChildClass__validate(),这俩根本不是同一个名字,自然找不到。
python复制class BaseValidator:
def __init__(self, value):
self.value = value
def __validate(self):
# 被改写成 _BaseValidator__validate
return self.value > 0
class PositiveValidator(BaseValidator):
def check(self):
# 这里写 self.__validate() 会报错
# 因为被改写成 _PositiveValidator__validate,找不到
return self._BaseValidator__validate() # 必须这样写,但绝不推荐
v = PositiveValidator(10)
print(v.check()) # 勉强能跑通
虽然技术上可以通过self._BaseValidator__validate()来调用,但这是非常糟糕的写法,等于强行突破了封装边界。正确做法是什么?如果你明确知道这个方法要被子类复用,就不要把它设为双下划线的私有方法,而是用单下划线_validate(),表示“受保护,子类可以访问”。
所以,设计类的时候要想清楚:这个方法是“内部的内部”,还是“内部但允许子类扩展的”?前者用__method,后者用_method。这个决策要在写类的时候就定了,事后改的代价远高于写的时候想清楚。
3.4 私有方法的测试与调试技巧
私有方法虽然不对外暴露,但测试的时候你仍然需要验证它。我的习惯是:单元测试不直接测私有方法,而是测公开方法间接覆盖私有方法分支。因为私有方法是实现细节,直接测它相当于把你的测试和内部实现绑死,以后重构私有方法,测试全挂,这违背了封装的意义。
但调试时就不同了,有些问题确实需要直接看私有方法的中间结果。两个技巧分享给大家:
第一,用dir()查看name mangling后的真实名字:
python复制order = Order("2024001", 199)
print(dir(order))
# 输出中会包含 '_Order__check_stock'、'_Order__calc_final_price' 等
第二,调试时临时通过改写后的名字调用,定位问题:
python复制order._Order__check_stock() # 调试用,定位完就删
记住,这只是调试手段,不是代码规范。我见过有人把这种调用写在生产代码里,那和直接访问全局变量没啥区别,封装形同虚设。
4. @property:把访问方式升级成字段一样优雅
私有属性用双下划线藏起来之后,外部要读写怎么办?最传统的做法是提供getter和setter方法,Java里就是这样。但Python社区更推荐用@property装饰器,它能把方法调用伪装成属性访问,读起来像字段,跑起来是方法。这一步操作下来,代码就没有Java那股“三件套”的繁重味儿了。
4.1 从getter/setter三件套到@property的演进
先写一版Java风格的Python类,算是“背面教材”:
python复制class Temperature:
def __init__(self, celsius):
self._celsius = celsius
def get_celsius(self):
return self._celsius
def set_celsius(self, value):
if value < -273.15:
raise ValueError("温度不能低于绝对零度")
self._celsius = value
调用的时候得写t.get_celsius()和t.set_celsius(25),每处都要带括号,显得啰嗦。更关键的问题是:如果这个类发布出去之后,你想把内部的_celsius存储逻辑改掉,或者在赋值时加校验,外部调用代码就得跟着改。这违背了封装“接口稳定”的目标。
而用@property实现同样的功能,调用方看到的是一个完全正常的属性:
python复制class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("温度不能低于绝对零度")
self._celsius = value
t = Temperature(25)
print(t.celsius) # 像属性一样读
t.celsius = 30 # 像属性一样写,自动走setter校验
t.celsius = -300 # 抛出 ValueError
外部代码的调用方式是t.celsius,不是t.celsius(),读起来就和直接访问属性一样。这就是@property的核心价值:用属性的语法,承载方法的逻辑。
4.2 完整的@getter、@setter与@deleter用法
在Python里,@property本身把方法变成只读属性;如果还要支持赋值,需要再定义同名方法并用@xxx.setter装饰;支持删除的话,用@xxx.deleter。
下面是一个完整的示例,包含了三种访问的完整实现:
python复制class User:
def __init__(self, name, age):
self._name = name
self._age = age
@property
def name(self):
"""只读属性:没有setter,外部不能赋值"""
return self._name
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if not isinstance(value, int):
raise TypeError("年龄必须是整数")
if not (0 <= value <= 150):
raise ValueError("年龄必须在0到150之间")
self._age = value
@age.deleter
def age(self):
print("删除年龄属性")
del self._age
u = User("张三", 18)
print(u.name) # 张三
u.name = "李四" # 报错:AttributeError,因为没有setter
u.age = 20 # 走setter校验
del u.age # 走deleter
几个容易忽略的细节:
- 只读属性就是“只有
@property装饰的getter,没有setter”。在__init__里如果直接写self.name = xxx,而name是只读property,会因为找不到setter而报错。所以存储用的_name得在__init__里直接赋值,不能通过self.name赋值。 @deleter用得很少,但当你需要在删除属性时做一些清理工作(比如释放资源、记录日志),它就派上用场了。- 方法名必须同名,这是Python装饰器机制决定的:
@property装饰的方法名是什么,@xxx.setter里的xxx就得是什么。
4.3 计算属性与只读属性的实际价值
@property还有一种非常经典的用法:不直接映射到某个存储属性,而是根据多个属性实时计算出一个“虚拟属性”。这类属性通常是只读的。
比如订单实付金额,按业务规则应该是:商品总额 - 满减优惠 + 运费。如果直接在代码里到处写这个公式,改规则的时候就要改很多地方,很容易漏。用计算属性可以把公式集中到一处:
python复制class Order:
def __init__(self, goods_total, coupon_discount, shipping_fee):
self._goods_total = goods_total
self._coupon_discount = coupon_discount
self._shipping_fee = shipping_fee
@property
def payable_amount(self):
"""计算属性:不存储,每次实时计算"""
return self._goods_total - self._coupon_discount + self._shipping_fee
外部使用的时候就是order.payable_amount,清新自然。而且因为它在内部读取的是三个私有属性,你以后把优惠逻辑改成阶梯满减,只需要动这一个方法,外部完全感知不到。这就是接口稳定的力量。
在我看来,计算属性是很多人忽略的@property用法,但它恰恰是封装中“变更隔离”价值的最佳体现。
4.4 property的本质:一个基于描述符协议的语法糖
很多教程讲到这里就结束了,但我觉得如果只知道语法不知道原理,遇到坑的时候会懵。所以花一小节点一下底层。
property本质上是一个实现了描述符协议的类。描述符协议就是__get__、__set__、__delete__这三个方法。@property装饰器把方法包进了一个property对象,这个对象在属性访问时自动调用对应的方法。
你可以直接用函数式写法等价替代装饰器语法:
python复制class Temperature:
def __init__(self, celsius):
self._celsius = celsius
def get_celsius(self):
return self._celsius
def set_celsius(self, value):
if value < -273.15:
raise ValueError("温度不能低于绝对零度")
self._celsius = value
celsius = property(get_celsius, set_celsius)
这行celsius = property(get_celsius, set_celsius)等价于用@property和@celsius.setter装饰出来的效果。了解这一点有什么好处?当你看到property(fget=None, fset=None, fdel=None, doc=None)这种写法,或者在某些框架源码里看到property直接用构造函数形式定义,就不会觉得陌生了。
理解了property是描述符,还能解释一个常见的坑:如果你在类里定义了一个普通属性和一个property同名,后者会覆盖前者;如果你在子类里重写了property的某个方法,必须连同getter一起重写,否则父类的property会被整体覆盖。这些问题光背语法是理解不了的,得靠原理。
5. 实战串联:一个订单类把所有封装手法都用起来
前面把知识点拆开了讲,这一章我把它们放回一个完整的业务场景里,看看真实的封装长什么样。为了保证可读性,我会把代码控制在能直接运行的长度,注释写清楚。
5.1 需求设计与封装边界
假设我们要写一个简化的订单系统,核心需求如下:
- 订单号在创建时确定,之后不可修改
- 订单金额必须在创建时校验,必须大于0
- 折扣范围必须在0到1之间,超过边界要报错
- 实际成交价是计算属性,由金额和折扣共同决定
- 订单状态严格按流程流转:未支付 -> 已支付 -> 已发货 -> 已完成
- 内部校验和状态流转判断方法不对外暴露
这个案例把私有属性、私有方法、@property计算属性、@property.setter校验全用上了。
5.2 完整代码实现与运行结果
python复制class Order:
# 合法状态及允许的流转目标
_ALLOWED_TRANSITIONS = {
"unpaid": {"paid"}, # 未支付 -> 已支付
"paid": {"shipped"}, # 已支付 -> 已发货
"shipped": {"completed"}, # 已发货 -> 已完成
"completed": set(), # 已完成,终态
}
def __init__(self, order_id, amount, discount=1.0):
self.__order_id = order_id # 订单号:只读
self.__amount = 0 # 先设默认值,再通过setter校验
self.amount = amount # 触发 @amount.setter
self.discount = discount # 触发 @discount.setter
self.__status = "unpaid"
# ---------- 只读属性 ----------
@property
def order_id(self):
return self.__order_id
# ---------- 金额:setter校验 ----------
@property
def amount(self):
return self.__amount
@amount.setter
def amount(self, value):
if not isinstance(value, (int, float)):
raise TypeError("金额必须是数字")
if value <= 0:
raise ValueError("金额必须大于0")
self.__amount = value
# ---------- 折扣:setter校验 ----------
@property
def discount(self):
return self.__discount
@discount.setter
def discount(self, value):
if not isinstance(value, (int, float)):
raise TypeError("折扣必须是数字")
if not (0 < value <= 1):
raise ValueError("折扣必须在(0, 1]范围内")
self.__discount = value
# ---------- 计算属性 ----------
@property
def payable_amount(self):
return round(self.__amount * self.__discount, 2)
# ---------- 状态流转:公开方法 ----------
def transition_to(self, target_status):
if target_status not in self._ALLOWED_TRANSITIONS:
raise ValueError(f"未知状态:{target_status}")
if target_status not in self._ALLOWED_TRANSITIONS[self.__status]:
raise ValueError(f"不允许从 {self.__status} 流转到 {target_status}")
self.__status = target_status
# ---------- 内部私有方法 ----------
def __validate_amount(self, value):
return value > 0
def __validate_discount(self, value):
return 0 < value <= 1
def __repr__(self):
return (f"Order(order_id={self.__order_id}, "
f"amount={self.__amount}, discount={self.__discount}, "
f"status={self.__status})")
测试一下:
python复制order = Order("20240001", 1000, 0.8)
print(order.payable_amount) # 800.0
print(order.order_id) # 20240001
print(order.status) # 报错,但没有status属性,说明状态被藏起来了
order.discount = 0.5
print(order.payable_amount) # 500.0
order.transition_to("paid") # 成功
order.transition_to("paid") # 报错:不允许从 paid 流转到 paid
order.transition_to("unpaid") # 报错:状态不能回退
运行结果:
text复制800.0
20240001
...
ValueError: 不允许从 paid 流转到 paid
看到没有,外部只能通过order_id、amount、discount、payable_amount、transition_to()这些公开接口和属性来操作订单。内部核心状态__status、存储属性__amount、__discount全部不可直接访问,校验逻辑全在setter里集中处理。这就是一个封装做得比较到位的类。
5.3 封装过程中最容易被问到的几个“为什么”
这个案例写完,我猜你内心一定有几个疑问,直接在这里解答。
“为什么__init__里要先写self.__amount = 0,再写self.amount = amount?”
因为amount是一个property,并且有setter,直接self.amount = amount会触发setter。如果setter里要求amount > 0,那么在__init__里首次赋一个正值是没问题的。但为了保险,我先给__amount一个默认值,再通过self.amount = amount走一轮setter校验。这样即使setter逻辑以后变复杂了,也不会影响初始化流程。
“为什么金额和折扣不用双下划线存储,而是用单下划线?”
在这个案例里,amount和discount都是通过property控制读写的,真正存储用的是__amount和__discount这种双下划线。实际上,用单下划线_amount也完全可以,因为外部已经有property这一层保护了,几乎不会绕过property直接访问存储属性。我这里的双下划线更多是为了示范name mangling的隔离效果,并且在担心子类覆盖时提供真正的保护。实际项目中,我更倾向于单下划线+property的组合,因为代码更简洁、更符合Python社区的普遍风格。
“为什么状态转移的方法叫transition_to而不是直接暴露status属性?”
因为订单状态流转是有规则的:你不能从“未支付”直接跳到“已完成”,也不能从“已支付”退回到“未支付”。如果暴露status属性,外部想怎么改就怎么改,规则形同虚设。用transition_to()这个公开方法,把状态迁移规则封装在方法内部,外部调用的只是“我想把订单变成已支付”,至于允不允许、怎么校验,是类自己说了算。这就是封装保护状态的最好体现。
6. 封装实战中踩过的坑与我的使用习惯
最后一章,不聊理论了,讲点实在的。这些年写Python,关于封装知识我踩过不少坑,也形成了一些相对稳定的习惯,拿出来给你做个参考。
6.1 不要为了“私有”而私有
第一个要提醒的就是:滥用私有化会让代码变得很难读。有些同学学完双下划线之后,所有属性前都加两个下划线,觉得这样才“封装严密”。结果就是:类内部到处是self.__foo、self.__bar,Debug的时候看__dict__一片_ClassName__前缀,阅读成本巨高。
我的判断标准很简单:私有的目的是缩小变更面,不是藏秘密。如果一个属性只是内部使用,但改了它不会影响外部行为,那用单下划线就够了;只有当你明确担心“外部调用者或者子类会误用/覆盖这个名字”的时候,才有必要上双下划线。绝大多数业务代码里,双下划线的使用频率远低于单下划线。
6.2 property的三个高频坑
第一个坑:property和同名字面属性冲突。在__init__里写self.balance = 0,而balance是一个只读property(没有setter),这行代码会直接抛AttributeError。很多人第一次遇到会懵:我明明只是初始化啊,怎么还报错?原因就是赋值触发了property的__set__,而只读property没有定义setter。解决办法是直接给存储属性赋值,比如self._balance = 0。
第二个坑:getter里别做重活。@property让人用起来像属性,但它本质上是一个方法调用。如果你在getter里写数据库查询、写网络请求、写大文件读取,那么外部每访问一次属性就会卡一次。有一次我排查一个性能问题,发现一个页面上有个字段用了getter,里面查了三次数据库,而模板循环里又访问了这个字段几十次,一次渲染就跑了几十次查询。后来我把这个字段改成在初始化时一次性算好缓存起来,性能立刻回升。需要缓存场景的,可以直接用functools.cached_property,这个标准库工具就是为此设计的。
第三个坑:继承中覆盖property的边界。子类如果只想改property的setter,不能只重写setter方法,必须把getter也一起重写,否则整个property都会被覆盖成普通方法或丢失。因为property对象是一个整体,你重定义同名方法时,实际上是创建了一个新的方法对象,而不是在原有property上打补丁。正确的做法是子类里把getter、setter都重新定义一遍,保持行为一致性。
6.3 封装、继承、多态三者的配合
封装是面向对象的第一步,但它从来不是孤立的。很多人搜“封装继承多态”,说明这三个概念在学习时是绑在一起的。
我的理解是:封装决定了每个类的边界内聚,继承决定了类之间的代码复用,多态决定了接口在不同子类中的行为差异。先有封装,你才知道哪些东西该暴露、哪些该隐藏,边界清楚了,继承才不会破坏父类的状态;继承关系清楚了,多态才能在子类中安全地替换父类行为。
所以学习路线建议是:先把封装的这一套玩明白,尤其是私有属性、私有方法和@property这三板斧,再去啃继承和多态,会顺很多。切不要跳过封装直接冲多态,到时候一个继承冲突就够你查一晚上的。
6.4 我个人目前的封装编码习惯
踩了这么多坑之后,我现在写Python类的习惯基本固定了,分享给大家作为参考:
对外公开的属性,一律用@property控制读写,存储用单下划线私有属性;只有涉及框架级类设计、担心子类属性覆盖时,才使用双下划线。私有方法默认用于“内部实现步骤”,用单下划线表示受保护;只有完全不希望子类访问的步骤才用双下划线。状态字段默认不暴露,通过公开方法控制流转。
这套习惯不是一蹴而就的,是经历了好几次线上问题、代码重构之后慢慢沉淀下来的。Python是一门信任开发者的语言,把设计意图写在命名里,比锁死访问权更符合这个社区的习惯。封装不是为了防别人,是为了让代码的边界清晰、让各自负责的部分各司其职——边界清楚了,改动才敢动手,重构才不慌。这也是我在项目里越来越依赖封装的原因。
