3.1 函数进阶
函数是Python的核心内容之一,能够提高代码的复用率。为了更好地使用函数,我们有必要了解函数的一些运行机制和高级用法。
3.1.1 函数参数传递
1. 参数的传递机制
在Python中,函数参数的传递机制与赋值机制一样,其本质都是共用内存地址,不会真正地复制对象。这种共享内存的方式叫做引用传递。
引用传递(Call By Reference)模式指的是函数在调用过程中,参数与实际传入的对象共享同一块内存地址。
为了进一步理解引用传递的本质,我们使用id()函数来进行简单说明。
定义如下函数:
对于给定的参数x,该函数返回出参数x的对应的内存地址。
我们将函数作用在一个对象上:
函数f(a)的返回值与a的内存地址是一致的。
当我们调用函数f时,Python并没有将a的值复制一份传给参数x,而是让参数x与a共享了同一块内存。也就是说,x和a是同一个对象。
再看传入列表作为参数的情况:
共享同一个对象的机制意味着,我们可以在函数中修改传入参数的值。
例如,我们定义这样的一个函数,对于参数x,让x[0]变为999,并返回修改后的x:
对列表a应用这个函数,Python会先让x指向a所在的内存。由于x和a共享同一个对象,修改x[0]会让a的值相应改变:
假设我们在函数中给参数x赋了一个新值,如另一个列表。根据赋值机制,虽然x指向一个新的内存位置,但原来的变量不会改变:
2. 默认参数的传递
有默认参数的情况下,Python会在定义函数时,预先为默认参数分配内存,每次调用默认参数时,Python会从这个预先分配好的内存地址得到这个默认参数,以免每次生成一个额外的默认参数。这样做能够节约一定的空间,不过也可能会得到一些与直觉不符的结果。
例如,考虑这样的函数f,该函数接受一个参数x,该参数的默认值为空列表:
如果我们不给f传递参数,直觉上它应该返回列表[1]。实际上,由于默认参数始终使用同一个列表,每次调用默认参数时,函数中的.append()方法改变了这个列表,因此,这个列表会随之改变:
因此,我们在使用默认参数时,应当尽量减少使用可变的容器类型,如列表、字典等作为函数的默认参数,以免出现意料之外的结果。
这种机制并不是Python设计上的问题,在某些情况下,这样的机制有时候还能方便我们进行操作,如提供对计算结果的缓存等。
3.1.2 高阶函数
以函数作为参数,或者返回一个函数的函数都是高阶函数(High-Order Function)。
1. 函数的对象性
在Python中,函数也是一种基本类型的对象,例如:
In [1]: max
Out[1]: <function max>
对象性意味着我们可以对函数进行以下操作:
● 将函数作为参数传给另一个函数;
● 将函数作为字典的值;
● 将函数作为另一个函数的返回值。
我们定义这样两个函数:
把它们作为字典的值构造一个字典:
调用d["power3"](3)就相当于调用cube(3):
In [6]: d["power3"](3)
Out[6]: 27
2. 以函数为参数的函数
我们可以定义这样一个函数:
该函数接受两个参数:一个函数f和一个序列sq,其作用是将函数f作用在序列sq的每个元素上。由于参数f是一个函数,所以我们定义的这个函数是一个高阶函数。
Python中已经有map()函数实现同样的功能,其用法为:
map(f, sq)
其作用是将函数f作用到sq的每一个元素上去,并返回所有结果组成的列表。例如,可以将之前定义的函数square()应用到一个序列上去:
In [7]: map(square, range(5))
Out[7]: [0, 1, 4, 9, 16]
3. 以函数为返回值的函数
函数的返回值也可以是个函数。
Python支持在函数中定义子函数,我们定义一个函数,利用子函数将平方和立方函数一般化:
该函数接受一个参数num,并定义了一个子函数func(),最终返回定义的这个子函数。在子函数func()中,我们使用了传入的num参数。
有了这个函数,平方和立方函数可以这样定义:
4. 固定部分参数的函数
Python的functools模块提供了一个叫partial()的函数,它接受一个函数作为参数,并返回一个将这个函数的一部分参数固定的新函数。
首先导入这个函数:
In [13]: from functools import partial
定义一个计算幂的函数:
该函数接受两个参数:x和它的幂次num。
使用partial()函数定义平方:
In [15]: square3 = partial(power, num=2)
partial()函数的第一个参数是需要固定参数的函数,后面是函数中需要固定的参数值。需要固定的参数可以采用键值对的形式指定:如“num=2”。
通过固定参数值,我们定义了一个返回值为x ** 2的函数,即平方函数:
In [16]: square3(4)
Out[16]: 16
类似地,可以让num参数固定为3,定义立方函数如下:
在partial()函数中,除了键值对的形式,需要固定的参数值也可以不指定名称,按照函数定义的顺序依次固定各个参数。
例如,对于下面的用法:
partial(power, 2)
partial()函数会将power()函数的第一个参数x固定为2,所以,相当于生成了一个计算2的n次方的函数:
In [19]: partial(power, 2)(3)
Out[19]: 8
3.1.3 函数map()、filter()和reduce()
map()函数接受一个函数f和一个序列sq:
map(f, sq)
其作用是将函数f作用在序列的所有元素上,等价于:
[f(x) for x in sq]
filter()函数也接受一个函数f和一个序列sq:
filter(f, sq)
其作用是通过函数f来筛选序列中的元素,等价于:
[x for x in sq if f(x)]
比如一个判断偶数的函数:
使用filter()函数,可以得到序列中的所有偶数:
In [2]: filter(is_even, range(5))
Out[2]: [0, 2, 4]
还可以将map()函数和filter()函数一起使用,求序列中所有偶数的平方:
In [3]: map(square, filter(is_even, range(5)))
Out[3]: [0, 4, 16]
相当于:
[square(x) for x in range(5) if is_even(x)]
另一个常用的高阶函数为reduce()函数,其基本用法为:
reduce(f, sq)
与map()函数和filter()函数不同,reduce()函数接受的是一个支持二元操作的函数:f(x, y),实现对序列sq中的元素累加计算,并返回单一的结果。
定义一个加法函数:
将该函数作为reduce()函数的参数使用:
In [5]: reduce(add, [1,2,3,4,5])
Out[5]: 15
这种用法相当于进行了一个这样的一个计算:((((1+2)+3)+4)+5)。第一步计算的输入参数是序列的前两个元素,并将得到的结果与下一个元素进行计算,直到最后一个元素。
reduce()函数还可以提供初始值:
In [6]: reduce(add, [1,2,3,4,5], 10)
Out[6]: 25
给定初始值时,第一步计算的输入参数是初始值10与序列的第一个元素1,而不是序列的前两个元素1和2。
3.1.4 Lambda表达式
在使用函数作为参数的时候,如果传入的函数比较简单或者使用次数较少,在文件中直接定义这些函数就显得比较浪费。为此,Python提供了Lambda表达式(Lambda Expression)来简化函数的定义。
Lambda表达式用关键字lambda定义,其基本形式为:
lambda <variables>: <expression>
Lambda表达式返回的是一个函数对象,其中<varibales>是该函数的参数,<expression>则是函数的返回值,用冒号“:”进行分割。
Lambda表达式在定义时只有参数和返回值,并没有显示函数的名称。对于一些简单的函数,这样做可以省去定义函数的麻烦。
例如,平方函数可以用Lambda表达式定义:
In [1]: lambda x: x ** 2
Out[1]: <function __main__.<lambda>>
将它传入map()函数作为参数:
In [2]: map(lambda x: x ** 2, range(5))
Out[2]: [0, 1, 4, 9, 16]
用Lambda表达式作为filter()函数的输入,判断是否为偶数:
In [3]: filter(lambda x: x % 2 == 0, range(5))
Out[3]: [0, 2, 4]
用Lambda表达式和reduce()函数求和:
In [4]: reduce(lambda x, y: x + y, range(5))
Out[4]: 15
其中,lambda x, y: x+y表示函数接受x和y两个变量,并返回变量x和y的和。
可以直接用Lambda表达式进行赋值,得到的对象是个函数:
3.1.5 关键字global
我们可以在函数中直接使用外部变量的值。
例如,在函数foo()中,我们打印出外部变量x的值:
不过,如果我们在函数中,给外部变量x赋值,外面的x不会变化:
foo函数打印的x是18,但外部变量x仍然是15。在函数里直接给x赋值不会影响外面的变量x。
在函数中,我们可以使用关键字global来给外部变量重新赋值,只需要在函数中用global声明一下外部变量x,并在函数里给x赋值:
此时,调用foo()会改变外部变量x的值:
3.1.6 函数的递归
递归(Recursion)是指函数在执行的过程中调用了本身,通常用于分治法(Divide And Conquer),即将问题拆分为规模较小的子问题。
例如,阶乘函数可以写成:
f (n) = n! = n×(n-1)! = n×f (n-1)
我们把求解n阶乘的问题变成了一个求解n-1阶乘的问题,以此类推,我们只需要解决最简单的f(1)的问题。其函数定义如下:
利用递归,我们将fac()函数写成了一种非常紧凑的形式,如果n为1,返回1,否则返n*fac(n-1)。
递归可以更快地实现代码,不过在效率上可能会有一定的损失。
例如,斐波那契数列是这样的一个数列:1、1、2、3、5、8、13……其规律为:
F(0) = F(1) = 1, F(n) = F(n-1) + F(n-2)
即后一个数是前面两个数的和。
按照这个逻辑,我们很容易就能写出一个递归版本的代码:
用非递归的方式的实现:
非递归的版本基本过程如下:
● 初始情况:a=F(0),b=F(1)
● 第一轮更新:a=F(1),b=F(0)+F(1)=F(2)
● 第二轮更新:a=F(2),b=F(3)
……
● 第n轮更新:a=F(n),b=F(n+1)
利用IPython的魔术命令%timeit,我们对这两个函数的运行时间进行比较:
可以看到,两者的效率有很大的差别,非递归版本比递归版本要快很多。递归版本中存在大量的重复计算,例如,当我们调用fib1(n)时,需要计算一次fib1(n-1)和一次fib1(n-2),而调用fib1(n-1)时需要再调用一次fib1(n-2),这样fib1(n-2)事实上被计算了两次。
为了减少重复计算,可以考虑使用缓存机制来实现一个更快的递归版本,它利用默认参数可变的性质,使用缓存保存已经计算的结果:
默认参数cache初始化为一个字典,并且存储初始值F(0)、F(1),它起到一个缓存的作用。计算fib(n)时,首先在缓存cache中查找,如果cache中有键n,直接返回结果;如果没有,抛出一个KeyError,在except的部分,函数使用递归更新cache[n]的值,然后返回它。
对于该函数,调用fib3(n-1)使得缓存中保存了cache[n-2],再调用fib3(n-2)的时候会直接返回结果而不是重复计算,提高了计算效率。
带缓存的递归版本的时间效率:
In [11]: %timeit fib2(20)
1000000 loops, best of 3: 230 ns per loop