Python函数中有一些细节,注意到了有利于我们写出易读、易调用的代码,且防止程序中出现难以查找的bug。

14. 尽量用异常来表示特殊情况

有时候,程序员会在写函数时,用None来表示异常情况,比如除法运算时除以0。

def divide(a, b):
try:
return a / b
except ZeroDivisionError:
return None

函数的调用者可能不会专门判断函数返回值是否为None,会假定只要返回了和False等效的结果,就说明出错了。但是None和0以及空字符串,在条件表达式中都会被认为是False。有些调用者会因此犯错。

x, y = 0, 5
res = divide(x, y)
if not res:
print('Invalid inputs')
# Invalid inputs

推荐的做法是不返回None,而是把异常抛给上一级,使得调用者必须应对它。如下面,把ZeroDivisionError转换成ValueError,用以表示调用者所给的输入值是无效的。

def divide(a, b):
try:
return a / b
except ZeroDivisionError as e:
raise ValueError("Invalid inputs") from e

这样调用者就需要处理因输入值无效而引发的异常,且这种异常行为应该写入开发文档。这样写出的异常处理代码,也比较清晰。

x, y = 0, 5
try:
res = divide(x, y)
except ValueError:
print("Invalid inputs")
else:
print(res)
# 0.0

15. 了解如何在闭包内使用外围作用域中的变量

关于Python中的函数,有两个知识点:闭包(closure):闭包是一种定义在某个作用域中的函数,这种函数引用了那个作用域里面的变量。

Python的函数是一级对象(first-class object),也就是说,我们可以直接饮用函数、把函数赋给变量、把函数当成参数传给其他函数,并通过表达式、if语句对其进行比较和判断等等。

我们看这样一个问题,对一份列表里的数字进行排序,但是排序时需要把出现在某个群组内的数字放在群组外的那些数字之前。

def sort_priority(values, group):
def helper(x):
if x in group:
return 0, x
return 1, x
values.sort(key=helper)
nums = [1, 4, 2, 8, 3, 0]
group = [4, 3, 8]
sort_priority(nums, group)
print(nums)
# [3, 4, 8, 0, 1, 2]

上面的函数可以正常运行。如果我们想要加入一个功能,让函数返回一个值,表示是否出现了优先级较高的元素(即在group内的元素)。那么可以回这么做

def sort_priority(values, group):
flag = False
def helper(x):
if x in group:
flag = True
return 0, x
return 1, x
values.sort(key=helper)
return flag
nums = [1, 4, 2, 8, 3, 0]
group = [4, 3, 8]
flag = sort_priority(nums, group)
print(nums) # [3, 4, 8, 0, 1, 2]
print(flag) # False

可以看到,虽然排序的结果是对的,但是flag错了,nums中有数字在group中,但是flag是False。原因在于,helper里面的flag是定义在helper里的新变量,因此赋值的时候不改变sort_priority中的那个变量。Python这么设计是为了防止函数中的局部变量污染函数外面的那个模块。

那怎么获取闭包中的数据呢?可以使用nonlocal语句,下面的代码就符合我们的要求了。

def sort_priority(values, group):
flag = False
def helper(x):
nonlocal flag
if x in group:
flag = True
return 0, x
return 1, x
values.sort(key=helper)
return flag
nums = [1, 4, 2, 8, 3, 0]
group = [4, 3, 8]
flag = sort_priority(nums, group)
print(nums) # [3, 4, 8, 0, 1, 2]
print(flag) # True

需要注意的是,Python2不支持nonlocal。可以使用一个列表来表示,比如flag = [False],然后在helper里直接改变flag[0]即可。上级作用域中的变量是字典,集合或某个类的实例时,同样可以这么做。

16. 考虑用生成器来改写直接返回列表的函数

如果我们要查找字符串中每个词的首字母在整个字符串里的位置。下面是直接返回列表的方法。

def index_words(text):
result = []
if text:
result.append(0)
for index, letter in enumerate(text):
if letter == " ":
result.append(index+1)
return result

上面这段代码的主要问题是,在返回前需要将所有的结果都放到列表里。如果输入量很大,那么可能内存不足。另外,这段代码过于拥挤,函数首尾需要创建和返回列表,中间要一个一个插入到列表中。

可以使用生成器(generator)优化上面代码。生成器是使用yield表达式的函数,调用生成器时,它不会真的运行,而是会返回一个迭代器。每次在迭代器上调用内置的next函数,迭代器就会把生成器推进到下一个yield表达式那里。因此,生成器版本的函数,可以应对任意长度的输入数据。

def index_words(text):
if text:
yield 0
for index, letter in enumerate(text):
if letter == " ":
yield index + 1

