Effective Python -- 第 1 章 用 Pythonic 方式来思考(下)

第 1 章 用 Pythonic 方式来思考(下)

第 7 条:用列表推导式来取代 map 和 filter

Python 提供了一种精炼的写法,可以根据一份列表来制作另外一份。这种表达式称为 list comprehension(列表推导)。例如,要用列表中每个元素的平方值构建另一份列表。如果采用列表推导来实现,那就同时指定制作新列表时所要迭代的输入序列,以及计算新列表中每个元素的值时所用的表达式。

a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
squares = [x**2 for x in a]
print(squares)
>>>
[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

除非是调用只有一个参数的函数,否则,对于简单的情况来说,列表推导要比内置的 map 函数更清晰。如果使用 map,那就要创建 lambda 函数,以便计算新列表中各个元素的值,这会使代码看起来有些乱。

squares = map(lambda x: x ** 2, a)

列表推导则不像 map 那么复杂,它可以直接过滤原列表中的元素,使得生成的新列表不会包含对应的计算结果。例如,在计算平方值时,只想计算那些可以为 2 所整除的数。如果采用列表推导来做,那么只需在循环后面添加条件表达式即可:

even_squares = [x**2 for x in a if x % 2 == 0]
print(even_squares)
>>>
[4, 16, 36, 64, 100]

把内置的 filter 函数与 map 结合起来,也能达成同样的效果,但是代码会写得非常难懂。

alt = map(lambda x: x**2, filter(lambda x: x % 2 == 0, a))
assert even_squares == list(alt)

字典(dict)与集合(set),也有和列表类似的推导机制。编写算法时,可以通过这些推导机制来创建衍生的数据结构。

chile_ranks = {'ghost': 1, 'habanero': 2, 'cayenne': 3}
rank_dict = {rank: name for name, rank in chile_rank.items()}
chile_len_set = {len(name) for name in rank_dict.values()}
print(rank_dict)
print(chile_len_set)
>>>
{1: 'ghost', 2: 'habanero', 3: 'cayenne'}
{8, 5, 7}

总结

  • 列表推导要比内置的 map 和 filter 函数清晰,因为它无需额外编写 lambda 表达式。
  • 列表推导可以跳过输入列表中的某些元素,如果改用 map 来做,那就必须辅以 filter 方能实现。
  • 字典与集合也支持推导表达式。

第 8 条:不要使用含有两个以上表达式的列表推导

除了基本的用法之外,列表推导也支持多重循环。例如,要把矩阵(二维列表)简化成一维列表,使原来的每个单元格都成为新列表中的普通元素。这个功能采用包含两个 for 表达式的列表推导即可实现,这些 for 表达式会按照从左至右的顺序来评估。

matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [x for row in matrix for x in row]
>>>
[1, 2, 3, 4, 5, 6, 7, 8, 9]

上面这个例子简单易懂,这就是多重循序的合理用法。还有一种包含多重循环的合理用法,那就是根据输入列表来创建有两层深度的新列表。例如,要对二维矩阵中的每个单元格取平方,然后用这些平方值构建新的矩阵。由于要多使用一对中括号,所以实现该功能的代码会比上例稍微复杂一点,但是依然不难理解。

squared = [[x**2 for x in row] for row in matrix]
print(squared)
>>>
[[1, 4, 9], [16, 25, 36], [49, 64, 81]]

如果表达式里还有一层循环,那么列表推导就会变得很长,这时必须把它分成多行来写,才能看得清楚一些。

my_lists = [
    [[1, 2, 3], [4, 5, 6]],
    # ...
]
flat = [x for sublist1 in my_lists
        for sublist2 in sublist1
        for x in sublist2]

可以看出,此时的列表推导并没有比普通写法更加简洁。于是,改用普通的循环语句来实现相同的结果。由于循环语句带有适当的缩进,所以看上去要比列表推导更清晰。

flat = []
for sublist1 in my_lists:
    for sublist2 in sublist1:
        flat.extend(sublist2)

列表推导也支持多个 if 条件。处在同一循环级别中的多项条件,彼此之间默认形成 and 表达式。例如,要从数字列表中选出大于 4 的偶数,那么下面这种列表推导方式是等效的。

a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
b = [x for x in a if x > 4 if x % 2 == 0]
c = [x for x in a if x > 4 and x % 2 == 0]

每一级循环的 for 表达式后面都可以指定条件。例如,要从原矩阵中把那些本身能为 3 所整除,且其所在行的各元素之和又大于等于 10 的单元格挑出来。我们只需编写很简短的代码,就可以用列表推导来实现此功能,但是,这样的代码非常难懂。

matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
filtered = [[x for x in row if x % 3 ==0]
            for row in matrix if sum(row) >= 10]
print(filtered)
>>>
[[6], [9]]

尽管这个例子稍微有点复杂,但在实际编程中,确实会出现这种看上去似乎合适用列表推导来实现的情况。强烈建议尽量不要编写这种包含复杂式子的列表推导。这样会使其他人很难理解这段代码。

在列表推导中,最好不要使用两个以上的表达式。可以使用两个条件、两个循环或一个条件搭配一个循环。如果要写的代码比这还复杂,那就应该使用普通的 if 和 for 语句,并编写辅助函数。

总结

  • 列表推导支持多级循环,每一级循环也支持多项条件。
  • 超过两个表达式的列表推导是很难理解的,应该尽量避免。

第 9 条:用生成器表达式来改写数据量较大的列表推导

列表推导的缺点是:在推导过程中,对于输入序列中的每个值来说,可能都要创建仅含一项元素的全新列表。当输入的数据比较少时,不会出问题,但如果输入的数据非常多,那么可能会消耗大量内存,并导致程序崩溃。

例如,要读取一份文件并返回每行的字符数。若采用列表推导来做,则需把文件每一行的长度都保存在内存中。如果这个文件特别大,或是通过无休止的 network socket(网络套接字)来读取,那么这种列表推导就会出问题。下面的这段列表推导代码,只适合处理少量的输入值。

value = [len(x) for x in open('/tmp/my_file.txt')]
print(value)
>>>
[100, 57, 15, 1, 12, 75, 5, 86, 89, 11]

为了解决此问题,Python 提供了生成器表达式(generator expression),它是对列表推导和生成器的一种泛华(generalization)。生成器表达式在运行的时候,并不会把整个输出序列都呈现出来,而是会估值为迭代器(iterator),这个迭代器每次可以根据生成器表达式产生一项数据。

把实现列表推导所用的那种写法放在一对圆括号中,就构成了生成器表达式。下面给出的生成器表达式与刚才的代码等效。二者的区别在于,对生成器表达式求值的时候,它会立刻返回一个迭代器,而不会深入处理文件中的内容。

it = (len(x) for x in open('/tmp/my_file.txt'))
print(it)
>>>
<generator object <genexpr> at 0x101b81480>

以刚才返回的那个迭代器为参数,逐次调用内置的 next 函数,即可使其按照生成器表达式来输出下一个值。可以根据自己的需要,多次命令迭代器根据生成器表达式来生成新值,而不用担心内存用量激增。

print(next(it))
print(next(it))
>>>
100
57

使用生成器表达式还有个好处,就是可以互相组合。下面这行代码会把刚才那个生成器表达式所返回的迭代器用作另外一个生成器表达式的输入值。

roots = ((x, x**0.5) for x in it)

外围的迭代器每次前进时,都会推动内部那个迭代器,这就产生了连锁效应,使得执行循环、评估条件表达式、对接输入和输出等逻辑都组合在了一起。

print(next(roots))
>>>
(15, 3.872983346207417)

上面这种连锁生成器表达式,可以迅速在 Python 中执行。如果要把多种手法组合起来,以操作大批量的输入数据,那最好是用生成器表达式来实现。只要是注意:由生成器表达式所返回的那个迭代器是有状态的,用过一轮之后,就不要反复使用了。

总结

  • 当输入的数据量较大时,列表推导可能会因为占用太多内存而出问题。
  • 由生成器表达式所返回的迭代器,可以逐次产生输出值,从而避免了内存用量问题。
  • 把某个生成器表达式所返回的迭代器,放在另一个生成器表达式的 for 子表达式中,即可将二者组合起来。
  • 串在一起的生成器表达式执行速度很快。

第 10 条:尽量用 enumerate 取代 range

在一系列整数上面迭代时,内置的 range 函数很有用。

random_bits = 0
for i in range(64):
    if randint(0, 1):
        random_bits |= 1 << i

对于字符串列表这样的序列数据结构,可以直接在上面迭代。

flavor_list = ['vanilla', 'chocolate', 'pecan', 'strawberry']
for flavor in flavor_list:
    print('%s is delicious' % flavor)

当迭代列表的时候,通常还想知道当前元素在列表中的索引。例如,要按照喜好程度打印出爱吃的冰淇淋口味。一种办法是用 range 来做。

for i in range(len(flavor_list)):
    flavor = flavor_list[i]
    print('%d: %s' % (i + 1, flavor))

与单纯迭代 flavor_list 或是单纯使用 range 的代码相比,上面这段代码有些生硬。必须获取列表长度,并且通过下标来访问数组。这种代码不便于理解。

Python 提供了内置的 enumerate 函数,以解决此问题。enumerate 可以把各种迭代器包装为生成器,以便稍后产生输出值。生成器每次产生一对输出值,其中,前者表示循环下标,后者表示从迭代器中获取到的下一个序列元素。这样写出来的代码会非常简洁。

for i, flavor in enumerate(flavor_list):
    print('%d: %s' % (i + 1, flavor))
>>>
1: vanilla
2: chocolate
3: pecan
4: strawberry

还可以直接指定 enumerate 函数开始计数时所用的值,这样能把代码写得更短。

for i, flavor in enumerate(flavor_list, 1):
    print('%d: %s' % (i, flavor))

总结

  • enumerate 函数提供了一种精简的写法,可以在遍历迭代器时获知每个元素的索引。
  • 尽量用 enumerate 来改写那种 range 与下标访问相结合的序列遍历代码。
  • 可以给 enumerate 提供第二个参数,以指定开始计数时所用的值(默认为 0)。

第 11 条:用 zip 函数同时遍历两个迭代器

在编写 Python 代码时,通常要面对很多列表,而这些列表里的对象,可能也是互相关联的。通过列表推导,很容易就能根据某个表达式从源列表推算出一份派生类表。

name = ['Cecilia', 'Lise', 'Marie']
letters = [len(n) for n in names]

对于本例中的派生列表和源列表来说,相同索引处的两个元素之间有着关联。如果想平行地迭代这两份列表,那么可以根据 names 源列表的长度来执行循环。

longest_name = None
max_letters = 0
for i in range(len(names)):
    count = letters[i]
    if count > max_letters:
        longest_name = names[i]
        max_letters = count

print(longest_name)
>>>
Cecilia

什么这段代码的问题在于,整个循环语句看上去很乱。用下标来访问 names 和 letters 会使代码不易阅读。用循环下标 i 来访问数组的写法一共出现了两次。改用 enumerate 来做可以稍稍缓解这个问题,但仍然不够理想。

for i, name in enumerate(names):
    count = letters[i]
    if count > max_letters:
        longest_name = name
        max_letters = count

使用 Python 内置的 zip 函数,能够令上述代码变得更为简洁。在 Python 3 中的 zip 函数,可以把两个或两个以上的迭代器封装为生成器,以便稍后求值。这种 zip 生成器会从每个迭代器中获取该迭代器的下一个值,然后把这些值汇聚成元组(tuple)。与通过下标来访问多份列表的那种写法相比,这种用 zip 写出来的代码更加清晰。

for name, count in zip(names, letters):
    if count > max_letters:
        longest_name = name
        max_letters = count

内置的 zip 函数有两个问题。

第一个问题是,Python 2 中的 zip 并不是生成器,而是会把开发者所提供的那些迭代器,都平行地遍历一遍,在此过程中,它都会把那些迭代器所产生的值汇聚成元组,并把那些元组所构成的列表完整地返回给调用者。这可能会占用大量内存并导致程序崩溃。如果要在 Python 2 里用 zip 来遍历数据量非常大的迭代器,那么应该使用 itertools 内置模块中的 izip 函数。

第二个问题是,如果输入的迭代器长度不同,那么 zip 会表现出奇怪的行为。例如,我们又给 names 里添加了一个名字,但却忘了把这个名字的字母数量更新到 letters 之中。现在,如果用 zip 同时遍历这两份列表,那就会产生意外的结果。

names.append('Rosalind')
for name, count in zip(names, letters):
    print(name)
>>>
Cecilia
Lise
Marie

新元素 ‘Rosalind’ 并没有出现在遍历结果中。这正是 zip 的运作方式。受封装的那些迭代器中,只要有一个耗尽,zip 就不再产生元组了。如果待遍历的迭代器长度都相同,那么这种运作方式不会出问题,由列表推导所推算出派生列表一般都和源列表等长。如果待遍历的迭代器长度不同,那么 zip 会提取终止,这将会导致意外的结果。若不能确定 zip 所封装的列表是否等长,则可考虑改用 itertools 内置模块中的 zip_longest 函数(此函数在 Python 2 里叫做 izip_longest)。

总结

  • 内置的 zip 函数可以平行地遍历多个迭代器。
  • Python 3 中的 zip 相当于生成器,会在遍历过程中逐次产生元组,而 Python 2 中的 zip 则是直接把这些元组完全生成好,并一次性地返回整份列表。
  • 如果提供的迭代器长度不等,那么 zip 就会自动提前终止。
  • itertools 内置模块中的 zip_longest 函数可以平行地遍历多个迭代器,而不用在乎它们的长度是否相等。

第 12 条:不要在 for 和 while 循环后面写 else 块

Python 提供了一种很多编程语音都不支持的功能,那就是可以在循环内部的语句块后面直接编写 else 块。

for i in range(3):
    print('Loop %d' % i)
else:
    print('Else block!')
>>>
Loop 0
Loop 1
Loop 2
Else block!

奇怪的是,这种 else 块会在整个循环执行完之后立刻运行。既然如此,那它为什么叫做 else 呢?为什么不叫 and?在 if/else 语句中,else 的意思是:如果不执行前面那个 if 块,那就执行 else 块。在 try/except 语句中,except 的定义也类似:如果前面那个 try 块没有成功执行,那就执行 except 块。

同理,try/except/else 也是如此,该结构的 else 的含义是:如果前面的 try 块没有失败,那就执行 else 块。try/finally 同样非常直观,这里的 finally 的意思是:执行过前面的 try 块之后,总是执行 finally 块。

明白了 else、except 和 finally 的含义后,刚接触 Python 的程序员可能会把 for/else 结构中的 else 块理解为:如果循环没有正常执行完,那就执行 else 块。实际上刚好相反–在循环里用 break 语句提前跳出,会导致程序不执行 else 块。

for i in range(3):
    print('Loop %d' % i)
    if i == 1:
        break
else:
    print('Else block!')
>>>
Loop 0
Loop 1

还有一个奇怪的地方:如果 for 循环要遍历的序列是空的,那么就会立刻执行 else 块。

for x in []:
    print('Never run')
else:
    print('For Else block!')
>>>
For Else block!

初始循环条件为 false 的 while 循环,如果后面跟着 else 块,那它也会立刻执行。

while False:
    print('Never runs')
else:
    print('While Else block!')
>>>
While Else block!

知道了循环后面的 else 块所表现出的行为之后,会发现:在搜索某个事物的时候,这种写法是有意义的。例如,要判断两个数是否互质,可以把有可能成为公约数的每个值都遍历一轮,逐个判断两数是否能以该值为公约数。尝试完每一种可能的值之后,循环就结束了。如果两个数确实互质,那么在执行循环的过程中,程序就不会因 break 语句而跳出,于是,执行完循环后,程序会紧接着执行 else 块。

a = 4
b = 9
for i in range(2, min(a, b) + 1):
    print('Testing', i)
    if a % i == 0 and b % i == 0:
        print('Not coprime')
        break
else:
    print('Coprime')
>>>
Testing 2
Testing 3
Coprime

实际上,一般不会这样写代码,而是会用辅助函数来完成计算。这样的辅助函数,有两种常见的写法。

第一种写法是,只要发现受测参数符合自己想要搜寻的条件,就尽早返回。如果整个循环都完整地执行了一遍,那就说明受测参数不符合条件,于是返回默认值。

def coprime(a, b):
    for i in range(2, min(a, b) + 1):
        if a % i == 0 and b % i == 0:
            return False
    return True

第二种写法是,用变量来记录受测参数是否符合自己想要搜寻的条件。一旦符合,就用 break 跳出循环。

def coprime2(a, b):
    is_coprime = True
    for i in range(2, min(a, b) + 1):
        if a % i == 0 and b % i == 0:
            is_coprime = False
            break
    return is_coprime

对于不熟悉 for/else 的人来说,这两种写法都要比早前那种写法清晰很多。for/else 结构中的 else 块虽然也能够实现相应的功能,但是将来回顾这段程序的时候,却会令阅读代码的人,感到相当费解。像循环这种简单的语言结构,在 Python 程序中应该写得非常直白才对。所以,完全不应该在循环后面使用 else 块。

总结

  • Python 有种特殊语法,可在 for 及 while 循环的内部语句块之后紧跟一个 else 块。
  • 只有当整个循环主体都没遇到 break 语句时,循环后面的 else 块才会执行。
  • 不要在循环后面使用 else 块,因为这种写法既不直观,又容易引人误解。

第 13 条:合理利用 try/except/else/finally 结构中的每个代码块

Python 程序的异常处理可能要考虑四种不同的时机。这些时机可以用 try、except、else 和 finally 块来表述。符合语句中的每个块都有特定的用途,它们可以构成很多种有用的组合方式。

1.finally 块

如果既要将异常向上传播,又要在异常发生时执行清理工作,那就可以使用 try/finally 结构。这种结构有一项常见的用途,就是确保程序能够可靠地关闭文件句柄。

handle = open('/tmp/random_data.txt')  # May raise IOError
try:
    data = handle.read()  # May raise UnicodeDecodeError
finally:
    handle.close()  # Always runs after try:

在上面这段代码中,read 方法所抛出的异常会向上传播给调用方,而 finally 块中的 handle.close 方法则一定能够执行。open 方法必须放在 try 块外面,因为如果打开文件时发生异常(例如,由于找不到该文件而抛出 IOError),那么程序应该跳过 finally 块。

2.else 块

try/except/else 结构可以清晰地描述出哪些异常会由自己的代码来处理、哪些异常会传播到上一级。如果 try 块没有发生异常,那么就执行 else 块。有了这种 else 块,我们可以尽量缩减 try 块内的代码量,使其更加易读。例如,要从字符串中加载 JSON 字典数据,然后返回字典里某个键所对应的值。

def load_json_key(data, key):
    try:
        result_dict = json.loads(data)  # May raise ValueError
    except ValueError as e:
        raise KeyError from e
    else:
        return result_dict[key]  # May raise KeyError

如果数据不是有效的 JSON 格式,那么用 json.loads 解码时,会产生 ValueError。这个异常会由 except 块来捕获并处理。如果能够解码,那么 else 块里的查找语句就会执行,它会根据键来查出相关的值。查询时若有异常,则该异常会向上传播,因为查询语句并不在刚才那个 try 块的范围内。这种 else 子句,会把 try/except 后面的内容和 except 块本身区分开,使异常的传播行为变得更加清晰。

3.混合使用

如果要在符合语句中把上面几种机制都用到,那就编写完整的 try/except/else/finally 结构。例如,要从文件中读取某项事务的描述信息,处理该事务,然后就地更新该文件。为了实现此功能,我们可以用 try 块来读取文件并处理其内容,用 except 块来应对 try 块中可能发生的相关异常,用 else 块实时地更新文件并把更新中可能出现的异常回报给上级代码,然后用 finally 块来清理文件句柄。

UNDEFINED = object()

def divide_json(path):
    handle = open(path, 'r+')  # May raise IOError
    try:
        data = handle.read()  # May raise UnicodeDecodeError
        op = json.loads(data)  # May raise ValueError
        value = (
            op['numerator'] /
            op['denominator']  # May raise ZeroDivisionError
        )
    except ZeroDivisionError as e:
        return UNDEFINED
    else:
        op['result'] = value
        result = json.dumps(op)
        handle.seek(0)
        handle.write(result)  # May raise IOError
    finally:
        handle.close()  # Always runs

这种写法很有用,因为这四块代码互相配合得非常到位。例如,即使 else 块在写入 result 数据时发生异常,finally 块中关闭文件句柄的那行代码,也依然能执行。

总结

  • 无论 try 块是否发生异常,都可以利用 try/finally 复合语句中的 finally 块来执行清理工作。
  • else 块可以用来缩减 try 块中的代码量,并把没有发生异常时所要执行的语句与 try/except 代码隔开。
  • 顺利运行 try 块后,若想使某些操作能在 finally 块的清理代码之前运行,则可将这些操作写到 else 块中。
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值