在 Python 中,属性访问是最常见的操作之一。但简单的属性赋值往往不够——你可能需要校验、计算或延迟加载。@property 装饰器和描述符协议提供了优雅的解决方案,让你能在不破坏外部接口的前提下控制属性行为。

@property 装饰器

从 getter/setter 到 property

我们先看一个传统 getter/setter 的写法:

class Person:
    def __init__(self, name: str, age: int):
        self._name = name
        self._age = age

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

    def set_name(self, value: str) -> None:
        if not value.strip():
            raise ValueError("Name cannot be empty")
        self._name = value


p = Person("Alice", 30)
print(p.get_name())
p.set_name("Bob")

这种 Java 风格在 Python 中并不推荐。@property 提供了更 Pythonic 的方式:

class Person:
    def __init__(self, name: str, age: int):
        self._name = name
        self._age = age

    @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

    @name.deleter
    def name(self) -> None:
        print(f"删除属性: {self._name}")
        del self._name


p = Person("Alice", 30)

# 像普通属性一样访问
print(p.name)      # Alice(调用 getter)

# 像普通属性一样赋值
p.name = "Bob"     # 调用 setter
print(p.name)      # Bob

# p.name = ""      # ValueError: 姓名不能为空

# 删除属性
del p.name         # 删除属性: Bob

property 的基本语法

class Temperature:
    def __init__(self, celsius: float = 0):
        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 = value

    # 计算属性:没有 setter,只读
    @property
    def fahrenheit(self) -> float:
        return self._celsius * 9 / 5 + 32


t = Temperature(100)
print(t.celsius)     # 100
print(t.fahrenheit)  # 212.0

t.celsius = 0
print(t.fahrenheit)  # 32.0

# t.fahrenheit = 50  # AttributeError: can't set attribute

Property 的函数式创建

property() 也可以作为函数调用:

class Circle:
    def __init__(self, radius: float):
        self._radius = radius

    def _get_radius(self) -> float:
        return self._radius

    def _set_radius(self, value: float) -> None:
        if value <= 0:
            raise ValueError("半径必须为正数")
        self._radius = value

    def _del_radius(self) -> None:
        print("删除半径")
        del self._radius

    radius = property(_get_radius, _set_radius, _del_radius, "圆的半径")

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

    @property
    def diameter(self) -> float:
        return self._radius * 2

计算属性

计算属性不存储值,而是在访问时计算:

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

    @property
    def perimeter(self) -> float:
        return 2 * (self.width + self.height)

    @property
    def is_square(self) -> bool:
        return abs(self.width - self.height) < 1e-9


r = Rectangle(4, 5)
print(r.area)        # 20
print(r.perimeter)   # 18
print(r.is_square)   # False

r.width = 5
print(r.is_square)   # True

带依赖的计算属性

class Order:
    def __init__(self, items: list[dict], tax_rate: float = 0.08):
        self.items = items
        self.tax_rate = tax_rate

    @property
    def subtotal(self) -> float:
        return sum(item["price"] * item.get("qty", 1) for item in self.items)

    @property
    def tax(self) -> float:
        return self.subtotal * self.tax_rate

    @property
    def total(self) -> float:
        return self.subtotal + self.tax

    @property
    def item_count(self) -> int:
        return sum(item.get("qty", 1) for item in self.items)


order = Order([
    {"name": "Book", "price": 39.00, "qty": 2},
    {"name": "Pen", "price": 5.00, "qty": 3},
])

print(f"小计: {order.subtotal:.2f}")      # 小计: 93.00
print(f"税: {order.tax:.2f}")             # 税: 7.44
print(f"总计: {order.total:.2f}")         # 总计: 100.44
print(f"商品数: {order.item_count}")      # 商品数: 5

Property Setter 校验

property 最常见的用途之一是在赋值时进行校验:

import re


class User:
    def __init__(self, name: str, email: str, age: int):
        self._name = None
        self._email = None
        self._age = None

        # 通过 setter 初始化
        self.name = name
        self.email = email
        self.age = age

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

    @name.setter
    def name(self, value: str) -> None:
        if not isinstance(value, str):
            raise TypeError("姓名必须是字符串")
        if not value.strip():
            raise ValueError("姓名不能为空")
        if len(value) > 50:
            raise ValueError("姓名长度不能超过 50 个字符")
        self._name = value.strip()

    @property
    def email(self) -> str:
        return self._email

    @email.setter
    def email(self, value: str) -> None:
        if not re.match(r"^[\w.%+-]+@[\w.-]+\.[a-zA-Z]{2,}$", value):
            raise ValueError(f"无效的邮箱地址: {value}")
        self._email = value.lower()

    @property
    def age(self) -> int:
        return self._age

    @age.setter
    def age(self, value: int) -> None:
        if not isinstance(value, int):
            raise TypeError("年龄必须是整数")
        if value < 0 or value > 150:
            raise ValueError(f"年龄超出有效范围: {value}")
        self._age = value


