Python 代码判断逻辑太复杂?这里有几条最佳实践和技巧

论坛 期权论坛 期权     
北极星网络   2019-7-27 13:21   2601   0


编写条件分支代码是编码过程中不可或缺的一部分。
如果用道路来做比喻,现实世界中的代码从来都不是一条笔直的高速公路,而更像是由无数个岔路口组成的某个市区地图。我们编码者就像是驾驶员,需要告诉我们的程序,下个路口需要往左还是往右。
编写优秀的条件分支代码非常重要,因为糟糕、复杂的分支处理非常容易让人困惑,从而降低代码质量。所以,这篇文章将会种重点谈谈在 Python 中编写分支代码应该注意的地方。
[h2]Python 里的分支代码[/h2]Python 支持最为常见的
  1. if/else
复制代码
条件分支语句,不过它缺少在其他编程语言中常见的
  1. switch/case
复制代码
语句。
除此之外,Python 还为
  1. for/while
复制代码
循环以及
  1. try/except
复制代码
语句提供了 else 分支,在一些特殊的场景下,它们可以大显身手。
下面我会从
  1. 最佳实践
复制代码
  1. 常见技巧
复制代码
  1. 常见陷阱
复制代码
三个方面讲一下如果编写优秀的条件分支代码。
[h1]最佳实践[/h1][h2]1. 避免多层分支嵌套[/h2]如果这篇文章只能删减成一句话就结束,那么那句话一定是“要竭尽所能的避免分支嵌套”。
过深的分支嵌套是很多编程新手最容易犯的错误之一。假如有一位新手 JavaScript 程序员写了很多层分支嵌套,那么你可能会看到一层又一层的大括号:
  1. if{if{if{...}}}
复制代码
。俗称“嵌套 if 地狱(Nested If Statement Hell)”。
但是因为 Python 使用了缩进来代替
  1. {}
复制代码
,所以过深的嵌套分支会产生比其他语言下更为严重的后果。比如过多的缩进层次很容易就会让代码超过 PEP8 中规定的每行字数限制。让我们看看这段代码:
    1. def buy_fruit(nerd, store):
    复制代码
    1.     """去水果店买苹果
    复制代码

    1.     - 先得看看店是不是在营业
    复制代码
    1.     - 如果有苹果的话,就买 1 个
    复制代码
    1.     - 如果钱不够,就回家取钱再来
    复制代码
    1.     """
    复制代码
    1.     if store.is_open():
    复制代码
    1.         if store.has_stocks("apple"):
    复制代码
    1.             if nerd.can_afford(store.price("apple", amount=1)):
    复制代码
    1.                 nerd.buy(store, "apple", amount=1)
    复制代码
    1.                 return
    复制代码
    1.             else:
    复制代码
    1.                 nerd.go_home_and_get_money()
    复制代码
    1.                 return buy_fruit(nerd, store)
    复制代码
    1.         else:
    复制代码
    1.             raise MadAtNoFruit("no apple in store!")
    复制代码
    1.     else:
    复制代码
    1.         raise MadAtNoFruit("store is closed!")
    复制代码
上面这段代码最大的问题,就是过于直接翻译了原始的条件分支要求,导致短短十几行代码包含了有三层嵌套分支。
这样的代码可读性和维护性都很差。不过我们可以用一个很简单的技巧:“提前结束” 来优化这段代码:
    1. def buy_fruit(nerd, store):
    复制代码
    1.     if not store.is_open():
    复制代码
    1.         raise MadAtNoFruit("store is closed!")
    复制代码

    1.     if not store.has_stocks("apple"):
    复制代码
    1.         raise MadAtNoFruit("no apple in store!")
    复制代码

    1.     if nerd.can_afford(store.price("apple", amount=1)):
    复制代码
    1.         nerd.buy(store, "apple", amount=1)
    复制代码
    1.         return
    复制代码
    1.     else:
    复制代码
    1.         nerd.go_home_and_get_money()
    复制代码
    1.         return buy_fruit(nerd, store)
    复制代码
“提前结束”指:在函数内使用
  1. return
复制代码
  1. raise
复制代码
等语句提前在分支内结束函数。比如,在新的
  1. buy_fruit
复制代码
函数里,当分支条件不满足时,我们直接抛出异常,结束这段这代码分支。这样的代码没有嵌套分支,更直接也更易读。
[h2]2. 封装那些过于复杂的逻辑判断[/h2]如果条件分支里的表达式过于复杂,出现了太多的
  1. not/and/or
复制代码
,那么这段代码的可读性就会大打折扣,比如下面这段代码:
    1. # 如果活动还在开放,并且活动剩余名额大于 10,为所有性别为女性,或者级别大于 3
    复制代码
    1. # 的活跃用户发放 10000 个金币
    复制代码
    1. if activity.is_active and activity.remaining > 10 and \
    复制代码
    1.         user.is_active and (user.sex == 'female' or user.level > 3):
    复制代码
    1.     user.add_coins(10000)
    复制代码
    1.     return
    复制代码
