第6章 抽象

​ 本章介绍如何将语句组合成函数,这让你能够告诉计算机如何完成任务,且只需说一次,无需反复向计算机传达详细指令。本章详细介绍参数和作用域,还将讨论递归是什么及其在程序中的用途。

1、懒惰是一种美德

num = input('How many numbers do you want? ')
print(fibs(num))

​ 在这里,只具体地编写了这个程序独特的部分(读取数字并打印结果)。实际上,斐波那契数的计算是以抽象的方式完成的:你只是让计算机这样做,而没有具体地告诉它如何做。你创建了一个名为fibs的函数,并在需要计算斐波那契数时调用它。如果需要在多个地方计算斐波那契数,这样做可节省很多精力。

2、抽象和结构

​ 抽象可节省人力,但实际上还有个更重要的优点:抽象是程序能够被人理解的关键所在(无论对编写程序还是阅读程序来说,这都至关重要)。计算机本身喜欢具体而明确的指令,但人通常不是这样的。例如,如果你向人打听怎么去电影院,就不希望对方回答:“向前走10步,向左转90度,接着走5步,再向右转45度,然后走123步。”听到这样的回答,你肯定一头雾水。如果对方回答:“沿这条街往前走,看到过街天桥后走到马路对面,电影院就在你左边。”你肯定能明白。这里的关键是你知道如何沿街往前走,也知道如何过天桥,因此不需要有关这些方面的具体说明。

​ 组织计算机程序时,你也采取类似的方式。程序应非常抽象,如下载网页、计算使用频率、打印每个单词的使用频率。这很容易理解。下面就将前述简单描述转换为一个Python程序。

page = download_page()
freqs = compute_frequencies(page)
for word, freq in freqs:
	print(word, freq)

​ 看到这些代码,任何人都知道这个程序是做什么的。然而,至于具体该如何做,你未置一词。你只是让计算机去下载网页并计算使用频率,至于这些操作的具体细节,将在其他地方(独立的函数定义)中给出。

3、自定义函数

​ 函数执行特定的操作并返回一个值,你可以调用它(调用时可能需要提供一些参数——放在圆括号中的内容)。一般而言,要判断某个对象是否可调用,可使用内置函数callable。

>>> import math
>>> x = 1
>>> y = math.sqrt
>>> callable(x)
False
>>> callable(y)
True

​ 前一节说过,函数是结构化编程的核心。那么如何定义函数呢?使用def(表示定义函数)语句。

def hello(name):
	return 'Hello, ' + name + '!'

​ 运行这些代码后,将有一个名为hello的新函数。它返回一个字符串,其中包含向唯一参数指定的人发出的问候语。你可像使用内置函数那样使用这个函数。如果编写一个函数,返回一个由斐波那契数组成的列表呢?很容易!只需使用前面介绍的代码,但不从用户那里读取数字,而是通过参数来获取。

def fibs(num):
	result = [0, 1]
	for i in range(num-2):
		result.append(result[-2] + result[-1])
	return result

​ 执行这些代码后,解释器就知道如何计算斐波那契数了。现在你不用再关心这些细节,而只需调用函数fibs。在这个示例中,num和result也可以使用其他名字,但return语句非常重要。return语句用于从函数返回值(在前面的hello函数中,return语句的作用也是一样的)。

3.1 给函数编写文档

