跳到主要内容

使用 @property

@property 可以把一个方法包装成“像属性一样使用”的接口。调用方写 account.balance,类内部仍然可以执行计算、校验、转换或访问其他存储。这样既保留了点号语法的简洁,又不会把实现细节直接暴露给外部代码。

本文示例适用于 Python 3.10 及以上版本。学完后,你应该能够:

  • 使用 getter、setter 和 deleter 定义属性行为;
  • 区分只读属性、可读写属性和计算属性;
  • 在 setter 中维护对象不变量,避免递归赋值;
  • 理解 property 与描述器、继承和抽象基类的关系;
  • 判断什么时候应该使用普通属性、property 或方法。

先看一个完整例子​

下面的订单对象把金额统一保存为“分”,对外仍提供元为单位的属性。读取时格式化订单摘要,赋值时自动校验并转换输入:

class Order:
def __init__(self, customer: str, total_yuan: float):
self.customer = customer
self.total_yuan = total_yuan

@property
def total_yuan(self) -> float:
"""以元为单位返回金额。"""
return self._total_cents / 100

@total_yuan.setter
def total_yuan(self, value: float | str) -> None:
if isinstance(value, bool):
raise TypeError("订单金额不能是布尔值")
try:
amount = float(value)
except (TypeError, ValueError) as error:
raise TypeError("订单金额必须是数字") from error
if amount < 0:
raise ValueError("订单金额必须是非负数")
self._total_cents = round(amount * 100)

@property
def summary(self) -> str:
return f"{self.customer}:¥{self.total_yuan:.2f}"


order = Order("小林", 19.9)
print(order.total_yuan) # 19.9
order.total_yuan = "29.90"
print(order.summary) # 小林:¥29.90
# order.total_yuan = -1 # ValueError

调用方不需要知道金额实际保存在 _total_cents 中,也不需要手动调用 get_total() 或 set_total()。setter 还是对象创建时的统一入口,因此初始化和后续修改遵守同一套规则。

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: property 'area' ... has no setter

area 没有单独保存一份值,而是在读取时计算。宽度或高度改变后,面积自然保持最新。只读并不等于绝对不可变:如果类提供了改变 width、height 的接口,area 的结果仍会变化。

可读写属性​

在同名 property 方法后使用 @属性名.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 通常做三件事:检查类型或范围、把输入规范化成内部类型、写入内部存储。校验失败时应尽早抛出 TypeError 或 ValueError,不要让对象进入无法解释的状态。

删除属性​

只有业务上确实存在“删除”的含义时,才定义 deleter:

class CacheEntry:
def __init__(self, value: str | None):
self._value = value

@property
def value(self) -> str | None:
return self._value

@value.deleter
def value(self) -> None:
self._value = None


entry = CacheEntry("结果")
del entry.value
print(entry.value) # None

如果删除会破坏对象不变量,宁可不提供 deleter,让 del entry.value 直接失败,也不要定义含义模糊的删除操作。

装饰器语法等价于 property()​

下面两种写法效果相同:

class User:
def __init__(self, name: str):
self._name = name

@property
def name(self) -> str:
return self._name

@name.setter
def name(self, value: str) -> None:
if not value.strip():
raise ValueError("姓名不能为空")
self._name = value.strip()
class User:
def __init__(self, name: str):
self._name = name

def get_name(self) -> str:
return self._name

def set_name(self, value: str) -> None:
if not value.strip():
raise ValueError("姓名不能为空")
self._name = value.strip()

name = property(get_name, set_name)

property(fget=None, fset=None, fdel=None, doc=None) 接收读取、写入、删除函数和文档字符串。装饰器版本通常更易读,显式调用 property() 适合需要动态组装属性或阅读旧代码时使用。

@property 装饰的方法名决定了公开属性名;setter 必须装饰同一个 property:

class Score:
@property
def value(self) -> int:
return self._value

@value.setter
def value(self, number: int) -> None:
self._value = number

