Python3 基础教程

Python3 装饰器:@property、@staticmethod、@classmethod

🎉摘要:本文深入解析 Python 中 @property、@classmethod 和 @staticmethod 三大内置装饰器的核心用法与区别,通过实战代码演示属性封装、工厂方法、静态工具等场景,帮助开发者写出接口干净、职责清晰的类。

Python 的类中有三种常用的内置装饰器,它们改变的是方法的调用方式,而不是方法本身的功能。很多初学者把这三者混为一谈,其实它们解决的问题截然不同:

  • @property 让你把方法当属性访问,不用加括号;

  • @classmethod 让方法绑定到类而不是实例,第一个参数是 cls;

  • @staticmethod 让方法既不绑定实例也不绑定类,纯粹是挂在类名下的普通函数。

理解它们的区别,才能写出接口干净、职责清晰的类。本章会逐个拆解,并给出贴近实战的代码。

@property:把方法伪装成属性

为什么需要 property

假设你有一个 User 类,最初用 age 作为普通属性:

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

后来需求变了:年龄不能是负数,而且需要支持 "虚岁" 展示。如果你直接改 age 为方法,所有调用 user.age 的地方都要改成 user.age(),改动量巨大。@property 就是解决这个矛盾的:对外保持 user.age 的写法,内部用方法控制逻辑。

class User:
    def __init__(self, name: str, age: int):
        self.name = name
        self._age = age  # 内部用私有属性存储

    @property
    def age(self) -> int:
        """只读属性:获取年龄"""
        return self._age

    @property
    def virtual_age(self) -> int:
        """虚岁:周岁 + 1"""
        return self._age + 1

# 使用
u = User("Alice", 25)
print(u.age)          # 25  —— 不用括号,像属性一样访问
print(u.virtual_age)  # 26
# u.age = 30  # AttributeError: can't set attribute

可写属性:setter 与 deleter

@property 默认只读。如果需要允许修改,用同名的 @age.setter 装饰器:

class User:
    def __init__(self, name: str, age: int):
        self._name = name
        self._age = None
        self.age = age  # 触发 setter 做校验

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

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

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

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

    @age.deleter
    def age(self):
        print("年龄属性被删除")
        self._age = None

# 使用
u = User("Bob", 20)
print(u.age)   # 20
u.age = 30     # 通过 setter 修改
print(u.age)   # 30

try:
    u.age = -5  # ValueError: 年龄超出合理范围
except ValueError as e:
    print(f"拦截错误: {e}")

del u.age        # 触发 deleter
print(u.age)     # None

运行代码,输出:

20
30
拦截错误: 年龄超出合理范围
年龄属性被删除
None

@xxx.setter 和 @xxx.deleter 的命名有严格要求:必须是已经定义了 @property 的同名方法,装饰器名称必须是 属性名.setter 和 属性名.deleter。如果写错名字,Python 不会报错,但你会得到两个独立的方法而不是属性控制。

缓存属性:@property 配合 @functools.cached_property

对于计算耗时的属性,如果结果不变,可以缓存起来避免重复计算。Python 3.8+ 提供了 functools.cached_property:

from functools import cached_property

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

    @cached_property
    def area(self) -> float:
        """计算圆的面积"""
        print("[计算面积] 只执行一次")
        import math
        return math.pi * self.radius ** 2

    @property
    def perimeter(self) -> float:
        """计算圆的周长"""
        print("[计算周长] 每次访问都会执行")
        import math
        return 2 * math.pi * self.radius

c = Circle(5.0)
print(c.area)   # [计算面积] 只执行一次 → 78.54...
print(c.area)   # 78.54...  —— 直接从缓存读取,不重新计算

print(c.perimeter)  # [计算周长] 每次访问都会执行 → 31.41...
print(c.perimeter)

c.radius = 10.0
# cached_property 不会自动更新!需要手动清理缓存
del c.area      # 删除缓存,下次访问重新计算
print(c.area)   # [计算面积] 只执行一次 → 314.15...

