0%

流畅的 Python 第 2 版(7):函数是一等对象

在 Python 中,函数是一等对象(但 Python 并不算函数式编程语言)。编程语言研究人员把 一等对象 定义为满足以下条件的程序实体:

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

把函数视为对象

Python 函数就是对象。这里我们创建一个函数,然后调用它,读取它的 __doc__ 属性,再确认函数对象本身是 function 类的实例:

1
2
3
4
5
6
7
8
>>> def test():
... """ test function """
... return "test"
...
>>> type(test)
<class 'function'>
>>> test.__doc__
' test function '
  • __doc__ 属性用于生成对象的帮助文本,例如 help(test)

如下展示了函数对象的 一等 本性。可以把 test 函数赋值给变量,然后通过变量名调用。也可以将函数作为参数传给其他函数:

1
2
3
4
5
6
7
8
9
>>> t = test
>>> t()
'test'

>>> def call_func(func):
... return func()
...
>>> call_func(test)
'test'

有了一等函数,便可以使用函数式风格编程。函数式编程的特色之一是高阶函数。

高阶函数

接受函数为参数或者把函数作为结果返回的函数是高阶函数(higher-order function)。

1
2
3
4
5
6
>>> fruits = ["apple", "cherry", "banana"]
>>> sorted(fruits)
['apple', 'banana', 'cherry']

>>> sorted(fruits, key=len)
['apple', 'cherry', 'banana']

在函数式编程范式中,最为人熟知的高阶函数有 map、filter、reduce 等。在 Python3 中,map 和 filter 还是内置函数,但是由于引入了列表推导式和生成器表达式,因此二者就变得没那么重要了。列表推导式或生成器表达式兼具 map 和 filter 这两个函数的功能,而且代码可读性更高。在 Python3 中,map 和 filter 返回生成器(一种迭代器)​,因此现在它们的直接替代品是生成器表达式

Python3 中 reduce 在 functools 模块中,这个函数最常用于求和。内置函数 sum 也能实现这样的效果。sum 和 reduce 的整体运作方式是一样的,即把某个操作连续应用到序列中的项上,累计前一个结果,把一系列值归约成一个值。

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

内置的归约函数还有 all 和 any:

  • all(iterable):iterable 中没有表示假值的元素时返回 True。all([​]) 返回 True
  • any(iterable):只要 iterable 中有元素是真值就返回 True。any([​]) 返回False

匿名函数

为了使用高阶函数,有时创建一次性的小型函数更便利。这便是匿名函数存在的原因。lambda 关键字使用 Python 表达式创建匿名函数。受 Python 简单的句法限制,lambda 函数的主体只能是纯粹的表达式:

  • 也就是说,lambda 函数的主体中不能有 while、try 等 Python 语句
  • 使用 = 赋值也是一种语句,不能出现在 lambda 函数的主体中

在高阶函数的参数列表中最适合使用匿名函数:

1
2
3
4
>>> fruits = ['strawberry', 'fig', 'apple', 'cherry', 'raspberry', 'banana']
>>> sorted(fruits, key=lambda word: word[::-1])
['banana', 'apple', 'fig', 'raspberry', 'strawberry', 'cherry']
>>>

除了作为参数传给高阶函数,Python 很少使用匿名函数。由于句法上的限制,非平凡的 lambda 表达式要么难以阅读,要么无法写出(如果 lamda 表达式无法写出,可以转成使用 def 创建函数对象)。lambda 句法只是语法糖,lambda 表达式会像 def 语句一样创建函数对象。

9 种可调用对象

