0%

函数

第19条:不要把函数返回的多个值拆分到三个以上的变量中

python的unpacking机制允许python函数返回一个以上的值,函数返回一个以上的值的时候,实际上返回的是一个元组。

1
2
3
4
def get_min_max(numbers):
minimum = min(numbers)
maximum = max(numbers)
return minimum, maximum

在返回多个值的时候,可以用带星号的表达式接收那些没有被普通变量捕获到的值(参考第13条)

1
2
3
4
5
6
7
def get_avg_ratio(numbers):
average = sum(numbers) / len(numbers)
scaled = [x / average for x in numbers]
scaled.sort(reverse=True)
return scaled

longest, *middle, shortest = get_avg_ratio(numbers)

当我们用超过三个变量去接收函数的返回值时,会很容易出现将顺序弄错的情况。所以一般来时,一个元组最多只拆分到三个普通变量或者拆分到两个普通变量与一个万能变量(带星号的变量)。假如要拆分的值确实很多,那最好还是定义一个轻便的类或namedtuple(参见第37条),并让函数返回这样的实例。

第20条:遇到意外状况时应该抛出异常,不要返回None

编写工具函数(utility function)时,许多python程序员都爱用None这个返回值来表示特殊情况。对于某些函数来说,这或许有几分道理。例如,我们要编写一个辅助函数计算两数相除的结果,在除数是0的情况下,返回None似乎合理,因为这种除法的结果是没有意义的。

1
2
3
4
5
6
7
8
9
10
11
def careful_devide(a, b):
try:
return a / b
except ZeroDivisionError:
return None


x, y = 1, 0
result = careful_divide(x, y)
if result is None:
print('invalid inputs')

但是,如果传给careful_divide函数的被除数为0时,会怎么样呢?在这种情况下,只要除数不为0,函数返回的结果就应该是0。但是问题时,别人在使用这个工具函数时,在if表达式中不会明确判断返回值是否是None,而是去判断返回值是否相当于False:

1
2
3
4
x, y = 0, 5
result = careful_divide(x, y)
if not result:
print('invalid inputs')

上面这种if语句,会把函数返回0的情况和返回None的情况一样处理。由于这种写法经常出现在python代码里,因此,像careful_divide这样,用None来表示特殊情况的函数是很容易出错的。有两种办法可以减少这样的错误。

第一种,利用二元组把计算结果分成两部分返回,元组的首个元素表示操作是否成功,第二个元素表示计算的实际值:

1
2
3
4
5
6
7
8
9
10
def careful_divide(a, b):
try:
return True, a / b
except ZeroDivisionError:
return False, None


success, result = careful_divide(x, y)
if not success:
print('invalid inputs')

但是,有些调用方总喜欢忽略元组的第一个部分。第二种方法比刚才那种更好,就是不采用None表示特例,而是向调用方抛出异常,让他们自己去处理。

1
2
3
4
5
6
7
8
9
10
11
12
def careful_divide(a, b):
try:
return a / b
except ZeroDivisionError:
raise ValueError('invalid inputs')


x, y = 5, 2
try:
result = careful_divide(x, y)
except ValueError:
print('invalid inputs')

我们还可以利用类型注解指明函数返回float类型,这样就对外说明不会返回None了,但是,我们无法在函数的接口上说明函数可能抛出哪些异常,所以,我们只好把有可能抛出的异常写在文档里面,并希望调用方能够根据这份文档适当得捕获相关的异常(参见第84条)。

1
2
3
4
5
6
7
8
9
10
def careful_divide(a: float, b:float) -> float:
"""Divides a by b

Raises:
ValueError: When the inputs cannot by divided
"""
try:
return a / b
except ZeroDivisionError as e:
raise ValueError('invalid inputs')

总结:用返回值None表示特殊情况是很容易出错的,因为这样的值在条件表达式里面没法与0、空字符串、空数组之类的值进行区分,这些值都相当于False。

个人觉得作者在此处使用的代码示例不是很好,这个抛出异常版本的careful_divide函数根据没啥实际用处,使用者还不如直接去捕获ZeroDivisionError,作者的目的可能只是为了简明得解释这条建议。

