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

第 2 章 函数(上)

第 14 条:尽量用异常来表示特殊情况,而不要返回 None

编写工具函数(utility function)时,Python 程序员喜欢给 None 这个返回值赋予特殊意义。这么做有时是合理的。例如,要编写辅助函数,计算两数相除的商。在除数为 0 的情况下,计算结果是没有明确含义的(undefined,未定义的),所以似乎应该返回 None。

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

此函数的调用者,可以对这种特殊的返回值做相应的解读。

result = divide(x, y)
if result is None:
    print('Invalid inputs')

分子若是 0,会怎么样呢?在那种情况下,如果分母非零,那么计算结果就是 0。当在 if 等条件语句中拿这个计算结果做判断时,会出现问题。我们可能不会专门去判断函数的返回值是否为 None,而是会假定:只要返回了与 False 等效的运算结果,就说明函数出错了。

x, y = 0, 5
result = divide(x, y)
if not result:
    print('Invalid inputs')  # This is wrong!

如果 None 这个返回值,对函数有特殊意义,那么在编写 Python 代码来调用该函数时,就很容易犯上面这种错误。由此可见,令函数返回 None,可能会使调用它的人写出错误的代码。有两种办法可以减少这种错误。

第一种办法,是把返回值拆成两部分,并放到二元组(two-tuple)里面。二元组的首个元素,表示操作是否成功,接下来的那个元素,才是真正的运算结果。

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

该调用函数的人需要解析这个元组。这就迫使他们必须根据元组中表示运算状态的那个元素来做判断,而不能像从前那样,直接根据相除的结果做判断。

success, result = divide(x, y)
if not success:
    print('Invalid inputs')

问题在于,调用者可以通过以下划线为名称的变量,轻易跳过元组的第一部分(Python 程序员习惯用这种写法来表示用不到的变量)。这样写出来的代码,看上去似乎没错,但实际上,却和直接返回 None 的那种情况有着相同的错误。