# 构造时自动校验
user = User("Alice Wang", "alice@example.com", 28)
print(user.name)   # Alice Wang
print(user.email)  # alice@example.com
print(user.age)    # 28

# 更新时也会校验
user.age = 29
print(user.age)    # 29

# user.age = -1    # ValueError: 年龄超出有效范围: -1
# user.email = "bad"  # ValueError: 无效的邮箱地址: bad

缓存属性

@functools.cached_property

对于计算开销大的属性,可以缓存计算结果:

import functools
import time


class DataAnalyzer:
    def __init__(self, data: list[int]):
        self.data = data

    @functools.cached_property
    def mean(self) -> float:
        """计算平均值(耗时操作)"""
        print("计算平均值...")
        time.sleep(1)
        return sum(self.data) / len(self.data)

    @functools.cached_property
    def std(self) -> float:
        """计算标准差"""
        print("计算标准差...")
        time.sleep(1)
        m = self.mean  # 使用缓存的 mean
        variance = sum((x - m) ** 2 for x in self.data) / len(self.data)
        return variance ** 0.5

    @functools.cached_property
    def summary(self) -> dict:
        return {
            "count": len(self.data),
            "mean": self.mean,
            "std": self.std,
            "min": min(self.data),
            "max": max(self.data),
        }


data = DataAnalyzer(list(range(1, 10001)))

# 第一次访问:计算并缓存
start = time.perf_counter()
print(data.mean)  # 5000.5(约 1 秒)
print(f"耗时: {time.perf_counter() - start:.2f}s")

# 第二次访问:直接返回缓存值
start = time.perf_counter()
print(data.mean)  # 5000.5(瞬间返回)
print(f"耗时: {time.perf_counter() - start:.4f}s")

# 触发 std 计算时复用了缓存的 mean
print(data.std)   # 瞬间完成(mean 已缓存)

手动实现可失效的缓存

class WeatherData:
    def __init__(self):
        self._cache: dict[str, object] = {}
        self._cache_time: dict[str, float] = {}
        self._ttl = 60  # 缓存 60 秒

    def _is_cache_valid(self, key: str) -> bool:
        import time
        if key not in self._cache_time:
            return False
        return time.time() - self._cache_time[key] < self._ttl

    @property
    def temperature(self) -> float:
        if self._is_cache_valid("temperature"):
            return self._cache["temperature"]
        # 模拟 API 调用
        import time
        time.sleep(0.5)
        value = 25.5  # 实际会从 API 获取
        self._cache["temperature"] = value
        self._cache_time["temperature"] = __import__("time").time()
        return value

    def invalidate_cache(self) -> None:
        self._cache.clear()
        self._cache_time.clear()

描述符协议

描述符是 Python 中非常强大的底层机制。propertyclassmethodstaticmethod 本质上都是描述符。

描述符协议方法

描述符是实现了以下一个或多个方法的类:

  • __get__(self, obj, objtype) — 获取属性
  • __set__(self, obj, value) — 设置属性
  • __delete__(self, obj) — 删除属性
  • __set_name__(self, owner, name) — Python 3.6+,在类创建时自动调用
class PositiveNumber:
    """描述符:限制值必须为正数"""

    def __set_name__(self, owner, name):
        """自动获取属性名"""
        self._private_name = f"_{name}"

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self._private_name)

    def __set__(self, obj, value):
        if not isinstance(value, (int, float)):
            raise TypeError("值必须是数字")
        if value <= 0:
            raise ValueError("值必须为正数")
        setattr(obj, self._private_name, value)


class Product:
    # 在类级别定义描述符
    price = PositiveNumber()
    stock = PositiveNumber()

    def __init__(self, name: str, price: float, stock: int):
        self.name = name
        self.price = price
        self.stock = stock


p = Product("Laptop", 5999.00, 10)
print(p.price)     # 5999.0
print(p.stock)     # 10

# p.price = -100   # ValueError: 值必须为正数
# p.stock = "abc"  # TypeError: 值必须是数字

带校验的类型描述符

from datetime import datetime


