Python实践提升-条件分支控制流

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

假如一个类同时定义了 lenbool 两个方法,解释器会优先使用 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
Trueid 相等,在内存中是同一个对象
>>> 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 数据模型

定义 lenbool 魔法方法,可以自定义对象的布尔值规则
定义 eq 方法,可以修改对象在进行 == 运算时的行为
  (3) 代码可读性技巧

不同分支内容易出现重复或类似的代码,把它们抽到分支外可提升代码的可读性
使用“德摩根定律”可以让有多重否定的表达式变得更容易理解
  (4) 代码可维护性技巧

尽可能让三元表达式保持简单
扁平优于嵌套:使用“提前返回”优化代码里的多层分支嵌套
当条件表达式变得特别复杂时,可以尝试封装新的函数和方法来简化
and 的优先级比 or 高,不要忘记使用括号来让逻辑更清晰
在使用 or 运算符替代条件分支时,请注意避开因布尔值运算导致的陷阱
  (5) 代码组织技巧

bisect 模块可以用来优化范围类分支判断
字典类型可以用来替代简单的条件分支语句
尝试总结条件分支代码里的规律,用更精简、更易扩展的方式改写它们
使用 any() 和 all() 内置函数可以让条件表达式变得更精简

  • 26
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值