访问限制
访问限制用于回答一个问题:类外部的代码应该直接读写哪些数据,哪些数据必须通过类提供的行为访问?
Python 不像 Java 或 C++ 那样提供强制性的 private、protected 关键字。它更强调约定、清晰的接口和运行时可检查性。常见做法是:公开成员直接使用,内部成员以下划线标记,需要校验时用 @property 提供受控访问。
本文示例适用于 Python 3.10 及以上版本。学完后,你应该能够:
- 区分公开名称、单下划线名称和双下划线名称;
- 解释名称改写的目的和局限;
- 使用
@property控制属性的读取和修改; - 为可继承的类设计稳定、易用的公开接口。
先看一个完整例子
class BankAccount:
def __init__(self, owner: str, initial_balance: float = 0):
self.owner = owner # 公开属性
self._balance = initial_balance # 内部实现细节
@property
def balance(self) -> float:
return self._balance
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("存款金额必须大于 0")
self._balance += amount
account = BankAccount("小林", 100)
account.deposit(50)
print(account.owner) # 小林
print(account.balance) # 150
# account.balance = -1 # AttributeError:balance 没有 setter
调用方只需要知道 owner 和 deposit(),不必知道余额实际保存在 _balance 中。以后即使更换存储方式,公开接口仍然可以保持不变。
三种常见命名层级
公开成员:name
不以下划线开头的名称表示公开 API:
class User:
def __init__(self, name: str):
self.name = name
def greet(self) -> str:
return f"你好,我是{self.name}。"
公开成员应当是可以长期支持的接口。调用方可以正常读取、调用和根据文档使用它们。
单下划线成员:_name
单下划线表示“这是内部成员,请谨慎依赖”:
class User:
def __init__(self, name: str):
self._name = name
def display_name(self) -> str:
return self._name.strip()
单下划线不会阻止访问:user._name 仍然可以运行。它是一种沟通约定,告诉使用者该名称可能随实现变化,不属于稳定接口。
模块也可以使用单下划线标记内部函数。使用 from module import * 时,以单下划线开头的名称默认不会被导入;显式导入仍然可以访问它们。大型项目通常还会通过 __all__ 明确公开 API。
双下划线成员:__name
在类中以双下划线开头、且不以双下划线结尾的名称会触发名称改写(name mangling):
class User:
def __init__(self, token: str):
self.__token = token
def valid(self, token: str) -> bool:
return token == self.__token
user = User("secret")
print(user.valid("secret")) # True
# print(user.__token) # AttributeError
print(user._User__token) # secret,改写后的名称仍可访问
Python 会把 __token 改写为类似 _User__token 的名称。它的主要作用是降低子类无意覆盖父类属性的风险,不是加密,也不是安全边界。
名称改写有几个细节:
- 只处理类体中定义的名称,不处理局部变量;
- 末尾是双下划线的特殊方法(例如
__init__)不会被改写; - 名称最长部分会保留,类名中的前导下划线会被忽略;
- 子类写自己的
__token,通常会改写成另一个名字,从而与父类分开。
class Base:
def __init__(self):
self.__value = "base"
class Child(Base):
def __init__(self):
super().__init__()
self.__value = "child"
child = Child()
print(child.__dict__) # {'_Base__value': 'base', '_Child__value': 'child'}
如果子类确实需要扩展父类的内部状态,优先使用受支持的方法或单下划线成员,不要依赖改写后的 _Base__value 名称。
用 @property 提供受控属性
直接公开属性最简单,但有时需要在赋值时校验、在读取时计算,或者把旧方法平滑地升级为属性。property 可以保留自然的点号语法,同时把逻辑集中在类内部。
只读属性
只定义 getter 时,外部可以读取但不能赋值:
class Rectangle:
def __init__(self, width: float, height: float):
self._width = width
self._height = height
@property
def area(self) -> float:
return self._width * self._height
rectangle = Rectangle(3, 4)
print(rectangle.area) # 12
# rectangle.area = 20 # AttributeError
area 没有存储一份重复数据,而是在读取时根据宽和高计算,因此不会出现修改尺寸后面积过期的问题。
可读写属性和 setter
使用 @属性名.setter 为属性增加赋值逻辑:
class Temperature:
def __init__(self, celsius: float):
self.celsius = celsius
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError("温度不能低于绝对零度")
self._celsius = float(value)
temperature = Temperature(20)
temperature.celsius = 25
print(temperature.celsius) # 25.0
setter 应负责维护对象的不变量。校验失败时抛出明确异常,比让非法状态悄悄进入对象更容易定位问题。
删除属性
如果确实需要支持 del object.attribute,可以实现 deleter:
class Cache:
def __init__(self, value: str | None = None):
self._value = value
@property
def value(self) -> str | None:
return self._value
@value.deleter
def value(self) -> None:
self._value = None
没有明确的删除语义时,不必提供 deleter,避免接口行为过于复杂。
访问限制与可变对象
即使属性本身是只读的,如果它返回一个可变对象,调用方仍可能修改内部状态:
class Playlist:
def __init__(self, songs: list[str]):
self._songs = list(songs)
@property
def songs(self) -> list[str]:
return self._songs # 暴露了内部列表
playlist = Playlist(["A", "B"])
playlist.songs.append("C") # 绕过了类的方法
如果需要保护内部列表,可以返回副本或不可变视图:
class Playlist:
def __init__(self, songs: list[str]):
self._songs = list(songs)
@property
def songs(self) -> tuple[str, ...]:
return tuple(self._songs)
def add(self, song: str) -> None:
self._songs.append(song)
返回副本会产生少量时间和内存开销。是否复制取决于接口约定:需要让调用方修改,就明确提供方法;只允许读取,就不要无意中泄露可变引用。
继承时如何设计内部成员
单下划线通常适合需要被子类复用的内部成员:
class Report:
def __init__(self, rows: list[str]):
self._rows = rows
def _format_row(self, row: str) -> str:
return row.strip()
class HtmlReport(Report):
def render(self) -> str:
return "<br>".join(self._format_row(row) for row in self._rows)
双下划线更适合不希望子类无意覆盖的实现细节,但它也会让子类定制和调试更麻烦。不要把所有成员都写成 __name;根据继承需求选择合适的可见性约定。
常见误区
误区一:以为双下划线可以保护密码
名称改写只是改名,熟悉类名的人仍可通过 _ClassName__field 访问。真正的密钥、令牌和密码需要使用专门的秘密管理与权限控制,不能依赖 Python 命名规则。
误区二:为每个属性机械地写 getter 和 setter
没有校验、计算或兼容需求时,直接使用公开属性更清楚:
class Point:
def __init__(self, x: float, y: float):
self.x = x
self.y = y
只有当接口需要行为时,才引入 property。Python 的一个优点是:可以先公开属性,后来再改成同名 property,调用方的 point.x 写法无需改变。
误区三:把“内部”理解成“绝对不能调用”
单下划线是约定,不是权限系统。测试、调试和框架代码有时需要访问内部成员,但业务代码应优先依赖公开接口,以减少对实现细节的耦合。
设计建议
- 先设计少量稳定的公开方法和属性,再决定内部如何存储;
- 用单下划线表达实现细节,用双下划线避免特定的子类命名冲突;
- 需要校验或计算时使用
@property,不要让调用方手动维护不变量; - 返回集合时明确是否允许调用方修改,必要时返回副本或元组;
- 访问限制解决的是接口设计问题,不替代认证、授权和秘密管理。
练习
练习一:积分账户
定义 PointsAccount 类:
- 公开
owner属性; - 使用
_points保存积分; points只读,积分不能为负数;- 提供
earn(value)和spend(value),拒绝非正数和超额消费。
练习二:安全的标签集合
定义 Article 类,内部保存标签列表:
add_tag(tag)负责去重和空白校验;tags返回元组,调用方不能直接修改内部列表;- 尝试说明为什么直接返回
_tags会破坏封装。
小结
- Python 没有强制的
private关键字,访问限制主要依靠命名约定和接口设计; name表示公开成员,_name表示内部成员,__name会触发名称改写;- 双下划线不是加密或安全边界,改写后的名称仍然可以访问;
@property能在保持点号语法的同时执行校验、计算和只读控制;- 保护对象状态时还要注意不要返回内部可变对象;
- 面向子类复用的内部成员通常使用单下划线,稳定公开接口应尽量精简。