对于这样的代码,我们可以考虑将具体的分支逻辑封装成函数或者方法,来达到简化代码的目的:
    1. if activity.allow_new_user() and user.match_activity_condition():
    复制代码
    1.     user.add_coins(10000)
    复制代码
    1.     return
    复制代码
事实上,将代码改写后,之前的注释文字其实也可以去掉了。因为后面这段代码已经达到了自说明的目的。至于具体的 什么样的用户满足活动条件?这种问题,就应由具体的
  1. match_activity_condition()
复制代码
方法来回答了。
Hint: 恰当的封装不光直接改善了代码的可读性,事实上,如果上面的活动判断逻辑在代码中出现了不止一次的话,封装更是必须的。不然重复代码会极大的破坏这段逻辑的可维护性。
[h2]3. 留意不同分支下的重复代码[/h2]重复代码是代码质量的天敌,而条件分支语句又非常容易成为重复代码的重灾区。所以,当我们编写条件分支语句时,需要特别留意,不要生产不必要的重复代码。
让我们看下这个例子:
    1. # 对于新用户,创建新的用户资料,否则更新旧资料
    复制代码
    1. if user.no_profile_exists:
    复制代码
    1.     create_user_profile(
    复制代码
    1.         username=user.username,
    复制代码
    1.         email=user.email,
    复制代码
    1.         age=user.age,
    复制代码
    1.         address=user.address,
    复制代码
    1.         # 对于新建用户,将用户的积分置为 0
    复制代码
    1.         points=0,
    复制代码
    1.         created=now(),
    复制代码
    1.     )
    复制代码
    1. else:
    复制代码
    1.     update_user_profile(
    复制代码
    1.         username=user.username,
    复制代码
    1.         email=user.email,
    复制代码
    1.         age=user.age,
    复制代码
    1.         address=user.address,
    复制代码
    1.         updated=now(),
    复制代码
    1.     )
    复制代码
在上面的代码中,我们可以一眼看出,在不同的分支下,程序调用了不同的函数,做了不一样的事情。但是,因为那些重复代码的存在,我们却很难简单的区分出,二者的不同点到底在哪。
其实,得益于 Python 的动态特性,我们可以简单的改写一下上面的代码,让可读性可以得到显著的提升:
    1. if user.no_profile_exists:
    复制代码
    1.     profile_func = create_user_profile
    复制代码
    1.     extra_args = {'points': 0, 'created': now()}
    复制代码
    1. else:
    复制代码
    1.     profile_func = update_user_profile
    复制代码
    1.     extra_args = {'updated': now()}
    复制代码

    1. profile_func(
    复制代码
    1.     username=user.username,
    复制代码
    1.     email=user.email,
    复制代码
    1.     age=user.age,
    复制代码
    1.     address=user.address,
    复制代码
    1.     **extra_args
    复制代码
    1. )
    复制代码
当你编写分支代码时,请额外关注由分支产生的重复代码块,如果可以简单的消灭它们,那就不要迟疑。
[h2]4. 谨慎使用三元表达式[/h2]三元表达式是 Python 2.5 版本后才支持的语法。在那之前,Python 社区一度认为三元表达式没有必要,我们需要使用
  1. xandaorb
复制代码
的方式来模拟它。[注]
事实是,在很多情况下,使用普通的
  1. if/else
复制代码
语句的代码可读性确实更好。盲目追求三元表达式很容易诱惑你写出复杂、可读性差的代码。
所以,请记得只用三元表达式处理简单的逻辑分支。
    1. language = "python" if you.favor("dynamic") else "golang"
    复制代码
对于绝大多数情况,还是使用普通的
  1. if/else
复制代码
语句吧。
[h1]常见技巧[/h1][h2]1. 使用“德摩根定律”[/h2]在做分支判断时,我们有时候会写成这样的代码:
    1. # 如果用户没有登录或者用户没有使用 chrome,拒绝提供服务
    复制代码
    1. if not user.has_logged_in or not user.is_from_chrome:
    复制代码
    1.     return "our service is only available for chrome logged in user"
    复制代码
第一眼看到代码时,是不是需要思考一会才能理解它想干嘛?这是因为上面的逻辑表达式里面出现了 2 个
  1. not
复制代码
和 1 个
  1. or
复制代码
。而我们人类恰好不擅长处理过多的“否定”以及“或”这种逻辑关系。
这个时候,就该 德摩根定律 出场了。通俗的说,德摩根定律就是
  1. notAornotB
复制代码
等价于
  1. not(AandB)