如果误写成 @property 修饰了另一个名字,或者把 setter 赋给了不同名称,就不会得到预期的属性接口。

设计 getter:返回什么、何时计算​

计算属性不应保存重复状态​

当一个值可以由其他状态稳定推导出来时,优先实时计算:

class Cart:
def __init__(self, prices: list[float]):
self.prices = list(prices)

@property
def total(self) -> float:
return sum(self.prices)

如果同时保存 self._total 和 self.prices,每次价格变化都必须同步两个值,容易产生过期数据。只有计算成本很高、读取频率远高于修改频率时,才考虑缓存,并明确缓存失效策略。

property 不一定比方法更合适​

适合 property 的读取通常是轻量、无副作用、看起来像对象状态的操作,例如 area、full_name、is_expired。以下场景通常应该使用方法:

  • 操作需要参数,例如 convert_to("USD");
  • 操作可能执行 I/O、网络请求或昂贵计算;
  • 操作会修改状态或产生明显副作用;
  • 调用方需要知道这是一次动作而不是读取状态。

不要因为语法简短,就把发送邮件、查询数据库之类的动作伪装成 user.email。

返回可变对象时注意封装​

只读 property 不能阻止调用方修改它返回的列表:

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:
if not song.strip():
raise ValueError("歌曲名不能为空")
self._songs.append(song.strip())

返回元组表达不可变视图,返回副本则允许调用方自由修改副本。两者都不会泄露内部列表的可变引用,但副本会有额外的复制成本。

setter 的校验、转换与不变量​

不要在 setter 中给同名属性赋值​

setter 应写入内部名称,例如 _celsius,而不是再次写入 self.celsius:

class Correct:
@property
def value(self) -> int:
return self._value

@value.setter
def value(self, number: int) -> None:
self._value = number


class Recursive:
@property
def value(self) -> int:
return self._value

@value.setter
def value(self, number: int) -> None:
# self.value = number # 再次触发 setter,最终 RecursionError
self._value = number

这是使用 property 最常见的错误之一。内部存储可以采用单下划线名称、__name 名称改写,或其他明确的后端存储,但不要与 property 同名。

保持跨字段不变量​

有些规则涉及多个字段,应在一个公共入口中一起校验:

class DateRange:
def __init__(self, start: int, end: int):
self._start = 0
self._end = 0
self._set_range(start, end)

@property
def start(self) -> int:
return self._start

@property
def end(self) -> int:
return self._end

def _set_range(self, start: int, end: int) -> None:
if start > end:
raise ValueError("起点不能晚于终点")
self._start, self._end = start, end

如果分别提供 start.setter 和 end.setter,其中一次赋值可能暂时破坏“start 不大于 end”的规则。此时可以提供 move() 这样的整体操作,或者在每次 setter 中完整验证新的组合状态。

property 与继承​

子类可以覆盖 getter 或增加 setter​

property 是类属性,子类可以重写它:

class Document:
@property
def title(self) -> str:
return "未命名"


class Report(Document):
def __init__(self, title: str):
self._title = title

@property
def title(self) -> str:
return self._title

如果父类提供只读 property,子类可以重新声明同名 property 并添加 setter,但应确认这不会违反父类原本对调用方的承诺。把只读接口突然变成可写接口通常是兼容的扩展;改变读取结果或异常约定则需要谨慎。

使用 super() 复用父类 property​

重写 setter 时,如果还需要父类的校验,可以通过 super() 获取父类 property:

class Person:
def __init__(self, name: str):
self.name = name

@property
def name(self) -> str:
return self._name

@name.setter
def name(self, value: str) -> None:
value = value.strip()
if not value:
raise ValueError("姓名不能为空")
self._name = value


class Employee(Person):
@Person.name.setter
def name(self, value: str) -> None:
value = value.strip()
if len(value) > 30:
raise ValueError("姓名不能超过 30 个字符")
# 追加规则通过后,再复用父类的非空校验和存储逻辑
Person.name.fset(self, value)