第21条:了解如何在闭包里面使用外围作用域中的变量

假设,现在有一个需求,我们要给列表中的元素排序,而且要优先把在另外一个群组的元素放在其他元素的前面。实现这种做法的一种常见方案,是把辅助函数通过key参数传给列表的sort方法,让这个方法根据辅助函数返回的值来决定元素在列表中的先后顺序,辅助函数先判断当前元素是否处在重要群组里,如果在,就把返回值的第一项写成0,让它能够排在不属于这个组的那些元素之前

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def sort_priority(values, group):
def helper(x):
if x in group:
return 0, x
return 1, x

values.sort(key=helper)


numbers = [8, 3, 1, 2, 5, 4, 7, 6]
priority_group = {2, 3, 5, 7}

sort_priority(numbers, priority_group)
print(numbers)

>>>
[2, 3, 5, 7, 1, 4, 6, 8]

在sort_priority函数中,引用了外部函数的group参数,在一个内部函数中,对外部作用域的变量进行引用,那么内部函数就被认为是闭包。

假设现在需求新增,sort_priority函数还需要告诉我们,列表里面是否有位于重要群组之中,那么第一个想法就是添加一个标志位:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def sort_priority(values, group):
found = False

def helper(x):
if x in group:
found = True
return 0, x
return 1, x

values.sort(key=helper)
return found


numbers = [8, 3, 1, 2, 5, 4, 7, 6]
priority_group = {2, 3, 5, 7}

found = sort_priority(numbers, priority_group)
print(numbers)
print('found: ', found)

>>>
[2, 3, 5, 7, 1, 4, 6, 8]
found: False

虽然排序结果没有问题,但是却发现标志本应该为True,但是返回的确是False。

在表达式中引用某个变量时,Python解释器会按照下面的顺序,在各个作用域(scope)里面查找这个变量,以解析这次引用(变量出现在=右边时)。

  1. 当前函数作用域

  2. 外围作用域(例如包含当前函数的其他函数所对应的作用域)

  3. 包含当前代码的那个模块所对应的作用域(也叫全局作用域,global scope)

  4. 内置作用域(built-in scope,也就是包含len与str等函数的那个作用域)

如果这些作用域中都没有定义名称相符的变量,那么程序就抛出NameError异常。

当对变量进行赋值时(变量出现在=左边),需要分两种情况处理:如果变量已经定义在当前作用域中,那么直接把新值赋给它就行了。如果当前作用域中不存在这个变量,那么即使外围作用域里有同名的变量,Python也还是会把这次赋值操作当成变量的定义来处理。这会产生一个重要的效果,也就是说,Python会把包含赋值操作的这个函数当作新定义的这个变量的作用域。这也就解释了为什么found还是为False。

这种问题有时也称为作用域bug(scoping bug),Python新手可能认为这样的赋值规则很奇怪,但实际上Python是故意这么设计的,因为这样可以防止函数的局部变量污染外围模块,假设不这么做,那么函数里的每条赋值语句都有可能影响全局作用域的变量,这不仅混乱,而且会让全局变量之间彼此交互影响,从而导致更多难以探查的bug。

Python有一种特殊的写法,可以把闭包里面的数据赋给闭包外面的变量。用`nonlocal`描述变量,就可以让系统在处理针对这个变量的赋值操作时,去外围作用域查找。然而,nonlocal有个限制,就是不能侵入模块级别的作用域(以防污染全局作用域)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def sort_priority(values, group):
found = False

def helper(x):
if x in group:
nonlocal found
found = True
return 0, x
return 1, x

values.sort(key=helper)
return found


numbers = [8, 3, 1, 2, 5, 4, 7, 6]
priority_group = {2, 3, 5, 7}

found = sort_priority(numbers, priority_group)
print(numbers)
print('found: ', found)

nonlocal语句清楚地说明,我们要把数据赋给闭包之外的变量。有一种跟它互补的语句,叫做global,用这种语句描述某个变量后,在给这个变量赋值时,系统会直接把它放到模块作用域中。

1
2
3
4
5
6
7
8
9
def to_global():
global a
a = 2

to_global()
print(a)