​ 要给函数编写文档,以确保其他人能够理解,可添加注释(以#打头的内容)。还有另一种编写注释的方式,就是添加独立的字符串。在有些地方,如def语句后面(以及模块和类的开头,这将在第7章和第10章详细介绍),添加这样的字符串很有用。放在函数开头的字符串称为文档字符串(docstring),将作为函数的一部分存储起来。下面的代码演示了如何给函数添加文档字符串:

def square(x):
	'Calculates the square of the number x.'
	return x * x

​ 可以像下面这样访问文档字符串:

>>> square.__doc__
'Calculates the square of the number x.'

注意 __ doc__是函数的一个属性。属性将在第7章详细介绍。属性名中的双下划线表示这是一个特殊的属性。特殊(“魔法”)属性将在第9章讨论。


​ 特殊的内置函数help很有用。在交互式解释器中,可使用它获取有关函数的信息,其中包含函数的文档字符串。

>>> help(square)
Help on function square in module __main__:
square(x)
Calculates the square of the number x.

3.2 其实并不是函数的函数

​ 数学意义上的函数总是返回根据参数计算得到的结果。在Python中,有些函数什么都不返回。在诸如Pascal等的语言中,这样的函数可能另有其名(如过程),但在Python中,函数就是函数,即使它严格来说并非函数。什么都不返回的函数不包含return语句,或者包含return语句,但没有在return后面指定值。

def test():
	print('This is printed')
	return
	print('This is not')

​ 这里使用return语句只是为了结束函数。

>>> x = test()
This is printed

​ 如你所见,跳过了第二条print语句。(这有点像在循环中使用break,但跳出的是函数。)既然test什么都不返回,那么x指向的是什么呢?下面就来看看:

>>> x
>>>
>>> print(x)
None

​ 这是一个你熟悉的值:None。由此可知,所有的函数都返回值。如果你没有告诉它们该返回什么,将返回None。


警告 不要让这种默认行为带来麻烦。如果你在if之类的语句中返回值,务必确保其他分支也返回值,以免在调用者期望函数返回一个序列时(举个例子),不小心返回了None。


4、参数魔法

​ 函数使用起来很简单,创建起来也不那么复杂,但要习惯参数的工作原理就不那么容易了。

4.1 值从哪里来

​ 定义函数时,你可能心存疑虑:参数的值是怎么来的呢?通常,你不用为此操心。编写函数旨在为当前程序(甚至其他程序)提供服务,你的职责是确保它在提供的参数正确时完成任务,并在参数不对时以显而易见的方式失败。(为此,通常使用断言或异常。异常将在第8章详细介绍。)


注意 在def语句中,位于函数名后面的变量通常称为形参,而调用函数时提供的值称为实参,但本书基本不对此做严格的区分。在很重要的情况下,我会将实参称为,以便将其与类似于变量的形参区分开来。


4.2 我能修改参数吗

​ 函数通过参数获得了一系列的值,你能对其进行修改吗?如果这样做,结果将如何?参数不过是变量而已,行为与你预期的完全相同。在函数内部给参数赋值对外部没有任何影响。

>>> def try_to_change(n):
... n = 'Mr. Gumby'
...
>>> name = 'Mrs. Entity'
>>> try_to_change(name)
>>> name
'Mrs. Entity'

​ 在try_to_change内,将新值赋给了参数n,但如你所见,这对变量name没有影响。说到底,这是一个完全不同的变量。传递并修改参数的效果类似于下面这样:

>>> name = 'Mrs. Entity'
>>> n = name # 与传递参数的效果几乎相同
>>> n = 'Mr. Gumby' # 这是在函数内进行的
>>> name
'Mrs. Entity'

​ 这里的结果显而易见:变量n变了,但变量name没变。同样,在函数内部重新关联参数(即给它赋值)时,函数外部的变量不受影响。


注意 参数存储在局部作用域内。作用域将在本章稍后讨论。


​ 字符串(以及数和元组)是不可变的(immutable),这意味着你不能修改它们(即只能替换为新值)。因此这些类型作为参数没什么可说的。但如果参数为可变的数据结构(如列表)呢?

>>> def change(n):
... n[0] = 'Mr. Gumby'
...
>>> names = ['Mrs. Entity', 'Mrs. Thing']
>>> change(names)
>>> names
['Mr. Gumby', 'Mrs. Thing']

​ 在这个示例中,也在函数内修改了参数,但这个示例与前一个示例之间存在一个重要的不同。在前一个示例中,只是给局部变量赋了新值,而在这个示例中,修改了变量关联到的列表。这很奇怪吧?其实不那么奇怪。下面再这样做一次,但这次不使用函数调用。

>>> names = ['Mrs. Entity', 'Mrs. Thing']
>>> n = names # 再次假装传递名字作为参数
>>> n[0] = 'Mr. Gumby' # 修改列表
>>> names
['Mr. Gumby', 'Mrs. Thing']

​ 这样的情况你早就见过。将同一个列表赋给两个变量时,这两个变量将同时指向这个列表。就这么简单。要避免这样的结果,必须创建列表的副本。对序列执行切片操作时,返回的切片都是副本。因此,如果你创建覆盖整个列表的切片,得到的将是列表的副本。

>>> names = ['Mrs. Entity', 'Mrs. Thing']
>>> n = names[:]
>>> n is names
False
>>> n == names
True

​ 现在n和names包含两个相等不同的列表。现在如果(像在函数change中那样)修改n,将不会影响names。


注意 你可能会问,函数内的局部名称(包括参数)会与函数外的名称(即全局名称)冲突吗?答案是不会。有关这方面的详细信息,请参阅本章后面对作用域的讨论。


1. 为何要修改参数

​ 在提高程序的抽象程度方面,使用函数来修改数据结构(如列表或字典)是一种不错的方式。假设你要编写一个程序,让它存储姓名,并让用户能够根据名字、中间名或姓找人。为此,你可能使用一个类似于下面的数据结构:

storage = {}
storage['first'] = {}
storage['middle'] = {}
storage['last'] = {}

​ 数据结构storage是一个字典,包含3个键:‘first’、‘middle’和’last’。在每个键下都存储了一个字典。这些子字典的键为姓名(名字、中间名或姓),而值为人员列表。例如,要将作者加入这个数据结构中,可以像下面这样做:

>>> me = 'Magnus Lie Hetland'
>>> storage['first']['Magnus'] = [me]
>>> storage['middle']['Lie'] = [me]
>>> storage['last']['Hetland'] = [me]

​ 每个键下都存储了一个人员列表。在这个例子里,这些列表只包含作者。现在,要获取中间名为Lie的人员名单,可像下面这样做:

>>> storage['middle']['Lie']
['Magnus Lie Hetland']

​ 如你所见,将人员添加到这个数据结构中有点繁琐,在多个人的名字、中间名或姓相同时尤其如此,因为在这种情况下需要对存储在名字、中间名或姓下的列表进行扩展。下面来添加我的妹妹,并假设我们不知道数据库中存储了什么内容。

>>> my_sister = 'Anne Lie Hetland'
>>> storage['first'].setdefault('Anne', []).append(my_sister)
>>> storage['middle'].setdefault('Lie', []).append(my_sister)
>>> storage['last'].setdefault('Hetland', []).append(my_sister)
>>> storage['first']['Anne']
['Anne Lie Hetland']
>>> storage['middle']['Lie']
['Magnus Lie Hetland', 'Anne Lie Hetland']

​ 抽象的关键在于隐藏所有的更新细节,为此可使用函数。下面首先来创建一个初始化数据结构的函数。这里只是将初始化语句移到了一个函数中。你可像下面这样使用这个函数:

def init(data):
	data['first'] = {}
	data['middle'] = {}
	data['last'] = {}
>>> storage = {}
>>> init(storage)
>>> storage
{'middle': {}, 'last': {}, 'first': {}}

​ 下面先来编写获取人员姓名的函数,再接着编写存储人员姓名的函数。

def lookup(data, label, name):
	return data[label].get(name)

​ 函数lookup接受参数label(如’middle’)和name(如’Lie’),并返回一个由全名组成的列表。换而言之,如果已经存储了作者的姓名,就可以像下面这样做:

>>> lookup(storage, 'middle', 'Lie')
['Magnus Lie Hetland']

​ 请注意,返回的是存储在数据结构中的列表。因此如果对返回的列表进行修改,将影响数据结构。(未找到任何人时除外,因为在这种情况下返回的是None。)下面来编写将人员存储到数据结构中的函数。(如果不能马上看懂这个函数,也不用担心。)

def store(data, full_name):
	names = full_name.split()
	if len(names) == 2: names.insert(1, '')
		labels = 'first', 'middle', 'last'
        
	for label, name in zip(labels, names):
		people = lookup(data, label, name)
		if people:
			people.append(full_name)
		else:
			data[label][name] = [full_name]

​ 函数store执行如下步骤。

(1) 将参数data和full_name提供给这个函数。这些参数被设置为从外部获得的值。

(2) 通过拆分full_name创建一个名为names的列表。

(3) 如果names的长度为2(只有名字和姓),就将中间名设置为空字符串。

(4) 将’first’、'middle’和’last’存储在元组labels中(也可使用列表,这里使用元组只是为了省略方括号)。

(5) 使用函数zip将标签和对应的名字合并,以便对每个标签-名字对执行如下操作:

  • 获取属于该标签和名字的列表;

  • 将full_name附加到该列表末尾或插入一个新列表。

    下面来尝试运行该程序:

>>> MyNames = {}
>>> init(MyNames)
>>> store(MyNames, 'Magnus Lie Hetland')
>>> lookup(MyNames, 'middle', 'Lie')
['Magnus Lie Hetland']
>>> store(MyNames, 'Robin Hood')
>>> store(MyNames, 'Robin Locksley')
>>> lookup(MyNames, 'first', 'Robin')
['Robin Hood', 'Robin Locksley']
>>> store(MyNames, 'Mr. Gumby')
>>> lookup(MyNames, 'middle', '')
['Robin Hood', 'Robin Locksley', 'Mr. Gumby']

2. 如果参数是不可变的

​ 在有些语言(如C++、Pascal和Ada)中,经常需要给参数赋值并让这种修改影响函数外部的变量。在Python中,没法直接这样做,只能修改参数对象本身。但如果参数是不可变的(如数)呢?不好意思,没办法。在这种情况下,应从函数返回所有需要的值(如果需要返回多个值,就以元组的方式返回它们)。例如,可以像下面这样编写将变量的值加1的函数:

>>> def inc(x): return x + 1
...
>>> foo = 10
>>> foo = inc(foo)
>>> foo
11

4.3 关键字参数和默认值

​ 前面使用的参数都是位置参数,因为它们的位置至关重要——事实上比名称还重要。本节介绍的技巧让你能够完全忽略位置。要熟悉这种技巧需要一段时间,但随着程序规模的增大,你很快就会发现它很有用。请看下面两个函数:

def hello_1(greeting, name):
	print('{}, {}!'.format(greeting, name))
def hello_2(name, greeting):
	print('{}, {}!'.format(name, greeting))

​ 这两个函数的功能完全相同,只是参数的排列顺序相反。

>>> hello_1('Hello', 'world')
Hello, world!
>>> hello_2('Hello', 'world')
Hello, world!

​ 有时候,参数的排列顺序可能难以记住,尤其是参数很多时。为了简化调用工作,可指定参数的名称

>>> hello_1(greeting='Hello', name='world')
Hello, world!

​ 像这样使用名称指定的参数称为关键字参数,主要优点是有助于澄清各个参数的作用。虽然这样做的输入量多些,但每个参数的作用清晰明了。另外,参数的顺序错了也没关系。然而,关键字参数最大的优点在于,可以指定默认值。

def hello_3(greeting='Hello', name='world'):
	print('{}, {}!'.format(greeting, name))

​ 像这样给参数指定默认值后,调用函数时可不提供它!可以根据需要,一个参数值也不提供、提供部分参数值或提供全部参数值。

>>> hello_3()
Hello, world!
>>> hello_3('Greetings')
Greetings, world!
>>> hello_3('Greetings', 'universe')
Greetings, universe!

注意 通常不应结合使用位置参数和关键字参数,除非你知道这样做的后果。一般而言,除非必不可少的参数很少,而带默认值的可选参数很多,否则不应结合使用关键字参数和位置参数。


​ 例如,函数hello可能要求必须指定姓名,而问候语和标点是可选的。

def hello_4(name, greeting='Hello', punctuation='!'):
	print('{}, {}{}'.format(greeting, name, punctuation))

​ 调用这个函数的方式很多,下面是其中的一些:

>>> hello_4('Mars')
Hello, Mars!
>>> hello_4('Mars', 'Howdy')
Howdy, Mars!
>>> hello_4('Mars', 'Howdy', '...')
Howdy, Mars...
>>> hello_4('Mars', punctuation='.')
Hello, Mars.
>>> hello_4('Mars', greeting='Top of the morning to ya')
Top of the morning to ya, Mars!
>>> hello_4()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: hello_4() missing 1 required positional argument: 'name'

4.4 收集参数

​ 有时候,允许用户提供任意数量的参数很有用。例如,在本章前面的姓名存储示例中(参见6.4.2节),每次只能存储一个姓名。如果能够像下面这样同时存储多个姓名就好了:

>>> store(data, name1, name2, name3)

​ 为此,应允许用户提供任意数量的姓名。实际上,这实现起来并不难。请尝试使用下面这样的函数定义:

def print_params(*params):
	print(params)

​ 这里好像只指定了一个参数,但它前面有一个星号。这是什么意思呢?尝试使用一个参数来调用这个函数,看看结果如何。

>>> print_params('Testing')
('Testing',)

​ 注意到打印的是一个元组,因为里面有一个逗号。这么说,前面有星号的参数将被放在元组中?复数params应该提供了线索。

>>> print_params(1, 2, 3)
(1, 2, 3)

​ 参数前面的星号将提供的所有值都放在一个元组中,也就是将这些值收集起来。这样的行为我们在5.2.1节见过:赋值时带星号的变量收集多余的值。它收集的是列表而不是元组中多余的值,但除此之外,这两种用法很像。星号意味着收集余下的位置参数。与赋值时一样,带星号的参数也可放在其他位置(而不是最后),但不同的是,在这种情况下你需要做些额外的工作:使用名称来指定后续参数。

>>> def in_the_middle(x, *y, z):
... 	print(x, y, z)
>>> in_the_middle(1, 2, 3, 4, 5, z=7)
1 (2, 3, 4, 5) 7
>>> in_the_middle(1, 2, 3, 4, 5, 7)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: in_the_middle() missing 1 required keyword-only argument: 'z'

​ 星号不会收集关键字参数。

def print_params_2(title, *params):
	print(title)
	print(params)
>>> print_params_2('Hmm...', something=42)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: print_params_2() got an unexpected keyword argument 'something'

​ 要收集关键字参数,可使用两个星号。这样得到的是一个字典而不是元组。

>>> def print_params_3(**params):
... 	print(params)
>>> print_params_3(x=1, y=2, z=3)
{'z': 3, 'x': 1, 'y': 2}

​ 现在回到最初的问题:如何在姓名存储示例中使用这种技术?解决方案如下:

def store(data, *full_names):
	for full_name in full_names:
		names = full_name.split()
		if len(names) == 2: names.insert(1, '')
		labels = 'first', 'middle', 'last'
		for label, name in zip(labels, names):
			people = lookup(data, label, name)
			if people:
				people.append(full_name)
			else:
				data[label][name] = [full_name]

​ 这个函数调用起来与只接受一个姓名的前一版一样容易。

>>> store(d, 'Luke Skywalker', 'Anakin Skywalker')
>>> lookup(d, 'last', 'Skywalker')
['Luke Skywalker', 'Anakin Skywalker']

4.5 分配参数

​ 前面介绍了如何将参数收集到元组和字典中,但用同样的两个运算符(* 和 **)也可执行相反的操作。与收集参数相反的操作是什么呢?假设有如下函数:

def add(x, y):
	return x + y

​ 同时假设还有一个元组,其中包含两个你要相加的数。

params = (1, 2)

​ 这与前面执行的操作差不多是相反的:不是收集参数,而是分配参数。这是通过在调用函数(而不是定义函数)时使用运算符*实现的。

>>> add(*params)
3

​ 这种做法也可用于参数列表的一部分,条件是这部分位于参数列表末尾。通过使用运算符**,可将字典中的值分配给关键字参数。


提示 使用这些拆分运算符来传递参数很有用,因为这样无需操心参数个数之类的问题,如下所示:

def foo(x, y, z, m=0, n=0):
	print(x, y, z, m, n)
def call_foo(*args, **kwds):
	print("Calling foo!")
	foo(*args, **kwds)

​ 这在调用超类的构造函数时特别有用(有关这方面的详细信息,请参阅第9章)。


4.6 练习使用参数

​ 面对如此之多的参数提供和接受方式,很容易犯晕。下面来看一个综合示例。首先来定义一些函数。

def story(**kwds):
	return 'Once upon a time, there was a ' \
	'{job} called {name}.'.format_map(kwds)
def power(x, y, *others):
	if others:
		print('Received redundant parameters:', others)
	return pow(x, y)
def interval(start, stop=None, step=1):
	'Imitates range() for step > 0'
	if stop is None: # 如果没有给参数stop指定值,
		start, stop = 0, start # 就调整参数start和stop的值
	result = []
	i = start # 从start开始往上数
	while i < stop: # 数到stop位置
		result.append(i) # 将当前数的数附加到result末尾
		i += step # 增加到当前数和step(> 0)之和
	return result

​ 下面来尝试调用这些函数。

>>> print(story(job='king', name='Gumby'))
Once upon a time, there was a king called Gumby.
>>> print(story(name='Sir Robin', job='brave knight'))
Once upon a time, there was a brave knight called Sir Robin.
>>> params = {'job': 'language', 'name': 'Python'}
>>> print(story(**params))
Once upon a time, there was a language called Python.
>>> del params['job']
>>> print(story(job='stroke of genius', **params))
Once upon a time, there was a stroke of genius called Python.
>>> power(2, 3)
8
>>> power(3, 2)
9
>>> power(y=3, x=2)
8
>>> params = (5,) * 2
>>> power(*params)
3125
>>> power(3, 3, 'Hello, world')
Received redundant parameters: ('Hello, world',)
27
>>> interval(10)
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> interval(1, 5)
[1, 2, 3, 4]
>>> interval(3, 12, 4)
[3, 7, 11]
>>> power(*interval(3, 7))
Received redundant parameters: (5, 6)
81

5、作用域

​ 变量到底是什么呢?可将其视为指向值的名称。因此,执行赋值语句x = 1后,名称x指向值1。这几乎与使用字典时一样(字典中的键指向值),只是你使用的是“看不见”的字典。实际上,这种解释已经离真相不远。有一个名为vars的内置函数,它返回这个不可见的字典:

>>> x = 1
>>> scope = vars()
>>> scope['x']
1
>>> scope['x'] += 1
>>> x
2

​ 这种“看不见的字典”称为命名空间作用域。那么有多少个命名空间呢?除全局作用域外,每个函数调用都将创建一个。

>>> def foo(): 
    	x = 42
>>> x = 1
>>> foo()
>>> x
1

​ 在这里,函数foo修改(重新关联)了变量x,但当你最终查看时,它根本没变。这是因为调用foo时创建了一个的命名空间,供foo中的代码块使用。赋值语句x = 42是在这个内部作用域(局部命名空间)中执行的,不影响外部(全局)作用域内的x。在函数内使用的变量称为局部变量(与之相对的是全局变量)。参数类似于局部变量,因此参数与全局变量同名不会有任何问题。

​ 但如果要在函数中访问全局变量呢?如果只是想读取这种变量的值(不重新关联它),通常不会有任何问题。

>>> def combine(parameter): 
    	print(parameter + external)
>>> external = 'berry'
>>> combine('Shrub')
Shrubberry

警告 像这样访问全局变量是众多bug的根源。务必慎用全局变量。



“遮盖”的问题

​ 读取全局变量的值通常不会有问题,但还是存在出现问题的可能性。如果有一个局部变量或参数与你要访问的全局变量同名,就无法直接访问全局变量,因为它被局部变量遮住了。

​ 如果需要,可使用函数globals来访问全局变量。这个函数类似于vars,返回一个包含全局变量的字典。(locals返回一个包含局部变量的字典。)

​ 例如,在前面的示例中,如果有一个名为parameter的全局变量,就无法在函数combine中访问它,因为有一个与之同名的参数。然而,必要时可使用globals()[‘parameter’]来访问它。

>>> def combine(parameter):
		print(parameter + globals()['parameter'])
>>> parameter = 'berry'
>>> combine('Shrub')
Shrubberry

重新关联全局变量(使其指向新值)是另一码事。在函数内部给变量赋值时,该变量默认为局部变量,除非你明确地告诉Python它是全局变量。那么如何将这一点告知Python呢?

>>> x = 1
>>> def change_global():
... global x
... x = x + 1
...
>>> change_global()
>>> x
2

作用域嵌套

​ Python函数可以嵌套,即可将一个函数放在另一个函数内,如下所示:

def foo():
	def bar():
		print("Hello, world!")
	bar()

​ 嵌套通常用处不大,但有一个很突出的用途:使用一个函数来创建另一个函数。这意味着可像下面这样编写函数:

def multiplier(factor):
	def multiplyByFactor(number):
		return number * factor
	return multiplyByFactor

​ 在这里,一个函数位于另一个函数中,且外面的函数返回里面的函数。也就是返回一个函数,而不是调用它。重要的是,返回的函数能够访问其定义所在的作用域。换而言之,它携带着自己所在的环境(和相关的局部变量)!

​ 每当外部函数被调用时,都将重新定义内部的函数,而变量factor的值也可能不同。由于Python的嵌套作用域,可在内部函数中访问这个来自外部局部作用域(multiplier)的变量,如下所示:

>>> double = multiplier(2)
>>> double(5)
10
>>> triple = multiplier(3)
>>> triple(3)
9
>>> multiplier(5)(4)
20

​ 像multiplyByFactor这样存储其所在作用域的函数称为闭包

​ 通常,不能给外部作用域内的变量赋值,但如果一定要这样做,可使用关键字nonlocal。这个关键字的用法与global很像,让你能够给外部作用域(非全局作用域)内的变量赋值。


6、递归

​ 前面深入介绍了如何创建和调用函数。你知道,函数可调用其他函数,但可能让你感到惊讶的是,函数还可调用自己。如果你以前没有遇到这种情况,可能想知道递归是什么意思。简单地说,递归意味着引用(这里是调用)自身。下面是一个递归式函数定义:

def recursion():
	return recursion()

​ 这个函数中的递归称为无穷递归(就像以while True打头且不包含break和return语句的循环被称为无限循环一样),因为它从理论上说永远不会结束。你想要的是能对你有所帮助的递归函数,这样的递归函数通常包含下面两部分。

  • 基线条件(针对最小的问题):满足这种条件时函数将直接返回一个值。

  • 递归条件:包含一个或多个调用,这些调用旨在解决问题的一部分。

    这里的关键是,通过将问题分解为较小的部分,可避免递归没完没了,因为问题终将被分解成基线条件可以解决的最小问题。

6.1 两个经典案例:阶乘和幂

​ 本节探讨两个经典的递归函数。首先,假设你要计算数字n的阶乘。n的阶乘为n × (n-1) × (n-2) × … × 1,在数学领域的用途非常广泛。例如,计算将n个人排成一队有多少种方式。如何计算阶乘呢?可使用循环。

def factorial(n):
	result = n
	for i in range(1, n):
		result *= i
	return result

​ 这种实现可行,而且直截了当。大致而言,它是这样做的:首先将result设置为n,再将其依次乘以1到n-1的每个数字,最后返回result。但如果你愿意,可采取不同的做法。关键在于阶乘的数学定义,可表述如下。

  • 1的阶乘为1。

  • 对于大于1的数字n,其阶乘为n - 1的阶乘再乘以n。

    下面来考虑如何使用函数来实现这个定义。理解这个定义后,实现起来其实非常简单。

def factorial(n):
	if n == 1:
		return 1
	else:
		return n * factorial(n - 1)

​ 这是前述定义的直接实现,只是别忘了函数调用factorial(n)和factorial(n – 1)是不同的实体。

​ 再来看一个示例。假设你要计算幂,就像内置函数pow和运算符**所做的那样。要定义一个数字的整数次幂,有多种方式,但先来看一个简单的定义:power(x, n)(x的n次幂)是将数字x自乘n - 1次的结果,即将n个x相乘的结果。换而言之,power(2, 3)是2自乘两次的结果,即2 × 2 × 2 = 8。

def power(x, n):
	result = 1
	for i in range(n):
		result *= x
	return result

​ 这是一个非常简单的小型函数,但也可将定义修改成递归式的。

  • 对于任何数字x,power(x, 0)都为1。
  • n>0时,power(x, n)为power(x, n-1)与x的乘积。
def power(x, n):
	if n == 0:
		return 1
	else:
		return x * power(x, n - 1)

提示 如果函数或算法复杂难懂,在实现前用自己的话进行明确的定义将大有裨益。以这种“准编程语言”编写的程序通常称为伪代码


​ 那么使用递归有何意义呢?难道不能转而使用循环吗?答案是肯定的,而且在大多数情况下,使用循环的效率可能更高。然而,在很多情况下,使用递归的可读性更高,且有时要高得多,在你理解了函数的递归式定义时尤其如此。另外,虽然你完全能够避免编写递归函数,但作为程序员,你必须能够读懂其他人编写的递归算法和函数。

6.2 另一个经典案例:二分查找

​ 下面来看看最后一个递归示例——二分查找算法。

​ 例如,对方心里想着一个1~100的数字,你必须猜出是哪个。当然,猜100次肯定猜对,但最少需要猜多少次呢?实际上只需猜7次。首先问:“这个数字大于50吗?”如果答案是肯定的,再问:“这个数字大于75吗?”不断将可能的区间减半,直到猜对为止。你无需过多地思考就能成功。这里的关键是,这种算法自然而然地引出了递归式定义和实现。先来回顾一下定义,确保你知道该如何做。

  • 如果上限和下限相同,就说明它们都指向数字所在的位置,因此将这个数字返回。
  • 否则,找出区间的中间位置(上限和下限的平均值),再确定数字在左半部分还是右半部分。然后在继续在数字所在的那部分中查找。

​ 在这个递归案例中,关键在于元素是经过排序的。找出中间的元素后,只需将其与要查找的数字进行比较即可。如果要查找的数字更大,肯定在右边;如果更小,它必然在左边。递归部分为“继续在数字所在的那部分中查找”,因为查找方式与定义所指定的完全相同。(请注意,这种查找算法返回数字应该在的位置。如果这个数字不在序列中,那么这个位置上的自然是另一个数字。)

def search(sequence, number, lower, upper):
	if lower == upper:
		assert number == sequence[upper]
		return upper
	else:
		middle = (lower + upper) // 2
		if number > sequence[middle]:
			return search(sequence, number, middle + 1, upper)
		else:
			return search(sequence, number, lower, middle)

​ 这些代码所做的与定义完全一致:如果lower == upper,就返回upper,即上限。请注意,你假设(断言)找到的确实是要找的数字(number == sequence[upper])。如果还未达到基线条件,就找出中间位置,确定数字在它左边还是右边,再使用新的上限和下限递归地调用search。为方便调用,还可将上限和下限设置为可选的。为此,只需给参数lower和upper指定默认值,并在函数开头添加如下条件语句:

def search(sequence, number, lower=0, upper=None):
	if upper is None: upper = len(sequence) - 1
...

​ 现在,如果你没有提供上限和下限,它们将分别设置为序列的第一个位置和最后一个位置。下面来看看这是否可行。

>>> seq = [34, 67, 8, 123, 4, 100, 95]
>>> seq.sort()
>>> seq
[4, 8, 34, 67, 95, 100, 123]
>>> search(seq, 34)
2
>>> search(seq, 100)
5

​ 然而,为何要如此麻烦呢?首先,你可使用列表方法index来查找。其次,即便你要自己实现这种功能,也可创建一个循环,让它从序列开头开始迭代,直至找到指定的数字。确实,使用index挺好,但使用简单循环可能效率低下。前面说过,要在100个数字中找到指定的数字,只需问7次;但使用循环时,在最糟的情况下需要问100次。你可能觉得“没什么大不了的”。但如果列表包含100 000 000 000 000 000 000 000 000 000 000 000个元素(对Python列表来说,这样的长度可能不现实),使用循环也将需要问这么多次,情况开始变得“很大”了。然而,如果使用二分查找,只需问117次。


提示 实际上,模块bisect提供了标准的二分查找实现。



函数式编程

​ 至此,你可能习惯了像使用其他对象(字符串、数、序列等)一样使用函数:将其赋给变量,将其作为参数进行传递,以及从函数返回它们。在有些语言(如scheme 和Lisp)中,几乎所有的任务都是以这种方式使用函数来完成的。在Python 中,通常不会如此倚重函数(而是创建自定义对象,这将在下一章详细介绍),但完全可以这样做。

​ Python提供了一些有助于进行这种函数式编程的函数:map、filter和reduce。在较新的Python版本中,函数map和filter的用途并不大,应该使用列表推导来替代它们。你可使用map将序列的所有元素传递给函数。

>>> list(map(str, range(10))) # 与[str(i) for i in range(10)]等价
['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']

​ 你可使用filter根据布尔函数的返回值来对元素进行过滤。

>>> def func(x):
... return x.isalnum()
...
>>> seq = ["foo", "x41", "?!", "***"]
>>> list(filter(func, seq))
['foo', 'x41']

​ 就这个示例而言,如果转而使用列表推导,就无需创建前述自定义函数。

>>> [x for x in seq if x.isalnum()]
['foo', 'x41']

​ 实际上,Python提供了一种名为lambda表达式的功能,让你能够创建内嵌的简单函数(主要供map、filter和reduce使用)。

>>> filter(lambda x: x.isalnum(), seq)
['foo', 'x41']

​ 然而,使用列表推导的可读性不是更高吗?

​ 要使用列表推导来替换函数reduce不那么容易,而这个函数提供的功能即便能用到,也用得不多。它使用指定的函数将序列的前两个元素合二为一,再将结果与第3个元素合二为一,依此类推,直到处理完整个序列并得到一个结果。例如,如果你要将序列中的所有数相加,可结合使用reduce和lambda x, y: x+y。

>>> numbers = [72, 101, 108, 108, 111, 44, 32, 119, 111, 114, 108, 100, 33]
>>> from functools import reduce
>>> reduce(lambda x, y: x + y, numbers)
1161

​ 当然,就这个示例而言,还不如使用内置函数sum。


7、小结

​ 本章介绍了抽象的基本知识以及函数。

  • 抽象:抽象是隐藏不必要细节的艺术。通过定义处理细节的函数,可让程序更抽象。
  • 函数定义:函数是使用def语句定义的。函数由语句块组成,它们从外部接受值(参数),并可能返回一个或多个值(计算结果)。
  • 参数:函数通过参数(调用函数时被设置的变量)接收所需的信息。在Python中,参数有两类:位置参数和关键字参数。通过给参数指定默认值,可使其变成可选的。
  • 作用域:变量存储在作用域(也叫命名空间)中。在Python中,作用域分两大类:全局作用域和局部作用域。作用域可以嵌套。
  • 递归:函数可调用自身,这称为递归。可使用递归完成的任何任务都可使用循环来完成,但有时使用递归函数的可读性更高。
  • 函数式编程:Python提供了一些函数式编程工具,其中包括lambda表达式以及函数map、filter和reduce。

本章介绍的新函数

函 数描 述
map(func, seq[, seq, …])对序列中的所有元素执行函数
filter(func, seq)返回一个列表,其中包含对其执行函数时结果为真的所有元素
reduce(func, seq[, initial])等价于func(func(func(seq[0], seq[1]), seq[2]), …)
sum(seq)返回seq 中所有元素的和
apply(func[, args[, kwargs]])调用函数(还提供要传递给函数的参数)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值