除了函数,调用运算符 () 还可以应用到其他对象上。如果想判断对象能否调用,可以使用内置的 callable() 函数。自 Python 3.9 起可用的 9 种可调用对象:

  • 用户定义的函数:使用 def 语句或 lambda 表达式创建的函数
  • 内置函数:使用 C 语言(CPython)实现的函数,例如 lentime.strftime
  • 内置方法:使用 C 语言实现的方法,例如 dict.get
  • 方法:在类主体中定义的函数
  • 类:调用类时运行类的 __new__ 方法创建一个实例,然后运行 __init__ 方法,初始化实例,最后再把实例返回给调用方。Python 中没有 new 运算符,调用类就相当于调用函数
  • 类的实例:如果类定义了 __call__ 方法,那么它的实例可以作为函数调用
  • 生成器函数:主体中有 yield 关键字的函数或方法。**调用 生成器函数 返回一个生成器对象
  • 原生协程函数:使用 async def 定义的函数或方法。调用 原生协程函数 返回一个协程对象
  • 异步生成器函数:使用 async def 定义,而且主体中有 yield 关键字的函数或方法。调用 异步生成器函数 返回一个异步生成器,供 async for 使用

与其他可调用对象不同,生成器函数、原生协程函数和异步生成器函数的返回值不是应用程序数据,而是需要进一步处理的对象,要么产出应用程序数据,要么执行某种操作:

  • 生成器函数会返回生成器(一种迭代器)
  • 原生协程函数和异步生成器函数返回的对象只能由异步编程框架(例如 asyncio)处理
1
2
>>> [callable(t) for t in [str, abs, "hi"]]
[True, True, False]

用户定义的可调用类型

不仅 Python 函数是真正的对象,而且任何 Python 对象都可以表现得像函数。为此,只需实现实例方法 __call__

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> class CallableTest():
... def __init__(self, content):
... self._content = content
... def __call__(self, repeat):
... print(self._content * repeat)
...

>>> c = CallableTest("test")
>>> c(3)
testtesttest

>>> callable(c)
True

实现 __call__ 方法是创建类似函数的对象的简便方式,此时通常在内部维护一个状态,让它在多次调用之间存续。__call__ 的另一个用处是实现装饰器。装饰器必须可调用,而且有时要在多次调用之间 记住 某些事

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

Python 函数最好的功能之一是提供了极为灵活的参数处理机制。与之密切相关的是,调用函数时可以使用 *** 拆包可迭代对象,映射各个参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
def tag(name, *content, class_=None, **attrs):
"""生成一个或多个HTML标签"""
if class_ is not None:
attrs['class'] = class_
attr_pairs = (f' {attr}="{value}"' for attr, value
in sorted(attrs.items()))
attr_str = ''.join(attr_pairs)
if content:
elements = (f'<{name}{attr_str}>{c}</{name}>'
for c in content)
return '\n'.join(elements)
else:
return f'<{name}{attr_str} />'
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
>>> tag('br')
'<br />'

>>> tag('p', 'hello')
'<p>hello</p>'

>>> print(tag('p', 'hello', 'world'))
<p>hello</p>
<p>world</p>

>>> tag('p', 'hello', id=33)
'<p id="33">hello</p>'

>>> print(tag('p', 'hello', 'world', class_='sidebar'))
<p class="sidebar">hello</p>
<p class="sidebar">world</p>

# 这里 content 是通过关键字参数传入,不会被绑定到 *content 中,而是会被绑定到 **attrs 中
>>> tag(content='testing', name="img")
'<img content="testing" />'

