别再挥舞数字魔杖了
37你根本不知道那个数字是什么意思,对吧?一个没有上下文或标签的数字只是一个随机值,它什么也告诉我们不了。想象一下,你路过一个广告牌,上面画着一个人在船上,文字却是一个巨大的数字89。我会感到好奇,但同时也会完全摸不着头脑。我们理所当然地会拒绝毫无意义的数字……
那么,为什么它们在源代码中如此频繁地出现呢?任何常量出现在表达式中,例如 `f(x)` 12,我们都称之为“魔法数字”。它们仿佛通过某种神秘力量凭空出现,充斥着我们的代码。我们不知道它们从何而来,它们的含义是什么,也不知道它们是如何出现的。
虽然我不会像这篇文章那样深入探讨面试技巧,但我的书里有一章专门讲解面试技巧,认为这是程序员必备的技能之一。像修复“魔法数字”这样简单的问题,就能给面试官留下深刻的印象。
魔法的一个例子
面试时,我会让候选人设计一副扑克牌。我允许他们简化扑克牌,使用顺序编号的牌,而不是花色和点数。在大多数代码中,我看到的循环都类似于这样:
for i in 0..52:
cards.add( i )
这个数字52本身并不代表任何信息。之所以出现这个数字,仅仅是因为我们在讨论纸牌游戏,而这位考生假设使用的是一副标准的扑克牌52。当然,有很多纸牌游戏并非只有52张牌。
我要求他们将牌分发给多名玩家。我也接受拆分牌组。我经常收到类似这样的代码:
for i in 0..26:
player1.add( cards[i] )
for i in 26..52:
player2.add( cards[i] )
乍一看,这并不能明显看出它是将牌组一分为二。我需要在脑海中重新构建一下。编写代码时很容易出错。如果换一种情况,你看到的是这样的代码:
for i in 0..27:
player1.add( cards[i] )
for i in 28..51:
player2.add( cards[i] )
代码是否考虑了包含/排除的结束条件?里面是不是有很多差一错误?这到底是什么51?它跟我所知的任何数字都不匹配,甚至跟扑克牌的数字也不一样。
神奇数字
这段代码中:
for i in 0...52:
这52被称为“魔法数字”。未来的程序员对它一无所知。这类数字本身并不包含任何关于其用途的信息。
删除它们很简单。给这些数字起个名字就行了。
num_cards_in_deck = 52
for i in 0..num_cards_in_deck:
我们立即提高了这段代码的质量。读者现在知道这个数字的52含义。循环现在能够合乎逻辑地对牌组中的每张牌执行操作。
给数字命名会消除它的神秘感。这里我们是在代码旁边做的,但通常情况下,像这样的符号num_cards_in_deck最终会成为全局常量。它们是与上下文无关的事实。如果多段代码需要同一个值,那就创建一个常量并共享它。
另一种解决这个难题的方法是添加注释,比如
# create a standard size deck在循环语句中添加注释。但千万别这么做!注释是最后的手段。它们没有结构,编译器无法强制执行,而且最终都会过时。使用正确的符号名称才是最佳选择——代码胜过文字。
有了新的常量,我们就可以修改第二个循环:
for i in 0..(num_cards_in_deck/2):
player1.add( cards[i] )
for i in (num_cards_in_deck/2)..num_cards_in_deck:
player2.add( cards[i] )
代码质量再次显著提升。这些循环现在有了意义;很容易看出我们将牌组视为两半。而且,由于我们让编译器进行除法运算,出错的可能性也大大降低。此外,即使牌的数量发生变化,这个循环仍然有效。
我之所以勉强使用这个例子,仅仅是因为它经常出现在面试中。这两个循环仍然很糟糕。从防御性编程的角度来看,你根本不应该使用常量值。你应该使用 ` len(cards)int` 和 `int` len(cards)/2。很多“魔法数字”都可以通过这种方式消除。与其将代码绑定到一个任意值,不如基于你已有的知识。在这个例子中,我们有一个cards包含所有必要信息的集合。
放下魔杖
并非所有常量都是神奇的数字。有一些特殊情况,特别是…… 1。给1一个数加上一个数,得到的是它的自然后继数。这样就不会造成任何混淆。还有其他一些特殊情况,但在没有看到具体代码的情况下,我不想列举普遍的例外情况。
即使像 1 这样的数字2看起来并不神奇,但仍然可以改进。在我给出的例子中,它显然是将某物分成两半,但并没有明确表达“一半”的含义。在num_players这个例子中,最好用更清晰的方式来表达。很多时候,即使数字的含义显而易见,给它起个名字也很有帮助。名字能让代码更清晰易懂。
尽管魔法很有趣,但我们应该放下这种欲望,停止将这些数字输入到我们的代码中。
阅读我的书《什么是编程?》,了解成为一名优秀程序员需要具备哪些素质。我将目光投向人——软件存在的理由,软件的核心代码,以及你——键盘背后的那个人。
附录:更完善的交易循环
我认为上面展示的发牌循环仍然存在问题。虽然这与魔法数字无关,但以下代码以更简洁的方式处理玩家之间的发牌,避免了重复循环。
for i in len(cards):
player_cards[i % num_players].add( cards[i] )
另一种方案更接近于模拟发牌过程,并且完全避免了范围问题。它还使用了一个包含卡牌的面向对象玩家。这种代码适用于需要以可视化方式建模所有状态,或者涉及部分发牌的情况。它会保留中间状态。
cur_player = 0
while len(cards) != 0:
player[cur_player].cards.add( cards.pop() )
cur_player = (cur_player + 1) % num_players