复制代码
。通过这样的转换,上面的代码可以改写成这样:
    1. if not (user.has_logged_in and user.is_from_chrome):
    复制代码
    1.     return "our service is only available for chrome logged in user"
    复制代码
怎么样,代码是不是易读了很多?记住德摩根定律,很多时候它对于简化条件分支里的代码逻辑非常有用。
[h2]2. 自定义对象的“布尔真假”[/h2]我们常说,在 Python 里,“万物皆对象”。其实,不光“万物皆对象”,我们还可以利用很多魔法方法(文档中称为:user-defined method),来自定义对象的各种行为。我们可以用很多在别的语言里面无法做到、有些魔法的方式来影响代码的执行。
比如,Python 的所有对象都有自己的“布尔真假”:
  • 布尔值为假的对象:
    1. None
    复制代码
    ,
    1. 0
    复制代码
    ,
    1. False
    复制代码
    ,
    1. []
    复制代码
    ,
    1. ()
    复制代码
    ,
    1. {}
    复制代码
    ,
    1. set()
    复制代码
    ,
    1. frozenset()
    复制代码
    , ... ...
  • 布尔值为真的对象:非
    1. 0
    复制代码
    的数值、
    1. True
    复制代码
    ,非空的序列、元组,普通的用户类实例,... ...
通过内建函数
  1. bool()
复制代码
,你可以很方便的查看某个对象的布尔真假。而 Python 进行条件分支判断时用到的也是这个值:
    1. >>> bool(object())
    复制代码
    1. True
    复制代码
重点来了,虽然所有用户类实例的布尔值都是真。但是 Python 提供了改变这个行为的办法:自定义类的
  1. __bool__
复制代码
魔法方法 (在 Python 2.X 版本中为
  1. __nonzero__
复制代码
)。当类定义了
  1. __bool__
复制代码
方法后,它的返回值将会被当作类实例的布尔值。
另外,
  1. __bool__
复制代码
不是影响实例布尔真假的唯一方法。如果类没有定义
  1. __bool__
复制代码
方法,Python 还会尝试调用
  1. __len__
复制代码
方法(也就是对任何序列对象调用
  1. len
复制代码
函数),通过结果是否为
  1. 0
复制代码
判断实例真假。
那么这个特性有什么用呢?看看下面这段代码:
    1. class UserCollection(object):
    复制代码

    1.     def __init__(self, users):
    复制代码
    1.         self._users = users
    复制代码


    1. users = UserCollection([piglei, raymond])
    复制代码

    1. if len(users._users) > 0:
    复制代码
    1.     print("There's some users in collection!")
    复制代码
上面的代码里,判断
  1. UserCollection
复制代码
是否有内容时用到了
  1. users._users
复制代码
的长度。其实,通过为
  1. UserCollection
复制代码
添加
  1. __len__
复制代码
魔法方法,上面的分支可以变得更简单:
    1. class UserCollection:
    复制代码

    1.     def __init__(self, users):
    复制代码
    1.         self._users = users
    复制代码

    1.     def __len__(self):
    复制代码
    1.         return len(self._users)
    复制代码


    1. users = UserCollection([piglei, raymond])
    复制代码

    1. # 定义了 __len__ 方法后,UserCollection 对象本身就可以被用于布尔判断了
    复制代码
    1. if users:
    复制代码
    1.     print("There's some users in collection!")
    复制代码
通过定义魔法方法
  1. __len__
复制代码
  1. __bool__
复制代码
,我们可以让类自己控制想要表现出的布尔真假值,让代码变得更 pythonic。
[h2]3. 在条件判断中使用 all() / any()[/h2]
  1. all()
复制代码
  1. any()
复制代码
两个函数非常适合在条件判断中使用。这两个函数接受一个可迭代对象,返回一个布尔值,其中:
    1. all(seq)
    复制代码
    :仅当
    1. seq
    复制代码
    中所有对象都为布尔真时返回
    1. True
    复制代码
    ,否则返回
    1. False
    复制代码
    1. any(seq)
    复制代码
    :只要
    1. seq
    复制代码
    中任何一个对象为布尔真就返回
    1. True
    复制代码
    ,否则返回
    1. False
    复制代码
假如我们有下面这段代码:
    1. def all_numbers_gt_10(numbers):
    复制代码
    1.     """仅当序列中所有数字大于 10 时,返回 True
    复制代码
    1.     """
    复制代码
    1.     if not numbers:
    复制代码
    1.         return False
    复制代码

    1.     for n in numbers:
    复制代码
    1.         if n  10 for n in numbers)
    复制代码