>>> my_tag = {'name': 'img', 'title': 'Sunset Boulevard',
... 'src': 'sunset.jpg', 'class': 'framed'}
>>> tag(**my_tag)
'<img class="framed" src="sunset.jpg" title="Sunset Boulevard" />'
  • 第一个参数后面的任意数量的参数被 *content 捕获,存入一个元组
  • class_ 参数只能作为关键字参数传入(由于 class 是 Python 关键字,因此使用 class_
  • tag 函数签名中没有明确指定名称的关键字参数被 **attrs 捕获,存入一个字典
  • 第一个参数是位置参数,调用时也能作为关键字参数传入
  • 在 my_tag 前面加上 **,字典中的所有项作为参数依次传入,同名键绑定到对应的具名参数上,余下的则被 **attrs 捕获

仅限关键字参数是 Python 3 新增的功能。class_ 参数只能通过关键字参数指定,它一定不会捕获无名位置参数。定义函数时,如果想指定仅限关键字参数,就要把它们放到前面有 * 的参数后面。如果不想支持数量不定的位置参数,但是想支持仅限关键字参数,则可以在签名中放一个 *

1
2
3
4
5
6
7
8
9
def f(a, *, b):
return a, b

>>> f(1, b=2)
(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

仅限关键字参数不一定要有默认值,强制要求传入实参。

仅限位置参数

Python 3.8 开始,用户定义的函数签名可以指定仅限位置参数。内置函数都是如此,例如 divmod(a, b) 只能使用位置参数调用,不能写成 divmod(a=10, b=4)。如果想定义只接受位置参数的函数,则可以在参数列表中使用 /。/ 左边均是仅限位置参数。在 / 后面,可以指定其他参数,处理方式一同往常。

1
2
3
4
5
6
7
8
9
10
>>> def f(a, /, *b, c, **d):
... print(a, b, c, d)
...
>>> f(1, 2, 3, c= 4, d = 5, e = 6)
1 (2, 3) 4 {'d': 5, 'e': 6}

>>> f(c = 3, d = 4)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: f() missing 1 required positional argument: 'a'

支持函数式编程的包

Python 的目标不是变成函数式编程语言,但是得益于一等函数、模式匹配,以及 operatorfunctools 等包的支持,其对函数式编程风格也可以 信手拈来

operator 模块

在函数式编程中,经常需要把算术运算符当作函数使用。operator 模块为多个算术运算符提供了对应的函数,无须再动手编写像 lambda a, b:a*b 这样的匿名函数。

1
2
3
4
5
from functools import reduce
from operator import mul

def factorial(n):
return reduce(mul, range(1, n+1))

operator 模块中还有一类函数,即工厂函数 itemgetterattrgetter,能替代从序列中取出项或读取对象属性的 lambda 表达式。

  • itemgetter 使用 [​] 运算符,因此它不仅支持序列,还支持映射和任何实现 __getitem__ 方法的类
  • itemgetter 的作用类似,attrgetter 创建的函数会根据名称来提取对象的属性。如果传给 attrgetter 多个属性名,那么它也会返回由提取的值构成的元组。如果参数名中包含 .(点号)​,那么 attrgetter 就会深入嵌套对象,检索属性

另外再介绍一个 methodcaller,它创建的函数会在对象上调用参数指定的方法:

1
2
3
4
5
6
7
>>> from operator import methodcaller
>>> f = methodcaller('upper')
>>> f("test")
'TEST'
>>> f = methodcaller('replace', '_', '-')
>>> f("hello_world")
'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 的第一个参数是一个可调用对象,后面跟着任意个要绑定的位置参数和关键字参数。如下将 partial 应用到之前定义的 tag 函数上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
>>> from tagger import tag
>>> tag
<function tag at 0x10206d1e0>

>>> from functools import partial
>>> picture = partial(tag, 'img', class_='pic-frame')
>>> picture(src='wumpus.jpeg')
'<img class="pic-frame" src="wumpus.jpeg" />'
>>> picture
functools.partial(<function tag at 0x10206d1e0>, 'img', class_='pic-frame')

>>> picture.func
<function tag at 0x10206d1e0>
>>> picture.args
('img',)
>>> picture.keywords
{'class_': 'pic-frame'}

可以看到,functools.partial 对象提供了访问原函数(func)和固定参数的属性(args 及 keywords)。

functools.partialmethod 函数的作用与 partial 一样,不过其用于处理方法。functools 模块中还有一些高阶函数可用作函数装饰器,例如 cachesingledispatch 等,后面将介绍。

小结

这里讨论了 Python 函数的一等本性,因此可以把函数赋值给变量、传给其他函数、存储在数据结构中,以及访问函数的属性,供框架和一些工具使用。