AI 技术博客
返回首页
Python 基础 21 分钟阅读

封装与 property:属性访问的守卫

Python面向对象封装property

引言

第八章「面向对象入门」共十篇,前三篇已经回答了三个基础问题:第 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,校验才能从对象出生就在岗。

练习与思考题

  1. 给「成绩」例子扩展:为 Student 增加一个只读 property level,score >= 90 返回「优秀」、>= 80 返回「良好」、>= 60 返回「及格」、否则返回「不及格」。要求把 setter 和 level 都在同一个类里实现,并验证 Student("小张", 95).level 的输出。
  2. 设计一个 Circle 类:radius 校验非负;diameter 与 circumference 为只读派生属性(周长 = 2 × π × r,用 round 保留两位小数)。思考:为什么直径和周长不该允许外部赋值?
  3. 思考题:既然 _balance 完全挡不住外部读写,封装还有意义吗?请从「规则集中在一处维护」与「调用方心智负担」两个角度给出你的答案,并举例说明。
  4. 判断题,逐条判断并说明理由:a) del obj.attr 一定会执行 deleter;b) 当 attr 是带 deleter 的 property 时,del obj.attr 执行 deleter(注意删的是哪个名字);c) 当 attr 是普通实例属性时,del obj.attr 会抛 AttributeError。提示:先想 del 对普通属性的语义,再对照 property 的三件套;b 与 c 恰好一真一假。