头图
原文地址:Booleans Are a Trap
原文作者:Katafrakt
本文永久链接:https://segmentfault.com/a/1190000046582649
译者:ChatGPT
校对者:Fw恶龙

作为开发者,我们经常使用布尔值(Boolean)。它们与计算机底层的工作方式完美契合,且与if语句等控制流工具配合得天衣无缝。布尔值简单易懂,似乎没有什么不妥。

我们甚至喜欢到将布尔值用于业务建模。然而,问题也由此而生。本文将通过一些例子展示使用布尔值可能带来的混乱,并提出更优的替代方案。

期望与现实的差距

业务建模是软件工程师的核心职责之一。简而言之,它是将现实世界的问题通过代码、数据库、网络调用等方式进行抽象和表示。然而,现实世界是复杂、混乱且难以预测的,往往无法完全契合我们理想的确定性模型。此外,软件开发中的一个确定性事实是:需求会不断变化。

这与布尔值有何关系?

假设你需要建模一个门(Door)。看起来很简单,对吧?

class Door {
  public isOpen: boolean;
}

这似乎很容易理解。你可能还会添加openclose方法,总体上没有什么复杂的地方。我们正如预期地使用了布尔值——门要么是打开的,要么是关闭的。还能有什么其他状态呢?

你高兴地提交了代码,但几周后,一个客户提出他们的门不仅仅是打开或关闭的,还可以上锁!产品经理为你添加了一个新需求。幸运的是,这仍然很容易实现。

class Door {
  public isOpen: boolean;
  public isLocked: boolean;
}

我们再次使用了布尔值,门可以上锁或不上锁,还有什么其他可能吗?需求完成了。

但让我们停下来重新审视这个模型。它有两个属性,每个属性都有两个可能的值。简单的数学告诉我们,门可以处于四种状态之一。等等,四种?

  • 关闭且上锁
  • 关闭且未上锁
  • 打开且未上锁
  • 打开且上锁

最后一种状态似乎不合理。现实中,技术上你可以在门打开时上锁,但这是否真正改变了门的状态?然而,我们的模型允许这种不可能的组合。我可以肯定地说,某个时候你会遇到一个对象,其状态为isOpen: true, isLocked: true。即使你非常小心,代码中没有官方路径导致这种状态,可能由于数据库中的某次迁移,某人不小心将特定建筑物的所有门设置为open

既然这种情况可能发生,你的代码就需要为此做好准备。你应该为这种情况编写测试。这会增加代码量、复杂性,以及测试套件的运行时间(即使只是几毫秒)。

超越门的例子

好吧,这只是一个简单的例子,可能容易被忽视。

让我讲一个基于我实际工作经验的小故事。该项目是一个 B2B SaaS 平台,主要实体是Company。我们并没有标准的定价,所有合同都是在系统外单独协商的。系统中唯一的表示是isPaying: boolean。一些公司仍在评估阶段,即未付款,另一些公司已经签约。这个模型运作良好,因为付款与否是唯一的区分因素,没有标准的试用期等。对于付款的公司,启用了额外功能,显示了发票等。简单的布尔值解决了问题。

几个季度后,出现了新的业务情况。我们现在有了“合作伙伴公司”:他们获得了完整的服务包,但是免费的——或者说,是以非货币的服务交换,例如在某处展示我们的公司,或向我们免费提供他们的服务。由于他们没有付款,显示发票部分会引起误解。原来的布尔值已无法满足需求,因此添加了另一个布尔值:isPartner: boolean

你可能已经看出,类似于门的情况再次发生。合作伙伴从未付款,但系统允许布尔值的这种组合,我们需要处理它,否则可能会导致严重的异常。但故事并未就此结束……

