Python偏函数结合类装饰器

案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import time
import functools


class DelayFunc:
def __init__(self, duration, func):
self.duration = duration
self.func = func

def __call__(self, *args, **kwargs):
print(f'Wait for {self.duration} seconds...')
time.sleep(self.duration)
return self.func(*args, **kwargs)

def eager_call(self, *args, **kwargs):
print('Call without delay')
return self.func(*args, **kwargs)


def delay(duration):
"""
装饰器:推迟某个函数的执行。
同时提供 .eager_call 方法立即执行
"""
# 此处为了避免定义额外函数,
# 直接使用 functools.partial 帮助构造 DelayFunc 实例
return functools.partial(DelayFunc, duration)


@delay(duration=2)
def add(a, b):
return a + b


print(add) # add 变成了类DelayFunc的实例
# <__main__.DelayFunc object at 0x000001EED91FA3C8>
print(add(3, 5)) # 直接调用实例,进入 __call__
# Wait for 2 seconds...
# 8
print(add.eager_call(1, 2)) # 实现实例方法
# Call without delay
# 3

提问:为啥这个 add 函数名会给自动传给 DelayFunc 的 init 函数中的 func 而不是 duration?duration 和 add 传过去时,python 是怎么区分的?

分析

  1. 首先,@delay(duration=2) 这个装饰器表达式会被首先执行:

    1
    2
    # 等价于执行
    delay(duration=2) # 返回 functools.partial(DelayFunc, duration=2)
  2. delay(duration=2) 返回了一个偏函数:

    1
    2
    # 等价于
    functools.partial(DelayFunc, 2) # 这个偏函数已经固定了第一个参数 duration=2
  3. 然后这个偏函数会作为装饰器作用在 add 函数上:

    1
    2
    3
    4
    # 等价于
    add = functools.partial(DelayFunc, 2)(add)
    # 进一步等价于
    add = DelayFunc(2, add)

所以完整的执行顺序是:

1
2
3
4
5
# 1. 首先执行装饰器函数
delayed_func = delay(duration=2) # 返回 partial(DelayFunc, 2)

# 2. 用返回的偏函数作为装饰器装饰 add 函数
add = delayed_func(add) # 等价于 DelayFunc(2, add)

用一个更直观的例子来说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import functools
import time

def debug_decorator():
print("1. 装饰器函数被调用")

class Wrapper:
def __init__(self, func):
print("3. Wrapper.__init__被调用,参数是:", func.__name__)
self.func = func

def __call__(self, *args, **kwargs):
print("4. 调用被装饰的函数")
return self.func(*args, **kwargs)

print("2. 返回Wrapper类")
return Wrapper

@debug_decorator()
def hello():
print("Hello!")

print("5. 开始调用hello()")
hello()

输出将是:

1
2
3
4
5
6
1. 装饰器函数被调用
2. 返回Wrapper类
3. Wrapper.__init__被调用,参数是: hello
5. 开始调用hello()
4. 调用被装饰的函数
Hello!

回到开始的例子,我们可以加入调试信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import time
import functools

class DelayFunc:
def __init__(self, duration, func):
print(f"DelayFunc.__init__被调用:duration={duration}, func={func.__name__}")
self.duration = duration
self.func = func

def __call__(self, *args, **kwargs):
print(f'Wait for {self.duration} seconds...')
time.sleep(self.duration)
return self.func(*args, **kwargs)

def delay(duration):
print(f"delay()被调用,参数duration={duration}")
partial_delay = functools.partial(DelayFunc, duration)
print("返回偏函数partial_delay")
return partial_delay

@delay(duration=2)
def add(a, b):
return a + b

print("开始调用add(1, 2)")
result = add(1, 2)
print(f"结果:{result}")

执行这段代码,输出会类似:

1
2
3
4
5
6
delay()被调用,参数duration=2
返回偏函数partial_delay
DelayFunc.__init__被调用:duration=2, func=add
开始调用add(1, 2)
Wait for 2 seconds...
结果:3

这就清晰地展示了整个过程:

  1. delay(duration=2) 被调用,返回一个偏函数
  2. 这个偏函数被用来装饰 add,此时创建了 DelayFunc 实例
  3. 当我们调用 add(1, 2) 时,实际上是在调用 DelayFunc 实例的 __call__ 方法

