当您第一次学习编程时,您会寻找(或者,可能是被分配)能够强化基本概念的项目。但是,一旦您获得了更多的知识和经验,您多久会从高级程序员的角度重新审视那些初学者项目?
在这篇文章中,我只想做到这一点。我想重温一个常见的初学者项目——用 Python 实现游戏“Rock Paper Scissors”——用我从近八年的 Python 编程经验中获得的知识。
目录
“石头剪刀布”的规则
在深入研究代码之前,让我们先概述一下“Rock Paper Scissors”是如何播放的。两名玩家每人选择三个项目之一:石头、纸或剪刀。玩家同时向对方透露他们的选择,获胜者由以下规则决定:
- 石头打剪刀
- 剪刀比纸
- 纸胜过岩石
长大后,我和我的朋友们用“石头剪刀布”解决了各种各样的问题。在单人视频游戏中谁先玩?谁得到最后一罐汽水?谁必须去收拾我们刚刚制造的烂摊子?重要的东西。
要求
让我们列出实现的一些要求。让我们专注于编写一个名为play()
的函数,而不是构建一个完整的游戏,它接受两个字符串参数——每个玩家选择的"rock"
、 "paper"
或"scissors"
——并返回一个字符串表示获胜者(例如, "paper wins"
)或如果游戏结果为平局(例如, "tie"
)。
以下是play()
的调用方式及其返回内容的一些示例:
>>> play("rock", "paper") 'rock wins' >>> play("scissors", "paper") 'scissors wins' >>> play("paper", "paper") 'tie'
如果两个参数中的一个或两个无效,这意味着它们不是"rock"
、 "paper"
或"scissors"
之一,那么play()
应该引发某种异常。
play()
也应该是可交换的。也就是说, play("rock", "paper")
应该返回与play("paper", "rock")
相同的内容。
“初学者”解决方案
要设置比较基准,请考虑初学者如何实现play()
函数。如果这个初学者和我刚学编程时一样,他们可能会开始写一大堆if
语句:
def play(player1_choice, player2_choice): if player1_choice == "rock": if player2_choice == "rock": return "tie" elif player2_choice == "paper": return "paper wins" elif player2_choice == "scissors": return "rock wins" else: raise ValueError(f"Invalid choice: {player2_choice}") elif player1_choice == "paper": if player2_choice == "rock": return "paper wins" elif player2_choice == "paper": return "tie" elif player2_choice == "scissors": return "rock wins" else: raise ValueError(f"Invalid choice: {player2_choice}") elif player1_choice == "scissors": if player2_choice == "rock": return "rock wins" elif player2_choice == "paper": return "scissors wins" elif player2_choice == "scissors": return "tie" else: raise ValueError(f"Invalid choice: {player2_choice}") else: raise ValueError(f"Invalid choice: {player1_choice}")
严格来说,这段代码没有任何问题。它运行无误并满足所有要求。它也类似于谷歌搜索“rock paper scissors python”的一些高级实现。
不过,有经验的程序员会很快识别出许多代码异味。尤其是代码是重复的,有很多可能的执行路径。
高级解决方案#1
从更高级的角度实现“Rock Paper Scissors”的一种方法是利用 Python 的字典类型。字典可以根据游戏规则将项目映射到他们击败的项目。
让我们称这个字典loses_to
(命名很难,你们大家):
loses_to = { "rock": "scissors", "paper": "rock", "scissors": "paper", }
loses_to
提供了一个简单的 API 来确定哪个物品输给了另一个物品:
>>> loses_to["rock"] 'scissors' >>> loses_to["scissors"] 'paper'
字典有几个好处。您可以使用它来:
- 通过检查成员资格或引发
KeyError
来验证所选项目 - 通过检查一个值是否输给相应的键来确定赢家
考虑到这一点, play()
函数可以编写如下:
def play(player1_choice, player2_choice): if player2_choice == loses_to[player1_choice]: return f"{player1_choice} wins" if player1_choice == loses_to[player2_choice]: return f"{player2_choice} wins" if player1_choice == player2_choice: return "tie"
在这个版本中, play()
在尝试访问无效键时利用了loses_to
字典引发的内置KeyError
。这有效地验证了玩家的选择。因此,如果任何一个玩家选择了一个无效的项目——比如"lizard"
或1234
—— play()
就会引发KeyError
:
>>> play("lizard", "paper") Traceback (most recent call last): File "<stdin>", line 1, in <module> File "<stdin>", line 2, in play KeyError: 'lizard'
尽管KeyError
不如带有描述性消息的ValueError
有用,但它仍然可以完成工作。
新的play()
函数比原来的函数简单得多。无需处理一堆显式案例,只需检查三种案例:
-
player2_choice
输给player1_choice
-
player1_choice
输给player2_choice
-
player1_choice
和player2_choice
是一样的
然而,还有第四个隐藏的案例,你几乎必须眯着眼才能看到。当其他三种情况都不为真时,就会发生这种情况,在这种情况下play()
返回None
值。
但是……这种情况真的会发生吗?实际上,没有。它不能。根据游戏规则,如果玩家 1 没有输给玩家 2 ,玩家 2 也没有输给玩家 1,那么两个玩家肯定选择了相同的物品。
换句话说,我们可以从play()
中删除最后一个if
块,如果其他两个if
块都没有执行,则只return "tie"
:
def play(player1_choice, player2_choice): if player2_choice == loses_to[player1_choice]: return f"{player1_choice} wins" if player1_choice == loses_to[player2_choice]: return f"{player2_choice} wins" return "tie"
我们已经做出了权衡。我们牺牲了清晰度——我认为与“初学者”版本相比,理解上述play()
函数的工作原理需要更大的认知负荷——以缩短函数并避免无法访问的状态。
这种折衷值得吗?我不知道。纯度胜过实用性吗?
高级解决方案 #2
以前的解决方案效果很好。它比“初学者”解决方案可读且短得多。但它不是很灵活。也就是说,如果不重写一些逻辑,它就无法处理“Rock Paper Scissors”的变体。
例如,有一个名为“Rock Paper Scissors Lizard Spock”的变体,它有一套更复杂的规则:
- 岩石击败剪刀和蜥蜴
- 纸击败摇滚和斯波克
- 剪刀打纸和蜥蜴
- 蜥蜴击败斯波克和纸
- 斯波克打败剪刀和石头
你如何调整代码来处理这种变化?
首先,将loses_to
字典中的字符串值替换为 Python 集。每个集合包含所有输给相应键的项目。下面是这个版本的loses_to
使用原始“Rock Paper Scissors”规则的样子:
loses_to = { "rock": {"scissors"}, "paper": {"rock"}, "scissors": {"paper"}, }
为什么要套?因为我们只关心给定密钥丢失了哪些项目。我们不关心这些项目的顺序。
要使play()
适应新的loses_to
字典,您所要做的就是将==
替换为in
以使用成员资格检查而不是相等检查:
def play(player1_choice, player2_choice): # vv--- replace == with in if player2_choice in loses_to[player1_choice]: return f"{player1_choice} wins" # vv--- replace == with in if player1_choice in loses_to[player2_choice]: return f"{player2_choice} wins" return "tie"
花点时间运行此代码并验证一切是否仍然有效。
现在将loses_to
替换为实现“Rock Paper Scissors Lizard Spock”规则的字典。这看起来像:
loses_to = { "rock": {"scissors", "lizard"}, "paper": {"rock", "spock"}, "scissors": {"paper", "lizard"}, "lizard": {"spock", "paper"}, "spock": {"scissors", "rock"}, }
新的play()
函数完美地适用于这些新规则:
>>> play("rock", "paper") 'paper wins' >>> play("spock", "lizard") 'lizard wins' >>> play("spock", "spock") 'tie'
在我看来,这是选择正确数据结构的力量的一个很好的例子。通过使用集合来表示丢失到loses_to
字典中的键的所有项目并将==
替换为in
,您已经制定了更通用的解决方案,而无需添加一行代码。
高级解决方案#3
让我们退后一步,采取一种稍微不同的方法。我们将构建一个包含所有可能输入及其结果的表格,而不是在字典中查找项目以确定获胜者。
你仍然需要一些东西来代表游戏规则,所以让我们从之前解决方案中的loses_to
字典开始:
loses_to = { "rock": {"scissors"}, "paper": {"rock"}, "scissors": {"paper"}, }
接下来,编写一个函数build_results_table()
,它接受一个规则字典,如loses_to
,并返回一个将状态映射到其结果的新字典。例如,以下是build_results_table()
在使用loses_to
作为参数调用时应返回的内容:
>>> build_results_table(loses_to) { {"rock", "scissors"}: "rock wins", {"paper", "rock"}: "paper wins", {"scissors", "paper"}: "scissors wins", {"rock", "rock"}: "tie", {"paper", "paper"}: "tie", {"scissors", "scissors"}: "tie", }
如果你认为有什么东西看起来在那里,你是对的。这本词典有两个问题:
-
{"rock", "rock"}
这样的集合不能存在。集合不能有重复的元素。在真实场景中,这个集合看起来像{"rock"}
。您实际上不必担心太多。我用两个元素编写了这些集合,以明确这些状态代表什么。 - 您不能将集合用作字典键。但是我们想使用集合,因为它们会自动为我们处理交换性。也就是说,
{"rock", "paper"}
和{"paper", "rock"}
的计算结果相等,因此在查找时应该返回相同的结果。
解决这个问题的方法是使用 Python 的内置frozenset
类型。像集合一样, frozensets
成员检查,并且当且仅当两个集合具有相同的成员时,它们才与另一个set
或frozenset
进行比较。然而,与标准集不同, frozenset
实例是不可变的。因此,它们可以用作字典键。
要实现build_results_table()
,您可以遍历loses_to
字典中的每个键,并为集合中与键对应的每个字符串值构建一个frozenset
实例:
def build_results_table(rules): results = {} for key, values in rules.items(): for value in values: state = frozenset((key, value)) result = f"{key} wins" results[state] = result return results
这让你走到了一半:
>>> build_results_table(loses_to) {frozenset({'rock', 'scissors'}): 'rock wins', frozenset({'paper', 'rock'}): 'paper wins', frozenset({'paper', 'scissors'}): 'scissors wins'}
但是,导致平局的州不包括在内。要添加这些,您需要为映射到字符串"tie"
的rules
字典中的每个键创建frozenset
实例:
def build_results_table(rules): results = {} for key, values in rules.items(): # Add the tie states results[frozenset((key,))] = "tie" # <-- New # Add the winning states for value in values: state = frozenset((key, value)) result = f"{key} wins" results[state] = result return results
现在build_results_table()
返回的值看起来是正确的:
>>> build_results_table(loses_to) {frozenset({'rock'}): 'tie', frozenset({'rock', 'scissors'}): 'rock wins', frozenset({'paper'}): 'tie', frozenset({'paper', 'rock'}): 'paper wins', frozenset({'scissors'}): 'tie', frozenset({'paper', 'scissors'}): 'scissors wins'}
为什么要经历所有这些麻烦?毕竟, build_results_table()
看起来比之前解决方案中的play()
函数更复杂。
你没有错,但我想指出这种模式非常有用。如果程序中可以存在的状态数量有限,您有时可以通过预先计算所有这些状态的结果来显着提高速度。这对于像“石头剪刀布”这样简单的东西来说可能有点过头了,但在有数十万甚至数百万个州的情况下可能会产生巨大的差异。
这种方法有意义的一个真实场景是强化学习应用中使用的Q 学习算法。在该算法中,维护了一张状态表——Q 表——将每个状态映射到一组预先确定的动作的概率。一旦代理受过训练,它就可以根据观察到的状态的概率进行选择和行动,然后采取相应的行动。
通常,会计算一个类似于build_results_table()
生成的表,然后将其存储在一个文件中。当程序运行时,预先计算的表被加载到内存中,然后由应用程序使用。
因此,既然您有一个可以构建结果表的函数, loses_to
表分配给outcomes
变量:
outcomes = build_results_table(loses_to)
现在您可以编写一个play()
函数,该函数根据传递给 play 的参数在outcomes
表中查找状态,然后返回结果:
def play(player1_choice, player2_choice): state = frozenset((player1_choice, player2_choice)) return outcomes[state]
这个版本的play()
非常简单。就两行代码!如果你愿意,你甚至可以将它写成一行:
def play(player1_choice, player2_choice): return outcomes[frozenset((player1_choice, player2_choice))]
就个人而言,我更喜欢两行版本而不是单行版本。
您的新play()
函数遵循游戏规则并且是可交换的:
>>> play("rock", "paper") 'paper wins' >>> play("paper", "rock") 'paper wins'
如果使用无效的选择调用play()
甚至会引发KeyError
,但由于outcomes
字典的键是集合,因此该错误的帮助较小:
>>> play("lizard", "paper") Traceback (most recent call last): File "<stdin>", line 1, in <module> File "<stdin>", line 21, in play return outcomes[state] KeyError: frozenset({'lizard', 'paper'})
然而,模糊的错误可能不是问题。在本文中,您只实现play()
函数。在“Rock Paper Scissors”的真正实现中,您很可能会捕获用户输入并在将用户的选择传递给play()
之前对其进行验证。
那么,这个实现与以前的实现相比要快多少呢?下面是一些计时结果,用于比较使用 IPython 的%timeit
魔术函数的各种实现的性能。 play1()
是高级解决方案 #2部分中play()
() 的版本,而play2()
是当前版本:
In [1]: %timeit play1("rock", "paper") 141 ns ± 0.0828 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each) In [2]: %timeit play2("rock", "paper") 188 ns ± 0.0944 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)
在这种情况下,使用结果表的解决方案实际上比以前的实现要慢。这里的罪魁祸首是将函数参数转换为frozenset
的行。因此,尽管字典查找速度很快,并且构建将状态映射到结果的表可能会提高性能,但您需要小心避免昂贵的操作,这些操作最终可能会否定您期望获得的任何收益。
结论
我写这篇文章作为练习。既然我有很多经验,我很想知道如何在 Python 中处理像“Rock Paper Scissors”这样的初学者项目。我希望你觉得它很有趣。如果您现在有任何灵感来重新审视自己的一些初学者项目,那么我想我已经完成了我的工作!
如果您确实修改了自己的一些初学者项目,或者您过去曾这样做过,请在评论中告诉我它的进展情况。你有学到什么新东西吗?您的新解决方案与您作为初学者编写的解决方案有何不同?
是什么启发了这篇文章?
来自 Julia 世界的一位资深人士Miguel Raz Guzmán Macedo让我关注Mosè Giordano的博客文章。 Mosè 利用 Julia 的多重调度范式用不到 10 行代码编写了“Rock Paper Scissors”:
我不会详细介绍 Mosè 的代码是如何工作的。 Python 甚至不支持开箱即用的多分派。 (虽然你可以在plum
package的帮助下使用它。)
Mosè 的文章让我的大脑开始运转,并鼓励我重新审视 Python 中的“Rock Paper Scissors”,思考如何以不同的方式处理这个项目。
然而,当我研究解决方案时,我想起了很久以前我为 Real Python 写过的一篇文章:
事实证明,我在这里“发明”的前两个解决方案与 Real Python 文章的作者 Chris Wilkerson 提出的解决方案相似。
Chris 的解决方案功能更全面。它包括一个交互式游戏机制,甚至使用 Python 的Enum
类型来表示游戏项目。那一定也是我第一次听说“Rock Paper Scissors Lizard Spock”的地方。
你喜欢这篇文章吗?通过订阅我的每周好奇代码通讯,随时了解我的所有内容的最新信息,尽早访问我的课程,并从 Python 和 Julia 社区中挑选的内容直接发送到您的收件箱。
来源: https://davidamos.dev/revisiting-rock-paper-scissors-in-python/