Python学习之路24-一等函数

《流畅的Python》笔记。

本篇主要讲述Python中函数的进阶内容。包括函数和对象的关系,函数内省,Python中的函数式编程。

1. 前言

本片首先介绍函数和对象的关系;随后介绍函数和可调用对象的关系,以及函数内省。函数内省这部分会涉及很多与IDE和框架相关的东西,如果平时并不写框架,可以略过此部分。最后介绍函数式编程的相关概念,以及与之相关的两个重要模块:operator模块和functools模块。

首先补充“一等对象”的概念。“一等对象”一般定义如下:

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

从上述定义可以看出,Python中的函数符合上述四点,所以在Python中函数也被视作一等对象。

“把函数视作一等对象”简称为“一等函数”,但这并不是指有一类函数是“一等函数”,在Python中所有函数都是一等函数

2. 函数

2.1 函数是对象

为了表明Python中函数就是对象,我们可以使用type()函数来判断函数的类型,并且访问函数的__doc__属性,同时我们还将函数赋值给一个变量,并且将函数作为参数传入另一个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def factorial(n):
"""return n!"""
return 1 if n < 2 else n * factorial(n - 1)
# 在Python控制台中,help(factorial)也会访问函数的__doc__属性。
print(factorial.__doc__)
print(type(factorial))
# 把函数赋值给一个变量
fact = factorial
print(fact)
fact(5)
# 把函数传递给另一个函数
print(list(map(fact, range(11))))

# 结果:
return n!
<class 'function'>
<function factorial at 0x000002421033C2F0>
[1, 1, 2, 6, 24, 120, 720, 5040, 40320, 362880, 3628800]

从上述结果可以看出,__doc__属性保存了函数的文档字符串,而type()的结果说明函数其实是function类的一个实例。将函数赋值给一个变量和将函数作为参数传递给另一个函数则体现了“一等对象”的特性。

2.2 高阶函数

接收函数作为参数,或者把函数作为结果返回的函数叫做高阶函数(higher-order function),上述的map函数就是高阶函数,还有我们常用的sorted函数也是。

大家或多或少见过mapfilterreduce三个函数,这三个就是高阶函数,在过去很常用,但现在它们都有了替代品:

  • Python3中,mapfilter依然是内置函数,但由于有了列表推导和生成器表达式,这两个函数已不常用;
  • Python3中,reduce已不是内置函数,它被放到了functools模块中。它常被用于求和,但现在求和最好用内置的sum函数。

sumreduce这样的函数叫做归约函数,它们的思想是将某个操作连续应用到一系列数据上,累计之前的结果,最后得到一个值,即将一系列元素归约成一个值。

内置的归约函数还有allany

  • all(iterable):如果iterable中每个值都为真,则返回Trueall([])返回True
  • any(iterable):如果iterable中有至少一个元素为真,则返回Trueany([])返回False

2.3 匿名函数

lambda关键字在Python表达式内创建匿名函数,但在Python中,匿名函数内不能赋值,也不能使用whiletry等语句。但它和def语句一样,实际创建了函数对象。

如果使用lambda表达式导致一段代码难以理解,最好还是将其转换成用def语句定义的函数。

3. 可调用对象

函数其实一个可调用对象,它实现了__call__方法。Python数据模型文档列出了7种可调用对象:

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

3.1 用户定义的可调用类型

任何Python对象都可以表现得像函数,只要实现__call__方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class SayHello:
def sayhello(self):
print("Hello!")

def __call__(self):
self.sayhello()

say = SayHello()
say.sayhello()
say()
print(callable(say))

# 结果:
Hello!
Hello!
True

实现__call__方法的类是创建函数类对象的简便方式。有时这些类必须在内部维护一些状态,让它在调用之间可用,比如装饰器。装饰器必须是函数,而且有时还要在多次调用之间保存一些数据。

3.2 函数内省

以下内容在编写框架和IDE时用的比较多。