运行代码,输出:

[计算面积] 只执行一次
78.53981633974483
78.53981633974483
[计算周长] 每次访问都会执行
31.41592653589793
[计算周长] 每次访问都会执行
31.41592653589793
[计算面积] 只执行一次
314.1592653589793

cached_property 和 property 的区别:

  • property 每次访问都执行方法;

  • cached_property 只执行一次,结果存入实例字典。但如果依赖的属性变了,缓存不会自动失效,需要手动 del 清理。

注意:对于需要自动失效的复杂场景,可以用 functools.lru_cache 配合自定义逻辑,或者第三方库如 methodtools。

@classmethod:类方法,工厂模式的利器

基本用法

@classmethod 把方法的第一个参数绑定为类本身(cls),而不是实例(self)。它可以在不创建实例的情况下调用,常用于工厂方法、替代构造函数。

例如:

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

    @classmethod
    def from_birth_year(cls, name: str, year: int):
        """根据出生年份创建用户"""
        from datetime import date
        age = date.today().year - year
        return cls(name, age)

    @classmethod
    def anonymous(cls):
        """创建匿名用户"""
        return cls("Anonymous", 0)

    def __repr__(self):
        return f"User({self.name!r}, {self.age})"

# 使用
u1 = User("Alice", 25)
u2 = User.from_birth_year("Bob", 1995)
u3 = User.anonymous()

print(u1)  # User('Alice', 25)
print(u2)  # User('Bob', 30)  —— 结果随当前年份变化
print(u3)  # User('Anonymous', 0)

继承场景下的 cls 多态

classmethod 的 cls 参数在继承时非常有用。子类调用父类的类方法,返回的是子类实例,而不是父类实例。这是工厂模式能正确工作的关键。

例如:

class Animal:
    """动物基类"""
    def __init__(self, name: str):
        self.name = name

    @classmethod
    def from_dict(cls, data: dict):
        """从字典创建实例,子类自动获得此能力"""
        return cls(data["name"])

    def speak(self):
        raise NotImplementedError

class Dog(Animal):
    """狗类,继承自 Animal"""
    def __init__(self, name: str, breed: str = "Unknown"):
        super().__init__(name)
        self.breed = breed

    @classmethod
    def from_dict(cls, data: dict):
        """重写以支持额外字段"""
        return cls(data["name"], data.get("breed", "Unknown"))

    def speak(self):
        return f"{self.name} says: Woof!"

class Cat(Animal):
    """猫类,继承自 Animal"""
    def speak(self):
        return f"{self.name} says: Meow!"


# 父类方法返回 Dog 实例,因为 cls 是 Dog
dog = Dog.from_dict({"name": "Buddy", "breed": "Golden"})
print(type(dog))   # <class '__main__.Dog'>
print(dog.speak()) # Buddy says: Woof!

# 如果 Cat 没重写 from_dict,它仍然继承父类版本,但 cls 是 Cat
cat = Cat.from_dict({"name": "Kitty"})
print(type(cat))   # <class '__main__.Cat'>
print(cat.speak()) # Kitty says: Meow!

如果 from_dict 是普通方法或静态方法,你就无法优雅地返回子类实例。cls 的自动绑定让继承体系中的工厂方法天然正确。

类方法做注册表

类方法也常用于全局注册表模式,比如插件系统:

class Plugin:
    # 全局插件注册表:key=插件类名,value=对应的插件类
    _registry = {}

    def __init_subclass__(cls, **kwargs):
        """
        钩子方法:Python 在定义当前类的**子类**时会自动触发执行
        无需手动调用,只要写 class XXX(Plugin): 就会自动进入该方法
        :param cls: 刚刚定义完成的子类本身(例如 TextPlugin、HtmlPlugin)
        :param kwargs: 定义子类时传入的关键字参数,原样透传给父类的 __init_subclass__
        """
        # 调用 object 原生的 __init_subclass__,保证继承体系钩子逻辑完整
        super().__init_subclass__(**kwargs)
        # 把当前子类注册进注册表,键为子类的类名字符串
        # 这里不要使用 self.register(cls.__name__)
        cls.register(cls.__name__)

    @classmethod
    def register(cls, name: str):
        """
        类方法:将插件类存入全局注册表
        :param name: 用于索引插件的标识名称
        """
        # 以插件名称为键,插件类本身为值存入静态注册表
        cls._registry[name] = cls

    @classmethod
    def create(cls, name: str, *args, **kwargs):
        """
        工厂类方法:根据插件名称从注册表取出对应类并实例化
        :param name: 注册时使用的插件名称
        :param args: 实例化插件时的位置参数
        :param kwargs: 实例化插件时的关键字参数
        :return: 目标插件类的实例对象
        """
        # 从注册表查找指定名称的插件类
        plugin_cls = cls._registry.get(name)
        if not plugin_cls:
            raise ValueError(f"Unknown plugin: {name}")
        return plugin_cls(*args, **kwargs)


class TextPlugin(Plugin):
    """文本处理插件,继承Plugin后定义阶段自动注册"""
    def process(self, text: str) -> str:
        # 将输入文本全部转为大写后返回
        return text.upper()


class HtmlPlugin(Plugin):
    """HTML处理插件,继承Plugin后定义阶段自动注册"""
    def process(self, text: str) -> str:
        # 把文本包裹在p标签中,简单封装成HTML段落
        return f"<p>{text}</p>"


# 调用工厂方法,根据名称取出TextPlugin类并实例化
plugin = Plugin.create("TextPlugin")
# 调用插件业务处理方法
print(plugin.process("hello"))  # HELLO

注意,为什么不能将 cls.register(cls.__name__) 改为 self.register(cls.__name__),是因为此刻根本不存在实例 self:

  • self 指代类的实例对象,必须执行 TextPlugin() 创建出实例之后才会有 self;

  • __init_subclass__ 运行时机:只是把类定义好了,还没有创建任何插件实例,全程只有类本身,没有实例;

  • 没有实例,自然拿不出 self,强行写 self.register() 会直接报错未定义。

@staticmethod:脱离实例的纯函数

什么时候用 staticmethod?

@staticmethod 既不接收 self 也不接收 cls,它纯粹是把一个函数挂在类名下,起到命名空间的作用。如果某个功能与类有关,但不需要访问实例或类的任何状态,就用它。

例如:编写一个日期工具类,在 Java 中工具类中的方法使用 public static 修饰,这里类似。

class DateUtil:
    """日期工具类,提供与日期相关的静态方法"""
    
    @staticmethod
    def is_leap_year(year: int) -> bool:
        """判断闰年,与具体实例无关"""
        return (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0)

    @staticmethod
    def days_in_month(year: int, month: int) -> int:
        """返回指定年份和月份的天数"""
        if month == 2:
            return 29 if DateUtil.is_leap_year(year) else 28
        if month in (4, 6, 9, 11):
            return 30
        return 31

    @staticmethod
    def format_date(year: int, month: int, day: int) -> str:
        """格式化日期为字符串"""
        return f"{year:04d}-{month:02d}-{day:02d}"

# 调用方式:类名.方法名,不需要实例
print(DateUtil.is_leap_year(2024))      # True
print(DateUtil.days_in_month(2024, 2))  # 29
print(DateUtil.format_date(2024, 3, 15)) # 2024-03-15

DateUtil 里的方法完全可以写成模块级函数,但放在类里有几个好处:

  1. 逻辑归拢:所有日期相关工具都在 DateUtil 命名空间下;

  2. 可继承:子类可以重写或扩展这些静态方法;

  3. 可导入:从类导入比从模块导入一堆函数更清晰。

staticmethod  vs  模块级函数

下面通过一个例子体验模块级函数和 staticmethod 的区别:

# 模块级函数
import os

