Effective Python -- 第 2 章 函数(下)

柴高岑
2023-12-01

第 2 章 函数(下)

第 18 条:用数量可变的位置参数减少视觉杂讯

令函数接受可选的位置参数(由于这种参数习惯上写为 *args,所以又称为 star args,星号参数),能够使代码更加清晰,并能减少视觉杂讯(visual noise)。

例如,要定义 log 函数。以便把某些调试信息打印出来。加入该函数的参数个数固定不变,那它就必须接受一段信息及一份有待打印值的列表。

def log(message, values):
    if not values:
        print(message)
    else:
        values_str = ', '.join(str(x) for x in values)
        print('%s: %s' % (message, values_str))

log('My numbers are', [1, 2])
log('Hi there: 1, 2')
>>>
My numbers are: 1, 2
Hi there

即便没有值要打印,只想打印一条信息,调用者也必须像上面那样,手工传入一份空列表。这种写法既麻烦,又显得杂乱。最好是能令调用者把第二个参数完全省略掉。若想在 Python 中实现此功能,可以把最后那个位置前面加个 *,于是,对于现在的 log 函数来说,只有第一个参数 message 是调用者必须要指定的,该参数后面,可以跟随任意数量的位置参数。函数体不需要修改,只需修改调用该函数的代码。

def log(message, *values):  # The only difference
    if not values:
        print(message)
    else:
        values_str = ', '.join(str(x) for x in values)
        print('%s: %s' % (message, values_str))

log('My numbers are', 1, 2)
log('Hi there')  # Much better
>>>
My numbers are: 1, 2
Hi there

如果要把已有的列表,传给像 log 这样的带有变长参数的函数,那么调用的时候,可以给列表前面加上 * 操作符。这样 Python 就会把这个列表里的元素视为位置参数。

favorites = [7, 33, 99]
log('Favorite colors', *favorites)
>>>
Favorite colors: 7, 33, 99

接受数量可变的位置参数,会带来两个问题。

第一个问题是,变长参数在传给函数时,总要先转化成元组(tuple)。这就意味着,如果用带有 * 操作符的生成器为参数,来调用这种函数,那么 Python 就必须先把该生成器完整地迭代一轮,并把生成器所生成的每一个值,都放入元组之中。这可能会消耗大量内存,并导致程序崩溃。

def my_generator():
    for i in range(10):
        yield i

def my_func(*args):
    print(args)

it = my_generator()
my_func(*it)
>>>
(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)

只有当能够确定输入的参数个数比较少时,才应该令函数接受 *args 式的变长参数。在需要把很多字面量或变量名称一起传给某个函数的场合,使用这种变长参数,是较为理想的。该参数主要是为了简化程序员的编程工作,并使得代码更加易读。

使用 *args 参数的第二个问题是,如果以后要给函数添加新的位置参数,那就必须修改原来调用该函数的那些旧代码。若是只给参数列表前方添加新的位置参数,而不更新现有的调用代码,则会产生难以调试的错误。

def log(sequence, message, *values):
    if not values:
        print('%s: %s' % (sequence, message))
    else:
        values_str = ', '.join(str(x) for x in values)
        print('%s: %s: %S' % (sequence, message, values_str))
log(1, 'Favorites', 7, 33)  # New usage is OK
log('Favorites numbers', 7, 33)  # Old usage breaks
>>>
1: Favorites: 7, 33
Favorites numbers: 7, 33

问题在于:上面的第二条 log 语句是以前写好的,当时的 log 函数还没有 sequence 参数,现在多了这个参数,使得 7 从 values 值的一部分,变成了 message 参数的值。这种 bug 很难追踪,因为现在这段代码仍然可以运行,而且不抛出异常。为了彻底避免此类情况,应该使用只能以关键字形式指定的参数(keyword-only argument),来扩展这种接受 *args 的函数。

总结

  • 在 def 语句中使用 *args,即可令函数接受数量可变的位置参数。
  • 调用函数时,可以采用 * 操作符,把序列中的元素当成位置参数,传给该函数。
  • 对生成器使用 * 操作符,可能导致程序耗尽内存并崩溃。
  • 在已经接受 *args 参数的函数上面继续添加位置参数,可能会产生难以排查的 bug。

第 19 条:用关键字参数来表达可选的行为

与其他编程语言一样,调用 Python 函数时,可以按位置传递参数。

def remainder(number, divisor):
    return number % divisor

assert remainder(20, 7) == 6

