Python学习笔记--条件分支控制流

本文摘自朱雷老师所著《Python工匠》一书内容,作为笔记予以记录。

《Python工匠》第四章讲解“条件分支控制流”,讲到不要显式地和空值做比较,和None做相等判断时使用is运算符等,对于我这样菜鸟,还是很受益的。

错综复杂的分支语句,让很多代码变得难以维护。可以转化一下思路,那些恼人的if/else分支也许可以被其它东西替代。当代码中的分支越少、分支越扁平、分支的判断条件越简单,代码就越容易为何。

一、Python工匠》第四章总结内容

(1)条件分支语句惯用写法

  • 不要显式地和布尔值做比较
  • 利用类型本身的布尔值规则,省略零值判断
  • 把not代表的否定逻辑移入表达式内部
  • 仅在需要判断某个对象是否是None、True、False时,使用is运算符(其它用==比较)

(2)Python数据模型

  • 定义_ _len_ _和 _ _bool_ _魔法方法,可以自定义对象的布尔值规则
  • 定义_ _eq_ _ 方法,可以修改对象在进行==运算时的行为

(3)代码可读性技巧

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

(4)代码可维护性技巧

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

(5)代码的组织技巧

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

二、重要知识点与技巧

1、基础知识

(1)省略零值判断

编写if分支时,如果需要判断某个类型的对象是否是零值,可能如下写:

if containers_count == 0:   # if  containers_count != []

    ...

当某个对象作为主角出现在if分支语句里时,Python解释器会主动对它进行“真假测试”,也就是调用bool()函数获取它的布尔值。而在计算布尔值时,每类对象都有各自的规则,比如整型0的布尔值为False,其它都为True;空列表、字典的布尔值为False,其它为True。

所以,当我们需要在条件语句里做空值判断时,可以直接吧代码简写成如下:

if not containers_count:   #  containers_count对象无论是数值0,或空列表、空字典

    ...

  • 布尔值为假:None、0、False、[] 、{} 、() 、set() 、frozenset(),等等
  • 布尔值为真:非0的数值、True、非空的列表、元组、字典、集合,用户定义的类和实例,等

(2)把否定逻辑移入表达式内

if not number  < 10:  # 解释器会1)先做number < 10 比较运算,2)not 运算 ,3)bool测试运算

    ...

推荐修改为:

if number  >= 10 :    # 解释器会1)先做number < 10 比较运算,3)bool测试运算

    ...

(3)与None比较时使用is运算符

当我们需要判断两个对象是否相等时,通常会使用双等号==运算符,它会对比两个值是否一致,然后返回一个布尔值结果,但是对于自定义对象来说,它们在进行==运算时行为是可以操纵的:只要在自定义类型的_ _eq_ _魔法方法就行。

class EqualWithAnything:
    """与任何对象相等"""
    def __eq__(self,other)
        # 方法里的other参数代表 == 操作时右边的对象,比如
        # x == y 会调用x的__eq__方法,other的参数为y
        return True

上面定义的EqulWithAnything对象,在和任何东西通过==运算符做比较,会执行_ _eq_ _方法,总是返回True。所以如何严格检查某个对象是否为None呢?答案是使用is运算符。

==运算符对比两个对象是否相等,行为可被_ _eq_ _方法重载

is运算符判断两个对象是否是内存里的同一个东西,无法被重载

换句话说,当执行 x is y时,Python解释器是判断id(x)和id(y)的结果是否相等,二者是否是同一个对象。

(4)魔法方法_ _len_ _ 和 _ _bool_ _

当我们把某个对象用于分支判断时,解释器会对它进行“真假测试”,计算出它的布尔值,而所有用户自定义类和类的实例默认的计算结果都是True:

>>> class Foo:
...     pass
...
>>> bool(Foo)        # 自定义类,返回True
True
>>> bool(Foo())     # 自定义类Foo实例True
True
>>>

看看下面的例子:

class UserCollection:
    """用于保存多个用户的集合工具类"""
    def __init__(self,users):
        self.items = users

users = UserCollection(['liuyf','liuzx'])

# 仅当用户列表里面有数据时,打印
if len(users.items) > 0:
    print('用户列表中有这些人员:')

在上面代码中,要判断对象是否有数据,if分支判断语句用len(users.items) > 0 表达式,其实代码可以更简单,需要给UserCollection类实现_ _len_ _魔法方法,users对象就可以直接用于“真假测试”:

class UserCollection:
    """用于保存多个用户的集合工具类"""
    def __init__(self,users):
        self.items = users

    def __len__(self):
        return len(self.items)

users = UserCollection(['liuyf','liuzx'])

