引言
第八章「面向对象入门」共十篇,前三篇已经回答了三个基础问题:第 1 篇《类与实例》确立了类(class)是模板、实例(instance)是产品的思维模型;第 2 篇的 __init__ 负责在对象诞生时布置好初始状态;第 3 篇的 self 机制解释了方法体内为什么能拿到「谁在调用」这份数据。这三篇建立的世界观有一个共同点——属性们还处于「裸奔」状态:程序任何角落都可以直接执行 account.balance = -99 这样的赋值,而没有任何机制拦下它。
本篇要做两件事。第一,讲清面向对象三大特征之首的封装(encapsulation):把数据与操作数据的行为聚合在一起,让「修改状态的入口」可控,而不是把业务规则散落在全项目的赋值语句里。第二,介绍 Python 实现「受控属性」的两个层次:下划线命名约定(含名称改写机制 name mangling),以及 @property 装饰器这把「属性访问的守卫」。我们会用银行账户、温度与成绩三个贴近真实的例子,实测名称改写与 property 各部件的实际行为,最后给出 property 与普通方法的取舍原则。
阅读前提:已掌握前三篇的类定义、__init__ 与实例方法。魔法方法(__str__/__eq__ 等)留给第 7 篇,本篇只在需要处点到即止;继承与多态分别由第 5、6 篇负责,这里不提前展开。
概念与原理
封装:数据与行为的聚合
面向对象程序设计(OOP)的三大特征是封装、继承、多态。所谓封装,就是把数据(对象的状态)和操作这些数据的函数(对象的行为)绑定在同一个对象里,并且只对外开放经过设计的窄接口,把内部细节藏起来。这件事有两层含义。
第一层是聚合:状态必须和行为住在一起。设想一个银行账户系统——余额(状态)与存款、取款(行为)天然属于同一个概念。如果不封装,你会写出 update_balance(accounts, name, -200) 这样的游离函数,调用前还要先查一遍账户结构;而封装之后,account.withdraw(200) 一句话就把「操作哪个对象的哪个状态」交代清楚了。
第二层是信息隐藏(information hiding):对象对外只暴露「做什么」,不暴露「怎么存」。余额是否非负、取款能不能透支——这些规则只有账户自己最清楚。若任何代码都能直接改 balance,规则的执行点就散落在项目各处的赋值语句里,哪天产品要求「余额下限改为 100」,你得全局搜索所有给 balance 赋值的地方,漏一处就是线上事故。封装的本质就是给状态变更收口:所有修改必须经过类自己提供的入口,规则只在入口处写一遍。
Python 的「私有」哲学:没有真正的 private
C++/Java 提供 private/protected 关键字,由语言在编译期强制外部不可访问。Python 没有这个机制——这不是疏忽,而是设计取舍:Python 社区信奉一句著名的调侃「我们都是自愿的成年人」(We are all consenting adults),即语言信任程序员,约定与文档比强制更符合动态语言的气质。于是「私有」在 Python 里退化为两档命名约定:
- 单下划线
_x:纯粹写给读代码的人看的提示,表示「这是内部实现细节,请勿直接访问」。它没有任何机制约束——访问照样成功,赋值照样生效。from module import *不会导入下划线开头的顶层名字,这是它仅有的微薄「特权」。 - 双下划线
__x:触发名称改写(name mangling)。类定义内部出现的__x形式的标识符,在编译阶段会被改写成_ClassName__x。它主要不是为了防外部访问,而是防止子类意外覆盖父类的内部名字——这是一个防冲突机制,不是安全机制。
名称改写在「编译」时刻完成,只作用于类体内部的源码。这带来两个关键推论,我们稍后会在实测里看到:第一,类外写 obj.__x = 1 不会触发改写,得到的只是一个名为 __x 的普通属性;第二,改写后的名字 _ClassName__x 依然可以正常读写。所以「双下划线 = 绝对私有」是误解,真相是:Python 的一切属性都是公开的,所谓私有只是约定。
property:把方法包装成属性
假设你第一版代码用了普通属性 balance,三个月后需求变更:余额不允许为负。若把 balance 改成方法 get_balance()/set_balance(),所有调用方都要加括号、改名字——改动成本巨大。@property 就是为「先用普通属性、后收紧入口」这类场景准备的:它把一个方法变成「读起来像属性」的访问器,调用方完全无感。
property 的基本形态分三件套:@property 修饰的 getter(读)、@属性名.setter 修饰的 setter(写)、@属性名.deleter 修饰的 deleter(配合 del obj.attr)。规则如下:只有 getter 时属性只读,赋值直接抛 AttributeError;setter 负责校验并写入真实的存储字段(我们约定用 self._score 这类下划线字段存真值,避免与属性名冲突);deleter 在删除时收尾。特别注意:__init__ 里的 self.score = value 也会走 setter——所以只要构造函数里也走属性赋值,对象诞生的那一刻校验就在岗。
property 与普通方法的取舍
判断标准只有一条:它「是」一个数据,还是一个「动作」?
- 读起来是数据、无副作用、计算开销小——用 property。典型如「面积 = 宽 × 高」「含税价 = 原价 × 1.13」「开尔文 = 摄氏 + 273.15」。调用方写成
r.area比r.area()更贴合「它就是 x 的一个属性」的直觉。 - 触发动作、改变状态、或开销大到不该被隐式重复执行——用方法。典型如
scale(2)(放大矩形)、save()、send_email()。若把「发邮件」做成 property,别人读一次属性就发一封邮件,这种隐式副作用是事故之源。
经验法则:如果属性名需要动词(calculate_、get_、refresh_),它多半应该是方法;如果它只是另一个字段的纯函数(派生值、换算值),property 是更好的选择。
操作与实现
聚合演示:没有守卫的世界
先说清问题本身——下面的代码把数据和行为绑在了一起,但校验形同虚设,因为外部仍能绕过方法直接改属性。
class BankAccount:
"""把余额与操作余额的行为绑定在一起。"""
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("存款金额必须为正数")
self.balance += amount
def withdraw(self, amount):
if amount <= 0:
raise ValueError("取款金额必须为正数")
if amount > self.balance:
raise ValueError("余额不足")
self.balance -= amount
acct = BankAccount("小林", 1000)
acct.deposit(500)
acct.withdraw(200)
print(acct.owner, acct.balance)
acct.balance = -99
print("直接改属性后的余额:", acct.balance)
小林 1300
直接改属性后的余额: -99
注意第二行:方法内部的规则「余额不得为负」被一句 acct.balance = -99 直接击穿。第三节要介绍的 property,正是给这类字段补上门卫的手段。
名称改写实测
下面这段代码把单下划线、双下划线、名称改写后的名字、dir() 观察一次看全。
class Account:
def __init__(self):
self._balance = 100 # 单下划线:约定,不强制
self.__password = "abc123" # 双下划线:名称改写
def show(self):
print("类内部访问 __password:", self.__password)
a = Account()
print("外部访问 _balance:", a._balance)
a.show()
print("实例属性表:", a.__dict__)
print("改写后的名字:", a._Account__password)
print("原名是否还在:", hasattr(a, "__password"))
print([n for n in dir(a) if "password" in n])
外部访问 _balance: 100
类内部访问 __password: abc123
实例属性表: {'_balance': 100, '_Account__password': 'abc123'}
改写后的名字: abc123
原名是否还在: False
['_Account__password']
逐条解读:_balance 外部照读不误,印证单下划线只是约定;类内部写 self.__password 依然好使,因为类体内自己也会改写自己;__dict__ 显示真实存储的键名是 _Account__password;用改写后的名字 a._Account__password 可以从外部直接读写——这就是「没有真正的私有」的铁证;hasattr(a, "__password") 为 False,说明原名荡然无存,dir() 里也只剩改写后的名字。名称改写的面目,至此清清楚楚。
只读派生属性
先看只有 getter 的 property——它像「只读的计算字段」。摄氏度换算开尔文,就是一个典型派生值:任何时刻它都由 _celsius 决定,不该被外部单独赋值。
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@property
def kelvin(self):
"""只读派生属性:摄氏度换算为开尔文。"""
return self._celsius + 273.15
t = Temperature(25)
print(t.celsius, "°C =", t.kelvin, "K")
try:
t.kelvin = 300
except AttributeError as e:
print("拦截:", e)
25 °C = 298.15 K
拦截: property 'kelvin' of 'Temperature' object has no setter
setter:为写入加校验
现实世界的物理约束交给了 setter。注意构造函数里 self.celsius = celsius 走的就是 setter——所以 Temperature(-300) 同样会被拦下,校验从对象出生一刻就在岗。
class Temperature:
def __init__(self, celsius):
self.celsius = celsius # 赋值即触发 setter 校验
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("温度不能低于绝对零度 -273.15°C")
self._celsius = value
t = Temperature(25)
t.celsius = -50
print("调到 -50°C:", t.celsius)
try:
t.celsius = -300
except ValueError as e:
print("拦截:", e)
调到 -50°C: -50
拦截: 温度不能低于绝对零度 -273.15°C
-273.15 是热力学绝对零度——开尔文温标(以英国物理学家威廉·汤姆森,即开尔文勋爵命名的 SI 温度单位)的基准点,任何温度都不能低于它。这是物理规则,天然适合写死在 setter 里。业务规则同理——成绩必须落在 0 到 100 之间:
class Student:
def __init__(self, name, score):
self.name = name
self.score = score # 走 setter,构造时即校验
@property
def score(self):
return self._score
@score.setter
def score(self, value):
if not 0 <= value <= 100:
raise ValueError("成绩必须在 0 到 100 之间")
self._score = value
s = Student("小张", 85)
print(s.name, s.score)
try:
s.score = 150
except ValueError as e:
print("拦截:", e)
s.score = 92
print("更新后:", s.score)
小张 85
拦截: 成绩必须在 0 到 100 之间
更新后: 92
if not 0 <= value <= 100 是 Python 特有的链式比较——0 <= value 与 value <= 100 同时成立才通过,比 0 <= value and value <= 100 读起来更贴近数学记法。
取舍现场
一个类里同时出现 property 与方法的对照:area 是派生数据,用 property;scale 是改变状态的动作,用方法。
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@property
def area(self):
"""计算型属性:无副作用、开销小,适合 property。"""
return self.width * self.height
def scale(self, factor):
"""改变状态的动作用方法:有副作用。"""
self.width *= factor
self.height *= factor
r = Rectangle(3, 4)
print("面积:", r.area)
r.scale(2)
print("放大后:", r.width, r.height, r.area)
面积: 12
放大后: 6 8 48
deleter:删除时的收尾
property 还有第三件套 deleter,对应 del obj.attr 语句。典型用途是「清除敏感配置后无残留」:
class Config:
def __init__(self):
self._token = "secret-abc"
@property
def token(self):
return self._token
@token.deleter
def token(self):
print("deleter 触发,清除令牌")
del self._token
c = Config()
print(c.token)
del c.token
try:
print(c.token)
except AttributeError as e:
print("拦截:", e)
secret-abc
deleter 触发,清除令牌
拦截: 'Config' object has no attribute '_token'
deleter 执行 del self._token 删掉了真实存储字段,之后 getter 再读 self._token 便抛 AttributeError——这正是我们期望的「删干净」语义。
易错点与陷阱
易错点一:setter 里写错存储名,无限递归。 存储字段必须用下划线命名(如 self._score),若 setter 内部写 self.score = value,而 score 恰好就是 property 名,赋值会再次触发 setter——无限递归直到崩溃。
class Student:
def __init__(self, name, score):
self.name = name
self.score = score
@property
def score(self):
return self._score
@score.setter
def score(self, value):
self.score = value # 写错存储名:递归调用自身
try:
s = Student("小张", 85)
except RecursionError as e:
print("拦截:", e)
拦截: maximum recursion depth exceeded
Python 的保护机制 RecursionError(递归深度上限,默认约 1000 层)在这里兜了底,但报错信息对新手很不友好。记住口诀:property 名管读写,下划线名管存储。
易错点二:把名称改写当成安全机制。 双下划线挡不住任何人:_Account__password 可以直读直写;在类外写 obj.__x = 1 甚至不会触发改写,得到的只是一个与类内 __x 毫无关系的普通属性,两处数据各自为政。真实场景中,把「密码」「密钥」等敏感字段寄希望于双下划线,等于裸奔。Python 的防御思路是:用单下划线表达「请勿触碰」的约定,敏感数据根本不要存在对象属性里,或配合加密存储——语言层面的「私有」不存在。
易错点三:构造函数绕过 setter 导致「未校验的出生」。 若 __init__ 里写 self._score = score 而不是 self.score = score,Student("小张", 150) 会带着非法值诞生,因为校验只在 setter 里。一致性原则:类内凡是「外部也能改的公开属性」,赋值一律走 property;只有真·内部字段才直接操作下划线名。
易错点四:在 property 里做重活。 getter 每次访问都会执行。若 getter 里发了网络请求或做了 O(n²) 计算,print(r.area) 这种「读一下」就会卡住程序,且每次读都重复执行。耗时、有副作用的逻辑请放回普通方法,让读者一眼看到「这是在干活」。
小结
封装的第一要义是数据与行为的聚合,第二要义是给状态变更收口。Python 没有真正的 private:单下划线 _x 是约定,双下划线 __x 是编译期的名称改写(_ClassName__x),防冲突不防人,_Class__x 与 dir() 实测均可证。@property 把方法伪装成属性,让「先用普通属性、后收紧校验」成为可能:getter 定义读法,setter 负责校验与存储,deleter 处理删除;只读属性不该有 setter,派生值用 property,动作与重活用方法;构造赋值也走 setter,校验才能从对象出生就在岗。
练习与思考题
- 给「成绩」例子扩展:为
Student增加一个只读 propertylevel,score >= 90返回「优秀」、>= 80返回「良好」、>= 60返回「及格」、否则返回「不及格」。要求把 setter 和 level 都在同一个类里实现,并验证Student("小张", 95).level的输出。 - 设计一个
Circle类:radius校验非负;diameter与circumference为只读派生属性(周长 = 2 × π × r,用round保留两位小数)。思考:为什么直径和周长不该允许外部赋值? - 思考题:既然
_balance完全挡不住外部读写,封装还有意义吗?请从「规则集中在一处维护」与「调用方心智负担」两个角度给出你的答案,并举例说明。 - 判断题,逐条判断并说明理由:a)
del obj.attr一定会执行 deleter;b) 当attr是带 deleter 的 property 时,del obj.attr执行 deleter(注意删的是哪个名字);c) 当attr是普通实例属性时,del obj.attr会抛AttributeError。提示:先想del对普通属性的语义,再对照 property 的三件套;b 与 c 恰好一真一假。