0%

流畅的 Python(3):一等函数 & 使用一等函数实现设计模式

从设计上看,不管函数式语言的定义如何,Python 都不是一门函数式语言。Python 只是从函数式语言中借鉴了一些好的想法。这篇文章主要讲解 Python 中的函数,以及利用 Python 中的函数是一等对象这个特性,重新实现一些设计模式。

一等函数

在 Python 中,函数是一等对象,可以把 一等对象 定义为满足下列条件的程序实体:

  • 在运行时创建
  • 能赋值给变量或数据结构中的元素
  • 能作为参数传给函数
  • 能作为函数的返回结果

在 Python 中,所有的函数都是一等对象。

把函数视为对象

如下创建了一个函数,通过 type() 可以确定该函数对象是 function 类的实例,并且该函数对象具有 __doc__ 属性。

1
2
3
4
5
6
7
8
9
10
>>> def factorial(n):
... '''returns n!'''
... return 1 if n < 2 else n * factorial(n - 1)
...
>>> factorial(10)
3628800
>>> type(factorial)
<class 'function'>
>>> factorial.__doc__
'returns n!'

__doc__ 属性用于生成对象的帮助文本,通过 help() 可以获得该帮助文本:

1
2
3
4
5
>>> help(factorial)
Help on function factorial in module __main__:

factorial(n)
returns n!

接下来将该函数作为参数传递给 map 函数,map 函数返回一个可迭代对象,其结果是把第一个参数(一个可调用对象)应用到第二个参数(一个可迭代对象)中各个元素上得到的结果:

1
2
>>> list(map(factorial, range(1, 10)))
[1, 2, 6, 24, 120, 720, 5040, 40320, 362880]

高阶函数

有了一等函数,就可以使用函数式风格编程。函数式编程的特点之一是使用高阶函数。接受函数作为参数、或者把函数作为结果返回的函数是高阶函数。在函数式编程中,最为人熟知的高阶函数有 map、filter、reduce 和 apply。在 Python3 中 apply 已经被标记为移除了,如果想使用不定量的参数调用函数,可以直接使用 fn(*args, **keywords),不需要再编写 apply(fn, args, kwargs)

在 Python3 中 map 和 filter 还是内置函数,但是由于列表推导和生成器表达式,这两个函数变得没有那么重要,列表推导或生成器表达式:

1
2
3
4
5
6
7
8
>>> list(map(factorial, range(1, 10)))
[1, 2, 6, 24, 120, 720, 5040, 40320, 362880]
>>> [factorial(i) for i in range(1, 10)]
[1, 2, 6, 24, 120, 720, 5040, 40320, 362880]
>>> list(map(factorial, filter(lambda n: n % 2, range(10))))
[1, 6, 120, 5040, 362880]
>>> [factorial(i) for i in range(1, 10) if i % 2]
[1, 6, 120, 5040, 362880]

在 Python3 中,map 和 filter 返回生成器,因此它们的直接替代品是生成器表达式。reduce 函数在 Python3 中放到了 functools 模块里了。

1
2
3
4
5
6
>>> from functools import reduce
>>> from operator import add
>>> reduce(add, range(100))
4950
>>> sum(range(100))
4950

all 和 any 也是内置的规约函数:

  • all(iterable):如果 iterable 的每个元素都是真值,返回 True,all([]) 返回 True
  • any(iterable):如果 iterable 中有元素是真值,就返回 True,any([]) 返回 False

匿名函数

为了使用高阶函数,有时创建一次性的小型函数更便利。这便是匿名函数的使用场景。lambda 关键字在 Python 表达式内创建匿名函数,这也限制了 lambda 函数的定义体内只能使用纯表达式,所以 lambda 函数的定义体内不能使用 while、try 等 python 语句。

在参数列表中最适合使用匿名函数,如下按照单词翻转后进行排序:

1
2
3
>>> fruits = ['strawberry', 'apple', 'banana', 'watermelon']
>>> sorted(fruits, key = lambda n : n[::-1])
['banana', 'apple', 'watermelon', 'strawberry']

除了作为参数传给高阶函数,Python 很少使用匿名函数。由于句法上的限制,非平凡的 lambda 表达式要么难以阅读,要么无法写出。

lambda 句法只是语法糖,与 def 语句一样,lambda 表达式会创建函数对象。这是 Python 中几种可调用对象之一。

