tongsiying

阅读|运动|自律

0%

第16篇:函数进阶

**学习目标**:掌握函数进阶相关知识点

在之前的课程中,我们讲到过关于函数的知识,我们还讲到过Python中常用的数据类型,这些类型的变量都可以作为函数的参数或返回值。如果我们把这些知识汇总一下,我们的函数就可以做更多的事情。

高阶函数的用法

Python中任意一个函数都有数据类型,这种数据类型是function,被称为函数类型。函数类型的数据和其他类型的数据是一样的,任意类型的数据都可以作为函数返回值使用,还可以作为函数参数使用。这就意味着函数本身也可以作为函数的参数或返回值,这就是所谓的高阶函数

如果我们希望上面的calc函数不仅仅可以做多个参数求和,还可以做多个参数求乘积甚至更多的二元运算,我们就可以使用高阶函数的方式来改写上面的代码,将加法运算从函数中移除掉,具体的做法如下所示。

1
2
3
4
5
6
7
def calc(*args, init_value, op, **kwargs):
result = init_value
for arg in args:
result = op(result, arg)
for value in kwargs.values():
result = op(result, value)
return result

注意,上面的函数增加了两个参数,其中init_value代表运算的初始值,op代表二元运算函数。经过改造的calc函数不仅仅可以实现多个参数的累加求和,也可以实现多个参数的累乘运算,代码如下所示。

1
2
3
4
5
6
7
8
9
10
def add(x, y):
return x + y


def mul(x, y):
return x * y


print(calc(1, 2, 3, init_value=0, op=add, x=4, y=5)) # 15
print(calc(1, 2, x=3, y=4, z=5, init_value=1, op=mul)) # 120

通过对高阶函数的运用,calc函数不再和加法运算耦合,所以灵活性和通用性会变强,这是编程中一种常用的技巧,但是最初学者来说可能会稍微有点难以理解。需要注意的是,将函数作为参数和调用函数是有显著的区别的,调用函数需要在函数名后面跟上圆括号,而把函数作为参数时只需要函数名即可。上面的代码也可以不用定义addmul函数,因为Python标准库中的operator模块提供了代表加法运算的add和代表乘法运算的mul函数,我们直接使用即可,代码如下所示。

1
2
3
4
import operator

print(calc(init_value=0, op=operator.add, 1, 2, 3, x=4, y=5)) # 15
print(calc(init_value=1, op=operator.mul, 1, 2, x=3, y=4, z=5)) # 120

Python内置函数中有不少高阶函数,我们前面提到过的filtermap函数就是高阶函数,前者可以实现对序列中元素的过滤,后者可以实现对序列中元素的映射,例如我们要去掉一个整数列表中的奇数,并对所有的偶数求平方得到一个新的列表,就可以直接使用这两个函数来做到,具体的做法是如下所示。

1
2
3
4
5
6
7
8
9
10
11
def is_even(num):
return num % 2 == 0


def square(num):
return num ** 2


numbers1 = [35, 12, 8, 99, 60, 52]
numbers2 = list(map(square, filter(is_even, numbers1)))
print(numbers2) # [144, 64, 3600, 2704]

当然,要完成上面代码的功能,也可以使用列表生成式,列表生成式的做法更为简单优雅。

1
2
3
numbers1 = [35, 12, 8, 99, 60, 52]
numbers2 = [num ** 2 for num in numbers1 if num % 2 == 0]
print(numbers2) # [144, 64, 3600, 2704]

过滤函数filter()

Python中定义了一些用于数据处理的函数,如filter()map()等。我们先介绍filter函数。

filter()函数用于对容器中的元素进行过滤处理。filter()函数的语法如下:

1
filter(function or None, iterable) --> filter object
  • 参数function是一个提供过滤条件的函数,返回布尔值,可以为None。
  • 参数iterable是容器类型的数据。

在调用filter()函数时,iterable会被遍历,他的元素会被逐一传入function()函数中。function()函数若返回True,则元素被保留;返回False,则元素被过滤。最后遍历完成,已保留的元素被放到一个新的容器数据中,容器类型为迭代器(后面会讲)。