_, result = divide(x, y)
if not result:
    print('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 = 5, 2
try:
    result = divide(x, y)
except ValueError:
    print('Invalid inputs')
else:
    print('Result is %.1f' % result)
>>>
Result is 2.5

总结

  • 用 None 这个返回值来表示特殊意义的函数,很容易使调用者犯错,因为 None 和 0 及空字符串之类的值,在条件表达式里都会评估无 False。
  • 函数在遇到特殊情况时,应该抛出异常,而不要返回 None。调用者看到该函数的文档中所描述的异常之后,应该就会编写相应的代码来处理它们了。

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

假如有一份列表,其中的元素都是数字,现在要对其排序,但排序时,要把出现在某个群组内的数字,放在群组外的那些数字之前。这种用法在绘制用户界面时候可能会遇到,可以用这个办法把重要的消息或意外的事件优先显示在其他内容前面。

实现该功能的一种常见做法,是在调用列表的 sort 方法时,把辅助函数传给 key 参数。这个辅助函数的返回值,将会用来确定列表中各元素的顺序。辅助函数可以判断受测元素是否处在重要群组中,并据此返回相应的排序关键字(sort key)。

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 函数之所以能够访问 sort_priority 的 group 参数,原因就在于它是闭包。
  • Python 的函数是一级对象(first-class object),也就是说,可以直接引用函数、把函数赋给变量、把函数当成参数传给其他函数,并通过表达式及 if 语句对其进行比较和判断,等等。于是,可以把 helper 这个闭包函数,传给 sort 方法的 key 参数。
  • Python 使用特殊的规则来比较两个元组。它首先比较各元组中下标为 0 的对应元素,如果相等,再比较下标为 1 的对应元素,如果还想等,那就继续比较下标为 2 的对应元素,依次类推。

这个 sort_priority 函数如果能够改进一下,就更好了,它应该返回一个值,用来表示用户界面里是否出现了优先级较高的元件,使得该函数的调用者,可以根据这个返回值做相应的处理。添加这样的功能,看似非常简单。既然该函数里的闭包函数,能够判断受测数字是否处在群组内,那么不妨在发现优先级较高的元件时,从闭包函数中翻转某个标志变量,然后令 sort_priority 函数把经过闭包修改的那个标志变量,返回给调用者。

我们先试试下面这种简单的写法:

def sort_priority2(numbers, group):
    found = False
    def helper(x):
        if x in group:
            found = True  # Seem 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 值不对。numbers 里面的某些数字确实包含在 group 中,可以函数却返回了 False。这是为什么呢?

在表达式中引用变量时,Python 解释器将按如下顺序遍历各作用域,以解析给引用:

  1. 当前函数的作用域。
  2. 任何外围作用域(例如,包含当前函数的其他函数)。
  3. 包含当前代码的那个模块的作用域(也叫全局作用域,global scope)。
  4. 内置作用域(也就是包含 len 及str 等函数的那个作用域)。

如果上面这些地方都没有定义过名称相符的变量,那就抛出 NameError 异常。

给变量赋值时,规则有所不同。如果当前作用域内已经定义了这个变量,那么该变量就会具备新值。若是当前作用域内没有这个变量,Python 则会把这次赋值视为对该变量的定义。而新定义的这个变量,其作用域就是包含赋值操作的这个函数。

上面所说的这种赋值行为,可以解释 sort_priority2 函数的返回值错误的原因。将 found 变量赋值为 True,是在 helper 闭包里进行的。于是,闭包中的这次赋值操作,就相当于在 helper 内定义了名为 found 的新变量,而不是给 sort_priority2 中的那个found 赋值。

def sort_priority2(numbers, group):
    found = False  # Scope: 'sort_priority2'
    def helper(x):
        if x in group:
            found = True  # Scope: 'helper'  -- Bad!
            return (0, x)
        return (1, x)
    numbers.sort(key=helper)
    return found

这种问题有时称为作用域 bug(scoping bug),它可能会使 Python 新手感到困惑。其实,Python 语音是故意要这么设计的。这样做可以防止函数中的局部变量污染函数外面的那个模块。假如不这么做,那么函数来里的每个赋值操作,都会影响外围模块的全局作用域。那样不仅显得混乱,而且由于全局变量还会与其他代码产生交互作用,所以可能引发难以探查的 bug。

1.获取闭包内的数据

Python 3 中有一种特殊的写法,能够获取闭包内的数据。可以用 nonlocal 语句来表明这样的意图,也就是:给相关变量赋值的时候,应该在上层作用域中查找该变量。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 found

nonlocal 语句清楚地表明:如果在闭包内给该变量赋值,那么修改的其实是闭包外那个作用域中的变量。这与 global 语句互为补充,global 用来表示对该变量的赋值操作,将会直接修改模块作用域里的那个变量。

然而,nonlocal 也会像全局变量那样,遭到滥用,所以建议只在极其简单的函数里使用这种机制。nonlocal 的副作用很难追踪,尤其是在比较长的函数中,修饰某变量的 nonlocal 语句可能和修改该变量的赋值操作离得比较远,从而导致代码更加难以理解。

如果使用 nonlocal 的那些代码,已经写得越来越复杂,那就应该将相关的状态封装成辅助类(helper class)。下面定义饿这个类,与 nonlocal 所达成的功能相同。它虽然有点长,但是理解起来相当容易(其中有个名叫 __call__ 的特殊方法)。

class Sorter(object):
    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)
sorter = Sorter(group)
numbers.sort(key=sorter)
assert sorter.found is True

2.Python 2 中的值

不幸的是,Python 2 不支持 nonlocal 关键字。为了实现类似的功能,需要利用 Python 的作用域规则来解决。

总结

  • 对于定义在某作用域内的闭包来说,它可以引用这些作用域的变量。
  • 使用默认方式对闭包内的变量赋值,不会影响外围作用域中的同名变量。
  • 在 Python 3 中,程序可以在闭包内用 nonlocal 语句来修饰某个名称,使该闭包能够修改外围作用域中的同名变量。
  • 在 Python 2 中,程序可以使用可变值(例如,包含单个元素的列表)来实现与 nonlocal 语句相仿的机制。
  • 除了那种比较简单的函数,尽量不要用 nonlocal 语句。

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

如果函数要产生一系列结果,那么最简单的做法就是把这些结果都放在一份列表里,并将其返回给调用者。例如,要查出字符串中每个词的首字母,在整个字符串里的位置。下面这段代码,用 append 方法将这些词的首字母索引添加到 result 列表中,并在函数结束时将其返回给调用者。

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

输入一些范例值,以验证该函数能够正常运作:

address = 'Four score and seven years ago...'
print(result[:3])
>>>
[0, 5, 11]

index_words 函数有两个问题。

第一个问题是,这段代码写得有点拥挤。每次找到新的结果,都要调用 append 方法。但我们真正应该强调的,并不是对 result.append 方法的调用,而是该方法给列表中添加的那个值,也就是 index + 1。另外,函数首尾还各有一行代码用来创建及返回 result 列表。于是,在函数主体部分的约 130 个字符(不计空白字符)里,重要的大概只有 75 个。

这个函数改用生成器(generator)来写会更好。生成器是使用 yield 表达式的函数。调用生成器函数时,它并不会真的运行,而是会返回迭代器。每次在这个迭代器上面调用内置的 next 函数时,迭代器会把生成器推进到下一个 yield 表达式那里。生成器传给 yield 的每一个值,都会由迭代器返回给调用者。