笔者之前偶有见到”内省“,但一直不明白”内省“这个词究竟是什么意思。“自我反省”?其实在编程中,这个词的意思就是:让代码自动确定某一段代码能干什么。如果以函数举例,就是函数A自动确定函数B是什么,包含哪些信息,能干什么。不过在讲Python函数的内省之前,先来看看函数都有哪些属性和方法。

3.2.1 函数的属性和方法

dir函数可以检测一个参数所含有的属性和方法。我们可以用该函数查看一个函数所包含的属性和方法:

1
2
3
4
5
6
7
>>> 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对象共有的。函数独有的属性如下:

1
2
3
4
5
6
>>> 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__']

3.2.2 __dict__属性

与用户定义的常规类一样,函数使用__dict__属性存储用户赋予它的属性。这相当于一种基本形式的注解。

这里可能有人觉得别扭:之前都是给变量或者对象赋予属性,现在是给函数或者方法赋予属性。不过正如前面说的,Python中函数就是对象。

一般来说,为函数赋予属性不是个常见的做法,但Django框架就有这样的行为:

1
2
3
def upper_case_name(obj):
return ("%s %s" % (obj.first_name, obj.last_name)).upper()
upper_case_name.short_description = "Customer name" # 给方法赋予了一个属性

3.2.3 获取关于参数的信息

从这里开始就是函数内省的内容。在HTTP为框架Bobo中有个使用函数内省的例子,它以装饰器的形式展示:

1
2
3
4
5
import bobo

@bobo.query("/")
def hello(person):
return "Hello %s!" % person

通过装饰器bobo.query,Bobo会内省hello函数:Bobo会发现这个hello函数需要一个名为person的参数,然后它就会从请求中获取这个参数,并将这个参数传给hello函数。

有了这个装饰器,我们就不用自己处理请求对象来获取person参数,Bobo框架帮我们自动完成了。

那这究竟是怎么实现的呢?Bobo怎么知道我们写的函数需要哪些参数?它又是怎么知道参数有没有默认值呢?

这里用到了函数对象特有的一些属性(如果不了解参数类型,可以阅读笔者的“Python学习之路7”中的相关内容):

  • __defaults__的值是一个元组,存储着关键字参数的默认值位置参数
  • __kwdefaults__存储着命名关键字参数的默认值
  • __code__属性存储参数的名称,它的值是一个code对象引用,自身也有很多属性。

下面通过一个例子说明这些属性的用途:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def func(a, b=10):
"""This is just a test"""
c = 20
if a > 10:
d = 30
else:
e = 30

print(func.__defaults__)
print(func.__code__)
print(func.__code__.co_varnames)
print(func.__code__.co_argcount)

# 结果:
(10,)
<code object func at 0x0000021651851DB0, file "mytest.py", line 1>
('a', 'b', 'c', 'd', 'e')
2

可以看出,这种信息的组织方式并不方便:

  • 参数名在__code__.co_varnames中,它同时还存储了函数定义体中的局部变量,因此,只有前__code__.co_argcount个元素是参数名(不包含前缀为***的的变长参数);
  • 如果想将参数名和默认值对应上,只能从后向前扫描__default__属性,比如上例中关键字参数b的默认值10

不过,我们并不是第一个发现这种方式很不方便。已经有人为我们造好了轮子,我们可以使用inspect模块简化上述操作:

1
2
3
4
5
6
7
8
9
10
11
12
>>> from mytest import func
>>> from inspect import signature
>>> sig = signature(func) # 返回一个inspect.Signature对象(签名对象)
>>> sig
<Signature (a, b=10)>
>>> str(sig)
'(a, b=10)'
>>> for name, param in sig.parameters.items():
... print(param.kind, ":", name, "=",param.default)
...
POSITIONAL_OR_KEYWORD : a = <class 'inspect._empty'> # 表示没有默认值
POSITIONAL_OR_KEYWORD : b = 10