1
2
3
4
5
6
7
8
9
# 提供过滤条件的函数
def f1(x):
return x > 50 # 找出大于50的元素

data = [66, 15, 91, 28, 98, 50, 7, 80, 90]
filtered = filter(f1, data)
print(filtered) # filter object at 0x0000019C57EE92C8>
data2 = list(filtered) # 转换为列表
print(data2) # [66, 91, 98, 80, 90]

注意:filter()函数的返回值并不是一个列表,如果需要返回列表类型的数据,还需要通过list()函数进行转换。

补充:我们可以用列表生成式或列表生成器实现filter()函数同样的功能。

映射函数map()

map()函数用于对容器中的元素进行映射(或变换)。例如:我想讲列表中所有元素都乘以2,返回新的列表。

map()函数的语法如下:

1
map(function, iterable)
  • 参数function是一个提供变换规则的函数,返回变换之后的元素。
  • 参数iterable是容器类型的数据。

在调用map()函数时,iterable会被遍历,他的元素会被逐一传入function()函数中。function()函数对元素进行变换,最后遍历完成,变换后的元素被放到一个新的容器数据中,容器类型为迭代器(后面会讲)。

1
2
3
4
5
6
7
8
9
# 提供变换规则的函数
def f2(x):
return x * 2 # 变换规则为乘以2

data = [66, 15, 91, 28, 98, 50, 7, 80, 90]
map_res = map(f2, data)
print(map_res) # <map object at 0x000001F290FFD188>
data3 = list(map_res)
print(data3) # [132, 30, 182, 56, 196, 100, 14, 160, 180]

补充:我们可以用列表生成式或列表生成器实现filter()函数同样的功能。

reduce函数

对一个序列进行压缩运算,得到一个值。但是reduce在python2的时候是内置函数,到了python3移到了functools模块,所以使用之前需要 from functools import reduce

reduce()函数的语法如下:

1
reduce(function,iterable)
  • 参数function是一个对于两个元素进行运算的函数。
  • 参数iterable是容器类型的数据。

数将一个数据集合(链表,元组等)中的所有数据进行下列操作:

  • function(有两个参数)先对容器中的第 1、2 个元素进行操作,得到的结果再与第三个数进行操作。
  • 按照此规则用 function 函数运算,最后得到一个结果。
1
2
3
4
5
6
7
8
9
def kfactorial(k):
"""求k的阶乘"""
if k == 0 or k == 1:
return 1
from functools import reduce
return reduce(lambda x, y: x * y, list(range(1, k + 1)))


print(kfactorial(5))

Lambda函数

在使用高阶函数的时候,如果作为参数或者返回值的函数本身非常简单,一行代码就能够完成,那么我们可以使用Lambda函数来表示。Python中的Lambda函数是没有的名字函数,所以很多人也把它叫做匿名函数,匿名函数只能有一行代码,代码中的表达式产生的运算结果就是这个匿名函数的返回值。上面代码中的is_evensquare函数都只有一行代码,我们可以用Lambda函数来替换掉它们,代码如下所示。

1
2
3
numbers1 = [35, 12, 8, 99, 60, 52]
numbers2 = list(map(lambda x: x ** 2, filter(lambda x: x % 2 == 0, numbers1)))
print(numbers2) # [144, 64, 3600, 2704]

通过上面的代码可以看出,定义Lambda函数的关键字是lambda,后面跟函数的参数,如果有多个参数用逗号进行分隔;冒号后面的部分就是函数的执行体,通常是一个表达式,表达式的运算结果就是Lambda函数的返回值,不需要写return 关键字。

如果需要使用加减乘除这种简单的二元函数,也可以用Lambda函数来书写,例如调用上面的calc函数时,可以通过传入Lambda函数来作为op参数的参数值。当然,op参数也可以有默认值,例如我们可以用一个代表加法运算的Lambda函数来作为op参数的默认值。

