跳到主要内容

访问限制

访问限制用于回答一个问题:类外部的代码应该直接读写哪些数据,哪些数据必须通过类提供的行为访问?

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 写法无需改变。

误区三:把“内部”理解成“绝对不能调用”​

单下划线是约定,不是权限系统。测试、调试和框架代码有时需要访问内部成员,但业务代码应优先依赖公开接口,以减少对实现细节的耦合。

设计建议​

  1. 先设计少量稳定的公开方法和属性,再决定内部如何存储;
  2. 用单下划线表达实现细节,用双下划线避免特定的子类命名冲突;
  3. 需要校验或计算时使用 @property,不要让调用方手动维护不变量;
  4. 返回集合时明确是否允许调用方修改,必要时返回副本或元组;
  5. 访问限制解决的是接口设计问题,不替代认证、授权和秘密管理。

练习​

练习一:积分账户​

定义 PointsAccount 类:

  • 公开 owner 属性;
  • 使用 _points 保存积分;
  • points 只读,积分不能为负数;
  • 提供 earn(value) 和 spend(value),拒绝非正数和超额消费。

练习二:安全的标签集合​

定义 Article 类,内部保存标签列表:

  • add_tag(tag) 负责去重和空白校验;
  • tags 返回元组,调用方不能直接修改内部列表;
  • 尝试说明为什么直接返回 _tags 会破坏封装。

小结​

  • Python 没有强制的 private 关键字,访问限制主要依靠命名约定和接口设计;
  • name 表示公开成员,_name 表示内部成员,__name 会触发名称改写;
  • 双下划线不是加密或安全边界,改写后的名称仍然可以访问;
  • @property 能在保持点号语法的同时执行校验、计算和只读控制;
  • 保护对象状态时还要注意不要返回内部可变对象;
  • 面向子类复用的内部成员通常使用单下划线,稳定公开接口应尽量精简。