当然,我们可以使用list()函数把迭代器转换为列表。

还有需要注意的是,函数返回的那个迭代器是有状态的,调用者不应该反复使用它。

17. 在参数上面迭代时,要多加小心

上一小节提到,函数返回的迭代器是有状态的,调用者不应该反复使用它。因此,函数在输入的参数上面多次迭代时要注意:如要参数是迭代器,那么可能会出现问题。如下面这段代码:

def index_words(text):
if text:
yield 0
for index, letter in enumerate(text):
if letter == " ":
yield index + 1
words = "Hello world! I like coding!"
index = index_words(words)
print(list(index)) # [0, 6, 13, 15, 20]
print(list(index)) # []

当然可以使用列表来存储数据并返回,但是这个缺点上个小节已经提过了,对内存占用太大,不利于处理长大数据。

比较好的做法是新建一个实现迭代器协议(iterator protocol)的容器类。其实很简单,就是在类里面把__iter__方法实现为生成器。

class MyClass():
def __init__(self, words):
self.words = words
def __iter__(self):
if self.words:
yield 0
for index, letter in enumerate(self.words):
if letter == " ":
yield index + 1
words = "Hello world! I like coding!"
index = MyClass(words)
print(list(index)) # [0, 6, 13, 15, 20]
print(list(index)) # [0, 6, 13, 15, 20]

想判断某个值是迭代器还是容器,可以拿该值为参数,两次调用iter函数,若结果相同,则是迭代器,调用内置的next函数,即可令该迭代器前进一步。

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

在def语句中使用*args,即可令函数接受数量可变的位置参数。

调用函数时,可以采用*操作符,把序列中的元素当成位置参数,传给该函数。

def func(args1, *args2):
print(args1)
for x in args2:
print(x)
return
func(1, 2)
print("********")
func(1)
print("********")
func(1, [2,3,4])
print("********")
func(1, *[2,3,4])
输出结果
1
2
********
1
********
1
[2, 3, 4]
********
1
2
3
4

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

it = my_generator()

a = 1

func(a, *it)在已经接受*args参数的函数上面继续添加位置参数,参数的对应关系可能变,从而导致难以排查的bug。

19. 用关键字参数来表达可选的行为

函数参数可以按位置或关键字来指定。只使用位置参数来调用函数,可能会导致这些参数的含义不够明确,而关键字参数则可以清楚表示每个参数的意思。比如除法函数,如果用位置参数的话,不知道哪个是除数,哪个是被除数。

def divide(number, divisor):
return number / divisor
divide(1, 5)
divide(number=1, divisor=5) # 推荐

给函数添加新的行为时,可以使用带默认值的关键参数,以便与原有的函数调用代码保持兼容。因为新加的带默认值参数在调用时可以不写,属于可选的关键字参数。可选的关键字参数,总是应该以关键字的形式来指定,而不应该以位置参数的形式来指定。

def divide(number, divisor, is_integer=False):
if is_integer:
return int(number / divisor)
return number / divisor
divide(number=1, divisor=5) # 原来的调用方式不用变
divide(number=1, divisor=5, is_integer=True)
divide(1,5,True) # 不推荐
divide(number=1, divisor=5, True) # 报错,位置参数不能放关键字参数后面

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

如下面所示,我们本意是想让time是一个动态的默认值,但是两次调用结果是一样的。参数的默认值,会在每个模块加载进来的时候求出,而很多模块都是在程序启动时加载的。包含这段代码的模块一旦加载进来,参数的默认值就固定不变了,程序不会再执行time.time()了。

import time
def get_time(curr_time = time.time()):
print(curr_time)
get_time() # 1569638268.920848
time.sleep(0.1)
get_time() # 1569638268.920848

Python中想要实现动态的默认值,习惯上是把默认值设置为None,并在文档字符串中把None所对应的实际行为描述出来。

import time
def get_time(curr_time = None):
""":param curr_time: current time"""
print(time.time() if curr_time is None else curr_time)
get_time() # 1569638780.449208
time.sleep(0.1)
get_time() # 1569638780.554341

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

按关键字传递参数,是非常明确的调用函数的方式。但是常常这些参数也可以按位置传递,有些调用者为了偷懒,可能就不用关键字的方式调用了,比如19小节里的除法函数。有没有一种办法让调用者只能以关键字的形式调用函数呢?

有!那就是在参数列表中加入*,它标志着位置参数就此终结,之后那些参数,只能以关键字的形式指定,或者不指定使用默认值。如下面的代码,args3和args4都不能以位置参数的形式指定。

def func(args1, args2, *, args3=None, args4=None):

return

参考资料:

《Effective Python》