# 仅当用户列表里面有数据时,打印
if len(users):
    print('用户列表中有这些人员:')

因为类UserCollection定义了魔法_ _len_ _方法,此方法返回类UserCollection属性items的元素个数。故len(users)会返回一个数字,如果列表为空则返回0,if判断语句做“真假测试”,假如长度为0,if 语句判断为False,反之亦然,从而可以简化代码。

另外可以给对象定义_ _bool_ _方法,对它进行布尔运算会直接返回该方法的结果,举个例子:

class ScoreJudger:
    """仅当分数大于等于60时为真"""
    def __init__(self,score):
        self.score = score

    def __bool__(self):
        return self.score >= 60
    
print(bool(ScoreJudger(60)))  # 输出:True

print(bool(ScoreJudger(59)))  # 输出:False

假设60分为及格,那么通过ScoreJudger类参数,即可简单判断是否及格。

另外,假如一个类同时定义了_ _len_ _方法和_ _bool_ _方法,解释器会优先使用_ _bool_ _方法执行的结果。

2、实战技巧

# -*- coding: utf-8 -*-
import random

movies = [
    {'name': 'The Dark Knight', 'year': 2008, 'rating': '9'},
    {'name': 'Kaili Blues', 'year': 2015, 'rating': '7.3'},
    {'name': 'Citizen Kane', 'year': 1941, 'rating': '8.3'},
    {'name': 'Project Gutenberg', 'year': 2018, 'rating': '6.9'},
    {'name': 'Burning', 'year': 2018, 'rating': '7.5'},
    {'name': 'The Shawshank Redemption ', 'year': 1994, 'rating': '9.3'},
]


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'


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


all_sorting_types = ('name', 'rating', 'year', 'random')


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}')


if __name__ == '__main__':
    main()

上面代码处理保存在列表中的字典数据,字典数据中保存有电影名,年份,评分数据,使用一个类Movie,用来存放与电影数据和封装电影有关操作。在类Movie中定义了rank属性对象,并在rank内实现了按评分计算级别的逻辑。

在上面代码中Movie类中属性rank,使用了if语句:

@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'

观察上面代码,if/elif语句后面都跟着一个评分的分界点。这些分界点把评分划分为不同的段,让rating_num落在某个分段时,函数就会返回该分段所代表的“S/A/B/C/D”等级。简而言之,这十几行if语句代码就是为rating_num在这些分段里寻找正确的位置。

优化上面代码,首先把分界点收集起来,放在一个元组中:

# 已经排好序的评级分界点

breakpoints = (5,7,8,8.5)

接下来,就是根据rating的值(评分),判断它在breakpoints里的位置。

方法一是可以写一个循环——通过遍历元组breakpoints里的所有分界点,找出rating(评分)在其中的位置。

(1)使用bisect模块bisect函数优化范围类分支判断

更简单的方法是,使用Python内置模块bisect来实现查找功能。bisect是Python内置的二分算法模块,它有一个同名函数bisect,可以在有序列表里做二分查找。

将分界点定义成元组,并导入bisect模块后,之前的十几行代码可以简化如下:

@property
def rank(self):
    # 已经排好序的评级分界点
    breakpoints = (6,7,8,8.5)
    # 各评分区间级别名
    grades = ('D','C','B','A','S')

    index = bisect.bisect(breakpoints,float(self.rating))  # 需要上面import bisect模块
    return grades[index]

bisect函数的返回值0代表在breakpoints元组的第一个元素之前,1代表元组第一个元素之后,2代表元组第二个元素之后,依次类推。

>>> import bisect
>>> breatpoints = [6,7,8,8.5]
>>> grades =['D','C','B','A','S']
>>> index = bisect.bisect(breatpoints,3)
>>> index
0
>>> index = bisect.bisect(breatpoints,6.5)
>>> index
1
>>> index = bisect.bisect(breatpoints,10)   # 评分10
>>> index                          
4                                                                                     # index = 4
>>> grades[index]
'S'                                                                                   # 对应等级'S'
>>>

(2)使用字典优化分支代码

在最上面代码中,在get_sorted_movies()函数中,有一大段条件分支代码。它们负责根据函数的参数sorting_type值,选择不同的排序方式:

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

这段代码有两个明显特点:

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),
    }

这个字典的key是排序类型(参数sort_type),与之对应的值是一个元组,元组内有两个元素,分别对应sorted()函数中key参数,及reverse参数

有了这份字典以后,上面的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_movies

这样优化后,新代码比原来整齐很多,扩展性更强。如果要增加新的排序算法,只需在sorting_algos字典添加新成员即可。

(3)优化多层嵌套——提前返回