在简单场景中,直接使用 @Person.name.setter 最清晰。若继承层次复杂,应该用最小示例测试 property 的覆盖顺序,并避免依赖内部的 fget、fset 实现细节。

抽象 property​

抽象基类可以要求所有子类提供某个属性。组合装饰器时,先写 @property,再写 @abstractmethod:

from abc import ABC, abstractmethod


class Shape(ABC):
@property
@abstractmethod
def area(self) -> float:
"""返回图形面积。"""


class Square(Shape):
def __init__(self, side: float):
self.side = side

@property
def area(self) -> float:
return self.side**2


# Shape() # TypeError:抽象 property 未实现
print(Square(3).area) # 9

如果抽象属性还必须可写,可以在基类中同时声明抽象 getter 和 setter;子类需要实现完整的读写契约,而不是只实现 getter。

property 的底层:描述器​

property 是一种内置描述器。类属性只要实现 __get__()、__set__() 或 __delete__(),就能参与点号访问协议:

class LoggedValue:
def __set_name__(self, owner, name: str) -> None:
self.name = name
self.storage_name = f"_{name}"

def __get__(self, instance, owner=None):
if instance is None:
return self
return getattr(instance, self.storage_name, None)

def __set__(self, instance, value) -> None:
print(f"设置 {self.name} = {value!r}")
setattr(instance, self.storage_name, value)


class Settings:
theme = LoggedValue()


settings = Settings()
settings.theme = "dark"
print(settings.theme) # dark

学习 property 时不必马上手写描述器;只有当同一套属性逻辑需要复用到多个字段,或需要控制类创建与实例属性访问时,才考虑实现描述器。property 是更直接、更易读的单字段方案。

常见误区​

把 property 当作安全机制​

Python 的 property 主要用于接口设计和状态校验,不是权限边界。调用方仍可以访问 _value,也可以通过反射、调试器或修改类来绕过约定。真正的安全控制应放在权限验证、数据访问层和输入边界。

在 getter 中产生意外副作用​

用户看到 report.summary 通常会认为这只是读取。如果 getter 修改状态、写文件或发网络请求,调用时机就变得不可预测。把动作写成方法可以让成本和副作用更明显。

为每个字段机械地添加 property​

没有校验、转换或计算逻辑时,直接使用公开属性往往更简单:

class Point:
def __init__(self, x: float, y: float):
self.x = x
self.y = y

如果未来需要校验,通常可以把内部字段改为 _x,再增加 x property。点号调用代码不需要改变,这正是 property 的演进价值。

忽略文档和类型注解​

property 是公开 API,应说明单位、返回值、是否允许赋值以及可能抛出的异常。类型注解不能替代运行时校验,但可以帮助 IDE、类型检查器和阅读者理解契约。

什么时候使用 @property​

可以用下面的判断快速选择:

  1. 它是否表现得像对象的状态,而不是一个动作?是,优先考虑 property。
  2. 读取是否轻量、无明显副作用?不是,考虑方法。
  3. 是否需要在赋值时校验、转换或维护不变量?是,使用 setter。
  4. 是否只是简单字段转发,没有任何行为?普通公开属性可能更清楚。
  5. 是否需要同一套描述器逻辑应用于许多字段?考虑自定义描述器,而不是复制大量 property。

小结​

  • @property 把方法包装成自然的点号属性,适合计算、校验和兼容接口演进;
  • 只写 getter 得到只读属性,使用 @name.setter 和 @name.deleter 添加写入、删除行为;
  • setter 应写入不同名的内部存储,避免 self.name = ... 触发无限递归;
  • 计算属性不要保存重复状态,返回可变对象时要防止内部引用泄露;
  • property 可以被继承、覆盖,也可以与抽象基类组合;
  • property 本质上是描述器,但大多数单字段封装不需要手写描述器;
  • 没有行为的字段不必机械地使用 property,接口应保持简单。