简单、高效,同时也没有损失可用性。
[h2]4. 使用 try/while/for 中 else 分支[/h2]让我们看看这个函数:
    1. def do_stuff():
    复制代码
    1.     first_thing_successed = False
    复制代码
    1.     try:
    复制代码
    1.         do_the_first_thing()
    复制代码
    1.         first_thing_successed = True
    复制代码
    1.     except Exception as e:
    复制代码
    1.         print("Error while calling do_some_thing")
    复制代码
    1.         return
    复制代码

    1.     # 仅当 first_thing 成功完成时,做第二件事
    复制代码
    1.     if first_thing_successed:
    复制代码
    1.         return do_the_second_thing()
    复制代码
在函数
  1. do_stuff
复制代码
中,我们希望只有当
  1. do_the_first_thing()
复制代码
成功调用后(也就是不抛出任何异常),才继续做第二个函数调用。为了做到这一点,我们需要定义一个额外的变量
  1. first_thing_successed
复制代码
来作为标记。
其实,我们可以用更简单的方法达到同样的效果:
    1. def do_stuff():
    复制代码
    1.     try:
    复制代码
    1.         do_the_first_thing()
    复制代码
    1.     except Exception as e:
    复制代码
    1.         print("Error while calling do_some_thing")
    复制代码
    1.         return
    复制代码
    1.     else:
    复制代码
    1.         return do_the_second_thing()
    复制代码
  1. try
复制代码
语句块最后追加上
  1. else
复制代码
分支后,分支下的
  1. do_the_second_thing()
复制代码
便只会在 try 下面的所有语句正常执行(也就是没有异常,没有 return、break 等)完成后执行。
类似的,Python 里的
  1. for/while
复制代码
循环也支持添加
  1. else
复制代码
分支,它们表示:当循环使用的迭代对象被正常耗尽、或 while 循环使用的条件变量变为 False 后才执行 else 分支下的代码。
[h1]常见陷阱[/h1][h2]1. 与 None 值的比较[/h2]在 Python 中,有两种比较变量的方法:
  1. ==
复制代码
  1. is
复制代码
,二者在含义上有着根本的区别:
    1. ==
    复制代码
    :表示二者所指向的的值是否一致
    1. is
    复制代码
    :表示二者是否指向内存中的同一份内容,也就是
    1. id(x)
    复制代码
    是否等于
    1. id(y)
    复制代码
  1. None
复制代码
在 Python 语言中是一个单例对象,如果你要判断某个变量是否为 None 时,记得使用
  1. is
复制代码
而不是
  1. ==
复制代码
,因为只有
  1. is
复制代码
才能在严格意义上表示某个变量是否是 None。
否则,可能出现下面这样的情况:
    1. >>> class Foo(object):
    复制代码
    1. ...     def __eq__(self, other):
    复制代码
    1. ...         return True
    复制代码
    1. ...
    复制代码
    1. >>> foo = Foo()
    复制代码
    1. >>> foo == None
    复制代码
    1. True
    复制代码
在上面代码中,Foo 这个类通过自定义
  1. __eq__
复制代码
魔法方法的方式,很容易就满足了
  1. ==None
复制代码
这个条件。
所以,当你要判断某个变量是否为 None 时,请使用
  1. is
复制代码
而不是
  1. ==
复制代码

[h2]2. 留意 and 和 or 的运算优先级[/h2]看看下面这两个表达式,猜猜它们的值一样吗?
    1. >>> (True or False) and False
    复制代码
    1. >>> True or False and False
    复制代码
答案是:不一样,它们的值分别是
  1. False
复制代码
  1. True
复制代码
,你猜对了吗?
问题的关键在于:
  1. and
复制代码
运算符的优先级大于
  1. or
复制代码
。因此上面的第二个表达式在 Python 看来实际上是
  1. Trueor(FalseandFalse)
复制代码
。所以结果是
  1. True
复制代码
而不是
  1. False
复制代码

在编写包含多个
  1. and
复制代码
  1. or
复制代码
的表达式时,请额外注意
  1. and
复制代码
  1. or
复制代码
的运算优先级。即使执行优先级正好是你需要的那样,你也可以加上额外的括号来让代码更清晰。
[h1]结语[/h1]以上就是『Python 工匠』系列文章的第二篇。不知道文章的内容是否对你的胃口。
代码内的分支语句不可避免,我们在编写代码时,需要尤其注意它的可读性,避免对其他看到代码的人造成困扰。
看完文章的你,有没有什么想吐槽的?请留言告诉我吧。
[h1]注解[/h1]
  • 事实上
    1. xandaorb
    复制代码
    不是总能给你正确的结果,只有当 a 与 b 的布尔值为真时,这个表达式才能正常工作,这是由逻辑运算的短路特性决定的。你可以在命令行中运行
    1. TrueandNoneor0
    复制代码
    试试看,结果是 0 而非 None。
分享到 :
0 人收藏
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

积分:
帖子:
精华:
期权论坛 期权论坛
发布
内容

下载期权论坛手机APP