可调用对象

Python 中一共有 7 种可调用对象:

  • 用户定义函数:使用 def 或 lambda 表达式创建
  • 内置函数:使用 C 语言(CPython)实现的函数,如 len
  • 内置方法:使用 C 语言实现的方法,如 dict.get
  • 方法:在类的定义体中定义的函数
  • 类:调用类时会运行类的 __new__ 方法创建一个实例,然后运行 __init__ 方法初始化该实例,最后把实例返回给调用方。通常调用类会创建该类的实例,但是如果覆盖 __new__ 方法,也可能会出现其他行为
  • 类的实例:如果类定义了 __call__ 方法,那么它的实例可以作为函数调用
  • 生成器函数:使用 yield 关键字的函数或方法。调用生成器函数返回的是生成器对象

判断对象是否能调用,可以使用内置的 callable() 函数:

1
2
>>> [callable(obj) for obj in (abs, str, 13)]
[True, True, False]

用户定义的可调用类型

不仅仅 Python 函数是真正的对象,任何 Python 对象都可以表现的像函数一样。只要实现实例方法 __call__。如下实现了一个 BingoCage 类,该类的实例使用任何可迭代对象构建,然后内部存储一个随机顺序排列的列表。调用该实例会随机取出一个元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/usr/bin/env python3

import random

class BingoCage:
def __init__(self, items):
self._items = list(items)
random.shuffle(self._items)

def pick(self):
try:
return self._items.pop()
except IndexError:
raise LookupError('pick from empty BingoCage')

def __call__(self):
return self.pick()

b = BingoCage(range(10))
print(b())
print(b())

实现 __call__ 方法的类是创建函数类对象的简便方式,此时可以在对象内部一个状态,让在调用之间可用,例如 BingoCage 中的剩余元素。装饰器就是这样,装饰器必须是函数,而且有时要在多次调用之间记住某些状态。创建保有内部状态的函数,还有一种方式,是使用闭包。

函数内省

把函数视为对象处理的另一方面,是可以在运行时对函数进行内省。函数对象有很多属性,使用 dir() 函数可以查看:

1
2
>>> dir(factorial)
['__annotations__', '__call__', '__class__', '__closure__', '__code__', '__defaults__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__get__', '__getattribute__', '__globals__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__kwdefaults__', '__le__', '__lt__', '__module__', '__name__', '__ne__', '__new__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__']

其中大多数属性是 Python 对象共有的。函数使用 __dict__ 属性存储赋予它的用户属性,这相当于一种基本形式的注解。一般来说,为函数随意赋予属性不是很常见的做法:

1
2
3
4
5
>>> factorial.__dict__
{}
>>> factorial.short_description = "math calculate"
>>> factorial.__dict__
{'short_description': 'math calculate'}

有一些属性是函数专有的,而一般对象不存在这些属性。通过如下方式就可以计算出函数专有的属性:

1
2
3
4
5
6
7
>>> class C : pass
...
>>> obj = C()
>>> def func(): pass
...
>>> sorted(set(dir(func)) - set(dir(obj)))
['__annotations__', '__call__', '__closure__', '__code__', '__defaults__', '__get__', '__globals__', '__kwdefaults__', '__name__', '__qualname__']

从定位参数到仅限关键字参数

Python 最好的特性之一是提供了极为灵活的参数处理机制,Python3 进一步提供了仅限关键字参数(keyword-only argument)。另外调用函数时,使用 * 和 ** 可以展开 可迭代对象,将其映射到具体个参数中。如下例子中展示了 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
#!/usr/bin/env python3

def tag(name, *content, cls=None, **attrs):
"""Generate one or multiple HTML tags"""
if cls is not None:
attrs['class'] = cls
if attrs:
attr_str = ''.join(' %s="%s"' % (attr, value) for attr, value in sorted(attrs.items()))
else:
attr_str = ''

if content:
return '\n'.join('<%s%s>%s</%s>' % (name, attr_str, c, name) for c in content)
else:
return '<%s%s />' % (name, attr_str)