>>>
2
我们都知道全局变量不应该滥用,其实nonlocal也是这样,除比较简单的函数外,建议不要使用nonlocal语句。因为它造成的副作用有时很难发现。尤其是在那些比较长的函数里,nonlocal语句与其关联变量的赋值操作之间可能隔很远。 # 第22条:用数量可变的位置参数给函数设计清晰的参数列表 让函数接收数量可变的位置参数,可以把函数设计得更清晰(这些位置参数通常称为varargs,或者称为star args,因为我们习惯用\*args指代)。例如假设我们要记录调试信息。如果采用参数数量固定的方案来设计,那么函数应该接受一个表示信息的message参数和value列表,这个列表用于存放需要用来debug的一些变量值。
1
2
3
4
5
6
7
8
def log(msg, values):
if not values:
print(message)
else:
values_str = ', '.join(str(x) for x in values)
print(f'{message}: {values_str}')

log('the numbers are', [1, 2])
在调用log函数时,每次都需要传入一个列表,更好的方式是给values参数加上前缀\*,让其变量为数量可变的参数。
1
2
3
4
5
6
7
8
def log(msg, *values):
if not values:
print(message)
else:
values_str = ', '.join(str(x) for x in values)
print(f'{message}: {values_str}')

log('the numbers are', 1, 2)
如果想把已有序列里面的元素当成参数传给像log这样的参数个数可变的函数,那么可以在传递序列时采用\*操作符,这样Python把序列中的元素都当成位置参数传给这个函数。
1
2
numbers = [1, 2, 3]
log('numbers are', *numbers)
但是,令函数接受数量可变的位置参数,可能导致两个问题。第一个问题是,程序总是必须把这些参数转化为一个元组,然后才能把他们当成可选的位置参数传给函数。这意味着,在调用函数时,把带\*的操作符的生成器传了过去,那么程序必须先把这个生成器的所有元素迭代完(以便形成元组),然后才能继续往下执行(参见第30条)。这个元组包含生成器所给出的每个值,这可能耗费大量的内存,甚至会让程序崩溃。
1
2
3
4
5
6
7
8
9
10
11
12
def my_generator():
for i in range(10000):
yield i

def my_func(*args):
print(args)

it = my_generator()
my_func(*it)

>>>
(0, 1, 2, 3, ... , 9999)
接受\*args参数的函数,适合处理输入值不太多,而且数量可以提前预估的情况。在调用这种函数时,传给\*args这一部分的应该是许多个字面值或变量名。Python的这种机制主要是为了让代码写起来更方便、读起来更清晰。 第二个问题是,如果采用了\*args之后,又要给函数添加新的位置参数,那么原来的调用操作就需要全部更新。例如给log函数的参数列表开头添加新的位置参数sequence,那么原来的调用就会表现有问题。
1
2
3
4
5
6
7
8
9
10
11
12
def log(sequence, msg, *values):
if not values:
print(f'{sequence} - {message}')
else:
values_str = ', '.join(str(x) for x in values)
print(f'{sequence} - {message}: {values_str}')


log('the numbers are', 1, 2)

>>>
the numbers are - 1: 2
关键的问题是,之前的函数调用是不会报语法错误,只是行为不正常甚至是导致运行时错误。这样的bug有时很难去排查。为了避免这种漏洞,在给这种\*args函数添加参数时,应该使用只能通过关键字来指定的参数(keyword-only argument,参见25条)。要是想做得更稳妥一些,可以考虑添加类型注解(参见第90条)。 # 第23条:用关键字参数来表示可选的行为 与大多数其他编程语音一样,Python运行在调用函数时,按照位置传递参数,即按照参数列表所指定的顺序依次传递参数。
1
2
3
4
def remainder(number, divisor):
return number % divisor

assert remainder(20, 7) == 6
Python函数里面的所有普通参数,除了按位置传递外,还可以按关键字传递:调用函数时,在调用括号内可以把关键字的名称放在`=`左边,把参数写在右边。这种写法不在乎参数的顺序,只要把指定的所有位置参数全部传过去即可。另外,关键字形式与位置形式也可以混用,下面这四种写法效果相同:
1
2
3
4
remiander(20, 7)
remainder(20, divisor=7)
remainder(number=20, divisor=7)
remainder(divisor=7, number=20)
如果混用,那么位置参数必须出现在关键字参数之前,否则就会报错。
1
2
3
4
5
remainder(number=20, 7)