Python 函数中的所有位置参数,都可以按关键字传递。采用关键字形式来指定参数值时,会在表示函数调用操作的那一对圆括号内,以复制的格式,把参数名称和参数值分别放在等号左右两侧。关键字参数的顺序不限,只要把函数所要求的全部位置参数都指定好即可。还可以混合使用关键字参数和位置参数来调用函数。下面这些调用,都是等效的:

remainder(20, 7)
remainder(20, divisor=7)
remainder(number=20, divisor=7)
remainder(divisor=7, number=20)

位置参数必须出现在关键字参数之前。

remainder(number=20, 7)
>>>
SyntaxError: non-keyword arg after keyword arg

每个参数只能指定一次。

remainder(20, number=7)
>>>
TypeError: remainder() got ,ultiple values for argument 'number'

灵活使用关键字参数,能带来三个重要的好处。

首先,以关键字参数来调用函数,能使读到这行代码的人更容易理解其含义。如果读到了 remainder(20, 7) 这样的调用代码,那么必须查看方法的实现代码,才能够明白这两个参数里面,究竟哪一个是被除数,哪一个是除数。若是改用关键字的形式来调用,则立刻就能根据 number=20 和 division=7 等写法来获知每个参数的含义。

关键字参数的第二个好处是,它可以在函数定义中提供默认值。在大部分情况下,函数调用者只需要使用这些默认值就够了,若要开启某些附加功能,则可以指定相应的关键字参数。这样做可以消除重复代码,并使代码变得整洁。

例如,要计算液体流入容器的速率。如果容器比较大,那么可以根据两个时间点上的重量差及时间差来判断流率。

def flow_rate(weight_diff, time_diff):
    return weight_diff / time_diff

weight_diff = 0.5
time_diff = 3
flow = flow_rate(weight_diff, time_diff)
print('%.3f kg per second' % flow)

>>>
0.167 kg per second

通常情况下,求出每秒钟流过的千克数就可以了。然而某些时候,可能想根据传感器上一次的读数,在更大的时间跨度上面评估流率,如以小时或天来估算。只需给函数添加一个参数,用来表示两种时间段的比例因子,即可提供这种行为。

def flow_rate(weight_diff, time_diff, period):
    return (weight_diff / time_diff) * period

这样写的缺点是,每次调用函数时,都要指定 period 参数,即便想计算最常见的每秒流率,也依然要把 1 传给 period 参数。

flow_per_second = flow_rate(weight_diff, time_diff)
flow_per_hour = flow_rate(weight_diff, time_diff, period=3600)

这种办法适用于比较简单的默认值。如果默认值比较复杂,这样写就不太好了。

使用关键字参数的第三个好处,是可以提供一种扩充函数参数的有效方式,使得扩充之后的函数依然能与原有的那些调用代码相兼容。不需要迁移大量代码,即可给函数添加新的功能,这减少了引入 bug 的概率。

例如,要扩充上述的 flow_rate 函数,使它能够根据千克之外的其他重量单位来计算流率。为此,添加一个可选的参数,用以表示千克与那种重量单位之间的换算关系。

def flow_rate(weight_diff, time_diff, period=1, units_per_kg=1):
    return ((weight_diff * units_per_kg) / time_diff) * period

units_per_kg 参数的默认值是 1,也就是说,如果该参数取默认值,那么 flow_rate 函数仍然会以千克为重量单位来进行计算。这可以保证原来编写的那些函数调用代码,其行为都保持不变。而现在调用 flow_rate 的人,则可以通过这个新的关键字参数来使用该函数的新功能。

pounds_per_hour = flow_rate(weight_diff, time_diff, period=3600, units_per_kg=2.2)

这种写法只有一个缺陷,那就是像 period 和 units_per_kg 这种可选的关键字参数,仍然可以通过位置参数的形式来指定。

ponuds_per_hour = flow_rate(weight_diff, time_diff, 3600, 2)

以位置参数的形式来指定可选参数,是容易令人困惑的,因为 3600 和 2.2 这样的值,其含义并不清晰。最好的办法,是一直以关键字的形式来指定这些参数,而决不采用位置参数来指定它们。

总结

  • 函数参数可以按位置或关键字来指定。
  • 只使用位置参数来调用函数,可能会导致这些参数值的含义不够明确,而关键字参数则能够阐明每个参数的意图。
  • 给函数添加新的行为时,可以使用带默认值的关键字参数,以便与原有的函数调用代码保持兼容。
  • 可选的关键字参数,总是应该以关键字形式来指定,而不应该以位置参数的形式来指定。