print(tag('br'))
print(tag('p', 'hello'))
print(tag('p', 'hello', 'world'))
print(tag('p', 'hello', id = 33))
print(tag('p', 'hello', 'world', cls = 'sidebar', id = 33))
print(tag(content = 'testing', name = 'img'))
content_list = ["p", "A", "B", "C"]
print(tag(*content_list))
tag_dir = {'name': 'img', 'title': 'Sunset Boulevard', 'src':'sunset.jpg', 'cls':'framed'}
print(tag(**tag_dir))
  • cls 参数只能使用关键字参数的方式进行传入
  • 定位参数(位置参数)也可以通过关键字参数的方式进行调用,但是函数调用时,所有的关键字参数必须位于定位参数之后
  • 对于 content = 'testing' 参数 ,虽然与函数定义的 *content 重名,但是该参数也是作为关键字参数之一,被 attrs 参数接收
  • 通过在 content_list 前使用 *,列表中的所有元素会被作为定位参数传入,函数形参中的相应定位参数会被赋值,剩余的被 *content 捕获
  • 通过在 tag_dir 前使用 **,字典中的所有元素会被作为关键字参数传入,函数形参中的相应同名参数会被赋值,剩余的被 **attrs 捕获

仅限关键字参数是 Python3 新增的特性,它只能通过关键字参数指定,而且它肯定不会捕获未命名的定位参数。定位函数时,如果想仅指定关键字参数,要把它们放在前面有 * 参数的后面。如果不想支持数量不定的定位参数,但是想支持仅限关键字参数,可以在前面中放入一个 *,如下所示:

1
2
3
4
5
6
7
8
9
10
11
>>> def f1(a, *, b=1):
... print(a, b)
...
>>> f(1, b = 2)
1 2
>>> f(b = 2, a = 1)
1 2
>>> f(1, 2)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: f() takes 1 positional argument but 2 were given

注意,仅限关键字参数不一定要提供默认值。

获取参数中的信息

函数对象有一个 __defaults__ 属性,它的值是一个元祖,里面保存这定位参数和关键字参数的默认值。仅限关键字参数的默认值在 _kwdefaults__ 属性当中。参数的名称保存在 __code__ 属性中,它的值是一个 code 对象的引用,自身也有很多属性。

如下编写了一个函数,它在指定长度附近截断字符串,它的工作原理是在 max_len 的前面或者后面的第一个空格处截断文本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/usr/bin/env python3

def clip(text, max_len = 80):
"""
cut text before or after the first space of maxlen
"""

end = None
if len(text) > max_len:
space_before = text.rfind(' ', 0, max_len)
if (space_before >= 0):
end = space_before
else :
space_after = text.rfind(' ', max_len)
if space_after >= 0:
end = space_after
if end is None:
end = len(text)
return text[:end].rstrip()

print(clip("Hello World", 8))

接下来查看该函数参数的信息:

1
2
3
4
5
6
7
8
9
10
>>> from clip import clip
Hello
>>> clip.__defaults__
(80,)
>>> clip.__code__
<code object clip at 0x1075520e0, file "/Users/fuchencong/data/workspace/code/private/fluent_python/clip.py", line 3>
>>> clip.__code__.co_varnames
('text', 'max_len', 'end', 'space_before', 'space_after')
>>> clip.__code__.co_argcount
2
  • 参数名称在 __code__.co_varnames 中,但是这里面还有函数体内定义的局部变量。参数名称是前 N 个字符串,N 的值为 co_argcount。这里不包含前缀为 *** 的变长参数。参数的默认值只能通过他们在 __defaults__ 元祖中的位置确定。需要从后向前扫描才能把参数和默认值一一对应:例如这里只有一个默认参数值,因此肯定对应最后一个参数,即 max_len

使用 inspect 模块可以更方便地完成函数内省:

1
2
3
4
5
6
7
8
9
>>> from inspect import signature
>>> sig = signature(clip)
>>> str(sig)
'(text, max_len=80)'
>>> for name, param in sig.parameters.items():
... print(param.kind, ":", name, "=", param.default)
...
POSITIONAL_OR_KEYWORD : text = <class 'inspect._empty'>
POSITIONAL_OR_KEYWORD : max_len = 80
  • inspect.signature 函数返回一个 inspect.signature 对象,它有一个 parameters 属性,把参数名和 inspect.Parameter 对象关联起来
  • parameter 对象也有各自的属性,例如 name、default、kind 等
  • kind 属性目前有 5 种值
    • POSITIONAL_OR_KEYWORD:可以通过定位参数或关键字参数传入的形参
    • VAR_POSITIONAL:定位参数元祖
    • VAR_KEYWORD:关键字参数字典
    • KEYWORD_ONLY:仅限关键字参数(Python3 新增)
    • POSITIONAL_ONLY:仅限定位参数,Python 声明函数的语法不支持,使用 C 语言实现且不接受关键字参数的函数支持