def is_valid_filename(name: str) -> bool:
    return os.path.sep not in name and name.strip() != ""

# 静态方法
class FileValidator:
    
    @staticmethod
    def is_valid_filename(name: str) -> bool:
        import os
        return os.path.sep not in name and name.strip() != ""


# 两者功能完全一样,但后者归类更明确
print(FileValidator.is_valid_filename("test.txt"))  # True
print(FileValidator.is_valid_filename("test.txt"))  # False

如果工具函数与某个类强相关(比如只有 User 类才会用到年龄校验),放在类里作为 staticmethod 更合理。如果完全通用,模块级函数更简单。

静态方法在类内部的使用

通过实例压实在类方法、属性方法和普通方法中如何调用静态方法:

class Rectangle:
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    @staticmethod
    def validate_dimension(value: float) -> float:
        """验证尺寸是否为正数"""
        if value <= 0:
            raise ValueError("尺寸必须为正数")
        return float(value)

    @classmethod
    def create(cls, width: float, height: float):
        # 类方法里可以调用静态方法
        w = cls.validate_dimension(width)
        h = cls.validate_dimension(height)
        return cls(w, h)

    @property
    def area(self) -> float:
        # 属性方法中调用静态方法
        self.validate_dimension(self.width)
        self.validate_dimension(self.height)
        return self.width * self.height
    
    def perimeter(self) -> float:
        # 普通方法中调用静态方法
        self.validate_dimension(self.width)
        self.validate_dimension(self.height)
        return 2 * (self.width + self.height)

r = Rectangle.create(3, 4)
print(r.area)  # 12
print(r.perimeter())  # 14

这个例子展示了三者的配合:

  • validate_dimension 是静态方法,纯校验逻辑,不依赖实例或类。

  • create 是类方法,用 cls 构造实例,内部调用静态方法做校验。

  • area 是 property,对外像属性,内部是计算。

  • perimeter 是普通方法,对外提供功能。

三者对比与选型

特性实例方法@classmethod@staticmethod@property
第一个参数self(实例)cls(类)self(实例)
访问方式obj.method()Class.method() 或 obj.method()Class.method() 或 obj.method()obj.attr(无括号)
能否访问实例属性可以(通过实例)
能否访问类属性可以(通过类名)
典型用途实例业务逻辑工厂方法、替代构造工具函数、命名空间封装计算、校验、缓存
继承时行为绑定到实例类型cls 指向调用时的类不绑定,可被子类重写绑定到实例类型

常见陷阱

在 property 的 setter 里递归调用自己

class BadUser:
    def __init__(self, name):
        # 由于存在 @property 修饰 name 属性
        # 这里的 name 只是对外的访问入口,并不是真正存数据的变量
        self.name = name

    # 获取name值时,执行这里,从 _name 取值
    @property
    def name(self):
        return self._name

    # 设置name值时,执行这里
    # 函数内部的 self.name 会再次触发,形成递归调用
    @name.setter
    def name(self, value):
        self.name = value  # 错误!递归调用 setter,导致 RecursionError
        

b = BadUser("Alice")
b.name = "Bob"  # 这里将抛出 RecursionError 错误

正确做法是内部存储用 _name(单下划线约定),setter 给 _name 赋值,而不是 self.name。

    # 设置name值时,执行这里,存储到 _name 中
    @name.setter
    def name(self, value):
        self._name = value.strip()  # 正确

总结 name、_name、__name 和 __name__ 几种形式的含义:

写法约束级别意义
name无约束公开属性,放心外部调用。访问方式:实例.name
_name人为约定内部私有,语法允许访问,靠自觉不碰。访问方式:实例._name
__name名称改写防止子类重名覆盖,尽量外部也别碰。访问方式:实例. _ 类名 __name注意:Python 会自动把名字改写“_类名__name”。
__name__系统内置Python 自带魔法属性 / 方法。

自定义装饰器:理解 property 的底层

