Python函数及函数式编程
函数
函数调用
Python内置了很多有用的函数,我们可以直接调用。
要调用一个函数,需要知道函数的名称和参数,比如求绝对值的函数abs,只有一个参数。可以直接从Python的官方网站查看文档:
http://docs.python.org/3/library/functions.html#abs
也可以在交互式命令行通过help(abs)查看abs函数的帮助信息。
调用abs函数:
1 | >>> abs(100) |
数据类型转换
Python内置的常用函数还包括数据类型转换函数,比如int()函数可以把其他数据类型转换为整数:
1 | >>> int('123') |
函数名其实就是指向一个函数对象的引用,完全可以把函数名赋给一个变量,相当于给这个函数起了一个“别名”:
1 | >>> a = abs # 变量a指向abs函数 |
函数调用
在Python中,定义一个函数要使用def语句,依次写出函数名、括号、括号中的参数和冒号:,然后,在缩进块中编写函数体,函数的返回值用return语句返回。
我们以自定义一个求绝对值的my_abs函数为例:
1 | def my_abs(nub): |
空函数
如果想定义一个什么事也不做的空函数,可以用pass语句:
1 | def nop(): |
pass语句什么都不做,那有什么用?实际上pass可以用来作为占位符,比如现在还没想好怎么写函数的代码,就可以先放一个pass,让代码能运行起来。
pass还可以用在其他语句里,比如:
1 | if age >= 18: |
缺少了pass,代码运行就会有语法错误。
参数检查
调用函数时,如果参数个数不对,Python解释器会自动检查出来,并抛出TypeError:
1 | >>> my_abs(1, 2) |
但是如果参数类型不对,Python解释器就无法帮我们检查。试试my_abs和内置函数abs的差别:
1 | >>> my_abs('A') |
当传入了不恰当的参数时,内置函数abs会检查出参数错误,而我们定义的my_abs没有参数检查,会导致if语句出错,出错信息和abs不一样。所以,这个函数定义不够完善。
让我们修改一下my_abs的定义,对参数类型做检查,只允许整数和浮点数类型的参数。数据类型检查可以用内置函数isinstance()实现:
1 | def my_abs(x): |
添加了参数检查后,如果传入错误的参数类型,函数就可以抛出一个错误:
1 | >>> my_abs('A') |
返回多个值
函数可以返回多个值吗?答案是肯定的。
比如在游戏中经常需要从一个点移动到另一个点,给出坐标、位移和角度,就可以计算出新的新的坐标:
1 | import math |
import math语句表示导入math包,并允许后续代码引用math包里的sin、cos等函数。
然后,我们就可以同时获得返回值:
1 | >>> x, y = move(100, 100, 60, math.pi / 6) |
但其实这只是一种假象,Python函数返回的仍然是单一值:
1 | >>> r = move(100, 100, 60, math.pi / 6) |
原来返回值是一个tuple!但是,在语法上,返回一个tuple可以省略括号,而多个变量可以同时接收一个tuple,按位置赋给对应的值,所以,Python的函数返回多值其实就是返回一个tuple,但写起来更方便。
函数的参数
定义函数的时候,我们把参数的名字和位置确定下来,函数的接口定义就完成了。对于函数的调用者来说,只需要知道如何传递正确的参数,以及函数将返回什么样的值就够了,函数内部的复杂逻辑被封装起来,调用者无需了解。
Python的函数定义非常简单,但灵活度却非常大。除了正常定义的必选参数外,还可以使用默认参数、可变参数和关键字参数,使得函数定义出来的接口,不但能处理复杂的参数,还可以简化调用者的代码。
位置参数
对于power(x)函数,参数x就是一个位置参数。当我们调用power函数时,必须传入有且仅有的一个参数x。
如果我们要计算x^3怎么办?可以再定义一个power3函数,但是如果要计算x^4、x^5……怎么办?我们不可能定义无限多个函数。
你也许想到了,可以把power(x)修改为power(x, n),用来计算x^n。
修改后的power(x, n)函数有两个参数:x和n,这两个参数都是位置参数,调用函数时,传入的两个值按照位置顺序依次赋给参数x和n。
1 | def power(x, n): |
默认参数
当有多个位置参数,且调用函数没有输入正确的参数时,就会报错。
比如:
1 | power(5) |
这个时候,我们就可以把n设置为默认参数。
1 | def power(x, n=2): |
这样,当我们调用power(5)时,相当于调用power(5, 2)。
从上面的例子可以看出,默认参数可以简化函数的调用。设置默认参数时,有几点要注意:
一是必选参数在前,默认参数在后,否则Python的解释器会报错
二是如何设置默认参数。
当函数有多个参数时,把变化大的参数放前面,变化小的参数放后面。变化小的参数就可以作为默认参数。
使用默认参数有什么好处?最大的好处是能降低调用函数的难度。
传入部分默认参数
如下一个函数,我们把年龄和城市设为默认参数:
1 | def enroll(name, gender, age=6, city='Beijing'): |
以下写法都是可以的:
enroll('Bob', 'M')意思是,age和city都试用默认参数。enroll('Bob', 'M',7)意思是,age使用传入参数7,city使用默认参数。enroll('Bob', 'M',city='ChengDu')意思是,city参数用传进去的值,其他默认参数继续使用默认值。
*默认参数必须指向不可变对象
先定义一个函数,传入一个list,添加一个END再返回(错误示范):
1 | def add_end(L=[]): |
再次调用add_end()时,结果就不对了:
1 | >>> add_end() |
默认参数是[],但是函数似乎每次都“记住了”上次添加了'END'后的list。
原因解释如下:
Python函数在定义的时候,默认参数L的值就被计算出来了,即[],因为默认参数L也是一个变量,它指向对象[],每次调用该函数,如果改变了L的内容,则下次调用时,默认参数的内容就变了,不再是函数定义时的[]了。
** 定义默认参数要牢记一点:默认参数必须指向不变对象!
要修改上面的例子,我们可以用None这个不变对象来实现:
1 | def add_end(L=None): |
现在,无论调用多少次,都不会有问题:
1 | >>> add_end() |
为什么要设计str、None这样的不变对象呢?因为不变对象一旦创建,对象内部的数据就不能修改,这样就减少了由于修改数据导致的错误。此外,由于对象不变,多任务环境下同时读取对象不需要加锁,同时读一点问题都没有。我们在编写程序时,如果可以设计一个不变对象,那就尽量设计成不变对象。
可变参数
在Python函数中,还可以定义可变参数。顾名思义,可变参数就是传入的参数个数是可变的,可以是1个、2个到任意个,还可以是0个。
1 | def calc(*numbers): |
定义可变参数和定义一个list或tuple参数相比,仅仅在参数前面加了一个*号。在函数内部,参数numbers接收到的是一个tuple,因此,函数代码完全不变。但是,调用该函数时,可以传入任意个参数,包括0个参数:
1 | >>> calc(1, 2) |
这种写法当然是可行的,问题是太繁琐,所以Python允许你在list或tuple前面加一个*号,把list或tuple的元素变成可变参数传进去:
1 | >>> nums = [1, 2, 3] |
*nums表示把nums这个list的所有元素作为可变参数传进去。或者*numbers表示把numbers这个list的所有元素作为可变参数接收。这种写法相当有用,而且很常见。
关键字参数
可变参数允许你传入0个或任意个参数,这些可变参数在函数调用时自动组装为一个tuple。而关键字参数允许你传入0个或任意个含参数名的参数,这些关键字参数在函数内部自动组装为一个dict。
1 | def person(name, age, **kw): |
可以传入任意个数的关键字参数:
1 | >>> person('Bob', 35, city='Beijing') |
关键字参数有什么用?它可以扩展函数的功能。比如,在person函数里,我们保证能接收到name和age这两个参数,但是,如果调用者愿意提供更多的参数,我们也能收到。试想你正在做一个用户注册的功能,除了用户名和年龄是必填项外,其他都是可选项,利用关键字参数来定义这个函数就能满足注册的需求。
和可变参数类似,也可以先组装出一个dict,然后,把该dict转换为关键字参数传进去:
1 | >>> extra = {'city': 'Beijing', 'job': 'Engineer'} |
当然,上面复杂的调用可以用简化的写法:
1 | >>> extra = {'city': 'Beijing', 'job': 'Engineer'} |
**extra表示把extra这个dict的所有key-value用关键字参数传入到函数的**kw参数,kw将获得一个dict,注意kw获得的dict是extra的一份拷贝,对kw的改动不会影响到函数外的extra。
其实,关键字参数和可变参数的逻辑是一样。只是关键字参数对应的dict,而可变参数对应的是list
命名关键字参数
对于关键字参数,函数的调用者可以传入任意不受限制的关键字参数。至于到底传入了哪些,就需要在函数内部通过kw检查。限制关键字参数的名字,就可以用命名关键字参数,例如,只接收city和job作为关键字参数。
如果要限制关键字参数的名字,就可以用命名关键字参数,例如,只接收city和job作为关键字参数。这种方式定义的函数如下:
1 | def person(name, age, *, city, job): |
和关键字参数**kw不同,命名关键字参数需要一个特殊分隔符*,*后面的参数被视为命名关键字参数。
调用方式如下:
1 | >>> person('Jack', 24, city='Beijing', job='Engineer') |
如果函数定义中已经有了一个可变参数,后面跟着的命名关键字参数就不再需要一个特殊分隔符*了:
1 | def person(name, age, *args, city, job): |
参数组合
在Python中定义函数,可以用必选参数、默认参数、可变参数、关键字参数和命名关键字参数,这5种参数都可以组合使用。但是请注意,参数定义的顺序必须是:必选参数、默认参数、可变参数、命名关键字参数和关键字参数。
比如定义一个函数,包含上述若干种参数:
1 | def f1(a, b, c=0, *args, **kw): |
最神奇的是通过一个tuple和dict,你也可以调用上述函数:
1 | >>> args = (1, 2, 3, 4) |
所以,对于任意函数,都可以通过类似func(*args, **kw)的形式调用它,无论它的参数是如何定义的。
虽然可以组合多达5种参数,但不要同时使用太多的组合,否则函数接口的可理解性很差。
递归函数
在函数内部,可以调用其他函数。如果一个函数在内部调用自身本身,这个函数就是递归函数。
举个例子,我们来计算阶乘n! = 1 x 2 x 3 x ... x n,用函数fact(n)表示,可以看出:
fact(n) = n! = 1 x 2 x 3 x … x (n-1) x n = (n-1)! x n = fact(n-1) x n
所以,fact(n)可以表示为n x fact(n-1),只有n=1时需要特殊处理。
于是,fact(n)用递归的方式写出来就是:
1 | def fact(n): |
递归函数的优点是定义简单,逻辑清晰。理论上,所有的递归函数都可以写成循环的方式,但循环的逻辑不如递归清晰。
使用递归函数需要注意防止栈溢出。在计算机中,函数调用是通过栈(stack)这种数据结构实现的,每当进入一个函数调用,栈就会加一层栈帧,每当函数返回,栈就会减一层栈帧。由于栈的大小不是无限的,所以,递归调用的次数过多,会导致栈溢出。
高级特性
切片(Slice)
切片操作符是序列名后跟一个方括号,方括号中有一对可选的数字,并用冒号分割。注意这与你使用的索引操作符十分相似。记住数是可选的,而冒号是必须的。切片操作符中的第一个数(冒号之前)表示切片开始的位置,第二个数(冒号之后)表示切片到哪里结束,第三个数(冒号之后)表示切片间隔数。
如果不指定第一个数,Python就从序列首开始。如果没有指定第二个数,则Python会停止在序列尾。如果没有第三个数,则切片间隔数为1。
取一个list或tuple的部分元素是非常常见的操作。比如,一个list如下:
1 | >>> L = ['Michael', 'Sarah', 'Tracy', 'Bob', 'Jack'] |
取前3个元素,用一行代码就可以完成切片:
1 | >>> L[0:3] |
L[0:3]表示,从索引0开始取,直到索引3为止,但不包括索引3。即索引0,1,2,正好是3个元素。
如果第一个索引是0,还可以省略:
1 | >>> L[:3] |
同样的和索引操作一样,Python支持L[-1]取倒数第一个元素,那么它同样支持倒数切片,试试:
1 | >>> L[-2:] |
tuple也是一种list,唯一区别是tuple不可变。因此,tuple也可以用切片操作,只是操作的结果仍是tuple:
1 | >>> (0, 1, 2, 3, 4, 5)[:3] |
字符串'xxx'也可以看成是一种list,每个元素就是一个字符。因此,字符串也可以用切片操作,只是操作结果仍是字符串:
1 | >>> 'ABCDEFG'[:3] |
迭代(Iteration)
在Python中,迭代是通过for ... in来完成的。Python的for循环抽象程度要高于C的for循环,因为Python的for循环不仅可以用在list或tuple上,还可以作用在其他可迭代对象上。
list这种数据类型虽然有下标,但很多其他数据类型是没有下标的,但是,只要是可迭代对象,无论有无下标,都可以迭代,比如dict就可以迭代:
1 | >>> d = {'a': 1, 'b': 2, 'c': 3} |
因为dict的存储不是按照list的方式顺序排列,所以,迭代出的结果顺序很可能不一样。
默认情况下,dict迭代的是key。如果要迭代value,可以用for value in d.values(),如果要同时迭代key和value,可以用for k, v in d.items()。
由于字符串也是可迭代对象,因此,也可以作用于for循环:
1 | >>> for ch in 'ABC': |
所以,当我们使用for循环时,只要作用于一个可迭代对象,for循环就可以正常运行,而我们不太关心该对象究竟是list还是其他数据类型。
那么,如何判断一个对象是可迭代对象呢?方法是通过collections模块的Iterable类型判断:
1 | >>> from collections import Iterable |
最后一个小问题,如果要对list实现类似Java那样的下标循环怎么办?Python内置的enumerate函数可以把一个list变成索引-元素对,这样就可以在for循环中同时迭代索引和元素本身:
1 | >>> for i, value in enumerate(['A', 'B', 'C']): |
上面的for循环里,同时引用了两个变量,在Python里是很常见的,比如下面的代码:
1 | >>> for x, y in [(1, 1), (2, 4), (3, 9)]: |
列表生成式(List Comprehensions)
列表生成式即List Comprehensions,是Python内置的非常简单却强大的可以用来创建list的生成式。
它的基础语法是:[exp for iter_var in iterable]
首先迭代 iterable里所有内容, 每一次迭代, 都把 iterable里相应内容放到 iter_var 中, 再在表达式 `exp 中应用该 iter_var的内容, 最后用表达式的计算值生成一个新的列表.
举个例子,要生成list [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]可以用list(range(1, 11)):
1 | >>> list(range(1, 11)) |
但如果要生成[1x1, 2x2, 3x3, ..., 10x10]怎么做?
1 | >>> [x * x for x in range(1, 11)] |
for循环后面还可以加上if判断,这样我们就可以筛选出仅偶数的平方:
1 | >>> [x * x for x in range(1, 11) if x % 2 == 0] |
运用列表生成式, 可以写出非常简洁的代码。
例如, 把一个 list 中所有的字符串变成小写:
1 | L = ['Hello', 'World', 'IBM', 'Apple'] |
还可以使用两层循环,可以生成全排列:
1 | >>> [m + n for m in 'ABC' for n in 'XYZ'] |
甚至是多层循环,但是一般只会使用2层:
1 | >>> [m + n + x +y for m in 'ABC' for n in 'XYZ' for x in 'XYZ'for y in 'XYZ'] |
for循环其实可以同时使用两个甚至多个变量,比如dict的items()可以同时迭代key和value:
1 | >>> d = {'x': 'A', 'y': 'B', 'z': 'C' } |
再比如:L1 = ['Hello', 'World', 18, 'Apple', None],需求把列表中字符串大写修改为小写,非字符串原样。由于非字符串类型没有lower()方法,所以列表生成式会报错。
1 | >>> L = ['Hello', 'World', 18, 'Apple', None] |
在
[ s.lower() if isinstance(s,str) else s for s in L]中,可以理解成列表没有过滤条件,
[ s.lower() if isinstance(s,str) else s for s in L if True]其中
s.lower() if isinstance(s,str) else s是类似三目运算符的语法。(isinstance(s,str)?s.lower() :s)语法结构为:
为真时的结果 if 判断条件 else 为假时的结果(注意,没有冒号)
注意事项:
1. 当需要只是执行一个循环的时候尽量使用循环而不是列表解析, 这样更符合python提倡的直观性:
1
2for item in sequence:
process(item)122. 当有内建的操作或者类型能够以更直接的方式实现的, 不要使用列表解析.
例如复制一个列表时, 使用; L1=list(L) 即可, 不必使用: L1=[x for x in L]
3. 如果需要对每个元素都调用并且返回结果时, 应使用 L1=map(f,L), 而不是 L1=[f(x) for x in L].
个人觉得这是一个很有意思,很强大的特性。理解深入了对代码简洁易看有很大的帮助。
生成器(generator)
通过列表生成式,我们可以直接创建一个列表。但是,受到内存限制,列表容量肯定是有限的。而且,创建一个包含100万个元素的列表,不仅占用很大的存储空间,如果我们仅仅需要访问前面几个元素,那后面绝大多数元素占用的空间都白白浪费了。
所以,如果列表元素可以按照某种算法推算出来,那我们是否可以在循环的过程中不断推算出后续的元素呢?这样就不必创建完整的list,从而节省大量的空间。在Python中,这种一边循环一边计算的机制,称为生成器:generator。
要创建一个generator,有很多种方法。第一种方法很简单,只要把一个列表生成式的[]改成(),就创建了一个generator:
1 | >>> L = [x * x for x in range(10)] |
创建L和g的区别仅在于最外层的[]和(),L是一个list,而g是一个generator。
正确的方法是使用for循环,因为generator也是可迭代对象:
1 | >>> g = (x * x for x in range(10)) |
yield
著名的斐波拉契数列(Fibonacci),除第一个和第二个数外,任意一个数都可由前两个数相加得到:
1, 1, 2, 3, 5, 8, 13, 21, 34, …
斐波拉契数列用列表生成式写不出来,但是,用函数把它打印出来却很容易:
1 | def fib(max): |
注意,赋值语句:
1 | a, b = b, a + b |
相当于:
1 | t = (b, a + b) # t是一个tuple |
但不必显式写出临时变量t就可以赋值。
上面的函数可以输出斐波那契数列的前N个数:
1 | >>> fib(6) |
仔细观察,可以看出,fib函数实际上是定义了斐波拉契数列的推算规则,可以从第一个元素开始,推算出后续任意的元素,这种逻辑其实非常类似generator。
也就是说,上面的函数和generator仅一步之遥。要把fib函数变成generator,只需要把print(b)改为yield b就可以了:
1 | def fib(max): |
这就是定义generator的另一种方法。如果一个函数定义中包含yield关键字,那么这个函数就不再是一个普通函数,而是一个generator:
1 | >>> f = fib(6) |
这里,最难理解的就是generator和函数的执行流程不一样。函数是顺序执行,遇到return语句或者最后一行函数语句就返回。而变成generator的函数,在每次调用next()的时候执行,遇到yield语句返回,再次执行时从上次返回的yield语句处继续执行。
举个简单的例子,定义一个generator,依次返回数字1,3,5:
1 | def odd(): |
调用该generator时,首先要生成一个generator对象,然后用next()函数不断获得下一个返回值:
1 | >>> o = odd() |
可以看到,odd不是普通函数,而是generator,在执行过程中,遇到yield就中断,下次又继续执行。执行3次yield后,已经没有yield可以执行了,所以,第4次调用next(o)就报错。
回到fib的例子,我们在循环过程中不断调用yield,就会不断中断。当然要给循环设置一个条件来退出循环,不然就会产生一个无限数列出来。
同样的,把函数改成generator后,我们基本上从来不会用next()来获取下一个返回值,而是直接使用for循环来迭代:
1 | >>> for n in fib(6): |
但是用for循环调用generator时,发现拿不到generator的return语句的返回值。如果想要拿到返回值,必须捕获StopIteration错误,返回值包含在StopIteration的value中:
1 | >>> g = fib(6) |
关于如何捕获错误,后面的错误处理还会详细讲解。
迭代器(Iterator)
我们已经知道,可以直接作用于for循环的数据类型有以下几种:
一类是集合数据类型,如list、tuple、dict、set、str等;
一类是generator,包括生成器和带yield的generator function。
这些可以直接作用于for循环的对象统称为可迭代对象:Iterable。
可以使用isinstance()判断一个对象是否是Iterable对象:
1 | >>> from collections import Iterable |
而生成器不但可以作用于for循环,还可以被next()函数不断调用并返回下一个值,直到最后抛出StopIteration错误表示无法继续返回下一个值了。
可以被next()函数调用并不断返回下一个值的对象称为迭代器:Iterator。
可以使用isinstance()判断一个对象是否是Iterator对象:
1 | >>> from collections import Iterator |
生成器都是Iterator对象,但list、dict、str虽然是Iterable,却不是Iterator。
把list、dict、str等Iterable变成Iterator可以使用iter()函数:
1 | >>> isinstance(iter([]), Iterator) |
你可能会问,为什么list、dict、str等数据类型不是Iterator?
这是因为Python的Iterator对象表示的是一个数据流,Iterator对象可以被next()函数调用并不断返回下一个数据,直到没有数据时抛出StopIteration错误。可以把这个数据流看做是一个有序序列,但我们却不能提前知道序列的长度,只能不断通过next()函数实现按需计算下一个数据,所以Iterator的计算是惰性的,只有在需要返回下一个数据时它才会计算。
Iterator甚至可以表示一个无限大的数据流,例如全体自然数。而使用list是永远不可能存储全体自然数的。
函数式编程
函数式编程的一个特点就是,允许把函数本身作为参数传入另一个函数,还允许返回一个函数!
Python对函数式编程提供部分支持。由于Python允许使用变量,因此,Python不是纯函数式编程语言。
高阶函数(Higher-order function)
变量可以指向函数
以Python内置的求绝对值的函数abs()为例,调用该函数用以下代码:
1 | >>> abs(-10) |
但是,如果只写abs呢?
1 | >>> abs |
可见,abs(-10)是函数调用,而abs是函数本身。
要获得函数调用结果,我们可以把结果赋值给变量:
1 | >>> x = abs(-10) |
但是,如果把函数本身赋值给变量呢?
1 | >>> f = abs |
结论:函数本身也可以赋值给变量,即:变量可以指向函数。
如果一个变量指向了一个函数,那么,可否通过该变量来调用这个函数?用代码验证一下:
1 | >>> f = abs |
成功!说明变量f现在已经指向了abs函数本身。直接调用abs()函数和调用变量f()完全相同。
函数名也是变量
那么函数名是什么呢?函数名其实就是指向函数的变量!对于abs()这个函数,完全可以把函数名abs看成变量,它指向一个可以计算绝对值的函数!
如果把abs指向其他对象,会有什么情况发生?
1 | >>> abs = 10 |
把abs指向10后,就无法通过abs(-10)调用该函数了!因为abs这个变量已经不指向求绝对值函数而是指向一个整数10!
当然实际代码绝对不能这么写,这里是为了说明函数名也是变量。要恢复abs函数,请重启Python交互环境。
注:由于abs函数实际上是定义在import builtins模块中的,所以要让修改abs变量的指向在其它模块也生效,要用import builtins; builtins.abs = 10。
传入函数
既然变量可以指向函数,函数的参数能接收变量,那么一个函数就可以接收另一个函数作为参数,这种函数就称之为高阶函数。
一个最简单的高阶函数:
1 | def add(x, y, f): |
当我们调用add(-5, 6, abs)时,参数x,y和f分别接收-5,6和abs,根据函数定义,我们可以推导计算过程为:
1 | x = -5 |
map/reduce
map()
map()函数接收两个参数,一个是函数,一个是Iterable,map将传入的函数依次作用到序列的每个元素,并把结果作为新的Iterator返回。
举例说明,比如我们有一个函数f(x)=x2,要把这个函数作用在一个list [1, 2, 3, 4, 5, 6, 7, 8, 9]上,就可以用map()实现如下:
1 | f(x) = x * x |
现在,我们用Python代码实现:
1 | >>> def f(x): |
map()传入的第一个参数是f,即函数对象本身。由于结果r是一个Iterator,Iterator是惰性序列,因此通过list()函数让它把整个序列都计算出来并返回一个list。
你可能会想,不需要map()函数,写一个循环,也可以计算出结果:
1 | L = [] |
的确可以,但是,从上面的循环代码,能一眼看明白“把f(x)作用在list的每一个元素并把结果生成一个新的list”吗?
所以,map()作为高阶函数,事实上它把运算规则抽象了,因此,我们不但可以计算简单的f(x)=x2,还可以计算任意复杂的函数,比如,把这个list所有数字转为字符串:
1 | >>> list(map(str, [1, 2, 3, 4, 5, 6, 7, 8, 9])) |
只需要一行代码。
我们要数图书馆中的所有书。你数1号书架,我数2号书架。这就是“Map”。我们人越多,数书就更快。
reduce()
再看reduce的用法。reduce把一个函数作用在一个序列[x1, x2, x3, ...]上,这个函数必须接收两个参数,reduce把结果继续和序列的下一个元素做累积计算,其效果就是:
1 | reduce(f, [x1, x2, x3, x4]) = f(f(f(x1, x2), x3), x4) |
比方说对一个序列求和,就可以用reduce实现:
1 | >>> from functools import reduce |
当然求和运算可以直接用Python内建函数sum(),没必要动用reduce。
但是如果要把序列[1, 3, 5, 7, 9]变换成整数13579,reduce就可以派上用场:
1 | >>> from functools import reduce |
我们要数图书馆中的所有书。你数1号书架,我数2号书架。现在我们到一起,把所有人的统计数加在一起。这就是“Reduce”。
filter()
Python内建的filter()函数用于过滤序列。
和map()类似,filter()也接收一个函数和一个序列。和map()不同的是,filter()把传入的函数依次作用于每个元素,然后根据返回值是True还是False决定保留还是丢弃该元素。
例如,在一个list中,删掉偶数,只保留奇数,可以这么写:
1 | def is_odd(n): |
sorted()
排序也是在程序中经常用到的算法。无论使用冒泡排序还是快速排序,排序的核心是比较两个元素的大小。如果是数字,我们可以直接比较,但如果是字符串或者两个dict呢?直接比较数学上的大小是没有意义的,因此,比较的过程必须通过函数抽象出来。
Python内置的sorted()函数就可以对list进行排序:
1 | >>> sorted([36, 5, -12, 9, -21]) |
此外,sorted()函数也是一个高阶函数,它还可以接收一个key函数来实现自定义的排序,例如按绝对值大小排序:
1 | >>> sorted([36, 5, -12, 9, -21], key=abs) |
key指定的函数将作用于list的每一个元素上,并根据key函数返回的结果进行排序。对比原始的list和经过key=abs处理过的list:
1 | list = [36, 5, -12, 9, -21] |
然后sorted()函数按照keys进行排序,并按照对应关系返回list相应的元素:
1 | keys排序结果 => [5, 9, 12, 21, 36] |
我们再看一个字符串排序的例子:
1 | >>> sorted(['bob', 'about', 'Zoo', 'Credit']) |
默认情况下,对字符串排序,是按照ASCII的大小比较的,由于'Z' < 'a',结果,大写字母Z会排在小写字母a的前面。
现在,我们提出排序应该忽略大小写,按照字母序排序。要实现这个算法,不必对现有代码大加改动,只要我们能用一个key函数把字符串映射为忽略大小写排序即可。忽略大小写来比较两个字符串,实际上就是先把字符串都变成大写(或者都变成小写),再比较。
这样,我们给sorted传入key函数,即可实现忽略大小写的排序:
1 | >>> sorted(['bob', 'about', 'Zoo', 'Credit'], key=str.lower) |
要进行反向排序,不必改动key函数,可以传入第三个参数reverse=True:
1 | >>> sorted(['bob', 'about', 'Zoo', 'Credit'], key=str.lower, reverse=True) |
从上述例子可以看出,高阶函数的抽象能力是非常强大的,而且,核心代码可以保持得非常简洁。
匿名函数
当我们在传入函数时,有些时候,不需要显式地定义函数,直接传入匿名函数更方便。
通过对比可以看出,匿名函数lambda x: x * x实际上就是:
1 | def f(x): |
关键字lambda表示匿名函数,冒号前面的x表示函数参数。
匿名函数有个限制,就是只能有一个表达式,不用写return,返回值就是该表达式的结果。
返回函数
高阶函数除了可以接受函数作为参数外,还可以把函数作为结果值返回。
我们来实现一个可变参数的求和。通常情况下,求和的函数是这样定义的:
1 | def calc_sum(*args): |
但是,如果不需要立刻求和,而是在后面的代码中,根据需要再计算怎么办?可以不返回求和的结果,而是返回求和的函数:
1 | def lazy_sum(*args): |
当我们调用lazy_sum()时,返回的并不是求和结果,而是求和函数:
1 | >>> f = lazy_sum(1, 3, 5, 7, 9) |
调用函数f时,才真正计算求和的结果:
1 | >>> f() |
在这个例子中,我们在函数lazy_sum中又定义了函数sum,并且,内部函数sum可以引用外部函数lazy_sum的参数和局部变量,当lazy_sum返回函数sum时,相关参数和变量都保存在返回的函数中,这种称为“闭包(Closure)”的程序结构拥有极大的威力。
请再注意一点,当我们调用lazy_sum()时,每次调用都会返回一个新的函数,即使传入相同的参数:
1 | >>> f1 = lazy_sum(1, 3, 5, 7, 9) |
f1()和f2()的调用结果互不影响。
偏函数
Python的functools模块提供了很多有用的功能,其中一个就是偏函数(Partial function)。要注意,这里的偏函数和数学意义上的偏函数不一样。
int()函数还提供额外的base参数,默认值为10。如果传入base参数,就可以做N进制的转换:
1 | >>> int('12345', base=8) |
假设要转换大量的二进制字符串,每次都传入int(x, base=2)非常麻烦,于是,我们想到,可以定义一个int2()的函数,默认把base=2传进去:
1 | def int2(x, base=2): |
这样,我们转换二进制就非常方便了:
1 | >>> int2('1000000') |
functools.partial就是帮助我们创建一个偏函数的,不需要我们自己定义int2(),可以直接使用下面的代码创建一个新的函数int2:
1 | >>> import functools |
所以,简单总结functools.partial的作用就是,把一个函数的某些参数给固定住(也就是设置默认值),返回一个新的函数,调用这个新函数会更简单。
注意到上面的新的int2函数,仅仅是把base参数重新设定默认值为2,但也可以在函数调用时传入其他值:
1 | >>> int2('1000000', base=10) |
最后,创建偏函数时,实际上可以接收函数对象、*args和**kw这3个参数,当传入:
1 | int2 = functools.partial(int, base=2) |
实际上固定了int()函数的关键字参数base,也就是:
1 | int2('10010') |
相当于:
1 | kw = { 'base': 2 } |
当传入:
1 | max2 = functools.partial(max, 10) |
实际上会把10作为*args的一部分自动加到左边,也就是:
1 | max2(5, 6, 7) |
相当于:
1 | args = (10, 5, 6, 7) |
结果为10。