1
2
3
4
5
6
7
8
9
10
11
12
13
def calc(*args, init_value=0, op=lambda x, y: x + y, **kwargs):
result = init_value
for arg in args:
result = op(result, arg)
for value in kwargs.values():
result = op(result, value)
return result


# 调用calc函数,使用init_value和op的默认值
print(calc(1, 2, 3, x=4, y=5)) # 15
# 调用calc函数,通过lambda函数给op参数赋值
print(calc(1, 2, 3, x=4, y=5, init_value=1, op=lambda x, y: x * y)) # 120

提示:注意上面的代码中的calc函数,它同时使用了可变参数、关键字参数、命名关键字参数,其中命名关键字参数要放在可变参数和关键字参数之间,传参时先传入可变参数,关键字参数和命名关键字参数的先后顺序并不重要。

有很多函数在Python中用一行代码就能实现,我们可以用Lambda函数来定义这些函数,调用Lambda函数就跟调用普通函数一样,代码如下所示。

1
2
3
4
5
6
7
8
9
10
import operator, functools

# 一行代码定义求阶乘的函数
fac = lambda num: functools.reduce(operator.mul, range(1, num + 1), 1)
# 一行代码定义判断素数的函数
is_prime = lambda x: x > 1 and all(map(lambda f: x % f, range(2, int(x ** 0.5) + 1)))

# 调用Lambda函数
print(fac(10)) # 3628800
print(is_prime(9)) # False

提示1:上面使用的reduce函数是Python标准库functools模块中的函数,它可以实现对数据的归约操作,通常情况下,过滤(filter)、映射(map)和归约(reduce)是处理数据中非常关键的三个步骤,而Python的标准库也提供了对这三个操作的支持。

提示2:上面使用的all函数是Python内置函数,如果传入的序列中所有布尔值都是Trueall函数就返回True,否则all函数就返回False

偏函数

函数在执行时,要带上所有必要的参数进行调用。但是,有时参数可以在函数被调用之前提前获知。这种情况下,一个函数有一个或多个参数预先就能用上,以便函数能用更少的参数进行调用。偏函数是将所要承载的函数作为partial()函数的第一个参数,原函数的各个参数依次作为partial()函数后续的参数,除非使用关键字参数。通过语言描述可能无法理解偏函数是怎么使用的,那么就举一个常见的例子来说明。在这个例子里,我们实现了一个取余函数,对于整数 100,取得对于不同数 m 的 100%m 的余数。

1
2
3
4
5
6
7
8
9
10
11
from functools import partial


def mod(n, m):
return n % m


mod_by_100 = partial(mod, 100)

print(mod(100, 7)) # 2
print(mod_by_100(7)) # 2

由于之前看到的例子一般选择加法或乘法来讲解,无法体会偏函数参数的位置问题,容易给人造成 partial 的第二个参数也是原函数的第二个参数的假象,所以我在这里选择 mod 来讲解。而对于有关键字参数的情况下,就可以不按照原函数的参数位置和个数了。下面再看一个例子,讲的是如何进行不同的进制转换。

1
2
3
4
5
6
7
8
9
from functools import partial

bin2dec = partial(int, base=2)
print(bin2dec('0b10001')) # 17
print(bin2dec('10001')) # 17

hex2dec = partial(int, base=16)
print(hex2dec('0x67')) # 103
print(hex2dec('67')) # 103

偏函数的这些应用看似简单,用途却很大,可以现在心里放个位置,等哪天用到的时候就会发现它的强大。

递归调用

Python中允许函数嵌套定义,也允许函数之间相互调用,而且一个函数还可以直接或间接的调用自身。函数自己调用自己称为递归调用,那么递归调用有什么用处呢?现实中,有很多问题的定义本身就是一个递归定义,例如我们之前讲到的阶乘,非负整数N的阶乘是N乘以N-1的阶乘,即N! = N * (N-1)!,定义的左边和右边都出现了阶乘的概念,所以这是一个递归定义。既然如此,我们可以使用递归调用的方式来写一个求阶乘的函数,代码如下所示。

1
2
3
4
def fac(num):
if num in (0, 1):
return 1
return num * fac(num - 1)