property 本身是一个类,不是魔法。它的构造函数是 property(fget=None, fset=None, fdel=None, doc=None)。你甚至可以不用 @ 语法,手动创建:

class ManualProp:
    def __init__(self, value):
        # 单下划线 _value:真实存储数据的底层实例变量,约定类内部使用,外部不建议直接操作
        self._value = value

    def _get_value(self):
        """取值回调函数:读取 m.value 时自动执行该方法"""
        return self._value

    def _set_value(self, v):
        """赋值回调函数:执行 m.value = xxx 时自动执行该方法"""
        self._value = v

    def _del_value(self):
        """删除回调函数:执行 del m.value 时自动执行该方法"""
        del self._value

    # 手动实例化 property 对象,绑定读写删回调 + 属性文档说明
    # property(getter, setter, deleter, docstring) 参数顺序:
    # 1. _get_value:获取属性时触发的方法
    # 2. _set_value:给属性赋值时触发的方法
    # 3. _del_value:del 删除该属性时触发的方法
    # 4. "The value property":该属性的文档字符串,help(ManualProp.value) 可以查看
    # 整体效果等价于装饰器写法 @property + @value.setter + @value.deleter
    value = property(_get_value, _set_value, _del_value, "The value property")


# 实例化对象,初始化数据存入底层 self._value
m = ManualProp(10)

# 读取 m.value → 自动调用 _get_value(),返回 self._value
print(m.value)   # 10

# 给 m.value 赋值 → 自动调用 _set_value(20),把20存入 self._value
m.value = 20

# 再次读取,依旧走取值方法
print(m.value)   # 20

这个知识在写高级元编程时有用,但日常代码中 @ 语法更直观。它揭示了装饰器的本质:@property 就是把方法包装成 property 类的实例,然后赋值给同名属性。

那么,下面这行代码有什么用?

value = property (_get_value, _set_value, _del_value, "The value property")

把类属性 value 包装成托管属性,以后执行 obj.value、obj.value = x、del obj.value 时,不再直接读写实例字典里的字段,而是自动分别调用你指定的取值、赋值、删除函数,实现对属性读写的拦截与管控。

对比原生普通属性,看出它解决了什么问题?

普通属性写法(无管控)

class Test:
    def __init__(self):
        self.value = 10

t = Test()
t.value = "随便塞字符串"  # 想赋值什么就赋值什么,无法拦截

外部可以肆无忌惮读写、篡改,你没办法在赋值时加校验逻辑。

用 property 托管之后,底层真实数据存在 self._value,外部碰不到底层变量,所有操作必须走你写的函数:

def _set_value(self, v):
    # 举例:强制只能赋值数字
    if not isinstance(v, int):
        raise TypeError("只能传入整数")
    self._value = v

时外部执行 m.value = "abc" 会直接报错,实现数据校验。

其实,下面两种写法本质完全等价:

写法 A:你现在这种手动绑定 property

value = property(_get_value, _set_value, _del_value, "The value property")

写法 B:日常更常用的 @property 装饰器

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

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

@value.deleter
def value(self):
    del self._value

装饰器语法只是语法糖,底层最后依然是调用 property() 构建属性对象,二者运行机制一模一样。

装饰器是 Python 的语法糖,但糖吃多了会腻。判断一个方法该用 @property 还是普通方法,看它是否需要参数;判断该用 @classmethod 还是 @staticmethod,看它内部是否需要拿到类本身 cls。

保持简单,是最高级的工程技巧。

说说我的看法
全部评论(
没有评论
关于
本网站专注于 Java、数据库(MySQL、Oracle)、Linux、软件架构及大数据等多领域技术知识分享。涵盖丰富的原创与精选技术文章,助力技术传播与交流。无论是技术新手渴望入门,还是资深开发者寻求进阶,这里都能为您提供深度见解与实用经验,让复杂编码变得轻松易懂,携手共赴技术提升新高度。如有侵权,请来信告知:hxstrive@outlook.com
其他应用
公众号