class TypeChecked:
    """类型检查描述符"""

    def __init__(self, expected_type):
        self.expected_type = expected_type
        self._name = None

    def __set_name__(self, owner, name):
        self._name = f"_typed_{name}"

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self._name, None)

    def __set__(self, obj, value):
        if not isinstance(value, self.expected_type):
            raise TypeError(
                f"期望类型 {self.expected_type.__name__}, "
                f"实际类型 {type(value).__name__}"
            )
        setattr(obj, self._name, value)


class Range:
    """范围校验描述符"""

    def __init__(self, min_value: float, max_value: float):
        self.min_value = min_value
        self.max_value = max_value

    def __set_name__(self, owner, name):
        self._name = f"_range_{name}"

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self._name, None)

    def __set__(self, obj, value):
        if not self.min_value <= value <= self.max_value:
            raise ValueError(
                f"{value} 不在范围 [{self.min_value}, {self.max_value}] 内"
            )
        setattr(obj, self._name, value)


class Student:
    name = TypeChecked(str)
    age = TypeChecked(int)
    score = Range(0, 100)

    def __init__(self, name: str, age: int, score: float):
        self.name = name
        self.age = age
        self.score = score


s = Student("Alice", 20, 85.5)
print(s.name, s.age, s.score)  # Alice 20 85.5

# s.name = 123       # TypeError: 期望类型 str, 实际类型 int
# s.score = 200      # ValueError: 200 不在范围 [0, 100] 内

内置描述符的工作原理

property 描述符

我们可以手动实现简化版的 property

class Property:
    """简化版的 property 描述符"""

    def __init__(self, fget=None, fset=None, fdel=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError("unreadable attribute")
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError("can't set attribute")
        self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError("can't delete attribute")
        self.fdel(obj)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel)

    def deleter(self, fdel):
        return type(self)(self.fget, self.fset, fdel)


# 使用自定义 Property
class TestClass:
    def __init__(self):
        self._value = 0

    @Property
    def value(self):
        return self._value

    @value.setter
    def value(self, new_val):
        self._value = new_val

__slots__ 内存优化

默认情况下,每个 Python 对象都有一个 __dict__ 字典来存储实例属性,这有较大的内存开销。__slots__ 可以固定属性集合,减少内存占用:

import sys


class PointWithDict:
    """使用 __dict__ 的类"""
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y


class PointWithSlots:
    """使用 __slots__ 的类"""
    __slots__ = ("x", "y")

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


# 内存对比
p1 = PointWithDict(1, 2)
p2 = PointWithSlots(1, 2)

print(f"__dict__ 大小: {sys.getsizeof(p1.__dict__)} bytes")  # 约 72 bytes

# __slots__ 的对象没有 __dict__
print(hasattr(p2, "__dict__"))  # False

# 大量实例时的内存差异
points_dict = [PointWithDict(i, i) for i in range(100_000)]
points_slots = [PointWithSlots(i, i) for i in range(100_000)]

# 分别计算内存(粗略估计)
import sys
size_dict = sum(sys.getsizeof(p) for p in points_dict[:1000]) / 1000
size_slots = sum(sys.getsizeof(p) for p in points_slots[:1000]) / 1000
print(f"平均每个对象内存 - __dict__: {size_dict:.1f} bytes")
print(f"平均每个对象内存 - __slots__: {size_slots:.1f} bytes")

__slots__ 的注意事项

class SlotsDemo:
    __slots__ = ("name", "age")

    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age


s = SlotsDemo("Alice", 30)
print(s.name)       # Alice

# s.gender = "F"    # AttributeError! 不能添加未在 __slots__ 中声明的属性
# print(s.__dict__) # AttributeError! 没有 __dict__

继承中的 __slots__

class Base:
    __slots__ = ("x",)

class Derived(Base):
    __slots__ = ("y",)  # 也需要声明 __slots__

    def __init__(self, x, y):
        self.x = x
        self.y = y


d = Derived(1, 2)
print(d.x, d.y)  # 1 2

# 注意:Derived 仍然没有 __dict__,因为所有父类都有 __slots__
print(hasattr(d, "__dict__"))  # False(如果父类没有 __slots__,子类会有 __dict__)

综合示例:数据验证模型

将所学知识综合起来,构建一个简单的验证框架:

class Validator:
    """描述符基类"""

    def __set_name__(self, owner, name):
        self.name = f"_{name}"

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.name)

    def __set__(self, obj, value):
        self.validate(value)
        setattr(obj, self.name, value)

    def validate(self, value):
        pass


