6 设计复杂的if…elif链
在大多数情况下,脚本涉及大量选择。有时这些选择很简单,我们一眼就能判断出设计的质量。有时这些选择非常复杂,不容易确定if语句是否正确处理了所有条件。
在最简单的情况下,假设有一个条件C和它的相反条件\neg C。它们是if…else语句的两个条件,其中一个条件\neg C声明在if子句中,另一个条件隐含在else子句中。
本实例将使用p\vee q来表示Python的OR运算符。我们可以称这两个条件完备,因为:
C~\vee \neg C=\mathbf{T}
之所以称之为完备,是因为不存在其他条件,没有第三种选择,这就是排中律(law of excluded middle)。这也是else子句背后的工作原理。要么执行if语句,要么执行else语句,没有第三种选择。
在实际编程中,经常存在复杂的选择。假设有一组条件,C={C_1,C_2,C_3,\cdots,C_n\}。
我们不想简单地假设:
C_1~\vee~C_2~\vee~C_3~\vee~\cdots~\vee~C_n=\mathbf{T}
可以使用\mathop{\vee} \limits_{c\in C}c
来表示与any©或者any([C_1, C_2, C_3, …, C_n]相似的含义。我们需要证明\mathop{\vee} \limits_{c\in C}c=\mathbf{T}
,而不能臆断该公式的结果为true。
我们可能遗漏了条件C_{n+1}
,然后逻辑就乱了。遗漏这个条件意味着程序在这种情况下将无法正常工作。
怎样才能确保没有遗漏条件呢?
6.1 准备工作
首先,来看一个if…elif链的具体例子。在赌场游戏双骰子(Craps)中,有多种适用于一次掷两个骰子的规则。以下规则适用于游戏的出场(come out)掷。
2、3或者12是双骰,输掉所有过线注。
7或11,赢下所有过线注。
剩下的数字建立一个点数(point)。
许多玩家把赌注放在过线(pass line)。另外还有一种不常用的不过线(don’t pass line)。我们将以上面这三个条件为例来介绍本实例,因为这三个条件中有一个潜在的不明确的条件。
6.2 实战演练
在编写if语句时,即使语句看起来不重要,也需要确保覆盖所有条件。
(1) 枚举已知的选择。本实例有三个规则:(2, 3, 12)、(7, 11)、剩余不明确的数字。
(2) 确定所有可能存在的条件域。在本例中,条件域中有11个条件:从2到12之间的数字。
(3) 对比已知的选择和条件域。条件集C和条件域U之间的比较可能有3种结果。
已知的选择比条件域更多,即C\supset U。设计有很大问题,需要重新思考。
已知条件和所有条件的条件域之间有一个分歧:U\setminus C\neq \O。在某些情况下,很明显还没有覆盖所有条件。在其他情况下,需要仔细推理。我们需要用更准确的定义术语来替换任何模糊或不明确的术语。
本例有一个模糊的术语,我们可以将其替换为更具体的内容。术语剩下的数字似乎是值(4, 5, 6, 8, 9, 10)的列表。提供这个列表可以消除任何可能的分歧和怀疑。
已知选择与可能的选择域匹配,即U\equiv C。两种常见的情况如下。
像C~\vee \neg C这样简单的情况,可以使用一个if…else语句,不需要使用本实例,因为很容易就可以推断出\neg C。
更复杂的情况。因为我们知道整个条件域,即,所以需要使用本实例来编写一个if语句和elif语句的逻辑链,一个条件一个子句。
然而,区别并不总是清晰的。在本例中,我们没有为其中一个条件制定详细的规范,但条件多半是明确的。如果我们认为遗漏的条件是显而易见的,那么可以使用一个else子句,而不是显式地写出来。如果我们认为遗漏的条件可能会被误解,那么应该认为它是含混不清的,并使用这个实例。
(1) 编写覆盖所有已知条件的if…elif…elif链。对于本例,如下所示:
dice = die_1 + die_2
if dice in (2, 3, 12):
game.craps()
elif dice in (7, 11):
game.winner()
elif dice in (4, 5, 6, 8, 9, 10):
game.point(die)
(2) 添加抛出异常的else子句,如下所示:
else:
raise Exception('Design Problem Here: not all conditions accounted for')
额外的else崩溃条件为我们提供了一种可以在发现逻辑问题时积极地识别的方法。可以确信任何错误都会引发明显问题。
6.3 工作原理
我们的目标是确保程序一直正常工作。虽然可以借助于测试,但是在设计用例和测试用例时仍然可能出现错误假设。
虽然严格的逻辑是必不可少的,但是我们仍然会犯错误。此外,其他人也可能尝试调整我们的代码,并导致错误。更令人尴尬的是,我们在改变自己的代码时,也可能导致故障。
else冲突选项迫使我们明确每个条件,没有任何假设。正如前面提到的,在抛出异常时,任何逻辑上的错误都将暴露。
else冲突选项并没有显著的性能影响。一个简单的else子句比一个带条件的elif语句略快。如果我们认为应用程序的性能取决于单个表达式的开销,那么就需要解决更严重的设计问题。计算单个表达式的开销极少会成为一个算法中开销最大的部分。
在出现设计问题时,通过异常引发崩溃是明智的行为,而遵循设计模式向日志中写入警告消息并没有多大意义。如果出现这样的逻辑分歧,那么程序将会出现致命错误,尽快找到并解决这个问题很重要。
6.4 补充知识
在许多情况下,在程序处理的某个时刻,可以从对所需后置条件的检查中派生出一个if…elif…elif链。例如,我们需要一个语句来表示m为a或b中较大的值。
(为了符合逻辑,避免使用m = max(a, b)。)
规范化后的最终条件为:
(m=a~\vee~m=b)~\wedge~m>a~\wedge~m>b
可以从最终条件倒推,将最终目标编写为assert语句:
# 执行某些处理
assert (m = a or m = b) and m > a and m > b
在规定了目标之后,就可以识别出达到该目标的语句了。类似m = a和m = b这样明确的赋值语句是恰当的,但是只能在一定的条件下使用。
上述每个语句都是解决方案的一部分,我们可以推导出一个前置条件,说明应该如何使用语句。赋值语句的前置条件是if和elif表达式。当a >= b时,需要使用m = a;当b >= a时,需要使用m = b。把逻辑重新编排成为代码,如下所示:
if a >= b:
m = a
elif b >= a:
m = b
else:
raise Exception( 'Design Problem')
assert (m = a or m = b) and m > a and m > b
请注意,条件域U={a\geqslant b,b\geqslant a\}
是完备的,没有其他可能的关系。另外还需要注意,在a = b的边界情况下,我们实际上并不关心使用哪个赋值语句,Python将按顺序处理决策,并将执行m = a。事实上,这种选择是一致的,不应该对if…elif…elif链的设计产生任何影响。我们应该总是先编写条件,而不考虑子句的执行顺序。
6.5 延伸阅读
本实例类似于空悬else(dangling else)语法问题,请参阅https://en.wikipedia.org/wiki/Dang-ling_else。
Python的缩进解决了空悬else语法问题,但是解决不了在复杂的if…elif…elif链中正确说明所有条件的语义问题。
此外,请参阅https://en.wikipedia.org/wiki/Predicate_transformer_semantics。
7 设计正确终止的while语句
在大部分情况下,Python的for语句能够提供需要的所有迭代控制。在许多情况下,可以使用像map()、filter()和reduce()这样的内置函数来处理数据集合。
但是在某些情况下,需要使用while语句,其中一些情况涉及不能创建迭代器来遍历元素的数据结构。另外有些数据涉及用户交互,直到获取用户的输入时我们才有数据。
7.1 准备工作
假设我们将提示用户输入密码。本实例将使用getpass模块,这样就不会显示回显。
此外,为了确保用户正确地输入密码,我们将提示用户输入两次密码并比较结果。在这种情况下,一个简单的for语句不能很好地解决问题。虽然可以勉强使用for语句,但是由此产生的代码看起来很奇怪:for语句有明确的上限,但是提示用户输入并不具有上限。
7.2 实战演练
设计这种迭代算法的核心处理过程可以大致分为6步。简单的for语句不能解决问题时,可用这种迭代算法。
(1) 定义完成条件。本例有两个密码的副本——password_text
和confirming_password_text
。循环后必须为true的条件是password_text = = confirming_password_text
。理想情况下,从用户或文件读取信息是一种有界的活动。最终,用户会输入一对匹配值。在他们输入这对匹配值之前,无限迭代。
当然,还存在其他边界条件。例如,文件结束或者让用户返回到先前的提示符。在Python中,通常使用异常来处理这些边界条件。
当然,还可以将这些附加条件添加到之前的完成条件的定义中。我们可能需要一个复杂的终止条件,如文件结束或Password_text = = confirming_password_text。
本例将选择异常处理,并假设使用了try:语句块。这种方法极大简化了设计,使终止条件只有一个子句。
该循环的概要如下所示:
# 初始化某些条件
while # 当不满足终止条件时:
# 执行某些处理
assert password_text == confirming_password_text
我们将完成条件的定义编写为最后的assert语句,并为其余的迭代添加了注释,具体内容将在后续步骤中填充。
(2) 定义一个在循环迭代时为true的条件,这就是所谓的不变式(invariant),因为它在循环处理的开始和结束时始终为true。通常通过泛化后置条件或引入另一个变量来创建不变式。
当从用户(或文件)读取内容时,有一个隐含的状态变化,这个状态变化是不变式的重要组成部分,可以称之为获取下一个输入状态(get the next input)的变化。通常须清楚地表明,循环将从输入流获取下一个值。
无论while语句的循环体有多么复杂的逻辑,都必须保证循环正常得到下一个元素。有的条件实际上并没有得到下一个输入,这是一种常见的错误,将导致程序挂起——在while语句循环体的if语句中,逻辑路径没有状态变化。不变式没有正确地重置,或者在设计循环时不变式没有正确地连接。
在本例中,不变式将使用概念性的new-input()条件。当使用getpass()函数读取一个新值时,这个条件为true。经过扩展的循环设计如下所示:
# 初始化某些条件
# 断言不变式new-input(password_text)
# 和new-input(confirming_password_text)
while # 当不满足终止条件时:
# 执行某些处理
# 断言不变式new-input(password_text)
# 和new-input(confirming_password_text)
assert password_text == confirming_password_text
(3) 定义退出循环的条件。需要确保这个条件依赖于不变式为true。还需要确保,当这个终止条件最终为false时,目标状态将变为true。
在大多数情况下,循环条件是目标状态的逻辑否定。经过扩展的设计如下所示:
# 初始化某些条件
# 断言不变式new-input(password_text)
# 和new-input(confirming_password_text)
while password_text != confirming_password_text:
# 执行某些处理
# 断言不变式new-input(password_text)
# 和new-input(confirming_password_text)
assert password_text == confirming_password_text
(4) 定义初始化语句,确保不变式为true而且可以测试终止条件。本例需要为两个变量获取值。循环如下所示:
password_text= getpass()
confirming_password_text= getpass("Confirm: ")
# 断言new-input(password_text)
# 和new-input(confirming_password_text)
while password_text != confirming_password_text:
# 执行某些处理
# 断言new-input(password_text)
# 和new-input(confirming_password_text)
assert password_text == confirming_password_text
(5) 编写循环体,将不变式重置为true。我们需要编写最少的语句来完成该步骤。对于本示例循环,最少的语句很明显,即初始化语句。更新后的循环如下所示:
password_text= getpass()
confirming_password_text= getpass("Confirm: ")
# 断言new-input(password_text)
# 和new-input(confirming_password_text)
while password_text != confirming_password_text:
password_text= getpass()
confirming_password_text= getpass("Confirm: ")
# 断言new-input(password_text)
# 和new-input(confirming_password_text)
assert password_text == confirming_password_text
(6) 确定一个计时器,即一个单调递减函数,表明循环的每次迭代都越来越接近终止条件。
在收集用户输入时,我们不得不假设最终用户将输入一对匹配值。每次循环都将更加接近这对匹配值。为了更好地形式化,可以假定在遇到匹配值之前会有n个输入,那么程序就必须表明每次循环之后,n的值都变小了。
在复杂的情况下,可能需要将用户输入视为值列表。对于本示例,可以认为用户输入是一对值的序列:[(p_1,q_1),(p_2,q_2),(p_3,q_3),\cdots,(p_n,q_n)]
。根据这个有限的列表,我们更容易推断循环是否更加接近终止条件。
因为我们基于目标最终条件构建循环,所以可以绝对确定循环满足了设计要求。如果逻辑是合理的,那么循环将终止,并且以预期的结果终止。这是所有程序设计的目标:给定一些初始状态,使机器达到预期状态。
删除注释后的循环如下所示:
password_text= getpass()
confirming_password_text= getpass("Confirm: ")
while password_text != confirming_password_text:
password_text= getpass()
confirming_password_text= getpass("Confirm: ")
assert password_text == confirming_password_text
最后的后置条件是assert语句。对于复杂的循环,assert语句既是内置的测试,也可解释循环如何工作。
这种设计通常会产生一个循环,类似于我们基于直觉可能开发的循环。对于凭借直觉开发的设计,一步一步地论证并没有错。经过多次练习后,就可以更有信心地使用循环了,因为我们可以证明设计的合理性。
在本例中,循环体和初始化语句恰好是相同的代码。如果这是一个问题,那么我们可以定义一个只有两行的函数,以避免重复的代码。
7.3 工作原理
我们首先阐明了循环的目标,并且所做的一切将确保代码会实现目标。实际上,这是所有软件设计背后的动机——总是试图编写最少的语句来实现给定的目标状态。我们经常从目标反推初始化。推理链中的每一步实际上都说明了产生预期结果条件的某个语句S的最弱前置条件。
根据给定的后置条件,我们试图得出一个语句和前置条件。模式如下:
assert pre-condition
S
assert post-condition
后置条件是循环的完成条件。我们需要假设一个产生完成条件的语句S,以及该语句的前置条件。可以选择的语句有无数个,我们关注最弱的前置条件——具有最少假设的前置条件。
在某些情况下,通常在编写初始化语句时,前置条件只为true:任何初始状态都将作为语句的前置条件。这就是程序可以从任意初始状态开始并按预期完成的原因。这种情况是最理想的。
当设计while语句时,循环体内部有一个嵌套的上下文。循环体应当始终处于将不变式条件重置为true的处理过程中。在本例中,这意味着读取更多用户输入。在其他示例中,我们可能正在处理字符串中的另一个字符,或一组数字中的另一个数字。
我们需要证明当不变式为true且循环条件为false时,最终目标就实现了。当从最终目标开始,并根据最终目标创建不变式和循环条件时,这种证明会更容易一些。
更重要的是耐心地完成每一步,这样推理才是可靠的。我们需要证明循环能够正常运行,然后就可以放心地运行单元测试了。
7.4 延伸阅读
关于这个主题的一篇经典文章是David Gries的“A note on a standard strategy for developing loop invariants and loops”
。算法设计是一个很大的主题。Skiena的《算法设计手册》是很好的入门介绍。