>>>
Traceback ...
SyntaxError: positional argument follows keyword argument
每个参数只能指定一次,不能既通过位置形式指定,又通过关键字形式指定。
1
remainder(20, number=7)

如果有一份字典,而且字典里面的内容能够用来调用remainder这样的函数,那么可以吧**运算符加在字典前面,这会让Python把字典里面的键值以关键字参数的形式传给函数。

1
2
3
4
5
my_kwargs = {
'number': 20,
'divisor': 7,
}
assert remainder(**my_kwargs) == 6

调用函数时,带**操作符的参数可以和位置参数或关键字参数混用,只要不重复指定就行。

1
2
3
4
5
my_kwargs = {
'divisor': 7
}

assert remainder(number=20, **my_kwargs) == 6

也可以对多个字典分别施加**操作,只要这些字典所提供的参数不重叠就好。

1
2
3
4
5
6
7
my_kwargs = {
'number': 20
}
other_kwargs = {
'divisor': 7
}
assert remainder(**my_kwargs, **other_kwargs) == 6
定义函数时,如果想让这个函数接受任意数量的关键字参数,那么可以在参数列表里写上万能形参\*\*kwarg,它会把调用者传进来的参数集合到一个字典里面。
1
2
3
4
5
6
7
8
9
def print_parameters(**kwargs):
for key, value in kwargs.items():
print(f'{key} = {value}')

print_parameters(alpha=1.5, beta=9, gamma=4)
>>>
alpha = 1.5
beta = 9
gamma = 4
使用关键字参数调用函数有三个好处: 1. 用关键字参数调用函数可以让初次阅读代码的人更容易看懂 2. 它可以带有默认值,该值在定义函数时指定 3. 我们可以很灵活地扩充函数的参数,而不担心会影响原来函调用的代码 对于函数中定义的非万能关键字参数,python仍然可以按照位置来传递参数
1
2
3
4
5
def calculate_flow_rate(weight_diff, time_diff, period=3600, units_per_kg=2.2):
...


calculate_flow_rate(100, 20, 3600, 2.5)
通过位置来指定可选参数,可能会让读代码的人有点糊涂,所有最好是能以关键字的形式给这些参数传值,而不要按位置去传。从设计函数的角度来说,还可以考虑用更加明确的方案以降低出错概率(参见25条)。 # 第24条:用None和docstring来描述默认值会变的参数 有时,我们想把那种不能够提前固定的值,当作关键字参数的默认值。例如,记录日志消息时,默认的时间应该是触发事件的那一刻。所以,如果调用者没有明确指定时间,那么就默认把调用函数的那一刻当成这条日志的记录时间。如果我们写如下代码来实现:
1
2
3
4
5
from time import sleep
from datetime import datetime

def log(msg, when=datetime.now()):
print(f'{when}: {msg}')
这样写是不行的,因为`datetime.now()`只会执行一次,所有每条日志的时间戳都会相同。参数的默认值只会在系统加载这个模块的时候,计算一遍,而不会在每次执行时都重新计算,这通常意味着这些默认值在程序启动后,就已经定下来了。

要想在Python里实现这种效果,惯用的办法是把参数的默认值设为None,同时在docstring文档里面写清楚,这个参数为None时,函数会怎么运作(参见第84条)。给函数写实现代码时,在内部对参数进行判断。

1
2
3
4
5
6
7
8
9
10
11
def log(msg, when=None):
"""Log a message with a timestamp

Args:
msg: message to print
when: datetime of when the message occured.
Defaults to the present time
"""
if when is None:
when = datetime.now()
print(f'{when}: {msg}')

把参数的默认值写成None还有个重要的意义,就是用来表示那种以后可能由调用者修改内容的默认值(例如某个可变容器)。例如,我们要写一个函数对采用JSON格式编码的数据进行解码。如果无法解码,那么就返回调用时所指定的默认结果:

1
2
3
4
5
6
7
import json

def decode(data, default={}):
try:
return json.loads(data)
except ValueError:
return default

这样的写法与前面的datetime.now()的例子有同样的问题,系统只会计算一次default参数(在加载这个模块时),所有每次调用这个函数时,给调用者返回的都是一开始分配的那个字段,这就相当于凡是以默认值返回来调用这个函数的代码都共用的同一份字典。这会让程序出现奇怪的效果:

1
2
3
4
5
6
7
8
9
10
11
foo = decode('bad data')
foo['stuff'] = 5

bar = decode('bad data')
bar['meep'] = 1
print('Foo:', foo)
print('Bar:', bar)

>>>
Foo: {'stuff': 5, 'meep': 1}
Bar: {'stuff': 5, 'meep': 1}

我们的本意是让这两次操作得到两个不同的空白字典,但是实际上foo和bar是同一个字典。要解决这个问题,可以把默认值设置为None,而且在docstring文档里面说明,函数在这个值为None时会怎么做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def decode(data, default=None):
"""Load JSON data from a string

Args:
data: JSON data to decode
default: Value to return if decoding fails.
Defaults to an empty dictionary.
"""
try:
return json.loads(data)
except ValueError:
if default is None:
default = {}
return default

第25条:用只能以关键字指定和只能按位置传入的参数来设计清晰的参数列表

按关键字传递参数是Python函数的一项强大特性,这种关键字参数特别灵活,在很多情况下,都能让我们写出一看就冬的函数代码。

例如,计算两数相除的结果时,可能需要仔细考虑各种特殊情况。例如在除数为0的情况下,时抛出异常还是返回无穷;在结果益处的情况下,是抛出异常还是返回0:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def safe_division(number, divisor, 
ignore_overflow=False,
ignore_zero_dvision=False):
try:
return number / divisor
except OverflowError:
if ignore_overflow:
return 0
else:
raise
except ZeroDivisionError:
if ignore_zero_division:
return number * float('inf')
else:
raise

result = safe_division(1.0, 0, ignore_overflow=False)

调用者可以根据自己的需要对ignore_overflow和ignore_zero_division参数进行指定,而且调用者使用关键字形式进行传递会让代码显得更清晰。但是,按照上面的函数定义形式,我们没有办法要求调用者必须按照关键字形式来指定这两个参数。他们还是可以用传统的写法,按位置给safe_divison函数传递参数。

1
save_division(number, divisor, False, True)

对于这种参数比较复杂的函数,我们可以声明只能通过关键字指定的参数(keyword-only argument),这样的话,写出来的代码就能清楚地反映调用者的想法了。这种参数只能用关键字来指定,不能按位置传递。具体操作方式是使用*符号把参数列表分成两组,左边是位置参数,右边是只能通过关键字指定的参数。

1
2
3
4
def save_division(number, divisor, *,
ignore_overflow=False,
ignore_zero_division=False):
...

这时,如果按位置给只能用关键字指定的参数传值,那么程序就会出错。

1
2
3
4
5
save_division(1.0, 0, True, False)

>>>
Traceback ...
TypeError: save_divisoin() takes 2 positional arguments but 4 were given

但是,这样改依然还是有问题,因为在这个函数中,调用者在提供number和divisor参数时,既可以按位置提供,也可以按关键字提供,还可以把这两种方式混起来用:

1
save_division(number=2, 5)

在未来,也许因为扩展函数的需要,甚至是因为代码风格的变化,或许要修改这两个参数的名字。

1
2
3
4
def save_division(numerator, denominator, *,
ignore_overflow=False,
ignore_zero_division=False):
...

这看起来只是字面上的微调,但之前所有通过关键字形式来指定这两个参数的调用代码,都会出错。其实最重要的问题在于,我们根本没有打算把number和divisor这两个名称纳入函数的接口;我们只是在编写函数时,随意挑了两个比较顺口的名称而已。