下面的这个生成器函数,会产生和刚才那个函数相同的效果。

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

这个函数不需要包含与 result 列表相交互的那些代码,因而看起来比刚才那种写法清晰许多。原来那个 result 列表中的元素,现在都分别传给 yield 表达式了。调用该生成器后所返回的迭代器,可以传给内置的 list 函数,以将其转换为列表。

result = list(index_words_iter(address))

index_words 的第二个问题是,它在返回前,要先把所有结果都放在列表里面。如果输入量非常大,那么程序就有可能耗尽内存并崩溃。相反,用生成器改写后的版本,则可以应对任意长度的输入数据。

下面定义的这个生成器,会从文件里面依次读入各行内存,然后逐个处理每行中的单词,并产生相应结果。该函数执行时所耗的内存,由单行输入值的最大字符数来界定。

def index_file(handle):
    offset = 0
    for line in handle:
        if line:
            yield offset
        for letter in line:
            offset += 1
            if letter == ' ':
                yield offset

运行这个生成器函数,也能产生和原来相同的结果。

with open('/tmp/address.txt', 'r') as f:
    it = index_file(f)
    result = islice(it, 0, 3)
    print(list(results))
>>>
[0, 5, 11]

定义这种生成器函数的时候,唯一需要留意的就是:函数返回的那个迭代器,是有状态的,调用者不应该反复使用它。

总结

  • 使用生成器比把收集到的结果放入列表里返回给调用者更加清晰。
  • 由于生成器函数返回的那个迭代器,可以把生成器函数体中,传给 yield 表达式的那些值,逐次产生出来。
  • 无论输入量有多大,生成器都能产生一系列输出,因为这些输入量和输出量,都不会影响它在执行时所耗的内存。

第 17 条:在参数上面迭代时,要多加小心

如果函数接受的参数是个对象列表,那么很可能要在这个列表上面多次迭代。例如,要分析去某个省旅游的人数。假设数据集是由每个城市的游客数量构成的(单位是每百万人)。现在要统计来每个城市旅游的人数,占总游客的百分比。

为此,需要编写标准化函数(normalization function)。它会把所有的输入值加总,以求出每年的游客总数。然后,用每个城市的游客数除以总数,以求出该城市所占的比例。

def normalize(numbers):
    total = sum(numbers)
    result = []
    for value in numbers:
        percent = 100 * value / total
        result.append(percent)
    return result

把各城市的游客数量放在一份列表里,传给该函数,可以得到正确结果。

visits = [15, 35, 80]
percentages = normalize(visits)
print(percentages)
>>>
[11.53846, 26.92307, 61.538466]

为了扩大函数的应用范围,现在把这个省每个城市的游客放在一份文件里面,然后从该文件中读取数据。由于这套流程还能够分析全世界的游客数量,所以定义生成器函数来实现此功能,以便稍后把该函数重用到更为庞大的数据集上面。

def read_visits(data_path):
    with open(data_path) as f:
        for line in f:
            yield int(line)

奇怪的是,以生成器所返回的那个迭代器为参数,来调用 normalize,却没有产生任何结果。

it = read_visits('/tmp/my_numbers.txt')
percentages = normalize(it)
print(percentages)
>>>
[]

出现这种情况的原因在于,迭代器只能产生一轮结果。在抛出过 StopIteration 异常的迭代器或生成器上面继续迭代第二轮,是不会有结果的。

it = read_visits('/tmp/my_numbers.txt')
print(list(it))
print(list(it))  # Already exhausted
>>>
[15, 35, 80]
[]

通过上面这段代码,还可以看出一个奇怪的地方,那就是:在已经用完的迭代器上面继续迭代时,居然不会报错。for 循环、list 构造器以及 Python 标准库里的其他许多函数,都认为在正常的操作过程中完全有可能出现 StopIteration 异常,这些函数没办法区分这个迭代器是本来就没有输出值,还是本来有输出值,但现在已经用完了。

为解决此问题,可以明确地使用该迭代器制作一份列表,将它的全部内容都遍历一次,并复制到这份列表里,然后,就可以在复制出来的数据列表上面多次迭代了。下面这个函数的功能,与刚才的 normalize 函数相同,只是它会把包含输入数据的那个迭代器,小心地复制一份:

def normalize_copy(numbers):
    numbers = list(numbers)  # Copy the iterator
    total = sum(numbers)
    result = []
    for value in numbers:
        percent = 100 * value / total
        result.append(percent)
    return result