第 20 条:用 None 和文档字符串来描述具有动态默认值的参数

有时想采用一种非静态的类型,来做关键字参数的默认值。例如,在打印日志消息的时候,要把相关事件的记录时间也标注在这条消息中。默认情况下,消息里面所包含的时间,应该是调用 log 函数那一刻的时间。如果以为参数的默认值会在每次执行函数时得到评估,那可能就会写出下面这种代码。

def log(message, when=datetime.now()):
    print('%s: %s' % (when, message))

log('Hi there!')
sleep(0.1)
log('Hi again!')
>>>
2014-11-15 21:10:10.371432: Hi there!
2014-11-15 21:10:10.371432: Hi again!

两条消息的时间戳(timestamp)是一样的,这是因为 datetime.now 只执行了一次,也就是它只在函数定义的时候执行了一次。参数的默认值,会在每个模块加载进来的时候求出,而很多模块都是在程序启动时加载的。包含这段代码的模块一旦加载进来,参数的默认值就固定不变了,程序不会再次执行 datetime.now。

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.
    """
    when = datetime.now() if when is None else when
    print('%s: %s' % (when, message))

现在,两条消息的时间戳就不同了。

log('Hi there!')
sleep(0.1)
log('Hi again!')
>>>
2014-11-15 21:10:10.472303: Hi there!
2014-11-15 21:10:10.573395: Hi again!

如果参数的实际默认值是可变类型(mutable),那就一定要记得用 None 作为形式上的默认值。例如,从编码为 JSON 格式的数据中载入某个值。若解码数据时失败,则默认返回空的字典。可能会采用下面这种办法来实现此功能:

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

这种写法的错误和刚才的 datetime.now 类似。由于 default 参数的默认值只会在模块加载时评估一次,所以凡是以默认形式来调用 decode 函数的代码,都将共享同一份字典。这会引发非常奇怪的行为。

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

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

本以为 foo 和 bar 会表示两份不同的字典,每个字典里都有一对键和值,但实际上,修改了其中一个之后,另外一个似乎也会受到影响。这种错误的根本原因是:foo 和 bar 其实都等同于写在 default 参数默认值中的那个字典,它们都表示的是同一个字典对象。

assert foo is bar

解决办法,是把关键字参数的默认值设为 None,并在函数的文档字符串中描述它的实际行为。

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.
    """
    if default is None:
        default = {}
    try:
        return json.loads(data)
    except ValueError:
        return default

现在,再来运行和刚才相同的测试代码,就能产生符合预期的结果了。

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

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

总结

  • 参数的默认值,只会在程序加载模块并读到本函数的定义时评估一次。对于 {} 或 [] 等动态的值,这可能会导致奇怪的行为。
  • 对于以动态值作为实际默认值的关键字参数来说,应该把形式上的默认值写为 None,并在函数的文档字符串里面描述该默认值所对应的实际行为。

第 21 条:用只能以关键字形式指定的参数来确保代码明晰

按关键字传递参数,是 Python 函数的一项强大特性。由于关键字参数很灵活,所以在编写代码时,可以把函数的用法表达得更加明确。

例如,要计算两数相除的结果,同时要对计算时的特殊情况进行小心的处理。有时想忽略 ZeroDivisionError 异常并返回无穷。有时又想忽略 OverflowError 异常并返回 0。

def safe_division(number, division, ignore_overflow, ignore_zero_division):
    try:
        return number / division
    except OverflowError:
        if ignore_overflow:
            return 0
        else:
            raise
    except ZeroDivisionError:
        if ignore_zero_division:
            return float('inf')
        else:
            raise

这个函数用起来很直观。下面这种调用方式,可以忽略除法过程中的 float 溢出,并返回 0。

result = safe_division(1.0, 10**500, True, False)
print(result)
>>>
0.0

下面这种调用方式,可以忽略拿 0 做除数的错误,并返回无穷。

result = safe_division(1, 0, False, True)
print(result)

>>>
inf

该函数用了两个 Boolean 参数,来分别决定是否应该跳过除法计算过程中的异常,而问题在于,调用者写代码的时候,很可能分不清这两个参数,从而导致难以排查的 bug。提升代码可读性的一种办法,是采用关键字参数。在默认情况下,该函数会非常小心地进行计算,并且总是会把计算过程中发生的异常重新抛出。

def safe_division(number, division, ignore_overflow=False, ignore_zero_division=False):
    # ...

