实现“石头、剪刀、布”游戏
一天,我在一个 Python 技术群里看到一段有意思的讨论。讨论始于这么一个需求:
题目:写代码模拟“石头、剪刀、布”游戏。由玩家 A 和 B 随机进行 10 次游戏并打印结果。要求:用数字 0 来表示石头,1 表示剪刀,2 表示布。
紧跟着的,是一段实现了该需求的 Python 代码。如下所示:
import random
def game():
"""生成一局随机游戏,并打印游戏结果。"""
a = random.randint(0, 2)
b = random.randint(0, 2)
print(f"玩家 A:{a},玩家 B:{b}")
if a == b:
print("平局")
elif a == (b + 1) % 3:
print("玩家 B 获胜")
else:
print("玩家 A 获胜")
if __name__ == '__main__':
for num in range(10):
print(f">>> Game #{num}")
game()
不难看出,代码实现需求的方式有一点巧妙,主要体现在 elif a == (b + 1) % 3
上。要推导出这行代码,原作者需要历经以下几步思考:
[石头, 剪刀, 布]
分别对应 [0, 1, 2] 数字[石头, 剪刀, 布]
这个排列顺序,刚好是前一个赢过后一个,比如“石头(0)”克“剪刀(1)”,由此推导出判断语句:a == (b + 1)
- 到了“布”时,前一条规则回到了列表头:“布(2)”赢过“石头(0)”,由此推导出取模运算:
a == (b + 1) % 3
针对这段代码,大家当时主要争论的点是“性能”,即通过取模运算减少了分支后,对代码的执行性能有哪些影响。但我当时看到代码,脑子里冒出了另一个挥之不去的疑问:“这段代码真的实现了需求吗?”
毫无疑问,从执行结果来看,它的确实现了需求:
>>> Game #0
玩家 A:2,玩家 B:1
玩家 B 获胜
>>> Game #1
玩家 A:0,玩家 B:1
玩家 A 获胜
...
但问题的关键点在于,“实现需求”这个描述,实际上存在双重含义,而这份代码只满足了第一重。
“实现需求”的第一重含义是字面意义,它指代码是否满足了预期中的功能,面向的对象是普通用户。在这之外,隐藏着另一重更隐蔽的面向程序员的含义:是否能通过读代码来轻松还原需求。
代码的阅读体验不尽相同。当读到好代码时,我们可以轻松在大脑中描绘出需求的样貌,每行代码和原始需求之间,就像被一根根隐形的线连接了起来。借助代码这个媒介,需求晶莹剔透地展露在我们面前,丝毫毕现。
但是读糟糕的代码,就像是在充满污泥的池塘里寻找失物。需求藏身于浑浊的泥水里,轮廓模糊,让我们很难掌握它的踪迹。
如上所述,“实现需求”的第二重含义,指的是代码是否能将原始需求清晰地传递给读者。从这个维度看,上面的“剪刀石头布”代码远未达到要求。
改进“石头、剪刀、布”
为了能更好地“实现需求”,我重写了一份“石头剪刀布”的代码。如下所示:
import random
ROCK, SCISSOR, PAPER = range(3)
# 构建“赢”的基础规则:“我:对手”
WIN_RULE = {
ROCK: SCISSOR,
SCISSOR: PAPER,
PAPER: ROCK,
}
def build_rules():
"""构建完整的游戏规则"""
rules = {}
for k, v in WIN_RULE.items():
rules[(k, v)] = True
rules[(v, k)] = False
return rules
def game_v2(rules):
"""生成一局随机游戏,并打印游戏结果。"""
a = random.choice([ROCK, SCISSOR, PAPER])
b = random.choice([ROCK, SCISSOR, PAPER])
print(f"玩家 A:{a},玩家 B:{b}")
if a == b:
print("平局")
elif rules[(a, b)]:
print("玩家 A 获胜")
else:
print("玩家 B 获胜")
if __name__ == '__main__':
rules = build_rules()
for num in range(10):
print(f">>> Game #{num}")
game_v2(rules)
新代码最主要的改动,在于将“石头剪刀布”的游戏规则显式表达了出来。 通过定义 WIN_RULE
字典,我们清晰向读者传达了整个需求中最重要的部分,也就是游戏规则本身:“石头克剪刀”、“剪刀克布”、“布克石头”。
剩下的所有代码,基本就是对这条重要信息的补充与扩展。比如通过 build_rules()
函数,将规则扩展为可直接求值的结果表;在分支语句中,直接访问 rules
获取结果。
不论是从哪一重含义上看,新代码都很好地实现了需求。
相关扩展
“石头剪刀布”的新版代码用到了“数据驱动”技巧——拿一份游戏规则表驱动了整个程序。这么做除了能让代码显式对齐需求以外,还有一些额外的好处。比方说,调整游戏规则变得很容易,修改 WIN_RULE
就行。
除了“数据驱动”以外,编程领域中还有许多思想和规范,实际上都在为“实现需求”的第二重含义服务。
良好的命名和结构
在写代码时,如果对变量和函数名多些斟酌,让它们更具描述性,就能有效降低人们理解代码的成本。试着对比下面这两段代码:
# 来自“石头剪刀布”旧版本
a = random.randint(0, 2)
b = random.randint(0, 2)
# 来自“石头剪刀布”新版本
a = random.choice([ROCK, SCISSOR, PAPER])
b = random.choice([ROCK, SCISSOR, PAPER])
新版本显然更好理解,更贴近“让 A 和 B 随机出拳”这个需求。与之相比,旧版本对 randint()
的使用很容易让人不明所以。
引入额外抽象
虽然过度抽象的代码也很糟糕,但在现实中,缺少抽象的代码还是更为常见。如果代码中缺乏抽象,需求的真相就会被淹没在无数细节中,哪怕是指甲片大点代码,也需要翻来覆去看才能懂。
因此,程序员们要善于利用各种工具(函数、类、模块)创建出恰当的抽象,从而让需求完美融入在代码中。
在处理一些上下文极为狭窄的小需求(比如解答一道算法题)时,人们尤其容易忽视抽象。他们倾向于只写一个函数,长篇累牍,将一切算法和逻辑一股脑塞进其中。
针对这类小需求,我们仍需要把第二重含义放在心上。必要时,拆分出一些小函数,这会让算法更易理解,也更好维护。
面向对象编程
面向对象编程流行起来的一个重要原因,在于它能很好地与现实世界里的模型对应,而需求正是藏身于这些模型和它们之间的关系中。
举个例子,在面向对象的世界里,我们可以轻松创建一个小鸭类,给它加上“嘎嘎叫”方法。读到代码的人能轻松识别我们的意图。
class Duck:
def __init__(self, name):
self.name = name
def quack(self):
print(f"{self.name}: Quack!")
Duck('Donald').quack()
# 输出:Donald: Quack!
但在函数式编程的世界里,同样是这只“呱呱叫”的鸭子,代码实现它的方式就更为曲折,表现需求的能力稍逊一筹。
领域驱动设计
在《领域驱动设计:软件核心复杂性应对之道》 一书中,作者 Eric Evans 第一次提出了“Ubiquitous Language(统一语言)”概念。“统一语言”指一种在开发人员和用户间通用的精确语言体系,它由项目中用到的各式各样的领域模型构成,通常由领域专家和开发人员共同制定。
“统一语言”对“实现需求”的第二重含义的贡献,在于它鼓励所有人使用同一套思维模型来沟通需求。借助这种统一性,开发人员最终产出的代码,就更可能贴近最原始的用户需求。
结语
“实现需求”之所以有着双重含义,在于代码有两类不同的消费者:普通用户和程序员。前者消费代码所实现的功能,并不关心代码本身。后者消费代码的可读性,因此代码是否能有效地自我诠释需求尤其重要。
通过阅读代码来理解需求,就像是双腿站立于池塘中寻找一块手表。优秀的代码如同一池清水,我们透过它,一眼就能看到手表正安静地躺在池底,钻石般的表盘在阳光下熠熠生辉。
😊 如果你喜欢这篇文章,也欢迎了解我的书: 《Python 工匠:案例、技巧与工程实践》 。它专注于编程基础素养与 Python 高级技巧的结合,是一本广受好评、适合许多人的 Python 进阶书。