《Effective Python》Chapter2总结

Date:
Categories: python
Author: sysublackbear
Tags:

继续,Chapter2的梳理如下:

Chapter2.函数

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

要点:

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

举个例子:

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

这里会产生一个错觉,我们可能不会专门去判断函数的返回值是否为None,而是会假定:只要返回了与False 等效的运算结果,就说明函数出错了。

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

所以,选用以下这种方法更好一些,那就是根本不返回None,而是把异常抛给上一级,使得调用者必须应对它。本例中,把ZeroDivisionError 转化为 ValueError,用以表示调用者所给的输入值是无效的:

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

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

​ 假如有一份列表,其中的元素都是数字,现在要对其排序,但排序时,要把出现在某个群组内的数字,放在群组外的那些数字之前。

​ 实现该功能的一种常见做法,是在调用列表的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}  # set([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 的函数是一级对象,也就是说,我们可以直接引用函数,把函数赋值给变量,把函数当成参数传给其他函数,并通过表达式及if 语句对其进行比较和判断,等等。于是,我们可以把helper 这个闭包函数,传给sort方法的key 参数。
  • Python 使用特殊的规则来比较两个元组。它首先比较各元组中下标为0 的对应元素,如果相等,再比较下标为1 的对应元素,如果还是相等,那就继续比较下标为2 的对应元素,依次类推。

看下面修改的第二种写法:

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

found = sort_priority2(numbers, group)

但是下面有个问题,排序结果是对的,但是found 值不对,原因是:

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

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

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

因此上面的found变量会在两个区域都会建立一个。

其实,Python 语言是故意要这么设计的。这样做可以防止函数中的局部变量污染函数外面的那个模块。假如不这么做,那么函数里的每个赋值操作,都会影响外围模块的全局作用域。那样不仅显得混乱,而且由于全局变量还会与其他代码产生交互作用,所以可能引发难以探查的bug。

那么,该如何获取闭包内的数据呢?

  1. Python3 中有一种特殊的写法,能够获取闭包内的数据。我们可以用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也会像全局变量那样,遭到滥用,所以,建议大家只在极其简单的函数里使用这种机制。nonlocal的副作用很难追踪,尤其是在比较长的的函数中,修饰某变量的nonlocal语句可能和修改该变量的赋值操作离得比较远,从而导致代码更加难以理解。

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

class Sorter(object):
    def __init__(self):
        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
  1. 但是对于Python 2 而言,Python 2 不支持nonlocal关键字。为了实现类似的功能,需要利用Python 的作用域规则来解决。这种做法虽然不太优雅,但已经成为Python 的一种编程习惯。
# Python 2
def sort_priority(numbers, group):
    found = [False]
    def helper(x):
        if x in group:
            found[0] = True
            return (0 ,x)
        return (1, x)
    numbers.sort(key=helper)
    return found[0]   

​ 上级作用域中的found变量是个列表,由于列表本身是可供修改(mutable,可变的),所以获取到这个found列表后,我们就可以在闭包里面通过found[0]=True语句,来修改found的状态。这就是该技巧的原理。

​ 上级作用域中的变量是字典,集或某个类的实例时,这个技巧也同样适用。

要点:

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

第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

这里有个问题是,这段代码写得有点拥挤。每次找到新的结果,都要调用append方法。在函数主体部分的约130个字符里,重要的大概只有75个。

这个函数改用生成器来写会更好。生成器是使用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 = list(index_words_iter(address))

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

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)
    results = islice(it, 0 , 3)
    print(list(results))

>>> [0, 5, 13]

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

要点:

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

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

举下面的例子,需要统计一个序列中每个元素占总和的百分比,为此所编写的标准化函数如下:

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

使用方法如下:

visits = [15, 35, 80]
percentages = normalize(visits)
print(percentages)
>>>
[11, 26, 61]

为了扩大函数的使用范围,我们把数据写到文件里面,然后从该文件读取数据,我们定义了生成器函数来实现此功能,以便稍后把该函数重用到更为庞大的数据集上面。

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(visits)
print(percentages)
>>>
[]

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

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

为了解决此问题,我们可以明确地使用该迭代器制作一份列表,将它的全部内容都遍历一次,并复制到这份列表里,然后,就可以在复制出来的数据列表上面多次迭代了。

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

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

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_fuc函数的时候,可以传入lambda表达式,该表达式会调用生成器,以便每次都能产生新的迭代器。

percentages = normalize_func(lambda: read_visits(path))

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

实际上,只需要令自己的类把__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)

用法如下:

visits = ReadVisits(path)
percentages = normalize(visits)
print(percentages)

明白了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

要点:

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

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

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

例子如下:

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

使用方法有好几种:

log('My numbers are', 1, 2)
log('Hi there')
# 如果要把已有的列表,传给像log 这样带有变长参数的函数,那么调用的时候,可以给列表前面加上*操作符。这样Python 就会把这个列表里的元素视为位置参数。
favorites = [7, 33, 99]
log('Favorites colors', *favorites)

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

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

def my_func(*args):
    print(args)

it = my_generator()
my_func(*it)

要点:

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

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

例子:

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

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

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

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

要点:

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

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

有时我们想要采用一种非静态的类型,来做关键字参数的默认值。例如,在打印日志消息的时候,要把相关事件的记录时间也标注在这条消息中。但是如果这样写会有问题:

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

log('Hi there!')
sleep(0.1)
log('Hi again!')
>>>
2016-11-16 01:47:10.371432: Hi there!
2016-11-16 01:47:10.371432: Hi again!

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

​ 在Python 中若想正确实现动态默认值,习惯上是把默认值设为None ,并在文档字符串里面把None 所对应的实际行为描述出来。编写函数代码时,如果发现该参数的值为None ,那就将其设为实际的默认值。

def log(message, when=None):
    """Log a message with a timestamp

    Args:
        message: Message to print.
        when: datetime of when the message occured.
            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!')
>>>
2016-11-17 12:34:00.472303: Hi there!
2016-11-17 12:34:00.572303: Hi again!

字典的例子:

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

修改成:

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

要点:

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

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

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

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

def safe_division_b(number, 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)

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

在Python3 的例子如下:

def safe_division_c(number, divisor, *, 
                    ignore_overflow=Flase, 
                    ignore_zero_division=False):

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

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

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

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

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

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

print_args(1, 2, foo='bar', stuff='meep')

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

# Python 2
def safe_division_d(number, divisor, **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)
    # ...

要点:

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