原文地址:Booleans Are a Trap
原文作者:Katafrakt
本文永久链接:https://segmentfault.com/a/1190000046582649
译者:ChatGPT
校对者:Fw恶龙
作为开发者,我们经常使用布尔值(Boolean)。它们与计算机底层的工作方式完美契合,且与if
语句等控制流工具配合得天衣无缝。布尔值简单易懂,似乎没有什么不妥。
我们甚至喜欢到将布尔值用于业务建模。然而,问题也由此而生。本文将通过一些例子展示使用布尔值可能带来的混乱,并提出更优的替代方案。
期望与现实的差距
业务建模是软件工程师的核心职责之一。简而言之,它是将现实世界的问题通过代码、数据库、网络调用等方式进行抽象和表示。然而,现实世界是复杂、混乱且难以预测的,往往无法完全契合我们理想的确定性模型。此外,软件开发中的一个确定性事实是:需求会不断变化。
这与布尔值有何关系?
假设你需要建模一个门(Door)。看起来很简单,对吧?
class Door {
public isOpen: boolean;
}
这似乎很容易理解。你可能还会添加open
和close
方法,总体上没有什么复杂的地方。我们正如预期地使用了布尔值——门要么是打开的,要么是关闭的。还能有什么其他状态呢?
你高兴地提交了代码,但几周后,一个客户提出他们的门不仅仅是打开或关闭的,还可以上锁!产品经理为你添加了一个新需求。幸运的是,这仍然很容易实现。
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;
}
这可能看起来有些过度设计,但你已经听过前面的故事,所以可以猜到添加Locked
到DoorState
是我们的下一步,这样我们就得到了门的三种可能状态。
对于公司,我们可以对合同状态采用类似的解决方案。
enum ContractStatus {
Trial,
Paying,
Partner,
}
class Company {
public contractStatus: ContractStatus;
}
这样,对于完整服务包中可用的功能,我们可以检查状态是否为Paying
或Partner
,但对于显示发票页面,我们只需检查是否为Paying
。
我们解决了第一个问题,但对于仅对部分付款公司开放的额外功能呢?我们需要另一个枚举和一个集合。
enum PremiumFeature {
Ai,
ApiAccess,
Sso,
SalesforceIntegration,
// ...
}
class Company {
public premiumFeatures: Set<PremiumFeature>;
}
细心的读者可能注意到,结合这两个仍然会导致无法达到的状态。这是真的,有时确实很难避免。但我们可以推断出,整体状态远少于 4096 种,只有一种状态是无法达到的:公司处于Trial
状态,却拥有任何高级功能。这本质上只是一个需要处理的情况,更容易测试和防范。我坚信这比布尔值的爆炸要好得多。
布尔值本身有问题吗?
读完这篇文章后,你可能会想,是否应该完全避免使用布尔值,因为它们本身就不好。答案是:当然不是!布尔值绝对有其适用之处。我的建议是将布尔值的使用限制在技术层面,而不是业务逻辑或业务模型中。
例如,我们前面提到了集合。假设你编写了一个集合的实现。你需要一个hasElement
方法。你可以使用枚举作为返回值吗?当然可以。但实际上,在这里使用布尔值完全没问题,也更自然。因为你不会得到第三个值,比如“可能”或“很可能”。这是一个较低层次的技术概念,布尔值在这里完全适用。
附加内容:状态机的基础
当你通过枚举(enum)梳理清楚所有可能的状态后,其实就已经为另一个非常实用的工具——状态机(state machine)打下了很好的基础。以我们的门为例,它的状态机可能如下所示:
当前状态 | 事件 | 结果状态 |
---|---|---|
Open | Close | Closed |
Closed | Lock | Locked |
Closed | Open | Open |
Locked | Unlock | Closed |
就我个人而言,有时我对在业务逻辑中使用状态机持保留态度。毕竟,最终业务需求可能要求每个步骤之间都可以转换——因为人们会犯错,需要有办法修复它们。但如果你想使用状态机,从枚举开始会比从一堆布尔标志开始容易得多。
结语
“布尔值的陷阱”只是一个例子,说明那些看起来简单的建模决策,随着系统发展,可能会带来意想不到的问题。
布尔值在它本来的用途上——表示某个技术层面的“是/否”状态——是非常合适的。但一旦将它用于表示复杂的业务状态,它往往就不够用了,甚至会误导我们。
相比之下,使用枚举(enum
)或枚举集合(enum set
)能帮助我们构建更健壮、更贴近实际业务世界的模型,让代码更容易演进、扩展,也更能体现真实的业务规则。
有时候,与其盲目地再加一个布尔字段,不如重新思考我们究竟该如何表达这个状态。这不仅能让系统更清晰,也能避免陷入日后难以维护的困境。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。