当代码中有了多层分支嵌套,可读性和可维护性就会直线下降。可以使用“提前返回”技巧进行优化。“提前返回”指的是:当编写分支语句时,首先找到那些会中断执行的条件,把它们移到函数的最前面,然后在分支里直接使用return或者raise结束执行。

对下面代码进行优化例子:

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!")

优化之后:

def buy_fruit_version2(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)

在“Python之禅”里有一句:“扁平优于嵌套”。上面语句优化后,就比较扁平了。

(4)别写太复杂的条件表达式

假如某个分支的成立条件非常复杂,就连直接用文字描述都需要一大段,直接使用if 语句,一个包含大量not/and/or的复杂表达式就会横空出世,看起来是一个复杂的数学公式。下面是个案例:

# 活动:如果活动还在开放,并且活动剩余名额大于 10,为所有性别为女性,或者级别大于
# 3 的活跃用户发放 10000 个金币
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_conditions():封装“什么样的用户满足活动条件”

封装不仅仅是用来提升可读性的可选操作,有时甚至是必须要做的事情。举个例子,当上面的活动判断逻辑在项目中多次出现时,如果没有封装,那些复杂的条件表达式就会不断地“复制粘贴”,让项目代码变得难以维护。

(5)使用“德摩根定律”:not A  or  not  B  等价于not (A  and  B)

if not user.has_logged_in or not user.is_from_chrome:
    return "our service is only open for chrome logged in user"

优化后:

if not (user.has_logged_in and user.is_from_chrome):
    return "our service is only open for chrome logged in user"

(6)使用all()和any()函数构建条件表达式

在Python的众多内置函数中,有两个特别适合在构建条件表达式时使用,它们就是all()和any()。这两个函数接收一个可迭代对象作为参数,返回一个布尔值结果。

all(iterable):仅当iterable中所有成员的布尔值都为真时返回为True,否则返回False

any(iterable):只要iterable中任何一个成员的布尔值为真就返回True,否则返回False

判断一个列表里的所有数字是不是都大于10,使用普通循环,代码如下:

def all_number_gt_10(numbers):
    """仅当序列中所有数字都大于10时,返回True"""
    if not numbers:
        return

    for n in numbers:
        if n < = 10:
            return False
    return True 

使用all()内置函数,同时配合一个简单的生成器表达式,上面的代码优化如下:

def all_number_gt_10(numbers):
    return bool(numbers) and all(n >10 for n in numbers)

(7)留意and和or的运算优先级:

>>> (True or False) and False
False
>>> True or False and False
True
>>>

当编写包含多个and和or运算符的复杂逻辑表达式时,请留意优先级问题,不要吝啬括号(),让逻辑变得更清晰。

(8)避开or运算符的陷阱

or运算符是构建逻辑表达式时的常客。or最有趣的地方是它的“短路求值”特性。

下面例子代码中1/0永远不会被执行,也就意味不会抛出除零异常:

>>> True or (1/0)
True
>>>

a  or  b  or  c  or  d or ...  这样的表达式,会返回a/b/c/d/...这些变量里第一个布尔值为真的对象 ,直到最后为止。

使用 a  or  b 来表示“a为空时用b代替”的写法很普遍,其实也有一个陷阱,因为or计算的是变量的布尔真假值,所以None,0,[],{} 以及其它布尔值为假的都被or忽略,而有时候0可能会是一个数字,正确的配置被忽略。

(9)尽量降低分支内代码的相似性

编写条件分支语句,是为了让代码在不同情况下执行不同的操作。

有时候,不同的操作因为逻辑上的相似性,导致代码也很类似。这种“类似”有几种表现形式,有时是完全重复的语句,有时则是调用函数时的重复参数。

一个简单例子,下面分支语句中出现重复语句:

# 仅当分组处于活跃状态时,允许用户加入分组并记录操作日志
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,
)

上面代码_update_or_create是一个变量,在if语句中,根据条件不同,分别把函数create_user_profile或函数update_user_profile赋值给变量_update_or_create,这意味着它现在存储了赋值给它的函数对象的引用,可以理解为“别名”,根据user.no_profile_exists的值,_update_or_create这个函数调用会被相应地替换为create_user_profileupdate_user_profile

传递给这个函数的参数包括:用户名、性别、电子邮件、年龄、地址,以及从extra_args字典中获取的额外参数。在函数调用中,使用**extra_args可以将字典中的键值对作为关键字参数传递给函数。例如,如果extra_args = {'points': 0, 'created': now()},那么在函数内部,关键字参数points=0created=now()会被传递。

学完《Python工匠》第四章,感觉以前写的代码都需要修改,有些苦恼的地方,突然有些豁然开朗。

  • 21
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值