inspect.Signature对象有一个属性parameters,该属性是个有序映射,把参数名inspect.Parameter对象对应起来。inspect.Parameter也有自己的属性,如:

  • name:参数的名称;
  • default:参数的默认值;
  • kind:参数的类型,有5种,POSITIONAL_OR_KEYWORDVAR_POSITIONAL(任意数量参数,以一个号开头的那种参数),VAR_KEYWORD(任意数量的关键字参数,以\*开头的那种参数),KEYWORD_ONLY(命名关键字参数)和POSITIONAL_ONLY(Python句法不支持该类型)
  • annotationreturn_annotation:参数和返回值的注解,后面会讲到。

inspect.Signature对象有个bind方法,它可把任意个参数绑定到Singature中的形参上,框架可使用这个方法在真正调用函数前验证参数是否正确。比如你自己写的框架中的某函数A自动获取用户输入的参数,并根据这些参数调用函数B,但在调用B之前,你想检测下这些参数是否符合函数B对形参的要求,此时你就有可能用到这个bind方法,看能不能将这些参数绑定到函数B上,如果能,则可认为能够根据这些参数调用函数B:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
>>> from mytest import func
>>> from inspect import signature
>>> sig = signature(func)
>>> my_tag = {"a":10, "b":20}
>>> bound_args = sig.bind(**my_tag)
>>> bound_args
<BoundArguments (a=10, b=20)>
>>> for name, value in bound_args.arguments.items():
... print(name, "=", value)
a = 10
b = 20

>>> del my_tag["a"]
>>> bound_args = sig.bind(**my_tag)
Traceback (most recent call last):
TypeError: missing a required argument: 'a'

3.2.4 函数注解

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

1
2
3
4
5
6
# 未加注解
def func(a, b=10):
return a + b
# 添加注解
def func(a: int, b: 'int > 0' = 10) -> int:
return a + b

各个参数可以在冒号后面增加注解表达式,如果有默认值,注解放在冒号和等号之间。上述-> int是对返回值添加注解的形式。

这些注解都存放在函数的__annotations__属性中,它是一个字典:

1
2
3
4
5
print(func.__annotations__)

# 结果
# 'return'表示返回值
{'a': <class 'int'>, 'b': 'int > 0', 'return': <class 'int'>}

Python只是将注解存储在函数的__annotations__属性中,除此之外,再无任何操作。换句话说,这些注解对Python解释器来说没有意义。而这些注解的真正用途是提供给IDE、框架和装饰器等工具使用,比如Mypy静态类型检测工具,它就会根据你写的这些注解来检测传入的参数的类型是否符合要求。

inspect模块可以获取这些注解。inspect.Signature有个一个return_annotation属性,它保存返回值的注解;inspect.Parameter对象中的annotation属性保存了参数的注解。

函数内省的内容到此结束。后面将介绍标准库中为函数式编程提供支持的常用包。

4. 函数式编程

Python并不是一个函数式编程语言,但通过operator和functools等包的支持,也可以写出函数式风格的代码。

4.1 operator模块

在函数式编程中,经常需要把算术运算符当做函数使用,比如非递归求阶乘,实现如下:

1
2
3
from functools import reduce
def fact(n):
return reduce(lambda a, b: a * b, range(1, n + 1))

operator模块为多个算术运算符提供了对应的函数。使用算术运算符函数可将上述代码改写如下:

1
2
3
4
from functools import reduce
from operator import mul
def fact(n):
return reduce(mul, range(1, n + 1))

operator模块中还有一类函数,能替代从序列中取出元素或读取对象属性的lambda表达式:itemgetterattrgetter。这两个函数其实会自行构建函数。

4.1.1 itemgetter()

以下代码展示了itemgetter的常见用途:

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

test_data = [
("A", 1, "Alpha"),
("B", 3, "Beta"),
("C", 2, "Coco"),
]
# 相当于 lambda fields: fields[1]
for temp in sorted(test_data, key=itemgetter(1)):
print(temp)

