按关键字传递参数是 Python 函数的一项强大特性(参见 第23条)。这种关键字参数特别灵活,在很多情况下,都能让我们写出一看就能懂的函数代码。
例如,计算两个数字相除的结果时,可能需要仔细考虑各种特殊情况。例如,在除数为 0 的情况下,是抛出 ZeroDivisionError 异常,还是返回无穷(infinity);在结果溢出的情况下,是抛出 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 float('inf')
else:
raise
这个函数用起来很直观。如果想在结果溢出的情况下,让它返回 0,那么可以像下面一样调用函数。
result = safe_division(1.0, 10 ** 500, True, False)
print(result)
>>>
0
如果想在除数是 0 的情况下,让函数返回无穷,那么就按下面这样来写。
result = safe_division(1.0, 0, False, True)
print(result)
>>>
inf
表示要不要忽略异常的那两个参数都是 Boolean 值,所以容易弄错位置,这会让程序出现难以寻找的 bug。要想让代码看起来更加清晰,一种方法就是给这两个参数都指定默认值。按照默认值,该函数只要遇到特殊情况,就会抛出异常。
def safe_division_b(number, divisor,
ignore_overflow=False, # changed
ignore_zero_division=False): # changed
...
调用者可以用关键字指定覆盖其中某个参数的默认值,以调整函数在遇到那种特殊情况的处理方式,同时让另一个参数依然取那个参数自己的默认值。
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)
>>>
0
inf
然而,由于这些关键参数是可选的,我们没办法要求调用者必须按照关键字形式来指定这两个参数。他们还是可以用传统的写法,按位置给新定义的 safe_division_b 函数传递参数。
assert safe_division_b(1.0, 10**500, True, False) == 0
对于这种参数比较复杂的函数,我们可以声明只能通过关键字指定的参数(keyword-only argument),这样的话,写出来的代码就能清楚反映调用者的想法了。这种参数只能用关键字指定,不能按位置传递。
下面就重新定义 safe_division 函数,让它接收这样的参数。参数列表里的 * 符号把参数分成两组,左边是位置参数,右边是只能用关键字指定的参数。
def safe_division_c(number, divisor,*, # changed
ignore_overflow=False,
ignore_zero_division=False):
...
如果按位置给只能用关键字指定的参数传值,那么程序就会出错。
result = safe_division_c(1.0, 10 ** 500, True, False)
>>>
Traceback ...
TypeError: safe_division_c() takes 2 positional arguments but 4 were given
当然我们还是可以像前面那样,用关键字参数指定覆盖其中一个参数的默认值(即忽略其中一种特殊情况,并让函数在遇到另一种特殊情况时抛出异常)。
result = safe_division(1.0, 0, ignore_zero_division=True)
assert result == float('inf')
try:
result = safe_division_c(1.0, 0)
except ZeroDivisionError:
pass # Expected
这样改依然有问题,因为在 safe_division_c 版本的函数里面,有两个参数(也就是 number 和 division)必须由调用者提供。然而,调用者在提供这两个参数时,既可以按位置提供,也可以按关键字提供,还可以把这两种方式混起来用。
assert safe_division_c(number=2, divisor=5) == 0.4
assert safe_division_c(divisor=5, number=2) == 0.4
assert safe_division_c(2, divisor=5) == 0.4
在未来也许会因为扩展函数的需要,甚至是因为代码风格的变化,或许要修改这两个参数的名字。
def safe_division_c(numerator, denominator,*, # changed
ignore_overflow=False,
ignore_zero_division=False):
...
这看起来只是文字上面的微调,但之前通过关键字形式来指定这两个参数的调用代码都会出错。
safe_division_c(number=2, divisor=5) == 0.4
>>>
Traceback ...
TypeError: safe_division_c() got an unexpected keyword argument 'number'
其实最重要的问题在于,我们根本没打算把 number 和 divisor 这两个名称纳入函数的接口;我们只是在编写函数的实现代码时,随意挑选了这两个比较顺口的名称而已。
**Python 3.8 引入了一项新的特性,可以解决这个问题,就是只能按位置传递的参数 (positional-only argument)。**这种参数与刚才的只能通过关键字指定的参数(keyword-only argument) 相反,它们必须按照位置指定,绝不通过关键字形式指定。
下面我们来重新定义 safe_division 函数,使其前两个必须由调用者提供的参数位置来提供。参数列表中的 / 符号,表示它左边的那些参数只能按照位置指定。
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 float('inf')
else:
raise
下面来验证一下,看看调用者按照位置提供了两个参数后,能否得到正确结果。
assert safe_division_d(2, 5) == 0.4
假如调用者是通过关键字形式指定这两个参数的,那么程序就会在运行时抛出异常。
safe_division_d(numerator=2, denominator=5)
>>>
Traceback ...
TypeError: safe_division_d() got some positional-only arguments passed as keyword arguments: 'numerator, denominator'
现在我们可以确信:给 safe_division_d 函数的前两个参数(也就是那两个必备的参数)所挑选的名称,已经与调用者的代码解耦了。即便以后再次修改这两个参数的名称,也不会影响已经写好的调用代码。
在函数的参数列表中,/ 符号左侧的参数是智能按位置指定的参数,* 符号右侧的参数则是只能按关键字形式指定的参数。那么,这两个符号如果同时出现在参数列表中,又有什么效果呢?这是个值得注意的问题。这意味着,这两个符号的参数,既可以按位置提供,又可以用关键字形式指定(其实,如果不特别说明 Python 函数的参数全都属于这种参数).在设计 API 时,为了体现某编程风格或者实现某些需求,可能会允许某些参数既可以按照位置传递,也可以用关键字形式指定,这样可以让代码易读。例如,给下面的 safe_division 函数的参数列表添加一个可选的 ndigits 参数,允许调用者指定这次除法应该精确到小数点后第几位。
def safe_division_e(numerator, denominator, /,
ndigits=10, *, # changed
ignore_overflow=False,
ignore_zero_division=False):
try:
fraction = numerator / denominator
return round(fraction, ndigits) # Changed
except OverflowError:
if ignore_overflow:
return 0
else:
raise
except ZeroDivisionError:
if ignore_zero_division:
return float('inf')
else:
raise
下面我们用三种方式来调用这个 safe_division_e 函数。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)
>>>
3.1428571429
3.14286
3.14