文章

Python Q&A: 装饰器

第一期的Python Q&A。

Python Q&A: 装饰器

Question.

@ 在Python上是装饰器标识符。迄今为止我已经遇到了三个装饰器:

  • @dataclass,让需要接受大量初始化参数的类看上去更简洁;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    from dataclasses import dataclass
      
    @dataclass	
    class Custom:
      # 这些没有默认值的属性需要实例化的时候提供
      prop1: str
      prop2: str
      prop3: str
        
    # 之后照常实例化就好
    custom = Custom(prop1="hello", prop2="from", prop3="python!")
    
  • @classmethod,定义类方法,以在实例化之前先定义其行为;

    1
    2
    3
    4
    5
    6
    7
    8
    
    class Custom:
    # ......
        
      # 不同于实例方法,类方法可由实例化自该类(或实例化自继承它的类)的对象调用
      # 你可以轻松地让不同对象实现同一名称的方法,还能确保互不干扰
      @classmethod
      def fun_cls(cls, param1, param2):
        pass
    
  • @staticmethod,在类里定义一个不依赖于类的一个方法。

    1
    2
    3
    4
    5
    6
    7
    8
    
    class Custom:
    # ......
      
      # 静态方法可以不依赖于类/对象本身
      # 也就是说,你甚至可以不将 `cls`/`self` 作为必需参数
      @staticmethod
      def fun_static(param1, param2):
        pass
    

由于我学的很零散,我并不清楚工程上是如何利用这些装饰器的,以及除了这些还有没有其他的,目的完全不同的装饰器。

Answer.

装饰器是 Python 非常棒的特性之一,个人感觉比那些“魔术方法”更“魔术”(因为“魔术方法”看上去既不直观也不魔术,但以后或许会说。或许吧……)

对于不太明白装饰器定义的人,我简单说一下:装饰器装饰的是函数,它能在不改动函数的基础上赋予函数特殊的超能力。抛瓦!!!

以后如果面对了这些工程问题,那么装饰器将非常有用。

例如给一个外部应用程序写一个“Warpper”(差不多就是“API”),某些程序可能需要十几个,二十几个参数,将它们堆到 __init__ 里,场面将极其壮观:

1
2
3
class Warpper:
  def __init__(self, param1, param2, param3, param4, param5, param6,):
    pass

而如果使用 *args(按位置传值)或者 **kwargs(按键值对传值),使用 IDE 的人会很头疼,因为这样做就不知道它可以接受哪些参数,以及这些参数具有哪些类型限制。此时使用 @dataclass,就是非常棒的选择。

当然,Python 的装饰器类型很多,你也可以自己定义几个,从而方便你的程序设计。我大概举几个例子吧。

第一个案例,假设你有这样一个类:

1
2
3
class Circle:
  def __init__(self, radius):
    self._radius = radius

它具有半径 _radius 这一个属性。_开头的都是受保护的对象——当然,如果你想访问,这个障碍仍然挡不住你。即便如此你还是想上锁,那该怎么办?

@property就可以做到这一点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Circle:
  def __init__(self, radius):
      self._radius = radius

  @property
  def area(self):
    # 调用时不需要括号,直接 c.area 即可
    return 3.14 * (self._radius ** 2)

  @property
  def radius(self):
    return self._radius

# 尝试实例化一个 Circle 对象
circle = Circle(radius = 5)

# 访问其半径(注意这里用的是 radius,而不是 _radius,更不是 radius())
print(circle.radius)  # 返回 5
# 访问其面积
print(circle.area)    # 返回 78.5

用函数伪装成属性,就是它最大的功用——这后面两个函数(方法)如同活板门,它们只能将值传出去,而无法接受一个值以进行改动。更妙的是,调用它们不需要带(),让它们看上去更像属性,而不是函数(方法)!

虽然还是防不了有人用 circle._radius 强行“Hack”,但对于路人来说足够了。算了,至少 circle.radius 已经变成只读了,就这样吧。(笑)

另一个案例与性能有关。学习动态规划时,“计算斐波那契数列”是一个经典的入门问题。传统的“递归”(自顶向下)会反复对同一个输入求值(例如计算 F(45),其内部仍然要反复计算 F(1) 的值),所以我们引入了动态规划,引入了“剪枝”,引入了“自底向上”的方法,经过了无穷多的麻烦才赢得了 \(O(N)\) 的时间复杂度,而不是以往的 \(O(2^{n})\)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 一个经典的斐波那契数列通项函数,采用了滚动数组算法
# 如果你学过动态规划或者刷过 Leetcode,你会觉得相当眼熟
def fib(n: int) -> int:
  if n <= 1:
    return n

  # a 代表 f(0), b 代表 f(1)
  a, b = 0, 1

  # 从第 2 级开始计算,直到第 n 级
  for _ in range(2, n + 1):
    # 新的结果 = 前两个结果之和
    # 更新 a 和 b,并向后滚动
    a, b = b, a + b

  return b

不过在 Python,想避免重复计算,只需要这个装饰器:

1
2
3
4
5
6
from functools import lru_cache

@lru_cache(maxsize=128)
def fib(n):
  if n < 2: return n
    return fib(n-1) + fib(n-2)

它让函数具备自动缓存的能力,从而在下次获得同样输入时,通过查表直接返回结果。令人欣慰的是,它的时间复杂度同样是线性阶 \(O(N)\);但空间复杂度……或者说需要消耗多少内存,取决于你给了多少空间用来缓存结果。算是以空间换时间了。

最后一个案例,是自定义装饰器。假设某个用户(user)想要动数据库,想必第一道坎就是要检查他的权限。给每个操作都加个 if 检查其角色(role)也不是不行,但加上了装饰器之后,代码就更加简洁,也更直观了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def admin_required(func):
  # 这又是一个装饰器,但作用于`warpper`,
  # 目的是防止`func`原本的名字(`__name__`)、文档(`__doc__`)等被下面的这个函数覆盖掉
  @functools.wraps(func)
  def wrapper(user, *args, **kwargs):
    if user.role != 'admin':
      raise PermissionError("权限不足")
    return func(user, *args, **kwargs)
  return wrapper

@admin_required
def delete_database(user):
  # 此处无需 if 检查用户权限,而专心于实际的业务逻辑
  print("数据库已删除")