inspect.Signature 对象有个 bind 方法,它可以把任意个参数绑定到签名的形参中上,所用的规则与实参到形参的匹配方式一致。框架可以使用这个方法来验证参数,且无需真正调用函数。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
>>> import inspect
>>> from tag import tag
>>> sig = inspect.signature(tag)
>>> tag_dir = {'name': 'img', 'title': 'Sunset Boulevard', 'src':'sunset.jpg', 'cls':'framed'}
>>> bound_args = sig.bind(**tag_dir)
>>> for name, value in bound_args.arguments.items():
... print(name, '=', value)
...
name = img
cls = framed
attrs = {'title': 'Sunset Boulevard', 'src': 'sunset.jpg'}
>>> del tag_dir['name']
>>> bound_args = sig.bind(**tag_dir)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/usr/local/Cellar/python@3.9/3.9.1_3/Frameworks/Python.framework/Versions/3.9/lib/python3.9/inspect.py", line 3062, in bind
return self._bind(args, kwargs)
File "/usr/local/Cellar/python@3.9/3.9.1_3/Frameworks/Python.framework/Versions/3.9/lib/python3.9/inspect.py", line 2977, in _bind
raise TypeError(msg) from None
TypeError: missing a required argument: 'name'

从中可以看出,将 name 从参数字典中删除后,再次调用 bind 将抛出异常,因为 name 参数没有默认值,是一个必须指定的参数。这个示例在 inspect 模块的帮助下,展示了 Python 数据模型把实参绑定到给定函数调用中的形参的机制,这与 Python 解释器使用的机制不同。

框架和 IDE 等工具可以使用这些信息验证代码。函数注解也增加了这些信息的用途。

函数注解

Python3 提供了一种语法,用于为函数声明中的参数和返回值附加元数据。如下所示:

1
2
3
4
5
>>> def func(a:str, b:'int > 0'=80) -> str:
... return a + str(b)
...
>>> func("1", 0)
'10'
  • 函数声明中的各个参数可以在 : 之后增加注解表达式。如果参数有默认值,注解放在参数名和 = 之间。如果想注解返回值,在 ) 和函数声明的末尾 :之间添加 -> 和一个表达式
  • 注解表达式可以是任意类型。注解中最常用的类型是类(如 str 或 int)和字符串(如 `int > 0’)
  • 注解不会做任何处理,只是存储在函数的 __annotations__ 属性中
1
2
>>> func.__annotations__
{'a': <class 'str'>, 'b': 'int > 0', 'return': <class 'str'>}

Python 对注解所做的唯一事情是把它们存储在函数的 __annotations__ 属性里。Python 不会做检查、不做验证,什么操作也不做。注解只是元数据,可以供 IDE、框架和装饰器等工具使用。inspect.signature() 函数知道怎么提取注解:

1
2
3
4
5
6
7
8
9
10
>>> from inspect import signature
>>> sig = signature(func)
>>> sig.return_annotation
<class 'str'>
>>> for param in sig.parameters.values():
... note = repr(param.annotation)
... print(note, ':', param.name, '=', param.default)
...
<class 'str'> : a = <class 'inspect._empty'>
'int > 0' : b = 80

支持函数式编程的包

根据 Python 创始人 Gudio 的说法,Python 的目标不是变成函数式编程语言,但是得益于 operator 和 functiontools 等包的支持,函数式编程风格也很容易支持。

在函数式编程中,经常需要把算术运算符当成函数使用。operator 模块可以为算术运算符提供对应的函数,从而避免使用 lambda 匿名函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> from functools import reduce
>>> def fact(n):
... return reduce(lambda a, b : a * b, range(1, n+1))
...
>>> fact(10)

>>> from functools import reduce
>>> from operator import mul
>>> def fact(n):
... return reduce(mul, range(1, n+1))
...
>>> fact(10)
3628800

operator 模块中还有一类函数,能替代从序列中取出元素或读取对象属性的 lambda 表达式:

  • itemgetter 使用 [] 运算符,因此它不仅支持序列,还支持映射和任何使用 __getitem__ 方法的类。如果传入多个名称,它会返回提取的值构成的元祖
  • attrgetter 根据名称提取对象的属性,如果传入多个名称,返回属性所构成的元祖。如果参数名中包含 .,attrgetter 会深入嵌套对象,获取指定的属性
1
2
3
4
>>> from operator import itemgetter
>>> student_data = [('Mark', 'Male', 18), ('Lily', 'Female', 19), ('Jack', 'Male', 17)]
>>> sorted(student_data, key = itemgetter(2))
[('Jack', 'Male', 17), ('Mark', 'Male', 18), ('Lily', 'Female', 19)]
1
2
3
4
5
6
7
8
>>> from collections import namedtuple
>>> from operator import attrgetter
>>> StudentDetail = namedtuple('StudentDetail', 'Sex Age')
>>> Student = namedtuple('Student', 'Name StudentDetail')
>>> s = [Student(name, StudentDetail(sex, age)) for name, sex, age in student_data]
>>> get_name_and_age = attrgetter('Name', 'StudentDetail.Age')
>>> [get_name_and_age(n) for n in s]
[('Mark', 18), ('Lily', 19), ('Jack', 17)]

通过如下方式可以查看 operator 包所提供的函数:

1
2
3
>>> import operator
>>> [name for name in dir(operator) if not name.startswith('_')]
['abs', 'add', 'and_', 'attrgetter', 'concat', 'contains', 'countOf', 'delitem', 'eq', 'floordiv', 'ge', 'getitem', 'gt', 'iadd', 'iand', 'iconcat', 'ifloordiv', 'ilshift', 'imatmul', 'imod', 'imul', 'index', 'indexOf', 'inv', 'invert', 'ior', 'ipow', 'irshift', 'is_', 'is_not', 'isub', 'itemgetter', 'itruediv', 'ixor', 'le', 'length_hint', 'lshift', 'lt', 'matmul', 'methodcaller', 'mod', 'mul', 'ne', 'neg', 'not_', 'or_', 'pos', 'pow', 'rshift', 'setitem', 'sub', 'truediv', 'truth', 'xor']
  • 以 i 开头函数,对应的是增量赋值赋值运算符(例如 +=、*= 等)。如果第一个参数是可变的,那么这些函数直接就地修改它,否则与不带 i 的函数一样,直接返回运算结果
  • methodcaller 类似于 attrgetter 和 itemgetter,它会自行创建函数,创建的函数会在对象上调用参数所指定的方法
1
2
3
4
5
6
7
8
>>> from operator import methodcaller
>>> upcase = methodcaller('upper')
>>> s = 'hello, world'
>>> upcase(s)
'HELLO, WORLD'
>>> replace_white_space = methodcaller('replace', ' ', '-')
>>> replace_white_space(s)
'hello,-world'

可以看到 methodcaller 可以冻结某些参数,这也就是部分应用(partial application),这与 functools.partial 函数的作用类似。

使用 functools.partial 冻结参数

functools.partial 也是一个高阶函数,它用于部分应用一个函数。部分应用是指基于一个函数创建一个新的可调用对象,把原有函数的某些参数固定。使用这个函数可以把接受一个或多个参数的函数改变成需要更少参数的 API:

1
2
3
4
5
6
7
>>> from operator import mul
>>> from functools import partial
>>> triple = partial(mul, 3)
>>> triple(7)
21
>>> list(map(triple, range(1, 10)))
[3, 6, 9, 12, 15, 18, 21, 24, 27]

partial 的第一个参数是可调用对象,后面跟着任意个要绑定的定位参数和关键字参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
>>> def f(a, b, *c, d, **e):
... print(a, b, c, d, e)
...
>>> f3 = partial(f, 1, 2, 3)
>>> f3(d = 4)
1 2 (3,) 4 {}
>>> f3(4, 5, d = 6)
1 2 (3, 4, 5) 6 {}
>>> f3(4, 5, d = 6, h = 10)
1 2 (3, 4, 5) 6 {'h': 10}

>>> f3d6 = partial(f, 1, 2, 3, d = 6)
>>> f3d6(4, h = 7)
1 2 (3, 4) 6 {'h': 7}

>>> f3d6h9 = partial(f, 1, 2, 3, d = 6, h = 9)
>>> f3d6h9()
1 2 (3,) 6 {'h': 9}

functools.partial 对象提供了访问原函数和固定参数的属性:

1
2
3
4
5
6
>>> f3d6h9.func
<function f at 0x10e238e50>
>>> f3d6h9.args
(1, 2, 3)
>>> f3d6h9.keywords
{'d': 6, 'h': 9}

functools.partialmethod 函数的作用与 partial 一样,不过是用于处理方法的。

使用一等函数实现设计模式

虽然设计模式与语言无关,但是并不意味着每一个模式都能在每一门语言中使用。在有一等函数的语言中,可以把 策略模式命令模式模板方法 等模式中涉及的某些类的实例替换成简单的函数,从而减少代码。这里将使用函数对象,重构 策略模式

重构策略模式

策略模式的经典定义如下:

1
定义一系列算法,把它们一一封装起来,并且使他们可以相互替换。本模式使得算法可以独立于使用它的客户而变化。

如下了实现电商根据订单使用不同的打折策略:

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
#!/usr/bin/env python3

from abc import ABC, abstractmethod
from collections import namedtuple

Customer = namedtuple('Customer', 'name fidelity')

class LineItem:
def __init__(self, product, quantity, price):
self.product = product
self.quantity = quantity
self.price = price

def total(self):
return self.price * self.quantity

class Order:
def __init__(self, customer, cart, promotion=None):
self.customer = customer
self.cart = cart
self.promotion = promotion

def total(self):
if not hasattr(self, '__total'):
self.__total = sum(item.total() for item in self.cart)
return self.__total

def due(self):
if self.promotion is None:
discount = 0
else:
discount = self.promotion.discount(self)
return self.total() - discount

def __repr__(self):
fmt = '<Order total: {:.2f} due: {:.2f}>'
return fmt.format(self.total(), self.due())

class Promotion(ABC):
@abstractmethod
def discount(self, order):
"""return discount"""

class FidelityPromo(Promotion):
def discount(self, order):
return order.total() * 0.05 if order.customer.fidelity >= 1000 else 0

class BulkItemPromo(Promotion):
def discount(self, order):
discount = 0
for item in order.cart:
if item.quantity >= 20:
discount += item.total() * 0.1
return discount

class LargeOrderPromo(Promotion):
def discount(self, order):
distinct_items = {item.product for item in order.cart}
if len(distinct_items) >= 10:
return order.total() * 0.07
return 0


def test():
jack = Customer('Jack', 0)
ann = Customer('Ann', 1100)

cart = [LineItem('banana', 4, 0.5),
LineItem('apple', 10, 1.5),
LineItem('watermelon', 5, 5.0)]

o1 = Order(jack, cart, FidelityPromo())
o2 = Order(ann, cart, FidelityPromo())
print(o1)
print(o2)

large_cart = [LineItem('banana', 30, 0.5),
LineItem('apple', 10, 1.5)]
o1 = Order(jack, large_cart, BulkItemPromo())
print(o1)
o2 = Order(ann, large_cart, BulkItemPromo())
print(o2)

long_cart = [LineItem(str(i), 1, 1.0) for i in range(10)]
o1 = Order(jack, long_cart, LargeOrderPromo())
print(o1)
o2 = Order(ann, long_cart, LargeOrderPromo())
print(o2)

if __name__ != '__MAIN__':
test()

在该实现中,每一个具体策略都是一个类,而且都只定义了一个方法,此外这些策略实例没有状态(没有实例属性),它们看起来就像普通的函数。可以把这些具体策略换成简单的函数,这样就无需 Promo 抽象类:

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
#!/usr/bin/env python3

from abc import ABC, abstractmethod
from collections import namedtuple

Customer = namedtuple('Customer', 'name fidelity')

class LineItem:
def __init__(self, product, quantity, price):
self.product = product
self.quantity = quantity
self.price = price

def total(self):
return self.price * self.quantity

class Order:
def __init__(self, customer, cart, promotion=None):
self.customer = customer
self.cart = cart
self.promotion = promotion

def total(self):
if not hasattr(self, '__total'):
self.__total = sum(item.total() for item in self.cart)
return self.__total

def due(self):
if self.promotion is None:
discount = 0
else:
discount = self.promotion(self)
return self.total() - discount

def __repr__(self):
fmt = '<Order total: {:.2f} due: {:.2f}>'
return fmt.format(self.total(), self.due())


def fidelity_promo(order):
return order.total() * 0.05 if order.customer.fidelity >= 1000 else 0

def bulkitem_promo(order):
discount = 0
for item in order.cart:
if item.quantity >= 20:
discount += item.total() * 0.1
return discount

def large_order_promo(order):
distinct_items = {item.product for item in order.cart}
if len(distinct_items) >= 10:
return order.total() * 0.07
return 0


def test():
jack = Customer('Jack', 0)
ann = Customer('Ann', 1100)

cart = [LineItem('banana', 4, 0.5),
LineItem('apple', 10, 1.5),
LineItem('watermelon', 5, 5.0)]

o1 = Order(jack, cart, fidelity_promo)
o2 = Order(ann, cart, fidelity_promo)
print(o1)
print(o2)

large_cart = [LineItem('banana', 30, 0.5),
LineItem('apple', 10, 1.5)]
o1 = Order(jack, large_cart, bulkitem_promo)
print(o1)
o2 = Order(ann, large_cart, bulkitem_promo)
print(o2)

long_cart = [LineItem(str(i), 1, 1.0) for i in range(10)]
o1 = Order(jack, long_cart, large_order_promo)
print(o1)
o2 = Order(ann, long_cart, large_order_promo)
print(o2)

if __name__ != '__MAIN__':
test()

具体策略一般没有内部状态,只是处理上下文中的数据。此时,一定要使用普通的函数,别去编写只有一个方法的类。函数比用户定义的类的实例轻量,而且各个策略函数在 Python 编译模块时只会创建一次。普通函数也是可共享的对象,可以同时在多个上下文中使用。

选择最佳策略

如果我们想创建一个元策略,让它为指定的订单创建最佳折扣。利用函数模块都是对象,可以使用不同的方式实现这个需求。

方法 1:

1
2
3
promos = [fidelity_promo, bulkitem_promo, large_order_promo]
def best_promo(order):
return max(promo(order) for promo in promos)

这种方法直接遍历每个折扣函数,计算针对指定 order 的折扣,并返回最大的折扣值。因为函数本身是一等对象,所以可以构建函数列表。但是这种方法有个缺点,每次新增 XX_promo 都需要记得将其放到 promos 列表中。

方法 2:

在 Python 中,模块也是一等对象,而且标准库提供了几个处理模块的函数:

  • globals():返回一个字典,表示当前的全局符号表。这个符号表始终针对当前模块。对函数或方法来说,是指定义他们的模块,而不是调用它们的模块。
1
2
3
promos = [globals()[name] for name in globals() if name.endswith('_promo') and name != 'best_promo']
def best_promo(order):
return max(promo(order) for promo in promos)
  • globals() 直接返回字典,迭代字典,获得字典的每一个 key,即符号的名称
  • 如果名称是以 _promo 结尾,同时不是 best_promo,将其对值(即函数对象)加入到 promos 列表中

另外一种方法是将所有 XX_promo 函数放入单独的模块,通过如下方式,获取所有的折扣函数:

1
promos = [func for name, func in inspect.getmembers(promotions, inspect.isfunction)]

动态收集促销折扣函数更为显式的一种方案是使用简单的装饰器,之后会介绍该方案。

命令模式

命令设计模式也可以通过把函数作为参数传递而简化。命令模式的目的是解耦调用操作的对象(调用者)和提供实现的对象(接收者)。这种模式的做法是,在二者之间放入一个 Command 对象,让它实现只有一个方法 execute 的接口,调用接收者中的方法执行所需的操作,这样调用者无需了解接收者的接口。

可以不为调用者提供一个 Command 实例,而是给它一个函数,此时调用者不用调用 command.execute(),而是直接调用提供的函数即可。当然,对于复杂的命令模式可能需要更多,而不是简单的回调函数,也可以考虑使用 Python 提供的几个替代品:

  • 实现一个可调用实例,可以保存任何所需的状态,而且除了 call 之外还可以提供其他方法
  • 可以使用闭包在调用之间保存函数内部的状态

把实现单方法接口的类的实例替换成可调用对象。毕竟,每个 Python 可调用对象都实现了单方法接口,即 __call__