Python3.8引入了一项新特性,可以解决这个问题,这就是只能按位置传递的参数(positional-only argument)。这种参数与刚才的只能通过关键字指定的参数相反,它们必须按位置指定,绝不能通过关键字形式指定。具体操作方式是使用`/`符号表示左边的参数只能通过位置来指定:
1
2
3
4
def save_division(numerator, denominator, /, *,
ignore_overflow=False,
ignore_zero_division=False):
...
这时候,如果调用者使用关键字形式来指定numerator和denominator参数,程序就会在运行时抛出异常。 在函数的参数列表中, `/` 符号左侧的参数是只能按位置指定的参数,`*`符号右侧的参数则是只能按照关键字形式指定的参数。如果`*`出现在`/`的左边,则会出现语法错误;如果有参数出现在`/`和`*`的中间,那意味着,这两个符号之间的参数,既可以按照位置提供,又可以用关键字形式指定(其实,如果不特别说明,Python函数的参数全都属于这种参数)。 # 第26条:用functools.wraps定义函数修饰器 Python中有一种特殊的写法,可以用修饰器(decorator)来封装某个函数,从而让程序在执行这个函数之前与执行这个函数之后,分别运行某些代码。这意味着,调用者传给函数的参数值、函数返回给调用者的值,以及函数抛出的异常,都可以有修饰器访问并修改,这是个很有用的机制。 假如,我们要把函数执行时收到的参数与返回的值记录下来,这在调试递归函数时是很有用的,因为我们需要知道,这个函数执行没一层递归时,输入的是什么参数,返回的是什么参数。下面我们就定义一个修饰器,在实现这个修饰器时,用\*args与\*\*kwargs表示受修饰的原函数func所收到的参数:
1
2
3
4
5
6
def trace(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
print(f'{func.__name__}({args!r}, {kwargs!r}) -> {result!r}'
return result
return wrapper
写好之后,我们用`@`符号把修饰器运用在想要调试的函数上面。
1
2
3
4
5
6
@trace
def fibonacci(n):
"""Return the n-th Fibonacci number"""
if n in (0, 1):
return n
return (fibonacci(n-2) + fibonacci(n-1))
这样写,相当于先把受修饰的函数传给修饰器,然后将修饰器所返回的值赋给原来那个函数,这样的话,如果我们继续通过原来那个名字调用函数,那么执行的就是修饰器之后的函数。
1
2
3
4
5
6
7
fibonacci(4)

>>>
fibonacci((0,), {}) -> 0
fibonacci((1,), {}) -> 1
fibonacci((2,), {}) -> 1
fibonacci((1,), {}) -> 1

这样写确实能够满足要求,但是会带来一个我们不愿意看到的副作用。使用修饰器对fibonacci函数进行修饰后,fibonacci函数的名字本质上不再是fibonacci。

1
2
3
4
print(fibonacci)

>>>
<funtion trace.<locals>.wrapper at 0x108955dc>

这种现象解释起来并不困难。trace函数返回的,是它里面定义的wrapper函数,所以,当我们把这个返回值赋给fibonacci之后,fibonacci这个名称所表示的自然就是wrapper了。问题在于,这个可能会干扰需要利用反射机制来运作的工具。

例如,如果用内置的help函数来查看修饰后的fibonacci,那么打印出来的并不是我们想看的帮助文档,它本来应该打印前面定义时的那行’Return the n-th Fibonacci number文本才对’。

1
2
3
4
5
6
help(fibonacci)

>>>
Help on function wrapper in module __main__:

wrapper(*args, **kwargs)

对象序列化器也无法正常运作,因为它不能确定受修饰的那个原始函数的位置。

1
2
3
4
5
6
7
import pickle

pickle.dumps(fibonacci)

>>>
Traceback ...
AttibuteError: Can't pickle local object 'trace.<locals>.wrapper'

想要解决这些问题,可以改用functool内置模块之中的wraps辅助函数来实现。wraps本身也是个修饰器,它可以帮助你编写自己的修饰器。把它运用到wrapper函数上面,它就会将重要的元数据全部从内部函数复制到外部函数。

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

def trace(func):
@wraps(func)
def wrapper(*args, **kwargs):
...

return wrapper

@trace
def fibonacci(n):
...

现在我们就可以通过help函数看到正确的文档了,对象序列化器也可以正常使用,不会抛出异常了。