上面的代码中,fac函数中又调用了fac函数,这就是所谓的递归调用。代码第2行的if条件叫做递归的收敛条件,简单的说就是什么时候要结束函数的递归调用,在计算阶乘时,如果计算到01的阶乘,就停止递归调用,直接返回1;代码第4行的num * fac(num - 1)是递归公式,也就是阶乘的递归定义。下面,我们简单的分析下,如果用fac(5)计算5的阶乘,整个过程会是怎样的。

1
2
3
4
5
6
7
8
9
10
11
12
# 递归调用函数入栈
# 5 * fac(4)
# 5 * (4 * fac(3))
# 5 * (4 * (3 * fac(2)))
# 5 * (4 * (3 * (2 * fac(1))))
# 停止递归函数出栈
# 5 * (4 * (3 * (2 * 1)))
# 5 * (4 * (3 * 2))
# 5 * (4 * 6)
# 5 * 24
# 120
print(fac(5)) # 120

注意,函数调用会通过内存中称为“栈”(stack)的数据结构来保存当前代码的执行现场,函数调用结束后会通过这个栈结构恢复之前的执行现场。栈是一种先进后出的数据结构,这也就意味着最早入栈的函数最后才会返回,而最后入栈的函数会最先返回。例如调用一个名为a的函数,函数a的执行体中又调用了函数b,函数b的执行体中又调用了函数c,那么最先入栈的函数是a,最先出栈的函数是c。每进入一个函数调用,栈就会增加一层栈帧(stack frame),栈帧就是我们刚才提到的保存当前代码执行现场的结构;每当函数调用结束后,栈就会减少一层栈帧。通常,内存中的栈空间很小,因此递归调用的次数如果太多,会导致栈溢出(stack overflow),所以递归调用一定要确保能够快速收敛。我们可以尝试执行fac(5000),看看是不是会提示RecursionError错误,错误消息为:maximum recursion depth exceeded in comparison(超出最大递归深度),其实就是发生了栈溢出。

我们使用的Python官方解释器,默认将函数调用的栈结构最大深度设置为1000层。如果超出这个深度,就会发生上面说的RecursionError。当然,我们可以使用sys模块的setrecursionlimit函数来改变递归调用的最大深度,例如:sys.setrecursionlimit(10000),这样就可以让上面的fac(5000)顺利执行出结果,但是我们不建议这样做,因为让递归快速收敛才是我们应该做的事情,否则就应该考虑使用循环递推而不是递归。

再举一个之前讲过的生成斐波那契数列的例子,因为斐波那契数列前两个数都是1,从第3个数开始,每个数是前两个数相加的和,可以记为f(n) = f(n - 1) + f(n - 2),很显然这又是一个递归的定义,所以我们可以用下面的递归调用函数来计算第n个斐波那契数。

1
2
3
4
5
6
7
8
9
def fib(n):
if n in (1, 2):
return 1
return fib(n - 1) + fib(n - 2)

py
# 打印前20个斐波那契数
for i in range(1, 21):
print(fib(i))

需要提醒大家,上面计算斐波那契数的代码虽然看起来非常简单明了,但执行性能是比较糟糕的,原因大家可以自己思考一下,更好的做法还是之前讲过的使用循环递推的方式,代码如下所示。

1
2
3
4
5
def fib(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a

简单的总结

Python中的函数也是对象,所以函数可以作为函数的参数和返回值,也就是说,在Python中我们可以使用高阶函数。如果我们要定义的函数非常简单,只有一行代码且不需要名字,可以将函数写成Lambda函数(匿名函数)的形式。一些复杂的问题用函数递归调用的方式写起来真的很简单,但是函数的递归调用一定要注意收敛条件和递归公式,找到递归公式才有机会使用递归调用,而收敛条件确定了递归什么时候停下来。函数调用通过内存中的栈空间来保存现场和恢复现场,栈空间通常都很小,所以递归如果不能迅速收敛,很可能会引发栈溢出错误,从而导致程序的崩溃。

赞赏一下吧~