本文摘自朱雷老师所著《Python工匠》一书内容,作为笔记予以记录。
《Python工匠》第四章讲解“条件分支控制流”,讲到不要显式地和空值做比较,和None做相等判断时使用is运算符等,对于我这样菜鸟,还是很受益的。
错综复杂的分支语句,让很多代码变得难以维护。可以转化一下思路,那些恼人的if/else分支也许可以被其它东西替代。当代码中的分支越少、分支越扁平、分支的判断条件越简单,代码就越容易为何。
一、Python工匠》第四章总结内容
(1)条件分支语句惯用写法
(2)Python数据模型
(3)代码可读性技巧
(4)代码可维护性技巧
(5)代码的组织技巧
二、重要知识点与技巧
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,或空列表、空字典
...
(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_profile
或update_user_profile
。
传递给这个函数的参数包括:用户名、性别、电子邮件、年龄、地址,以及从extra_args
字典中获取的额外参数。在函数调用中,使用**extra_args
可以将字典中的键值对作为关键字参数传递给函数。例如,如果extra_args = {'points': 0, 'created': now()}
,那么在函数内部,关键字参数points=0
和created=now()
会被传递。
学完《Python工匠》第四章,感觉以前写的代码都需要修改,有些苦恼的地方,突然有些豁然开朗。