Python实践提升-条件分支控制流
从某种角度来看,编程这件事,其实就是把真实世界里的逻辑用代码的方式书写出来。
而真实世界里的逻辑通常很复杂,包含许许多多先决条件和结果分支,无法用一句简单的“因为……所以……”来概括。如果画成地图,这些逻辑不会是只有几条高速公路的郊区,而更像是包含无数个岔路口的闹市区。
为了表现这些真实世界里的复杂逻辑,程序员们写出了一条条分支语句。比如简单的“如果用户是会员,跳过广告播放”:
if user.is_active_member():
skip_ads()
return True
else:
print('你不是会员,无法跳过广告。')
return False
或者复杂一些的:
if user.is_active_member():
if user.membership_expires_in(30):
print('会员将在 30 天内过期,请及时续费,将在 3 秒后跳过广告')
skip_ads_with_delay(3)
return True
skip_ads()
return True
elif user.region != 'CN':
print('非中国区无法跳过广告')
return False
else:
print('你不是会员,无法跳过广告。')
return False
当条件分支变得越来越复杂,代码的可读性也会变得越来越差。所以,掌握如何写出好的条件分支代码非常重要,它可以帮助我们用更简洁、更清晰的代码来表达复杂逻辑。本章将会谈谈如何在 Python 中写出更好的条件分支代码。
4.1 基础知识
4.1.1 分支惯用写法
在 Python 里写条件分支语句,听上去是件挺简单的事。这是因为严格说来 Python 只有一种条件分支语法——if/elif/else1:
1在 Python 3.10 版本发布后,这个说法其实已不再成立。Python 在 3.10 版本里引入了一种新的分支控制结构:结构化模式匹配(structural pattern matching)。这种新结构启用了 match/case 关键字,实现了类似 C 语言中的 switch/case 语法。但和传统 switch 语句比起来,Python 的模式匹配功能要强大得多(语法也复杂得多)。因为本书的编写环境是 Python 3.8,所以我不会对“结构化模式匹配”做太多介绍。如果你对它感兴趣,可以阅读 PEP-634 了解更多内容。
#标准条件分支语句
if condition:
...
elif another_condition:
...
else:
...
当我们编写分支时,第一件要注意的事情,就是不要显式地和布尔值做比较:
#不推荐的写法
#if user.is_active_member() == True:
#推荐写法
if user.is_active_member():
绝大多数情况下,在分支判断语句里写 == True 都没有必要,删掉它代码会更短也更易读。但这条原则也有例外,比如你确实想让分支仅当值是 True 时才执行。不过即便这样,写 if == True 仍然是有问题的,我会在 4.1.3 节解释这一点。
省略零值判断
当你编写 if 分支时,如果需要判断某个类型的对象是否是零值,可能会把代码写成下面这样:
if containers_count == 0:
...
if fruits_list != []:
...
这种判断语句其实可以变得更简单,因为当某个对象作为主角出现在 if 分支里时,解释器会主动对它进行“真值测试”,也就是调用 bool() 函数获取它的布尔值。而在计算布尔值时,每类对象都有着各自的规则,比如整型和列表的规则如下:
#数字 0 的布尔值为 False,其他值为 True
>>> bool(0), bool(123)
(False, True)
#空列表的布尔值为 False,其他值为 True
>>> bool([]), bool([1, 2, 3])
(False, True)
正因如此,当我们需要在条件语句里做空值判断时,可以直接把代码简写成下面这样:
if not containers_count:
...
if fruits_list:
...
这样的条件判断更简洁,也更符合 Python 社区的习惯。不过在你使用这种写法时,请不要忘记一点,这样写其实隐晦地放宽了分支判断的成立条件:
#更精准:只有为 0 的时候,才会满足分支条件
if containers_count == 0:
....
#更宽泛:当 containers_count 的值为 0、None、空字符串等时,都可以满足分支条件
if not containers_count:
...
请时刻注意,不要因为过度追求简写而引入其他逻辑问题。
除整型外,其他内置类型的布尔值规则如下。
布尔值为假:None、0、False、[]、()、{}、set()、frozenset(),等等。
布尔值为真:非 0 的数值、True,非空的序列、元组、字典,用户定义的类和实例,等等。
把否定逻辑移入表达式内
在构造布尔逻辑表达式时,你可以用 not 关键字来表达“否定”含义:
>>> i = 10
>>> i > 8
True
>>> not i > 8
False
不过在写代码时,我们有时会过于喜欢用 not 关键字,反倒忘记了运算符本身就可以表达否定逻辑。最后,代码里会出现许多下面这种判断语句:
if not number < 10:
...
if not current_user is None:
...
if not index == 1:
...
这样的代码,就好比你在看到一个人沿着楼梯往上走时,不说“他在上楼”,而非说“他在做和下楼相反的事情”。如果把否定逻辑移入表达式内,它们通通可以改成下面这样:
if number >= 10:
...
if current_user is not None:
...
if index != 1:
...
这样的代码逻辑表达得更直接,也更好理解。
尽可能让三元表达式保持简单
除了标准分支外,Python 还为我们提供了一种浓缩版的条件分支——三元表达式:
#语法:
#true_value if <expression> else false_value
language = "python" if you.favor("dynamic") else "golang"
当你在编写三元表达式时,请参考 3.3.6 节的两个“不要”里的建议,不要盲目追求用一个表达式来表达过于复杂的逻辑。有时,平淡普通的分支语句远远胜过花哨复杂的三元表达式。
4.1.2 修改对象的布尔值
上一节提过,当我们把某个对象用于分支判断时,解释器会对它进行“真值测试”,计算出它的布尔值,而所有用户自定义的类和类实例的计算结果都是 True:
>>> class Foo:
... pass
...
>>> bool(Foo)
True
>>> bool(Foo())
True
这个现象符合逻辑,但有时会显得有点儿死板。如果我们稍微改动一下这个默认行为,就能写出更优雅的代码。
看看下面这个例子:
class UserCollection:
"""用于保存多个用户的集合工具类"""
def __init__(self, users):
self.items = users
users = UserCollection(['piglei', 'raymond'])
#仅当用户列表里面有数据时,打印语句
if len(users.items) > 0:
print("There's some users in collection!")
在上面这段代码里,我需要判断 users 对象是否真的有内容,因此里面的分支判断语句用到了 len(users.items) > 0 这样的表达式:判断对象内 items 的长度是否大于 0。
但其实,上面的分支判断语句可以变得更简单。只要给 UserCollection 类实现 len 魔法方法,users 对象就可以直接用于“真值测试”:
class UserCollection:
"""用于保存多个用户的集合工具类"""
def __init__(self, users):
self.items = users
def __len__(self):
return len(self.items)
users = UserCollection(['piglei', 'raymond'])
#不再需要手动判断对象内部 items 的长度
if users:
print("There's some users in collection!")
为类定义 len 魔法方法,实际上就是为它实现了 Python 世界的长度协议:
>>> users = UserCollection([])
>>> len(users)
0
>>> users = UserCollection(['piglei', 'raymond'])
>>> len(users)
2
Python 在计算这类对象的布尔值时,会受 len(users) 的结果影响——假如长度为 0,布尔值为 False,反之为 True。因此当例子中的 UserCollection 类实现了 len 后,整个条件判断语句就得到了简化。
不过,定义 len 并非影响布尔值结果的唯一办法。除了 len 以外,还有一个魔法方法 bool 和对象的布尔值息息相关。
为对象定义 bool 方法后,对它进行布尔值运算会直接返回该方法的调用结果。举个例子:
class ScoreJudger:
"""仅当分数大于 60 时为真"""
def __init__(self, score):
self.score = score
def __bool__(self):
return self.score >= 60
执行结果如下:
>>> bool(ScoreJudger(60))
True
>>> bool(ScoreJudger(59))
False
假如一个类同时定义了 len 和 bool 两个方法,解释器会优先使用 bool 方法的执行结果。
4.1.3 与 None 比较时使用 is 运算符
当我们需要判断两个对象是否相等时,通常会使用双等号运算符 ==,它会对比两个值是否一致,然后返回一个布尔值结果,示例如下:
>>> x, y, z = 1, 1, 2
>>> x == y
True
>>> x == z
False
但对于自定义对象来说,它们在进行 == 运算时行为是可操纵的:只要实现类型的 eq 魔法方法就行。
举个例子:
class EqualWithAnything:
"""与任何对象相等"""
def __eq__(self, other):
# 方法里的 other 方法代表 == 操作时右边的对象,比如
# x == y 会调用 x 的 __eq__ 方法,other 的参数为 y
return True
上面定义的 EqualWithAnything 对象,在和任何东西做 == 计算时都会返回 True:
>>> foo = EqualWithAnything()
>>> foo == 'string'
True
当然也包括 None:
>>> foo == None
True
既然 == 的行为可被魔法方法改变,那我们如何严格检查某个对象是否为 None 呢?答案是使用 is 运算符。虽然二者看上去差不多,但有着本质上的区别:
(1) == 对比两个对象的值是否相等,行为可被 eq 方法重载;
(2) is 判断两个对象是否是内存里的同一个东西,无法被重载。
换句话说,当你在执行 x is y 时,其实就是在判断 id(x) 和 id(y) 的结果是否相等,二者是否是同一个对象。
因此,当你想要判断某个对象是否为 None 时,应该使用 is 运算符:
>>> foo = EqualWithAnything()
>>> foo == None
True
#is 的行为无法被重载
>>> foo is None
False
#有且仅有真正的 None 才能通过 is 判断
>>> x = None
>>> x is None
True
到这里也许你想问,既然 is 在进行比较时更严格,为什么不把所有相等判断都用 is 来替代呢?
这是因为,除了 None、True 和 False 这三个内置对象以外,其他类型的对象在 Python 中并不是严格以单例模式存在的。换句话说,即便值一致,它们在内存中仍然是完全不同的两个对象。
拿整型举例:
>>> x = 6300
>>> y = 6300
>>> x is y
False
#它们在内存中是不同的两个对象
>>> id(x), id(y)
(4412016144, 4412015856)
#进行值判断会返回相等
>>> x == y
True
因此,仅当你需要判断某个对象是否是 None、True、False 时,使用 is,其他情况下,请使用 ==。
令人迷惑的整型驻留技术
假如我们稍微调整一下上面的代码,把数字从 6300 改成 100,会获得完全相反的执行结果:
>>> x = 100
>>> y = 100
>>> x is y
True
者 id 相等,在内存中是同一个对象
>>> id(x), id(y)
(4302453136, 4302453136)
为什么会这样?这是因为 Python 语言使用了一种名为“整型驻留”(integer interning)的底层优化技术。
对于从 -5 到 256 的这些常用小整数,Python 会将它们缓存在内存里的一个数组中。当你的程序需要用到这些数字时,Python 不会创建任何新的整型对象,而是会返回缓存中的对象。这样能为程序节约可观的内存。
除了整型以外,Python 对字符串也有类似的“驻留”操作。如果你对这方面感兴趣,可自行搜索“Python integer/string interning”关键字了解更多内容。
4.2 案例故事
假如把写代码比喻成翻译文章,那么我们在代码中写下许多 if/else 分支,就仅仅是在对真实逻辑做一种不假思索的“直译”。此时如果转换一下思路,这些直译的分支代码也许能完全消失,代码会变得更紧凑、更具扩展性,整个编码过程更像一种巧妙的“意译”,而非“直译”。
在下面这个故事里,我会通过重构一个电影评分脚本,向你展示从“直译”变为“意译”的有趣过程。
消失的分支
我是一名狂热的电影评分爱好者。一天,我从一个电影论坛上下载了一份数据文件,其中包含了许多新老电影的名称、年份、IMDB2 评分信息。
2一个较为权威的电影评分网站,上面的电影评分由网站用户提交,分值范围从 1 到 10,10 分为最佳。
我用 Python 提取出了文件里的电影信息数据,将其转换成了字典类型,数据格式如代码清单 4-1 所示。
代码清单 4-1 电影评分数据
movies = [
{'name': 'The Dark Knight', 'year': 2008, 'rating': '9'},
{'name': 'Kaili Blues', 'year': 2015, 'rating': '7.3'},
...
]
为了更好地利用这份数据,我想要编写一个小工具,它可以做到:
(1) 按评分 rating 的值把电影划分为 S、A、B、C 等不同级别;
(2) 按照指定顺序,比如年份从新到旧、评分从高到低等,打印这些电影信息。
工具的功能确定后,接下来进行编码实现。
现在的电影数据是字典(dict)格式的,处理起来不是很方便。于是,我首先创建了一个类:Movie,用来存放与电影数据和封装电影有关的操作。有了 Movie 类后,我在里面定义了 rank 属性对象,并在 rank 内实现了按评分计算级别的逻辑。
Movie 类的代码如代码清单 4-2 所示。
代码清单 4-2 电影评分脚本中 Movie 类的代码
class Movie:
"""电影对象数据类"""
def __init__(self, name, year, rating):
self.name = name
self.year = year
self.rating = rating
@property
def rank(self):
"""按照评分对电影分级:
- S: 8.5 分及以上
- A:8 ~ 8.5 分
- B:7 ~ 8 分
- C:6 ~ 7 分
- D:6 分以下
"""
rating_num = float(self.rating)
if rating_num >= 8.5:
return 'S'
elif rating_num >= 8:
return 'A'
elif rating_num >= 7:
return 'B'
elif rating_num >= 6:
return 'C'
else:
return 'D'
实现了按照分数评级后,接下来便是排序功能。
对电影列表排序,这件事乍听上去很难,但好在 Python 为我们提供了一个好用的内置函数:sorted()。借助它,我可以很便捷地完成排序操作。我新建了一个名为 get_sorted_movies() 的排序函数,它接收两个参数:电影列表(movies)和排序选项(sorting_type),返回排序后的电影列表作为结果。
get_sorted_movies() 函数的代码如代码清单 4-3 所示。
代码清单 4-3 电影评分脚本中的 get_sorted_movies() 函数
def get_sorted_movies(movies, sorting_type):
"""对电影列表进行排序并返回
:param movies: Movie 对象列表
:param sorting_type: 排序选项,可选值
name(名称)、rating(评分)、year(年份)、random(随机乱序)
"""
if sorting_type == 'name':
sorted_movies = sorted(movies, key=lambda movie: movie.name.lower())
elif sorting_type == 'rating':
sorted_movies = sorted(
movies, key=lambda movie: float(movie.rating), reverse=True
)
elif sorting_type == 'year':
sorted_movies = sorted(
movies, key=lambda movie: movie.year, reverse=True
)
elif sorting_type == 'random':
sorted_movies = sorted(movies, key=lambda movie: random.random())
else:
raise RuntimeError(f'Unknown sorting type: {sorting_type}')
return sorted_movies
为了把上面这些代码串起来,我在 main() 函数里实现了接收排序选项、解析电影数据、排序并打印电影列表等功能,如代码清单 4-4 所示。
代码清单 4-4 电影评分脚本中的 main() 函数
def main():
# 接收用户输入的排序选项
sorting_type = input('Please input sorting type: ')
if sorting_type not in all_sorting_types:
print(
'Sorry, "{}" is not a valid sorting type, please choose from '
'"{}", exit now'.format(
sorting_type,
'/'.join(all_sorting_types),
)
)
return
# 初始化电影数据对象
movie_items = []
for movie_json in movies:
movie = Movie(**movie_json)
movie_items.append(movie)
# 排序并输出电影列表
sorted_movies = get_sorted_movies(movie_items, sorting_type)
for movie in sorted_movies:
print(
f'- [{movie.rank}] {movie.name}({movie.year}) | rating: {movie.rating}'
)
这个脚本的最终执行效果如下:
#按评分排序,每一行结果的 [S] 代表电影评分级别
$ python movies_ranker.py
Please input sorting type: rating
- [S] The Shawshank Redemption (1994) | rating: 9.3
- [S] The Dark Knight(2008) | rating: 9
- [A] Citizen Kane(1941) | rating: 8.3
#按年份排序
$ python movies_ranker.py
Please input sorting type: year
- [C] Project Gutenberg(2018) | rating: 6.9
- [B] Burning(2018) | rating: 7.5
- [B] Kaili Blues(2015) | rating: 7.3
看上去还不错,对吧?只要短短的 100 行不到的代码,一个小工具就完成了。不过,虽然这个工具实现了我最初设想的功能,在它的源码里却藏着两大段可以简化的条件分支代码。如果使用恰当的方式,这些分支语句可以彻底从代码中消失。
我们来看看怎么做吧。
使用 bisect 优化范围类分支判断
第一个需要优化的分支,藏在 Movie 类的 rank 方法属性中:
@property
def rank(self):
rating_num = float(self.rating)
if rating_num >= 8.5:
return 'S'
elif rating_num >= 8:
return 'A'
elif rating_num >= 7:
return 'B'
elif rating_num >= 6:
return 'C'
else:
return 'D'
仔细观察这段分支代码,你会发现它里面藏着一个明显的规律。
在每个 if/elif 语句后,都跟着一个评分的分界点。这些分界点把评分划分成不同的分段,当 rating_num 落在某个分段时,函数就会返回该分段所代表的“S/A/B/C”等级。简而言之,这十几行分支代码的主要任务,就是为 rating_num 在这些分段里寻找正确的位置。
要优化这段代码,我们得先把所有分界点收集起来,放在一个元组里:
#已经排好序的评级分界点
breakpoints = (6, 7, 8, 8.5)
接下来要做的事,就是根据 rating 的值,判断它在 breakpoints 里的位置。
要实现这个功能,最直接的做法是编写一个循环——通过遍历元组 breakpoints 里的所有分界点,我们就能找到 rating 在其中的位置。但除此之外,其实还有更简单的办法。因为 breakpoints 已经是一个排好序的元组,所以我们可以直接使用 bisect 模块来实现查找功能。
bisect 是 Python 内置的二分算法模块,它有一个同名函数 bisect,可以用来在有序列表里做二分查找:
>>> import bisect
#注意:用来做二分查找的容器必须是已经排好序的
>>> breakpoints = [10, 20, 30]
#bisect 函数会返回值在列表中的位置,0 代表相应的值位于第一个元素 10 之前
>>> bisect.bisect(breakpoints, 1)
0
#3 代表相应的值位于第三个元素 30 之后
>>> bisect.bisect(breakpoints, 35)
3
将分界点定义成元组,并引入 bisect 模块后,之前的十几行分支代码可以简化成下面这样:
@property
def rank(self):
# 已经排好序的评级分界点
breakpoints = (6, 7, 8, 8.5)
# 各评分区间级别名
grades = ('D', 'C', 'B', 'A', 'S')
index = bisect.bisect(breakpoints, float(self.rating))
return grades[index]
优化完 rank 方法后,程序中还有另一段待优化的条件分支代码—— get_sorted_movies() 函数里的排序方式选择逻辑。
使用字典优化分支代码
在 get_sorted_movies() 函数里,同样有一大段条件分支代码。它们负责根据 sorting_type 的值,为函数选择不同的排序方式:
def get_sorted_movies(movies, sorting_type):
if sorting_type == 'name':
sorted_movies = sorted(movies, key=lambda movie: movie.name.lower())
elif sorting_type == 'rating':
sorted_movies = sorted(
movies, key=lambda movie: float(movie.rating), reverse=True
)
elif sorting_type == 'year':
sorted_movies = sorted(
movies, key=lambda movie: movie.year, reverse=True
)
elif sorting_type == 'random':
sorted_movies = sorted(movies, key=lambda movie: random.random())
else:
raise RuntimeError(f'Unknown sorting type: {sorting_type}')
return sorted_movies
这段代码有两个非常明显的特点。
(1) 它用到的条件表达式都非常类似,都是对 sorting_type 做等值判断(sorting_type == ‘name’)。
(2) 它的每个分支的内部逻辑也大同小异——都是调用 sorted() 函数,只是 key 和 reverse 参数略有不同。
如果一段条件分支代码同时满足这两个特点,我们就可以用字典类型来简化它。因为 Python 的字典可以装下任何对象,所以我们可以把各个分支下不同的东西——排序的 key 函数和 reverse 参数,直接放进字典里:
sorting_algos = {
# sorting_type: (key_func, reverse)
'name': (lambda movie: movie.name.lower(), False),
'rating': (lambda movie: float(movie.rating), True),
'year': (lambda movie: movie.year, True),
'random': (lambda movie: random.random(), False),
}
有了这份字典以后,我们的 get_sorted_movies() 函数就可以改写成下面这样:
def get_sorted_movies(movies, sorting_type):
"""对电影列表进行排序并返回
:param movies: Movie 对象列表
:param sorting_type: 排序选项,可选值
name(名称)、rating(评分)、year(年份)、random(随机乱序)
"""
sorting_algos = {
# sorting_type: (key_func, reverse)
'name': (lambda movie: movie.name.lower(), False),
'rating': (lambda movie: float(movie.rating), True),
'year': (lambda movie: movie.year, True),
'random': (lambda movie: random.random(), False),
}
try:
key_func, reverse = sorting_algos[sorting_type]
except KeyError:
raise RuntimeError(f'Unknown sorting type: {sorting_type}')
sorted_movies = sorted(movies, key=key_func, reverse=reverse)
return sorted_movie
相比之前的大段 if/elif,新代码变得整齐了许多,扩展性也更强。如果要增加新的排序算法,我们只需要在 sorting_algos 字典里增加新成员即可。
优化成果
通过引入 bisect 模块和算法字典,案例开头的小工具代码最终优化成了代码清单 4-5。
代码清单 4-5 重构后的电影评分脚本 movies_ranker_v2.py
import bisect
import random
class Movie:
"""电影对象数据类"""
def __init__(self, name, year, rating):
self.name = name
self.year = year
self.rating = rating
@property
def rank(self):
"""
按照评分对电影分级
"""
# 已经排好序的评级分界点
breakpoints = (6, 7, 8, 8.5)
# 各评分区间级别名
grades = ('D', 'C', 'B', 'A', 'S')
index = bisect.bisect(breakpoints, float(self.rating))
return grades[index]
def get_sorted_movies(movies, sorting_type):
"""对电影列表进行排序并返回
:param movies: Movie 对象列表
:param sorting_type: 排序选项,可选值
name(名称)、rating(评分)、year(年份)、random(随机乱序)
"""
sorting_algos = {
# sorting_type: (key_func, reverse)
'name': (lambda movie: movie.name.lower(), False),
'rating': (lambda movie: float(movie.rating), True),
'year': (lambda movie: movie.year, True),
'random': (lambda movie: random.random(), False),
}
try:
key_func, reverse = sorting_algos[sorting_type]
except KeyError:
raise RuntimeError(f'Unknown sorting type: {sorting_type}')
sorted_movies = sorted(movies, key=key_func, reverse=reverse)
return sorted_movies
在这个案例中,我们一共用到了两种优化分支的方法。虽然它们看上去不太一样,但代表的思想其实是类似的。
当我们编写代码时,有时会下意识地编写一段段大同小异的条件分支语句。多数情况下,它们只是对业务逻辑的一种“直译”,是我们对业务逻辑的理解尚处在第一层的某种拙劣表现。
如果进一步深入业务逻辑,尝试从中总结规律,那么这些条件分支代码也许就可以被另一种更精简、更易扩展的方式替代。当你在编写条件分支时,请多多思考这些分支背后所代表的深层需求,寻找简化它们的办法,进而写出更好的代码。
除了这个故事中展示的两种方式外,面向对象的多态也是消除条件分支代码的一大利器。在 9.3.2 节中,你可以找到一个用多态来替代分支代码的例子。
4.3 编程建议
4.3.1 尽量避免多层分支嵌套
如果你看完本章内容后,最终只能记住一句话,那么我希望那句话是:要竭尽所能地避免分支嵌套。
在大家编写代码时,每当业务逻辑变得越来越复杂,条件分支通常也会越来越多、越嵌越深。以下面这段代码为例:
def buy_fruit(nerd, store):
"""去水果店买苹果操作手册:
- 先得看看店是不是在营业
- 如果有苹果,就买 1 个
- 如果钱不够,就回家取钱再来
"""
if store.is_open():
if store.has_stocks("apple"):
if nerd.can_afford(store.price("apple", amount=1)):
nerd.buy(store, "apple", amount=1)
return
else:
nerd.go_home_and_get_money()
return buy_fruit(nerd, store)
else:
raise MadAtNoFruit("no apple in store!")
else:
raise MadAtNoFruit("store is closed!")
这个 buy_fruit() 函数直接翻译了原始需求,短短十几行代码里就包含了三层分支嵌套。
当代码有了多层分支嵌套后,可读性和可维护性就会直线下降。这是因为,读代码的人很难在深层嵌套里搞清楚,如果不满足某个条件到底会发生什么。此外,因为 Python 使用了空格缩进来表示分支语句,所以过深的嵌套也会占用过多的字符数,导致代码极易超过 PEP 8 所规定的每行字数限制。
幸运的是,这些多层嵌套可以用一个简单的技巧来优化——“提前返回”。“提前返回”指的是:当你在编写分支时,首先找到那些会中断执行的条件,把它们移到函数的最前面,然后在分支里直接使用 return 或 raise 结束执行。
使用这个技巧,前面的代码可以优化成下面这样:
def buy_fruit(nerd, store):
if not store.is_open():
raise MadAtNoFruit("store is closed!")
if not store.has_stocks("apple"):
raise MadAtNoFruit("no apple in store!")
if nerd.can_afford(store.price("apple", amount=1)):
nerd.buy(store, "apple", amount=1)
return
else:
nerd.go_home_and_get_money()
return buy_fruit(nerd, store)
实践“提前返回”后,buy_fruit() 函数变得更扁平了,整个逻辑也变得更直接、更容易理解了。
在“Python 之禅”里有一句:“扁平优于嵌套”(Flat is better than nested),这刚好说明了把嵌套分支改为扁平的重要性。
4.3.2 别写太复杂的条件表达式
假如某个分支的成立条件非常复杂,就连直接用文字描述都需要一大段,那当我们把它翻译成代码时,一个包含大量 not/and/or 的复杂表达式就会横空出世,看起来就像一个难懂的高等数学公式。
下面这段代码就是一个例子:
#如果活动还在开放,并且活动剩余名额大于 10,为所有性别为女或者级别大于 3
#的活跃用户发放 10 000 个金币
if (
activity.is_active
and activity.remaining > 10
and user.is_active
and (user.sex == 'female' or user.level > 3)
):
user.add_coins(10000)
return
针对这种代码,我们需要对条件表达式进行简化,把它们封装成函数或者对应的类方法,这样才能提升分支代码的可读性:
if activity.allow_new_user() and user.match_activity_condition():
user.add_coins(10000)
return
进行恰当的封装后,之前大段的注释文字甚至可以直接删掉了,因为优化后的条件表达式已经表意明确了。至于“什么情况下允许新用户参与活动”“什么样的用户满足活动条件”这种更具体的问题,就交由 allow_new_user() / match_activity_condition() 这些方法来回答吧。
封装不仅仅是用来提升可读性的可选操作,有时甚至是必须要做的事情。举个例子,当上面的活动判断逻辑在项目中多次出现时,假设缺少封装,那些复杂的条件表达式就会被不断地“复制粘贴”,彻底让代码变得不可维护。
4.3.3 尽量降低分支内代码的相似性
程序员们编写条件分支语句,是为了让代码在不同情况下执行不同的操作。
但很多时候,这些不同的操作会因为一些逻辑上的相似性,导致代码也很类似。这种“类似”有几种表现形式,有时是完全重复的语句,有时则是调用函数时的重复参数。
假如不同分支下的代码过于相似,读者就会很难理解代码的含义,因为他需要非常细心地区分不同分支下的行为究竟有什么差异。如果作者可以在编写代码时尽量降低这种相似性,就能有效提升可读性。
举个简单的例子,下面代码里的不同分支下出现了重复语句:
#仅当分组处于活跃状态时,允许用户加入分组并记录操作日志
if group.is_active:
user = get_user_by_id(request.user_id)
user.join(group)
log_user_activiry(user, target=group, type=ActivityType.JOINED_GROUP)
else:
user = get_user_by_id(request.user_id)
log_user_activiry(user, target=group, type=ActivityType.JOIN_GROUP_FAILED)
我们可以把重复代码移到分支外,尽量降低分支内代码的相似性:
user = get_user_by_id(request.user_id)
if group.is_active:
user.join(group)
activity_type = UserActivityType.JOINED_GROUP
else:
activity_type = UserActivityType.JOIN_GROUP_FAILED
log_user_activiry(user, target=group, type=activity_type)
像上面这种重复的语句很容易发现,下面是一个隐蔽性更强的例子:
#创建或更新用户资料数据
#如果是新用户,创建新 Profile 数据,否则更新已有数据
if user.no_profile_exists:
create_user_profile(
username=data.username,
gender=data.gender,
email=data.email,
age=data.age,
address=data.address,
points=0,
created=now(),
)
else:
update_user_profile(
username=data.username,
gender=data.gender,
email=data.email,
age=data.age,
address=data.address,
updated=now(),
)
在上面这段代码里,我们可以一眼看出,程序在两个分支下调用了不同的函数,做了不一样的事情。但因为那些重复的函数参数,我们很难一下看出二者的核心不同点到底是什么。
为了降低这种相似性,我们可以使用 Python 函数的动态关键字参数(**kwargs)特性,简单优化一下上面的代码:
if user.no_profile_exists:
_update_or_create = create_user_profile
extra_args = {'points': 0, 'created': now()}
else:
_update_or_create = update_user_profile
extra_args = {'updated': now()}
_update_or_create(
username=user.username,
gender=user.gender,
email=user.email,
age=user.age,
address=user.address,
**extra_args,
)
降低不同分支内代码的相似性,可以帮助读者更快地领会它们之间的差异,进而更容易理解分支的存在意义。
4.3.4 使用“德摩根定律”
当我们需要表达包含许多“否定”的逻辑时,经常会写出下面这样的条件判断代码:
#如果用户没有登录或者用户没有使用 Chrome,拒绝提供服务
if not user.has_logged_in or not user.is_from_chrome:
return "our service is only available for chrome logged in user"
当你第一眼看到代码时,是不是需要思考好一会儿,才能弄明白它想干什么?这是正常的,因为上面的逻辑表达式里同时用了 2 个 not 和 1 个 or,而人类恰巧不擅长处理这种有着过多“否定”的逻辑关系。
这时就该“德摩根定律”闪亮登场了。简单来说,“德摩根定律”告诉了我们这么一件事:not A or not B 等价于 not (A and B)。
因此,上面的代码可以改写成下面这样:
if not (user.has_logged_in and user.is_from_chrome):
return "our service is only available for chrome logged in user"
相比之前,新代码少了一个 not 关键字,变得好理解了不少。当你的代码出现太多“否定”时,请尝试用“德摩根定律”来化繁为简吧。
4.3.5 使用 all()/any() 函数构建条件表达式
在 Python 的众多内置函数中,有两个特别适合在构建条件表达式时使用,它们就是 all() 和 any()。这两个函数接收一个可迭代对象作为参数,返回一个布尔值结果。顾名思义,这两个函数的行为如下。
all(iterable):仅当 iterable 中所有成员的布尔值都为真时返回 True,否则返回 False。
any(iterable):只要 iterable 中任何一个成员的布尔值为真就返回 True,否则返回 False。
举个例子,我需要判断一个列表里的所有数字是不是都大于 10,如果使用普通循环,代码得写成下面这样:
def all_numbers_gt_10(numbers):
"""仅当序列中所有数字都大于 10 时,返回 True"""
if not numbers:
return False
for n in numbers:
if n <= 10:
return False
return True
但如果使用 all() 内置函数,同时配合一个简单的生成器表达式,上面的代码就可以简化成下面这样:
def all_numbers_gt_10_2(numbers):
return bool(numbers) and all(n > 10 for n in numbers)
简单、高效,同时没有损失可读性。
4.3.6 留意 and 和 or 的运算优先级
我们经常用 and 和 or 运算符来构建逻辑表达式,那么你对它们足够了解吗?看看下面这两个表达式,猜猜它们返回的结果一样吗?
>>> (True or False) and False
>>> True or False and False
答案是:不一样。这两个表达式的值分别是 False 和 True,你猜对了吗?
出现这个结果的原因是:and 运算符的优先级高于 or。因此在 Python 看来,上面第二个表达式实际上等同于 True or (False and False),所以最终结果是 True 而不是 False。
当你要编写包含多个 and 和 or 运算符的复杂逻辑表达式时,请留意运算优先级问题。如果加上一些括号可以让逻辑变得更清晰,那就不要吝啬。
4.3.7 避开 or 运算符的陷阱
or 运算符是构建逻辑表达式时的常客。or 最有趣的地方是它的“短路求值”特性。比如在下面的例子里,1 / 0 永远不会被执行,也就意味着不会抛出 ZeroDivisionError 异常:
>>> True or (1 / 0)
True
正因为这个“短路求值”特性,在很多场景下,我们经常使用 or 来替代一些简单的条件判断语句,比如下面这个例子:
context = {}
#仅当 extra_context 不为 None 时,将其追加进 context 中
if extra_context:
context.update(extra_context)
在上面这段代码里,extra_context 的值一般情况下会是一个字典,但有时也可能是 None。因此我加了一个条件判断语句:仅当值不为 None 时才做 context.update() 操作。
如果使用 or 运算符,上面三行语句可以变得更简练:
context.update(extra_context or {})
因为 a or b or c or … 这样的表达式,会返回这些变量里第一个布尔值为真的对象,直到最末一个为止,所以 extra_context or {} 表达式在对象不为空时就是 extra_context 自身,而当 extra_context 为 None 时就变成 {}。
使用 a or b 来表示“ a 为空时用 b 代替”的写法非常常见,你在各种编程语言、各类项目源码里都能发现它的影子,但在这种写法下,其实藏着一个陷阱。
因为 or 计算的是变量的布尔真假值,所以不光是 None,0、[]、{} 以及其他所有布尔值为假的东西,都会在 or 运算中被忽略:
#所有的 0、空列表、空字符串等,都是布尔假值
>>> bool(None), bool(0), bool([]), bool({}), bool(''), bool(set())
(False, False, False, False, False, False)
如果忘记了 or 的这个特点,你可能就会碰到一些很奇怪的问题。拿下面这段代码来说:
timeout = config.timeout or 60
虽然它的目的是判断当 config.timeout 为 None 时,使用 60 作为默认值。但假如 config.timeout 的值被主动配置成 0 秒,timeout 也会因为上面的 0 or 60 = 60 运算被重新赋值为 60,正确的配置反而被忽略了。
所以,这时使用 if 来进行精确的判断会更稳妥一些:
if config.timeout is None:
timeout = 60
4.4 总结
本章我们学习了在 Python 中编写条件分支语句时的一些注意事项。基础知识部分介绍了分支语句的一些惯用写法,比如不要显式地和空值做比较,和 None 做相等判断时使用 is 运算符,等等。
在编写分支代码时,最重要的一点是尽量避免多层分支嵌套,请谨记“扁平优于嵌套”。
虽然这么说不一定准确,但错综复杂的分支语句,确实是让许多代码变得难以维护的罪魁祸首。有时,如果你在写代码时转换一下思路,也许会发现恼人的 if/else 分支其实可以被其他东西替代。当代码里的分支越少、分支越扁平、分支的判断条件越简单时,代码就越容易维护。
以下是本章要点知识总结。
(1) 条件分支语句惯用写法
不要显式地和布尔值做比较
利用类型本身的布尔值规则,省略零值判断
把 not 代表的否定逻辑移入表达式内部
仅在需要判断某个对象是否是 None、True、False 时,使用 is 运算符
(2) Python 数据模型
定义 len 和 bool 魔法方法,可以自定义对象的布尔值规则
定义 eq 方法,可以修改对象在进行 == 运算时的行为
(3) 代码可读性技巧
不同分支内容易出现重复或类似的代码,把它们抽到分支外可提升代码的可读性
使用“德摩根定律”可以让有多重否定的表达式变得更容易理解
(4) 代码可维护性技巧
尽可能让三元表达式保持简单
扁平优于嵌套:使用“提前返回”优化代码里的多层分支嵌套
当条件表达式变得特别复杂时,可以尝试封装新的函数和方法来简化
and 的优先级比 or 高,不要忘记使用括号来让逻辑更清晰
在使用 or 运算符替代条件分支时,请注意避开因布尔值运算导致的陷阱
(5) 代码组织技巧
bisect 模块可以用来优化范围类分支判断
字典类型可以用来替代简单的条件分支语句
尝试总结条件分支代码里的规律,用更精简、更易扩展的方式改写它们
使用 any() 和 all() 内置函数可以让条件表达式变得更精简