答案在代码中:“实现需求”的双重含义

实现“石头、剪刀、布”游戏

一天,我在一个 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 上。要推导出这行代码,原作者需要历经以下几步思考:

  1. [石头, 剪刀, 布] 分别对应 [0, 1, 2] 数字
  2. [石头, 剪刀, 布] 这个排列顺序,刚好是前一个赢过后一个,比如“石头(0)”克“剪刀(1)”,由此推导出判断语句: a == (b + 1)
  3. 到了“布”时,前一条规则回到了列表头:“布(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(统一语言)”概念。“统一语言”指一种在开发人员和用户间通用的精确语言体系,它由项目中用到的各式各样的领域模型构成,通常由领域专家和开发人员共同制定。

“统一语言”对“实现需求”的第二重含义的贡献,在于它鼓励所有人使用同一套思维模型来沟通需求。借助这种统一性,开发人员最终产出的代码,就更可能贴近最原始的用户需求。

结语

“实现需求”之所以有着双重含义,在于代码有两类不同的消费者:普通用户和程序员。前者消费代码所实现的功能,并不关心代码本身。后者消费代码的可读性,因此代码是否能有效地自我诠释需求尤其重要。

通过阅读代码来理解需求,就像是双腿站立于池塘中寻找一块手表。优秀的代码如同一池清水,我们透过它,一眼就能看到手表正安静地躺在池底,钻石般的表盘在阳光下熠熠生辉。