class NotEmpty(Validator):
    def validate(self, value):
        if not isinstance(value, str):
            raise TypeError("必须是字符串")
        if not value.strip():
            raise ValueError("不能为空")


class Email(Validator):
    import re

    def validate(self, value):
        if not re.match(r"^[\w.%+-]+@[\w.-]+\.[a-zA-Z]{2,}$", value):
            raise ValueError(f"无效的邮箱: {value}")


class Range(Validator):
    def __init__(self, min_val=None, max_val=None):
        super().__init__()
        self.min_val = min_val
        self.max_val = max_val

    def validate(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError("必须是数字")
        if self.min_val is not None and value < self.min_val:
            raise ValueError(f"最小值 {self.min_val},实际 {value}")
        if self.max_val is not None and value > self.max_val:
            raise ValueError(f"最大值 {self.max_val},实际 {value}")


class Positive(Range):
    def __init__(self):
        super().__init__(min_val=0)


class Length(Validator):
    def __init__(self, min_len=None, max_len=None):
        super().__init__()
        self.min_len = min_len
        self.max_len = max_len

    def validate(self, value):
        if not isinstance(value, str):
            raise TypeError("必须是字符串")
        length = len(value)
        if self.min_len is not None and length < self.min_len:
            raise ValueError(f"最短 {self.min_len} 个字符,当前 {length}")
        if self.max_len is not None and length > self.max_len:
            raise ValueError(f"最长 {self.max_len} 个字符,当前 {length}")


class ManagedProduct:
    """使用描述符管理的产品类"""
    name = NotEmpty()
    email = Email()
    price = Positive()
    quantity = Range(min_val=0, max_val=10000)
    description = Length(max_len=500)

    def __init__(self, name: str, email: str, price: float, quantity: int, description: str = ""):
        self.name = name
        self.email = email
        self.price = price
        self.quantity = quantity
        self.description = description

    @property
    def total_value(self) -> float:
        return self.price * self.quantity


# 正确使用
product = ManagedProduct(
    name="Python Programming Book",
    email="seller@bookstore.com",
    price=79.00,
    quantity=100,
    description="A comprehensive Python book for beginners",
)

print(f"{product.name}: ¥{product.price} x {product.quantity} = ¥{product.total_value}")

# 错误使用
try:
    product.price = -50  # ValueError!
except ValueError as e:
    print(f"校验失败: {e}")

try:
    product.name = ""    # ValueError!
except ValueError as e:
    print(f"校验失败: {e}")

try:
    product.email = "bad-email"  # ValueError!
except ValueError as e:
    print(f"校验失败: {e}")

常见陷阱

陷阱 1:property 中忘记了 _ 前缀导致递归

class RecursionError:
    @property
    def name(self):
        return self.name  # 递归!调用自身

    @name.setter
    def name(self, value):
        self.name = value  # 递归!调用自身


class Correct:
    @property
    def name(self):
        return self._name  # 正确:访问私有属性

    @name.setter
    def name(self, value):
        self._name = value  # 正确

陷阱 2:cached_property 与可变对象

import functools


class Problem:
    @functools.cached_property
    def items(self) -> list:
        return []


obj = Problem()
obj.items.append(1)
obj.items.append(2)
print(obj.items)  # [1, 2] —— 缓存后的可变对象可能不是期望的行为

陷阱 3:__slots____dict__ 冲突

class OnlySlots:
    __slots__ = ("x",)

class Mixed(OnlySlots):
    def __init__(self):
        self.x = 1
        self.y = 2  # 可以!Parent 有 __slots__ 但 Mixed 没有声明,所以它有 __dict__


m = Mixed()
print(m.x, m.y)  # 1 2
print(hasattr(m, "__dict__"))  # True(Mixed 有 __dict__ 因为没声明 __slots__)

小结

本篇学习了 Python 中属性控制的核心机制:

  • @property 装饰器:用 getter/setter/deleter 优雅地控制属性访问
  • 计算属性:不存储值,访问时动态计算
  • property setter 校验:在赋值时验证数据有效性
  • 缓存属性@functools.cached_property 降低计算开销
  • 描述符协议__get____set____delete____set_name__ 构建可复用的属性行为
  • property 的描述符本质:理解 property 底层如何工作
  • __slots__ 内存优化:固定属性集合,减少内存占用,提升性能

这些机制让你能在不影响外部接口的前提下,精细地控制属性的获取、设置和删除行为。合理使用它们,可以显著提升代码的健壮性和可维护性。

Summary: @property、描述符协议与 slots 内存优化