随着时间的推移,我们添加了更多功能。例如:AI 功能。一些合同包含这些功能,一些则没有,但在试用期间从不允许。因此,我们又添加了一个布尔值:isAIEnabled,导致了一个不可能的状态:未付款的公司启用了 AI 功能。布尔值列表不断增加,某个时候我数了数,已经有 12 个不同的布尔标志。这意味着有 4096 种可能的组合,其中可能只有 20 种是有效的。这一切都是因为我们最初依赖于简单的布尔值,并且在适当的时候没有及时制止添加新的布尔值。

更好的方法

如果我已经说服你相信使用布尔值可能会导致问题,你可能会问:“那有什么解决方案?”确实,我有一个:使用枚举(enums)和枚举集合(enum sets)。

让我们从修复门的情况开始。考虑以下代码:

enum DoorState {
  Open,
  Closed,
  Locked,
}

class Door {
  public state: DoorState;
}

这可能看起来有些过度设计,但你已经听过前面的故事,所以可以猜到添加LockedDoorState是我们的下一步,这样我们就得到了门的三种可能状态。

对于公司,我们可以对合同状态采用类似的解决方案。

enum ContractStatus {
  Trial,
  Paying,
  Partner,
}

class Company {
  public contractStatus: ContractStatus;
}

这样,对于完整服务包中可用的功能,我们可以检查状态是否为PayingPartner,但对于显示发票页面,我们只需检查是否为Paying

我们解决了第一个问题,但对于仅对部分付款公司开放的额外功能呢?我们需要另一个枚举和一个集合。

enum PremiumFeature {
  Ai,
  ApiAccess,
  Sso,
  SalesforceIntegration,
  // ...
}

class Company {
  public premiumFeatures: Set<PremiumFeature>;
}

细心的读者可能注意到,结合这两个仍然会导致无法达到的状态。这是真的,有时确实很难避免。但我们可以推断出,整体状态远少于 4096 种,只有一种状态是无法达到的:公司处于Trial状态,却拥有任何高级功能。这本质上只是一个需要处理的情况,更容易测试和防范。我坚信这比布尔值的爆炸要好得多。

布尔值本身有问题吗?

读完这篇文章后,你可能会想,是否应该完全避免使用布尔值,因为它们本身就不好。答案是:当然不是!布尔值绝对有其适用之处。我的建议是将布尔值的使用限制在技术层面,而不是业务逻辑或业务模型中。

例如,我们前面提到了集合。假设你编写了一个集合的实现。你需要一个hasElement方法。你可以使用枚举作为返回值吗?当然可以。但实际上,在这里使用布尔值完全没问题,也更自然。因为你不会得到第三个值,比如“可能”或“很可能”。这是一个较低层次的技术概念,布尔值在这里完全适用。

附加内容:状态机的基础

当你通过枚举(enum)梳理清楚所有可能的状态后,其实就已经为另一个非常实用的工具——状态机(state machine)打下了很好的基础。以我们的门为例,它的状态机可能如下所示:

当前状态事件结果状态
OpenCloseClosed
ClosedLockLocked
ClosedOpenOpen
LockedUnlockClosed

就我个人而言,有时我对在业务逻辑中使用状态机持保留态度。毕竟,最终业务需求可能要求每个步骤之间都可以转换——因为人们会犯错,需要有办法修复它们。但如果你想使用状态机,从枚举开始会比从一堆布尔标志开始容易得多。

结语

“布尔值的陷阱”只是一个例子,说明那些看起来简单的建模决策,随着系统发展,可能会带来意想不到的问题。

布尔值在它本来的用途上——表示某个技术层面的“是/否”状态——是非常合适的。但一旦将它用于表示复杂的业务状态,它往往就不够用了,甚至会误导我们。

相比之下,使用枚举(enum)或枚举集合(enum set)能帮助我们构建更健壮、更贴近实际业务世界的模型,让代码更容易演进、扩展,也更能体现真实的业务规则。

有时候,与其盲目地再加一个布尔字段,不如重新思考我们究竟该如何表达这个状态。这不仅能让系统更清晰,也能避免陷入日后难以维护的困境。


Fw恶龙
276 声望47 粉丝

与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视。