现在,调用者可以根据自己的具体需要,用关键字参数来覆盖 Boolean 标志的默认值,以便跳过相关的错误。

safe_division_b(1, 10**500, ignore_overflow=True)
safe_division_b(1, 0, ignore_zero_division=True)

上面这种写法还是有缺陷。由于这些关键字参数都是可选的,所以没办法确保函数的调用者一定会使用关键字来明确指定这些参数的值。即便使用新定义的 safe_division_b 函数,也依然可以像原来那样,以位置参数的形式调用它。

safe_division_b(1, 10**500, True, False)

对于这种复杂的函数来说,最好是能够保证调用者必须以清晰的调用代码,来阐明调用该函数的意图。在 Python 3 中,可以定义一种只能以关键字形式来指定的参数,从而确保调用该函数的代码读起来会比较明确。这些参数必须以关键字的形式提供,而不能按位置提供。

下面定义的这个 safe_division_c 函数,带有两个只能以关键字形式来指定的参数。参数列表里的 * 号,标志着位置参数就此终结,之后的那些参数,都只能以关键字形式来指定。

def safe_division_c(number, division, *, ignore_overflow=False, ignore_zero_division=False):
    # ...

现在,就不能用位置参数的形式来指定关键字参数了。

safe_division_c(1, 10**500, True, False)
>>>
TypeError: safe_division_c() takes 2 positional arguments but 4 were given

关键字参数依然可以用关键字的形式来指定,如果不指定,也依然会采用默认值。

safe_division_c(1, 0, ignore_zero_division=True)  # OK

try:
    safe_division_c(1, 0)
except ZeroDivisionError:
    pass  # Excepted

在 Python 2 中实现只能以关键字来指定的参数

不幸的是,与 Python 3 不同,Python 2 并没有明确的语法来定义这种只能以关键字形式指定的参数。不过,可以在参数列表中使用 ** 操作符,并且令函数在遇到无效的调用时抛出 TypeErrors,这样就可以实现与 Python 3 相同的功能了。** 操作符与 * 操作符类似,但区别在于,它不是用来接受接受数量可变的位置参数,而是用来接受任意数量的关键字参数。即便某些关键字参数没有定义在函数中,它也依然能够接受。

# Python 2
def print_args(*args, **kwargs):
    print 'Positional:', args
    print 'Keyword:', kwargs

print_args(1, 2, foo='bar', stuff='meep')
>>>
Positional: (1, 2)
keyword: {'foo': 'bar', 'stuff': 'meep'}

为了使 Python 2 版本的 safe_division 函数具备只能以关键字形式指定的参数,可以先令该函数接受 **kwargs 参数,用 pop 方法把期望的关键字参数从 kwargs 字典中取走,如果字典的键里面没有那个关键字,那么 pop 方法的第二个参数就会成为默认值。最后,为了防止调用者提供无效的参数值,需要确认 kwargs 字典里面已经没有关键字参数了。

# Python 2
def safe_division_d(number, division, **kwargs):
    ignore_overflow = kwargs.pop('ignore_overflow', False)
    ignore_zero_div = kwargs.pop('ignore_zero_division', False)
    if kwargs:
        raise TypeError('Unexpected **kwargs: %r' % kwargs)
    # ...

现在,既可以用不带关键字参数的方式来调用 safe_division_d 函数,也可以用有效的关键字参数来调用它。

safe_division_d(1, 10)
safe_division_d(1, 0, ignore_zero_division=True)
safe_division_d(1, 10**500, ignore_overflow=True)

与 Python 3 版本的函数一样,也不能以位置参数的形式来指定关键字参数的值。

safe_division_d(1, 0, False, True)
>>>
TypeError: safe_division_d() takes 2 positional arguments but 4 were given

此外,调用者还不能传入不符合预期的关键字参数。

safe_division_d(0, 0, unexpected=True)
>>>
TypeError: Unexpected **kwargs: {'unexpected': True}

总结

  • 关键字参数能够使函数调用的意图更加明确。
  • 对于各参数之间很容易混淆的函数,可以声明只能以关键字形式指定的参数,以确保调用者必须通过关键字来指定它们。对于接受多个 Boolean 标志的函数,更应该这样做。
  • 在编写函数时,Python 3 有明确的语法来定义这种只能以关键字形式指定的参数。
  • Python 2 的函数可以接受 **kwargs 参数,并手工抛出 TypeError 异常,以便模拟只能以关键字形式来指定的参数。
 类似资料: