自学Python:编程基础、科学计算及数据分析
上QQ阅读APP看书,第一时间看更新

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