引言
Python函数提供了许多能够简化编程工作的特性,有些是Pythton特有的。这些特性能够更明确地体现出函数的目标。
#19 不要把函数返回的多个值拆分到三个以上的变量中
拆包机制允许Python函数返回一个以上的值。假如现在要统计一群鳄鱼的各项指标,把每条鳄鱼的体长都保存在列表里。接着要编写函数,查出列表中最短与最长的鳄鱼。可以用下面这种写法实现,它可以同时把这两个值都返回。
def get_stats(numbers):
minimum = min(numbers)
maximum = max(numbers)
return minimum, maximum
lengths = [63, 73, 72, 60, 67, 66, 71, 61, 72, 70]
minimum, maximum = get_stats(lengths) # Two return values
print(f'Min: {minimum}, Max: {maximum}')
Min: 60, Max: 73
函数返回的其实是个元组。相当于用这两个变量分别接收元组中的两个元素。下面演示一下拆包语句和返回多个值的函数是怎么使用的。
first, second = 1, 2
assert first == 1
assert second == 2
def my_function():
return 1, 2
first, second = my_function()
assert first == 1
assert second == 2
在返回多个值的时候,可以用带星号的表达式接收那些没有被普通变量捕获到的值。
例如,我们还要写一个函数,计算每条鳄鱼的长度与这些鳄鱼的平均长度之比。该函数会把比值放到列表里返回,我们可以只接受最长与最短的鳄鱼所对应的比值,其他的用带星号的写法总括。
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(lengths)
print(f'Longest: {longest:>4.0%}')
print(f'Shortest: {shortest:>4.0%}')
Longest: 108%
Shortest: 89%
假设现在需求又变了,我们这次还想知道平均长度、中位长度以及样本的总数。我们可以扩展原来的get_stats
函数,然后用元组返回。
def get_stats(numbers):
minimum = min(numbers)
maximum = max(numbers)
count = len(numbers)
average = sum(numbers) / count
sorted_numbers = sorted(numbers)
middle = count // 2
if count % 2 == 0:
lower = sorted_numbers[middle - 1]
upper = sorted_numbers[middle]
median = (lower + upper) / 2
else:
median = sorted_numbers[middle]
return minimum, maximum, average, median, count
minimum, maximum, average, median, count = get_stats(lengths)
print(f'Min: {minimum}, Max: {maximum}')
print(f'Average: {average}, Median: {median}, Count {count}')
assert minimum == 60
assert maximum == 73
assert average == 67.5
assert median == 68.5
assert count == 10
Min: 60, Max: 73
Average: 67.5, Median: 68.5, Count 10
这样写有两个问题。首先,函数返回的五个值都是数字,所以很容易搞错顺序。调用方同时接收这么多返回值,也容易出错。
第二问题是,调用函数并拆分返回值的那行代码会非常长,按照PEP8风格指南,可能需要折行,这让代码看起来很丑陋。
minimum, maximum, average, median, count = get_stats(
lengths)
minimum, maximum, average, median, count = \
get_stats(lengths)
(minimum, maximum, average,
median, count) = get_stats(lengths)
(minimum, maximum, average, median, count
) = get_stats(lengths)
为了避免这些问题,我们不应该把函数返回的多个值拆分到三个以上的变量里。一个三元组最多只能拆成三个普通变量,或两个普通变量与一个万能变量(带星号的变量)。
如果要拆分的值比较多,那么还是定义一个命名元组或轻便的类。
#20 遇到意外状况时应该抛出异常,不要返回None
编写工具函数时,许多人都喜欢用None
这个返回值表示特殊情况。这或许有几分道理,例如,编写一个辅助函数计算两数相除的结果,在除数为0的情况下,返回None
似乎很合理。
def careful_divide(a, b): try: return a / b except ZeroDivisionError: return None
调用这个函数时,可以按照自己的方式处理这样的返回值。
x, y = 1, 0result = careful_divide(x, y)if result is None: print('Invalid inputs')
Invalid inputs
如果传给careful_divide
的被除数为0,这时应该返回为0。问题是,在if
条件判断中,可能会根据值本身是否相当于False
来做判断:
x, y = 0, 5result = careful_divide(x, y)if not result: print('Invalid inputs')
Invalid inputs
这种情况,会把返回0也当成返回None
那样处理。因此careful_divide
这样,用None
来表示特殊状况的函数很容易出错。有两种办法可以减少这样的错误。
第一种办法是,利用二元组把计算结果分成两部分返回。元组的首个元素表示是否操作成功,第二个元素表示计算的实际值。
但是,有点麻烦。
第二种办法是,不用None
表示特例,而是向调用方抛出异常。
def careful_divide(a, b): try: return a / b except ZeroDivisionError as e: raise ValueError('Invalid inputs')
现在,调用方拿到函数的返回值之后,不用判断操作是否成功了。只要用try
把函数包起来。
x, y = 5, 2try: result = careful_divide(x, y)except ValueError: print('Invalid inputs')else: print('Result is %.1f' % result)
Result is 2.5
这个办法也可以扩展到那些使用类型注解的代码中,我们可以把函数的返回值指定为float
类型,这样它就不可能返回None
了。然而,Python采用的是动态类型与静态类型相搭配的gradual类型系统,我们不能再函数的接口上指定函数可能抛出哪些异常(像Java的受检异常)。所以,我们只好把有可能抛出的异常写在文档里面,并希望调用方能根据文档适当地捕获相关异常。
def careful_divide(a: float, b: float) -> float: """Divides a by b. Raises: ValueError: When the inputs cannot be divided. """ try: return a / b except ZeroDivisionError as e: raise ValueError('Invalid inputs')
这样写,输入、输出与异常都显得很清晰,所以调用方出错的概率就变得很小了。
#21 了解如何在闭包里面使用外围作用域中的变量
有时,我们要给列表中的元素排序,而且要优先把某个群组之中的元素放在其他元素的前面。
实现这种做法的一种常见方案,是把辅助函数通过key
参数传给列表的sort
方法,让这个方法根据辅助函数所返回的值来决定元素在列表中的先后顺序。
辅助函数先判断当前元素是否处在重要的群组里,如果在,就把返回值的第一项写成0,让它能够排在不属于这个组的那些元素之前。
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]group = {2, 3, 5, 7}sort_priority(numbers, group)print(numbers)
[2, 3, 5, 7, 1, 4, 6, 8]
它为什么能实现这个功能呢?要分三个原因来讲:
- Python支持闭包(closure),这让定义在大函数里面的小函数也能引用大函数之中的变量。此例中,
helper
函数能引用group
参数。 - 函数在Python里是头等对象(first-class object),所以你可以像操作其他对象那样,直接引用它们、把它们赋值给变量、将它们当成参数传给其他函数,或是在
in
表达式与if
语句里面对它做比较,等等。闭包函数也是函数,所以,同样可以传给sort
方法的key
参数。 - Python在判断两个序列(包括元组)的大小时。它首先比较0号位置的那两个元素,如果相等,就比较1号位的那两个元素;以此类推,直到得出结论为止。所以,我们可以利用这套规则让
helper
这个闭包函数返回一个元组,并把关键指标写成元组的首个元素表示当前排序的值是否需要优先。
如果这个sort_priority
函数还能告诉我们,列表里面有没有位于重要群组之中的元素,那就更好了,因为这样可以让调用者更方便地作出相应处理。
添加这样一个功能似乎相当简单,因为闭包函数本身就需要判断当前值是否处于重要群组之中,既然这样,那么不妨让它在发现这种值时,顺便把标志变量翻转过来。最后,让闭包外的大函数返回这个标志变量,如果闭包函数当时遇到过这样的值,那么这个标志肯定是True
。
def sort_priority2(numbers, group): found = False def helper(x): if x in group: found = True # Seems simple return (0, x) return (1, x) numbers.sort(key=helper) return found
我们还是用刚才的输入数据来运行这个新函数。
found = sort_priority2(numbers, group)print('Found:', found)print(numbers)
Found: False[2, 3, 5, 7, 1, 4, 6, 8]
排序结果没有问题。但是表示函数返回值的found
变量应该为True
,我们却看到False
,为什么?
在表达式中引用某个变量时,Python解释器会按照下面的顺序,在各个作用域里面查找这个变量,以解析这次引用。
- 当前函数的作用域
- 外围作用域(例如包含当前函数的其他函数所对应的作用域)。
- 包含当前代码的那个模块所对应的作用域(也叫全局作用域)。
- 内置作用域(build-in scope),也就是包含
len
与str
等函数的那个作用域。
如果这些作用域中都没有定义名称相符合的变量,那么程序就抛出NameError
异常。
foo = dese_not_exist * 5
---------------------------------------------------------------------------NameError Traceback (most recent call last)<ipython-input-119-b3d7a163059a> in <module>----> 1 foo = dese_not_exist * 5
NameError: name 'dese_not_exist' is not defined
刚才讲的是遍历出现在赋值符号(=)右边时,该怎么认定。现在讲变量出现在赋值符号左边时(变量赋值),该怎么处理。这要分两种情况处理,如果变量已经定义在当前作用域中,那么直接把新值交给它就行了。
如果当前作用域中不存在这个变量,那么即便外围作用域里有同名的变量,Python也还是会把这次的赋值操作当成变量的定义来处理,这会产生一个重要的效果,Python会把包含赋值操作的这个函数当成新定义的这个变量的作用域。
这解释了刚才那种写法错在何处。sort_priority2
函数里面的helper
闭包函数是把True
赋值给了变量found
。当前作用域里面没有这样一个叫found
的变量,所以就算外围的sort_priority2
函数里面有found
变量,系统也还是会把这次赋值当成定义,也就是会在helper
里面定义一个新的found
变量,而不是把它当成给sort_priority2
已有的那个found
变量赋值。
def sort_priority2(numbers, group): found = False # 作用域: 'sort_priority2' def helper(x): if x in group: found = True # 作用域: 'helper' -- Bad! return (0, x) return (1, x) numbers.sort(key=helper) return found
这种问题有时也称为作用域bug(scoping bug),Python新手可能会认为这样的赋值规则很奇怪,但实际上Python是故意这样设计的。
因为这样可以防止函数中的局部变量污染外围模块。假如不这样做,那么函数里的每条赋值语句都有可能影响全局作用域的变量,这样不仅混乱,而且会让全局变量之间彼此交互影响,从而导致很多难以探查的bug。
==Python有一种特殊的写法, 可以把闭包里面的数据赋给闭包外面的变量。==用nonlocal
语句描述变量,就可以让系统在处理针对这个变量的赋值操作时,去外围作用域查找。
然而,nonlocal
有个限制,就是不能侵入模块级别的作用域(以防污染全局作用域)。
def sort_priority3(numbers, group): found = False def helper(x): nonlocal found if x in group: found = True return (0, x) return (1, x) numbers.sort(key=helper) return foundnumbers = [8, 3, 1, 2, 5, 4, 7, 6]found = sort_priority3(numbers, group)assert found == Trueassert numbers == [2, 3, 5, 7, 1, 4, 6, 8]
nonlocal
语句清楚地表明,我们要把数据赋给闭包之外的变量。有一种跟它护镖的语句,叫作global
,用这种语句描述某个变量后,在给这个变量赋值时,系统会直接把它放到模块作用域(或者说全局作用域)中。
我们都知道全局变量不应该滥用,其实nonlocal
也是不能滥用。除比较简单的函数外,尽量不要用这个语句。
如果nonlocal
的用法比较复杂,最好用辅助函数来封装状态。
class Sorter: def __init__(self, group): self.group = group self.found = False def __call__(self, x): if x in self.group: self.found = True return (0, x) return (1, x)numbers = [8, 3, 1, 2, 5, 4, 7, 6]sorter = Sorter(group)numbers.sort(key=sorter)assert sorter.found is Trueassert numbers == [2, 3, 5, 7, 1, 4, 6, 8]
#22 用数量可变的位置参数给函数设计清晰的参数列表
让函数接受数量可变的位置参数,可以把函数设计得更加清晰。例如,假设我们要记录调试信息,如果采用参数数量固定的方案来设计,那么函数应该接受一个表示信息的message
参数和一个values
列表。
def log(message, values): if not values: print(message) else: values_str = ', '.join(str(x) for x in values) print(f'{message}: {values_str}')log('My numbers are', [1, 2])log('Hi there', [])
我们看到即便没有值需要添加到信息里面,也必须专门传一个空白的列表进去。这显得多余,而且代码比较乱。最好能允许调用者把第二个参数留空。
可以给最后一个位置参数加前缀*
,这样调用者就只需要提供不带星号的那些参数。
def log(message, *values): if not values: print(message) else: values_str = ', '.join(str(x) for x in values) print(f'{message}: {values_str}') log('My numbers are', [1, 2])log('Hi there')
这种写法与拆解数据时用在赋值语句左边带星号的拆包操作非常类似(参见第13条)。
如果想把已有序列里面的元素当成参数传递给像log
这样的参数个数可变的函数,那么可以在传递序列的时候采用*
操作符。这会让Python把序列中的元素都当成位置参数传给这个函数。
favorites = [7, 33, 99]log('Favorite colors', favorites)log('Favorite colors', *favorites)
令函数接受数量可变的位置参数,可能导致两个问题。
第一个问题是,程序总是必须先把这些参数转化成一个元组,然后才能把它们当成可选的位置参数传给函数。这意味着,如果调用函数时,把带*
操作符的生成器传了过去,那么程序必须先把这个生成器里面的所有元素迭代完以便形成元组,然后才能继续继续往下执行。这个元组包含生成器所给出的每个值,这可能会耗费大量内存。
def my_generator(): for i in range(10): yield idef my_func(*args): print(args)it = my_generator()my_func(*it)
接受*args
参数的函数,适合处理输入值不太多,而且数量可以提前预估的情况。在调用这种函数时,传给*args
这一部分的应该是许多个字面量或变量名才对。这种机制,主要是为了让代码写起来更方便、读起来更清晰。
第二个问题是,如果用了*args
之后,又要给函数添加新的位置参数,那么原有的调用操作就需要全部更新。例如,给参数列表开头添加sequence
参数,那么没有更新的那些调用代码就会出错。
def log(sequence, message, *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(1, 'Favorites', 7, 33) # New with *args OKlog(1, 'Hi there') # New message only OKlog('Favorite numbers', 7, 33) # Old usage breaks
问题在于,第三次调用log
函数的那个地方并没有根据新的参数列表传入sequence
参数,所以’Favorite numbers’就成了sequence
参数,7就成了message
参数。这样的bug很难排查,因为程序不会抛出异常。
为了彻底避免这种漏洞,在给这种*arg
函数添加参数时,应该使用只能通过关键字来指定的参数。要是想做得更稳妥一些,可以考虑添加类型注解。
#23 用关键字参数来表示可选的行为
Python允许在调用函数时,按照位置传递参数。
def remainder(number, divisor): return number % divisorassert remainder(20, 7) == 6
除了按位置传递外,还可以按关键字传递。调用函数时,在调用括号内可以把关键字的名称写在=
左边,把参数值写在右边。这种写法与参数的顺序无关,另外,关键字形式与位置形式也可以混用。
remainder(20, 7)remainder(20, divisor=7)remainder(number=20, divisor=7)remainder(divisor=7, number=20)
如果混用,那么位置参数必须出现在关键字参数之前,否则就会出错。
remainder(number=20, 7)
每个参数只能指定一次,不能既通过位置形式指定,又通过关键字形式指定。
remainder(20, number=7)
如果有一份字典,而且字典里面的内容能用来调用remainder
这样的函数,那么可以把**
运算符加在字典前面,这会让Python把字典里面的键值以关键字参数的形式传给函数。
my_kwargs = { 'number': 20, 'divisor': 7,}assert remainder(**my_kwargs) == 6
调用函数时,带**
操作符的参数可以和位置参数或关键字参数混用,只要不重复指定就行。
my_kwargs = { 'divisor': 7,}assert remainder(number=20, **my_kwargs) == 6
也可以对多个字典分别施加**
操作,只要这些字典所提供的的参数不重叠就好。
my_kwargs = { 'number': 20,}other_kwargs = { 'divisor': 7,}assert remainder(**my_kwargs, **other_kwargs) == 6
定义函数时,如果想让这个函数接受任意数量的关键字参数,那么可以在参数列表里写上万能形参**kwargs
,它会把调用者传进来的参数收集合到一个字典里面稍后处理。
def print_parameters(**kwargs): for key, value in kwargs.items(): print(f'{key} = {value}')print_parameters(alpha=1.5, beta=9, gamma=4)
关键字参数的灵活用法可以带来三个好处。
第一个好处,用关键字参数调用函数可以让初次阅读代码的人更容易看懂。
第二个好处,它可以带默认值,该值是在定义函数时指定的。
例如,我们要计算液体流入容器的速率。如果这个容器带刻度,那么可以取前后两个时间点的刻度差,并把它跟这两个时间点的时间差相除,就可以算出流速了。
def flow_rate(weight_diff, time_diff): return weight_diff / time_diffweight_diff = 0.5time_diff = 3flow = flow_rate(weight_diff, time_diff)print(f'{flow:.3} kg per second')
一般来说,我们用每秒的千克数表示流速。但有时,我们还想估算更长的时间段内的流速结果。只需要给同一个函数加一个period
参数来表示那个时间段相当于多少秒即可。
def flow_rate(weight_diff, time_diff, period): return (weight_diff / time_diff) * period
这样写有个问题,就是每次调用函数时,都得明确指定period
参数,哪怕计算每秒中的流速,也可以指定period
为1。
flow_per_second = flow_rate(weight_diff, time_diff, 1)
为了简化这种用法,我们可以给period
参数设定默认值。
def flow_rate(weight_diff, time_diff, period=1): return (weight_diff / time_diff) * periodflow_per_second = flow_rate(weight_diff, time_diff)flow_per_hour = flow_rate(weight_diff, time_diff, period=3600)print(flow_per_second)print(flow_per_hour)
这个办法适用于默认值比较简单的情况。如果默认值本身要根据比较复杂的逻辑来确定,那就得仔细考虑一下了。
关键字参数的第三个好处是,我们可以很灵活地扩充函数的参数,而不用担心会影响原有的函数调用代码。
例如,我们想继续扩充上面flow_rate
函数的功能,让它可以用千克之外的其他重量单位来计算流速。那只需要再添加一个可选参数,用来表示1千克相当于多少个那样的单位即可。
def flow_rate(weight_diff, time_diff, period=1, units_per_kg=1): return ((weight_diff * units_per_kg) / time_diff) * period# Example 15pounds_per_hour = flow_rate(weight_diff, time_diff, period=3600, units_per_kg=2.2)print(pounds_per_hour)
新参数units_per_kg
的默认值为1,表示默认以千克为重量单位。这样之前的代码就不用修改了。以后调用时还可以给这个参数指定值,表示我们想用的那种单位。
pounds_per_hour = flow_rate(weight_diff, time_diff, period=3600, units_per_kg=2.2)print(pounds_per_hour)
可选的关键字参数有助于维护向后兼容。对于接受带*args
参数的函数,也要注意向后兼容。
像period
和units_per_kg
这样可选的关键字参数,只有一个缺点,就是调用者仍然能够按照位置来指定。
pounds_per_hour = flow_rate(weight_diff, time_diff, 3600, 2.2)print(pounds_per_hour)
通过位置来指定可能让人有点糊涂。所以,最好能以关键字的形式给这些参数传值。
#24 用None和docstring来描述默认值会变的参数
有时,我们想把那种不能提前固定的值,当做关键字参数的默认值。例如,记录日志消息时,默认时间应该是触发事件的那一刻。所以,如果调用者没有明确指定时间,那么默认把调用函数的那一刻当成这条日志的记录时间。
from time import sleepfrom datetime import datetimedef log(message, when=datetime.now()): print(f'{when}: {message}')log('Hi there!')sleep(0.1)log('Hello again!')
但是这样写不行,因为datatime.now
只执行了一次,所以每条日志的时间戳相同。参数的默认值只会在系统加载这个模块的时候,计算一遍,而不会在每次执行时都重新计算。
要想实现这种效果,一般把参数的默认值设成None
,同时在docstring
文档里面写清楚,这个参数为None
时,函数会怎样运作。
def log(message, when=None): """Log a message with a timestamp. Args: message: Message to print. when: datetime of when the message occurred. Defaults to the present time. """ if when is None: when = datetime.now() print(f'{when}: {message}')# Example 3log('Hi there!')sleep(0.1)log('Hello again!')
这次,两条日志的时间戳就不同了。
把参数的默认值写成None
还有个重要的意义,就是用来表示那种以后可能由调用者修改内容的默认值。例如,我们要写一个函数对采用JSON格式编码的数据做解码。如果无法解码,那么就返回调用时所指定的默认结果。
import jsondef decode(data, default={}): try: return json.loads(data) except ValueError: return default
这样的写法与前面datetime.now
的例子有着同样的问题。系统只会计算一次default
参数,所以每次调用这个函数时,给调用者返回的都是一开始分配的那个字典。
# Example 5foo = decode('bad data')foo['stuff'] = 5bar = decode('also bad')bar['meep'] = 1print('Foo:', foo)print('Bar:', bar)
我们本意是想让这两次操作得到两个不同的空白字典。但实际上,它们用的是同一个字典。
assert foo is bar
要解决这个问题,还是把默认值设成None
,并且在docstring
文档里面说明。
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
这样写,再运行刚才那段测试代码,就可以得出预期的结果了。
foo = decode('bad data')foo['stuff'] = 5bar = decode('also bad')bar['meep'] = 1print('Foo:', foo)print('Bar:', bar)assert foo is not bar
这个思路可以跟类型注解搭配起来。下面这种写法把when
参数标注成可选值,并限定其类型为datetime
,于是它的取值就只有两种可能,要么是None
,要么是datetime
对象。
from typing import Optionaldef log_typed(message: str, when: Optional[datetime]=None) -> None: """Log a message with a timestamp. Args: message: Message to print. when: datetime of when the message occurred. Defaults to the present time. """ if when is None: when = datetime.now() print(f'{when}: {message}')
log_typed('Hi there!')sleep(0.1)log_typed('Hello again!')
#25 用只能以关键字指定和只能按位置传入的参数来设计清晰的参数列表
按关键字传递参数是Python的一项强大特性。这种关键字参数特别灵活,很多情况下,都能让我们写出一看就懂的函数代码。
例如,计算两数相除时,可能需要仔细考虑各种特殊情况。例如,在除数为0的情况下,是抛出ZeroDivisionError
异常,还是返回无穷;在结果溢出的情况下,是抛出OverflowError
异常,还是返回0。
def safe_division(number, divisor, ignore_overflow, ignore_zero_division): 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
这个函数用起来很直观。如果想在结果溢出的情况下,让它返回0,那么可以像下面这样调用函数。
result = safe_division(1.0, 10**500, True, False)print(result)
如果想在除数是0的情况下,让函数返回无穷,那么就按下面这样写。
result = safe_division(1.0, 0, False, True)print(result)
表示要不要忽略异常的这两个参数都是布尔值,所以容易弄错位置。要想让代码看起来更清晰,一种办法是给这两个参数都指定默认值。按照默认值,该函数只要遇到特殊情况,就会抛出异常。
def safe_division_b(number, divisor, ignore_overflow=False, # Changed ignore_zero_division=False): # Changed 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_b(1.0, 10**500, ignore_overflow=True)print(result)result = safe_division_b(1.0, 0, ignore_zero_division=True)print(result)
然后,由于这些参数是可选的,我们无法要求调用者必须按照关键字形式来指定。他们还是可以用传统的方法,按位置给这个新定义的safe_division_b
函数传递参数。
assert safe_division_b(1.0, 10**500, True, False) == 0
对于这种参数比较复杂的函数,我们可以声明只能通过关键字指定的参数,这样,写出来的代码就能反映调用者的想法了。这种参数只能用关键字指定,不能按位置传递。
下面就重新定义safe_division
函数,让它接受这样的参数。参数列表了的*
符号把参数分成两组,左边是位置参数,右边是只能用关键字指定的参数。
def safe_division_c(number, divisor, *, # Changed ignore_overflow=False, ignore_zero_division=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
如果按位置给只能用关键字指定的参数传值,那么程序就会出错。
safe_division_c(1.0, 10**500, True, False)
当然我们还是可以像前面那样,用关键字参数指定覆盖其中一个参数的默认值。
result = safe_division_c(1.0, 0, ignore_zero_division=True)assert result == float('inf')try: result = safe_division_c(1.0, 0)except ZeroDivisionError: pass # Expectedelse: assert False
这样改依然有问题,因为在safe_division_c
版本的函数里面,有两个参数必须由调用者提供。然而,调用者在提供这两个参数时,既可以按位置提供,也可以按关键字提供,还可以把这两种方式混起来用。
assert safe_division_c(number=2, divisor=5) == 0.4assert safe_division_c(divisor=5, number=2) == 0.4assert safe_division_c(2, divisor=5) == 0.4
在未来也许因为扩展函数的需要,甚至是因为代码风格的变化,或许要修改这两个参数的名字。
def safe_division_c(numerator, denominator, *, # Changed ignore_overflow=False, ignore_zero_division=False): try: return numerator / denominator except OverflowError: if ignore_overflow: return 0 else: raise except ZeroDivisionError: if ignore_zero_division: return numerator * float('inf') else: raise
这看起来只是文字上面的微调,但之前所有通过关键字形式来指定这两个参数的调用代码,都会出错。
safe_division_c(number=2, divisor=5)
其实最重要的问题是,我们根本就没打算把number
和divisor
这两个名称纳入函数的接口;我们只是在编写函数的实现代码时,随意挑了这两个比较顺口的名称而已。
Python3.8引入了一项新特性,可以解决这个问题,这就是只能按位置传递的参数。这种参数与刚才的只能通过关键字指定的参数相反,它们必须按位置指定,绝不能通过关键字形式指定。
下面来重新定义我们的函数,使其前两个必须由调用者提供的参数,只能按位置来提供。参数列表中的/
符号,表示它左边的那些参数只能按位置指定。
def safe_division_d(numerator, denominator, /, *, # Changed ignore_overflow=False, ignore_zero_division=False): try: return numerator / denominator except OverflowError: if ignore_overflow: return 0 else: raise except ZeroDivisionError: if ignore_zero_division: return numerator * float('inf') else: raise
assert safe_division_d(2, 5) == 0.4
我们上面按照位置提供了两个参数后,能正确得到结果。
假如调用者是通过关键字形式指定这两个参数的:
safe_division_d(numerator=2, denominator=5)
现在我们可以确信,给该函数的前两个参数所挑选的名称,已经与调用者代码解耦了。
在函数的参数列表中,/
符号左边的参数是只能按照位置指定的参数,*
符号右边的参数则是只能按关键字形式指定的参数。
如果这两个符号如果同时出现在参数列表中,会有什么效果呢? 这意味着,这两个符号之间的参数,既可以按位置提供,又可以用关键字形式指定。在设计API时,可能允许某些参数既可以按位置传递,也可以用关键字形式指定,这样可以让代码简单易读。
例如,给下面这个safe_division
函数的参数列表添加一个可选的ndigits
参数,允许调用者指定这次除法应该精确到小数点后第几位。
def safe_division_e(numerator, denominator, /, ndigits=10, *, # Changed ignore_overflow=False, ignore_zero_division=False): try: fraction = numerator / denominator # Changed return round(fraction, ndigits) # Changed except OverflowError: if ignore_overflow: return 0 else: raise except ZeroDivisionError: if ignore_zero_division: return numerator * float('inf') else: raise
下面我们用三种方式来调用该函数,ndigits
是个带默认值的普通参数,因此,它既可以按位置传递,也可以用关键字来指定,还可以直接省略。
result = safe_division_e(22, 7)print(result)result = safe_division_e(22, 7, 5)print(result)result = safe_division_e(22, 7, ndigits=2)print(result)
#26 用function.wraps定义函数修饰器
Python中有一种特殊的写法,可以用修饰器来封装某个函数,从而让程序在执行这个函数之前与之后,分别运行某些代码。这意味着,调用者传给函数的参数值、函数返回给调用者的值,以及函数抛出的异常,都可以由修饰器访问并修改。
这是很有用的机制,能够确保用户以正确的方式使用拿书,也能用来调试程序或实现函数注册功能,还有很多其他用途。
假如,我们要把函数执行时收到的参数与返回的值记录下来。这在调试递归函数时是很有用的,因为我们需要知道,这个函数执行每一层递归时,输入的是什么参数,返回的是什么值。
下面我们定义这样一个修饰器,在实现这个修饰器时,用*args
与**kwargs
表示受修饰的原函数func
所收到的参数。
def trace(func): def wrapper(*args, **kwargs): # ① result = func(*args, **kwargs) # ② print(f'{func.__name__}({args!r}, {kwargs!r}) ' f'-> {result!r}') return result return wrapper
写好之后,我们用@
符号把修饰器运用在想要调用的函数上面。
@tracedef fibonacci(n): """Return the n-th Fibonacci number""" if n in (0, 1): return n return (fibonacci(n - 2) + fibonacci(n - 1))
这样写,相当于把受修饰的函数传给修饰器,然后将修饰器所返回的值赋给原来那个函数。我们如果继续通过原来那个名字调用函数,那么执行的就是修饰之后的函数。
fibonacci = trace(fibonacci)
修饰过的fibonacci
函数,会在执行自身的代码前,先执行wrapper
里位于func(*args,**kwargs)
那一行之前的逻辑(①处);并且在执行完自身的代码后,执行wrapper
里位于func(*args,**kwargs)
那一行之后的逻辑(②处)。
本例中,它会在执行完自身的代码之后,打印出这次执行所用的参数与返回值,这样就能看到整个递归栈的情况了。
fibonacci(4)
fibonacci((0,), {}) -> 0fibonacci((1,), {}) -> 1fibonacci((2,), {}) -> 1fibonacci((1,), {}) -> 1fibonacci((0,), {}) -> 0fibonacci((1,), {}) -> 1fibonacci((2,), {}) -> 1fibonacci((3,), {}) -> 2fibonacci((4,), {}) -> 3
3
这样写确实能满足需求,但是会带来一个副作用。修饰器返回的那个值,即刚才调用的fibonacci
,它的名字并不叫“fibonacci”。
print(fibonacci)
<function trace.<locals>.wrapper at 0x000001BEEC289E50>
这种现象是可以解释的。trace
函数返回的,是它里面定义的wrapper
函数,所以,当我们把这个返回值赋给fibonacci
之后,fibonacci
这个名称所表示的自然就是wrapper
了。问题在于,这样可能会干扰那些需要利用introspection
(可理解为反射)机制来运作的工具。
例如,如果用内置的help
函数查看修饰后的fibonacci
,那打印出来的并不是我们想看的帮助文档。
help(fibonacci)
Help on function wrapper in module __main__:wrapper(*args, **kwargs)
对象序列化也无法正常运作,因为它不能确定受修饰的那个原始函数的位置。
import pickle pickle.dumps(fibonacci)
---------------------------------------------------------------------------AttributeError Traceback (most recent call last)<ipython-input-9-996f33bdbee0> in <module> 1 import pickle 2 ----> 3 pickle.dumps(fibonacci)
AttributeError: Can't pickle local object 'trace.<locals>.wrapper'
要想解决这个问题,可以改用functools
内置模块之中的wraps
辅助函数来实现。wraps
本身也是个修饰器,它可以帮助你编写自己的修饰器。把它运用到wrapper
函数上面,它就会将重要的元数据全都从内部函数复制到外部函数。
from functools import wraps
def trace(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
print(f'{func.__name__}({args!r}, {kwargs!r}) '
f'-> {result!r}')
return result
return wrapper
@trace
def fibonacci(n):
"""Return the n-th Fibonacci number"""
if n in (0, 1):
return n
return fibonacci(n - 2) + fibonacci(n - 1)
现在我们就可以通过help
函数看到正确的文档了。虽然原来的fibonacci
函数现在封装在修饰器里面,但我们还是可以看到它的文档。
help(fibonacci)
Help on function fibonacci in module __main__:
fibonacci(n)
Return the n-th Fibonacci number
对象序列化器,也正常了。
print(pickle.dumps(fibonacci))
b'\x80\x04\x95\x1a\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\tfibonacci\x94\x93\x94.'
除了这里讲到了几个方面之外,Python函数还有很多标准属性也应该在受到封装时得意保留,这样才能让相关的接口正常运作。wraps
可以帮助保留这些属性,使程序表现出正确的行为。