# 传入多个参数时,它构建的函数返回下标对应的值构成的元组
part_tuple = itemgetter(1, 0)
for temp in test_data:
print(part_tuple(temp))

# 结果:
('A', 1, 'Alpha')
('C', 2, 'Coco')
('B', 3, 'Beta')
(1, 'A')
(3, 'B')
(2, 'C')

itemgetter内部使用[]运算符,因此它不仅支持序列,还支持映射和任何实现了__getitem__方法的类。

4.1.2 attrgetter()

attrgetteritemgetter作用类似,它创建的函数根据名称提取对象的属性。如果传入多个属性名,它也会返回属性名对应的值构成的元组。这里要展示的是,如果参数名中包含句点.attrgetter会深入嵌套对象,获取指定的属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from collections import namedtuple
from operator import attrgetter

metro_data = [
("Tokyo", "JP", 36.933, (35.689722, 139.691667)),
("Delhi NCR", "IN", 21.935, (28.613889, 77.208889)),
("Mexico City", "MX", 20.142, (19.433333, -99.133333)),
]

LatLong = namedtuple("LatLong", "lat long")
Metropolis = namedtuple("Metropolis", "name, cc, pop, coord")
metro_areas = [Metropolis(name, cc, pop, LatLong(lat, long)) for
name, cc, pop, (lat, long) in metro_data]
# 返回新的元组,获取name属性和嵌套的coord.lat属性
name_lat = attrgetter("name", "coord.lat")
for city in sorted(metro_areas, key=attrgetter("coord.lat")): # 嵌套
print(name_lat(city))

# 结果:
('Mexico City', 19.433333)
('Delhi NCR', 28.613889)
('Tokyo', 35.689722)

4.1.3 methodcaller()

从名字也可看出,它创建的函数会在对象上调用参数指定的方法(注意是方法,而不是函数)。

1
2
3
4
5
6
7
8
>>> from operator import methodcaller
>>> s = "The time has come"
>>> upcase = methodcaller("upper")
>>> upcase(s) # 相当于s.upper()
'THE TIME HAS COME'
>>> hiphenate = methodcaller("replace"," ","-")
>>> hiphenate(s) # 相当于s.replace(" ", "-")
'The-time-has-come'

hiphenate这个例子可以看出,methodcaller还可以冻结某些参数,即部分应用(partial application),这与functools.partial函数的作用类似。

4.2 使用functools.partial冻结参数

functool模块提供了一系列高阶函数,reduce函数相信大家已经很熟悉了,本节主要介绍其中两个很有用的函数partial和它的变体partialmethod

functools.partial用到了一个“闭包”的概念,这个概念的详细内容下一篇再介绍。使用这个函数可以把接收一个或多个参数的函数改编成需要回调的API,这样参数更少。

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> from operator import mul
>>> from functools import partial
>>> triple = partial(mul, 3)
>>> triple(7)
21
>>> list(map(triple, range(1,10))) # 这里无法直接使用mul函数
[3, 6, 9, 12, 15, 18, 21, 24, 27]
>>> triple.func # 访问原函数
<built-in function mul>
>>> triple.args # 访问固定参数
(3,)
>>> triple.keywords # 访问关键字参数
{}

functools.partialmethod函数的作用于partial一样,只不过partialmethod用于方法,partial用于函数。

补充回调函数(callback function)可以简单理解为,当一个函数X被传递给函数A时,函数X就被称为回调函数,函数A调用函数X的过程叫做回调

5. 总结

本篇首先介绍了函数,包括函数与对象的关系,高阶函数和匿名函数,重点是函数就是对象;随后介绍了函数和可调用对象的关系,以及函数的内省;最后,我们介绍了关于函数式编程的概念以及与之相关的两个重要模块。

VPointer wechat
欢迎大家关注我的微信公众号"代码港"~~
您的慷慨将鼓励我继续创作~~