这次再把调用生成器所返回的迭代器传给 normalize_copy,就能产生正确结果了:

it = read_visits('/tmp/my_numbers.txt')
percentages = normalize_copy(it)
print(percentages)
>>>
[11.53846, 26.92307, 61.53846]

这种写法的问题在于,待复制的那个迭代器,可能含有大量输入数据,从而导致程序在复制迭代器的时候耗尽内存并崩溃。一种解决办法是通过参数来接受另外一个函数,那个函数每次调用后,都能返回新的迭代器。

def normalize_func(get_iter):
    total = sum(get_iter())  # New iterator
    result = []
    for value in get_iter():  # New iterator
        percent = 100 * value / total
        result.append(percent)
    return result

使用 normalize_func 函数的时候,可以传入 lambda 表达式,该表达式会调用生成器,以便每次都能产生新的迭代器。

percentages = normalize_func(lambda: read_visits(path))

这种办法虽然没错,但是像上面这样传递 lambda 函数,毕竟显得生硬。还有个更好的办法,也能达成同样的效果,那就是新编一种实现迭代器协议(iterator protocal)的容器类。

Python 在 for 循环及相关表达式中遍历容器的内容时,就要依靠这个迭代器协议。在执行类似 for x in foo 这样的语句时,Python 实际上会调用 iter(foo)。内置的 iter 函数又会调用 foo.__iter__ 这个特殊方法。该方法必须返回迭代器对象,而那个迭代器本身,则实现了名为 __next__ 的特殊方法。此后,for 循环会在迭代器对象上面反复调用内置的 next 函数,直至其耗尽并产生 StopIteration 异常。

这听起来比较复杂,但实际上,只需要令自己的类把 __iter__ 方法实现为生成器,就能满足上述要求。下面定义一个可以迭代的容器类,用来从文件中读取游客数据:

class ReadVisits(object):
    def __init__(self, data_path):
        self.data_path = data_path

    def __iter__(self):
        with open(self.data_path) as f:
            for line in f:
                yield int(line)

这种新型容器,可以直接传给原来那个 normalize 函数,无需再做修改,即可正常运行。

visits = ReadVisits(path)
percentages = normalize(visits)
print(percentagse)
>>>
[11.53846, 26.92307, 61.53846]

normalize 函数中的 sum 方法会调用 ReadVisits.__iter__,从而得到新的迭代器对象,而调整数值所用的那个 for 循环,也会调用 __iter__,从而得到另外一个新的迭代器对象,由于这两个迭代器会各自前进并走完一整轮,所以它们都可以看到全部的输入数据。这种方式的唯一缺点在于,需要多次读取输入数据。

明白了 ReadVisits 这种容器的工作原理之后,可以修改原来编写的 normalize 函数,以确保调用者传进来的参数,并不是迭代器本身。迭代器协议有这样的约定:如果把迭代器对象传给内置的 iter 函数,那么此函数会把该迭代器返回,反之,如果传给 iter 函数的是个容器类型的对象,那么 iter 函数则每次都会返回新的迭代器对象。于是,可以根据 iter 函数的这种行为来判断输入值是不是迭代器对象本身,如果是,就抛出 TypeError 错误。

def normalize_defensive(numbers):
    if iter(numbers) is iter(numbers):  # An iterator -- bad!
        raise TypeError('Must supply a container')
    total = sum(numbers)
    result = []
    for value in numbers:
        percent = 100 * value / total
        result.append(percent)
    return result

如果不愿意像原来的 normalize_copy 那样,把迭代器中的输入数据完整复制一份,但却想多次迭代这些数据,那么上面这种写法就比较理想。这个函数能够处理 list 和 ReadVisits 这样的输入参数,因为它们都是容器。凡是遵从迭代器协议的容器类型,斗鱼这个函数相兼容。

visits = [15, 35, 80]
normalize_defensive(visits)  # No error
visits = ReadVisits(path)
normalize_defensive(visits)  # No errro

如果输入的参数是迭代器而不是容器,那么此函数就会抛出异常。

it = iter(visits)
normalize_defensive(it)
>>>
TypeError: Must supply a container

总结

  • 函数在输入的参数上面多次迭代时要当心: 如果参数是迭代器,那么可能会导致奇怪的行为并错失某些值。
  • Python 的迭代器协议,描述了容器和迭代器应该如何与 iter 和 next 内置函数、for 循环及相关表达式相互配合。
  • __iter__ 方法实现为生成器,即可定义自己的容器类型。
  • 想判断某个值是迭代器韩式容器,可以拿该值为参数,两次调用 iter 函数,若结果相同,则是迭代器,调用内置的 next 函数,即可令该迭代器前进一步。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值