Python 之所以能正确区分参数,是因为:

  1. delay(duration=2) 创建了一个偏函数,这个偏函数已经固定了 DelayFunc 的第一个参数为 2
  2. 当这个偏函数被用作装饰器时,被装饰的函数 add 自动作为第二个参数传入
  3. DelayFunc__init__ 方法接收这两个参数,完成初始化

这就是为什么尽管看起来有点混乱,但参数还是能被正确地传递和识别。


偏函数在这里的应用:DelayFunc 类的初始化,需要两个参数,可以用偏函数来暂时固定一个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import functools

# 1. 假设我们有一个需要两个参数的类
class Person:
def __init__(self, age, name):
self.age = age
self.name = name
print(f"创建了一个{age}岁的{name}")

# 2. 我们可以用偏函数固定第一个参数
CreateChild = functools.partial(Person, 3) # 固定年龄为3岁

# 3. 现在使用这个偏函数,只需要提供name参数
child1 = CreateChild("小明") # 输出:创建了一个3岁的小明
child2 = CreateChild("小红") # 输出:创建了一个3岁的小红

# 等价于:
# child1 = Person(3, "小明")
# child2 = Person(3, "小红")

回到 DelayFunc 例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import time
import functools

class DelayFunc:
def __init__(self, duration, func):
self.duration = duration
self.func = func
print(f"初始化DelayFunc: duration={duration}, func={func.__name__}")

def print_some(self):
print("print_some")

# 1. 完整调用方式
normal_delayed = DelayFunc(2, print) # 需要两个参数

# 2. 使用偏函数固定第一个参数
Delay2Seconds = functools.partial(DelayFunc, 2) # 固定duration为2秒
delayed_print = Delay2Seconds(print) # 只需要提供func参数

# 让我们做一个更清晰的演示:
print("演示1: 创建等待3秒的偏函数")
Delay3Seconds = functools.partial(DelayFunc, 3)
print("Delay3Seconds是:", Delay3Seconds)

print("\n演示2: 使用这个偏函数创建实例")
def greet():
print("Hello!")

delayed_greet = Delay3Seconds(greet)
print("delayed_greet是:", delayed_greet)
print("isinstance(delayed_greet, DelayFunc):", isinstance(delayed_greet, DelayFunc))

delayed_print.print_some()

输出会类似:

1
2
3
4
5
6
7
8
演示1: 创建等待3秒的偏函数
Delay3Seconds是: functools.partial(<class '__main__.DelayFunc'>, 3)

演示2: 使用这个偏函数创建实例
初始化DelayFunc: duration=3, func=greet
delayed_greet是: <__main__.DelayFunc object at 0x...>
isinstance(delayed_greet, DelayFunc): True
print_some

可以再深入一点,看看偏函数是如何工作的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import functools

def demo_func(a, b, c):
print(f"a={a}, b={b}, c={c}")

# 创建偏函数,固定第一个参数
partial_demo = functools.partial(demo_func, 1)

# 查看偏函数的属性
print("偏函数的属性:")
print(f"- func: {partial_demo.func}")
print(f"- args: {partial_demo.args}")
print(f"- keywords: {partial_demo.keywords}")

# 不同的调用方式
print("\n不同的调用方式:")
partial_demo(2, 3) # 位置参数: a=1, b=2, c=3
partial_demo(b=2, c=3) # 关键字参数: a=1, b=2, c=3
partial_demo(c=3, b=2) # 关键字参数顺序不重要: a=1, b=2, c=3

# 创建一个固定关键字参数的偏函数
partial_demo_kw = functools.partial(demo_func, c=3)
print("\n固定关键字参数:")
partial_demo_kw(1, 2) # a=1, b=2, c=3

这样设计的好处是:

  1. 参数复用
    • 可以创建一个固定某些参数的模板
    • 减少重复代码
  2. 代码清晰
    • 通过固定某些参数,使函数调用更简洁
    • 代码意图更明确
  3. 灵活性
    • 可以固定任意位置的参数
    • 可以固定位置参数或关键字参数
  4. 工厂模式
    • 可以用来创建特定配置的函数或类实例
    • 很适合用在装饰器模式中

在你的装饰器例子中,partial(DelayFunc, 2) 创建了一个新的可调用对象,它:

  1. 已经固定了 DelayFunc 的第一个参数 (duration) 为 2
  2. 等待接收第二个参数 (func)
  3. 当装饰器应用到函数上时,这个函数就自动成为第二个参数

这就是为什么装饰器模式和偏函数配合得如此好的原因!