条件分支控制流
掌握如何写出好的条件分支代码非常重要,它可以帮助我们用更简洁、更清晰的代码来表达复杂逻辑。
分支惯用写法
当某个对象作为主角出现在 if 分支里时,解释器会主动对它进行真值测试,也就是调用 bool() 函数获取它的布尔值:
1 | >>> bool([]), bool([1, 2, 3]) |
不要因为过度追求简写而引入其他逻辑问题:
1 | # 更精准:只有为 0 的时候,才会满足分支条件 |
修改对象的布尔值
为类定义 __len__ 魔法方法,实际上就是为它实现了 Python 世界的长度协议,类的实例就可以直接用于真值测试:
1 | class UserCollection: |
不过,定义 __len__ 并非影响布尔值结果的唯一方法,还有一个魔法方法 __bool__ 和对象的布尔值息息相关:
1 | class ScoreJudger: |
1 | >>> bool(ScoreJudger(60)) |
与 None 比较时使用 is 运算符
对于自定义对象来说,它们在进行 == 运算时行为是可操纵的——只要实现类型的 __eq__ 魔法方法就行:
1 | class EqualWithAnything: |
1 | >>> foo = EqualWithAnything() |
== 和 is 的本质区别:
- == 对比两个对象的值是否相等,行为可被 __eq__ 方法重载;
- is 判断两个对象是否是内存里的同一个东西,无法被重载;
仅当你需要判断某个对象是否是 None、True、False 时使用 is,因为除了它们外,其他类型的对象在 Python 中并不是严格以单例模式存在的。Python 语言使用了一种名为整数驻留(Integer Interning)的底层优化技术,对于 -5 到 256 的这些常用小整数,Python 会将它们缓存在内存里的一个数组中。
编程建议
要竭尽所能地避免分支嵌套,多层嵌套可以用提前返回的技巧来优化。当你编写分支时,首先找到那些会中断执行的条件,把它们移到函数的最前面,然后在分支里直接使用 return 或 raise 结束执行:
1 | def buy_fruit(nerd, store): |
1 | def buy_fruit(nerd, store): |
我们可以使用 Python 函数的动态关键字参数(**kwargs)特性,降低分支内代码的相似性:
1 | if user.no_profile_exists: |
1 | if user.no_profile_exists: |
德摩根定律:not A or not B 等价于 not (A and B):
1 | if not user.has_logged_in or not user.is_from_chrome: |
1 | if not (user.has_logged_in and user.is_from_chrome): |
all() 和 any() 接受一个可迭代对象作为参数,返回一个布尔值结果:
1 | def all_numbers_gt_10(numbers): |
1 | def all_numbers_gt_10(numbers): |
or 最有趣的地方是它的短路求值特性,使用 a or b 来表示“a 为空时用 b 代替”的写法非常常见,你在各种编程语言、各类项目源码里都能发现它的影子。但在这种写法下,其实藏着一个陷阱:
1 | # 假如 config.timeout 的值被主动配置成 0 秒,timeout 也会被重新赋值为 60 |
异常与错误处理
如果能善用异常机制优雅地处理好程序里的错误,我们就能用更少、更清晰的代码,写出更健壮的程序。
优先使用异常捕获
两种截然不同的编程风格:
- LBYL (Look Before You Leap):三思而后行,在执行一个可能会出错的操作时,先做一些关键的条件判断,仅当条件满足时才进行操作;
- EAFP (Easier to Ask for Forgiveness than Permission):获取原谅比许可简单,不做任何事前检查直接执行操作,但在外层用 try 来捕获可能发生的异常;
和 LBYL 相比,EAFP 编程风格更为简单直接,它总是直奔主流程而去,把意外情况都放在异常处理 try/except 块内消化掉:
1 | def incr_by_one(value): |
1 | def incr_by_one(value): |
try 语句常用知识
Python 的内置异常类之间存在许多继承关系,比如 BaseException -> Exception -> LookupError -> KeyError,要把更精确的 except 语句放在前面。
异常捕获语句里的 else 表示:仅当 try 语句块里没有抛出任何异常时,才执行 else 分支下的内容,效果就像在 try 最后增加一个标记变量一样:
1 | def sync_user_profile(user): |
1 | def sync_user_profile(user): |
和 finally 语句不同,假如程序在执行 try 代码块时碰到了 return 或 break 等跳转语句,中断了本次异常捕获,那么即便代码没抛出任何异常,else 分支内的逻辑也不会被执行。
如果仅仅想记录下某个异常,然后把它重新抛出,交由上层处理。这时,不带任何参数的 raise 语句可以派上用场:
1 | def incr_by_key(d, key): |
抛出异常而不是返回错误
1 | def create_item(name): |
1 | class CreateItemError(Exception): |
用抛出异常替代返回错误后,整个代码结构乍看上去变化不大,但细节上的改变其实非常多:
- 新函数拥有更稳定的返回值类型,它永远只会返回 Item 类型或是抛出异常;
- 最好在函数文档里说明可能抛出的异常类型;
- 不同于返回值,异常在被捕获前会不断往调用栈上层汇报。但假如程序缺少一个顶级的统一异常处理逻辑,那么某个被所有人忽视了的异常可能会层层上报,最终弄垮整个程序;
使用上下文管理器
with 是一个神奇的关键字,它可以在代码中开辟一段由它管理的上下文,并控制程序在进入和退出这段上下文时的行为。只有满足上下文管理器(Context Manager)协议的对象才可以配合 with 使用,要创建一个上下文管理器只要实现 __enter__ 和 __exit__ 两个魔法方法即可:
1 | class DummyContext: |
上下文管理器用于替代 finally 语句清理资源:
1 | conn = create_conn(host, port, timeout=None) |
1 | class create_conn_obj: |
程序的行为取决于 __exit__ 方法的返回值:
- __exit__ 返回了 True,那么这个异常就会被当前的 with 语句压制住,不再继续抛出,达到忽略异常的效果;
- __exit__ 返回了 False,那这个异常就会被正常抛出,交由调用方处理;
使用上下文管理器,我们可以很方便地实现可复用的忽略异常功能:
1 | class ignore_closed: |
__exit__ 接收的三个参数:
- exc_type:异常类型;
- exc_value:异常对象;
- traceback:错误的堆栈对象;
在日常工作中,我们用到的大多数上下文管理器,可以直接通过生成器函数 + @contextmanager 的方式来定义,这比创建一个符合协议的类要简单得多:
1 | from contextlib import contextmanager |
以 yield 关键字为界,yield 前的逻辑会进入管理器时执行(类似于 __enter__),yield 后的逻辑会在退出管理器时执行(类似于 __exit__)。
编程建议
在代码中捕获异常,表面上是避免程序因为异常而直接崩溃,但它的核心,其实是编码者对处于程序主流程之外的、已知或未知情况的一种妥当处置:
- 永远只捕获那些可能会抛出异常的语句块;
- 尽量只捕获精确的异常类型,而不是模糊的 Exception;
- 如果出现了预期外的异常,让程序早点儿崩溃也未必是件坏事;
避免抛出抽象级别高于当前模块的异常:
- 让模块只抛出与当前抽象级别一致的异常;
- 在必要的地方进行异常包装与转换;
1 | def process_image(fp): |
1 | class ImageOpenError(Exception): |
我们同样应该避免泄漏低于当前抽象级别的异常,比如 urllib3 模块是 requests 依赖的低层实现细节。
除了极少数情况外,不要直接忽略异常,通过日志记录下这个异常总会更好。面对异常,调用方可以:
- 在 except 语句里捕获并处理它,继续执行后面的代码;
- 在 except 语句里捕获它,将错误通知给终端用户,中断执行;
- 不捕获异常,让异常继续往堆栈上层走,最终可能导致程序崩溃;
当开发者编写自定义异常类时,遵循的常见原则:
- 要继承 Exception 而不是 BaseException;
- 异常类名最好以 Error 或 Exception 结尾;
- 调用方是否能清晰区分各种异常;
我们可以利用异常间的继承关系,设计一些更精准的异常子类:
1 | class CreateItemError(Exception): |
还可以创建一些包含额外属性的异常类,比如包含错误代码:
1 | class CreateItemError(Exception): |
assert 是一个专供开发者调试程序的关键字,它所提供的断言检查,可以在执行 Python 时使用 -O 选项直接跳过。请不要拿 assert 来做参数校验,用 raise 语句来替代它吧。
对于所有编写代码的程序员来说,错误处理永远是一种在代码主流程之外的额外负担。空对象模式(Null Object Pattern)在本该返回 None 值或抛出异常时,返回一个符合正确结果接口的特制空类型对象来代替,以此免去调用方的错误处理工作。
循环与可迭代对象
对于一些常见的循环任务,使用 for 比 while 要方便得多。要把循环代码写得漂亮,有时关键不在循环结构自身,而在于另一个用来配合循环的主角:可迭代对象。
迭代器与可迭代对象
iter() 函数和 bool() 很像,调用 iter() 会尝试返回一个迭代器对象。迭代器最鲜明的特征是:
- 不断执行 next() 函数会返回下一次迭代结果,当迭代器没有更多值可以返回时,便会抛出 StopIteration 异常;
- 对迭代器执行 iter() 函数,尝试获取迭代器的迭代器对象时,返回的结果一定是迭代器本身;
下面这两段循环代码是等价的:
1 | names = ['foo', 'bar', 'baz'] |
1 | iterator = iter(names) |
要定义一个迭代器类型,关键在于实现 __iter__ 和 __next__ 两个魔法方法:
1 | class Range7: |
迭代器是可迭代对象的一种,它最常出现的场景是在迭代其他对象时,作为一种介质或工具对象存在。每个迭代器都对应一次完整的迭代过程,因此它自身必须保存与当前迭代相关的状态——迭代位置。如果想让 Range7 对象在每次迭代时都返回完整的结果,我们必须把现在的代码拆成两部分:
1 | class Range7: |
迭代器与迭代对象的区别:
- 可迭代对象不一定是迭代器,但迭代器一定是可迭代对象;
- 对可迭代对象使用 iter() 会返回迭代器,迭代器则会返回其自身;
- 每个迭代器的被迭代过程是一次性的,可迭代对象则不一定;
- 可迭代对象只需要实现 __iter__ 方法,而迭代器要额外实现 __next__ 方法;
生成器是一种懒惰的可迭代对象,使用它来替代传统列表可以节约内存,提升执行效率。生成器还是一种简化的迭代器实现,使用它可以大大降低实现传统迭代器的编码成本:
1 | def range_7_gen(start, end): |
我们可以用 iter() 和 next() 函数来验证生成器就是迭代器这个事实:
1 | >>> nums = range_7_gen(0, 20) |
修饰可迭代对象优化循环
虽然 enumerate() 函数很简单,但它其实代表了一种循环代码优化思路:通过修饰可迭代对象来优化循环。用生成器(或普通的迭代器)在循环外部包装原本的循环主体,完成一些原本必须在循环内部执行的工作:
1 | def sum_even_only(numbers): |
1 | def even_only(numbers): |
使用 itertools 模块优化循环
使用 product() 扁平化多层嵌套循环,product() 接收多个可迭代对象作为参数,然后根据它们的笛卡尔积不断生成结果:
1 | def find_twelve(num_list1, num_list2, num_list3): |
1 | from itertools import product |
使用 islice() 实现循环内隔行处理,islice(seq, start, end, step) 函数和数组切片操作接收的参数几乎完全一致:
1 | def parse_titles(filename): |
1 | from itertools import islice |
使用 takewhile() 替代 break 语句,takewhile(predicate, iterable) 会在迭代第二个参数 iterable 的过程中,不断使用当前值作为参数调用 predicate() 函数,并对返回结果进行真值测试:
1 | for user in users: |
1 | from itertools import takewhile |
循环语句的 else 关键字
for 循环(和 while 循环)后的 else 关键字,代表如果循环没有碰到任何 break,便执行该分支内的语句。因此,老式的“循环 + 标记变量”代码,就可以简写为“循环 + else 分支”:
1 | def process_tasks(tasks): |
1 | def process_tasks(tasks): |
编程建议
Python 语言不支持带标签的 break 语句,无法用一个 break 跳出多层循环。如果想快速从嵌套循环里跳出,需要把循环代码拆分成一个新函数,然后直接使用 return:
1 | def print_first_word(fp, prefix): |
1 | def find_first_word(fp, prefix): |
拿到字典 d 的第一个 key,先用 iter() 获取一个 d.keys() 的迭代器,再对它调用 next():
1 | >>> d = {'foo': 1, 'bar': 2} |
找到列表 nums 里面第一个可以被 7 整除的数字,直接用 next() 配合生成器表达式:
1 | >>> nums = [3, 6, 8, 2, 21, 30, 42] |
你需要将生成器(迭代器)可被一次性耗尽的特点铭记于心,避免写出由它所导致的 bug。假如要重复使用一个生成器,可以调用 list() 函数将它转成列表后再使用。