组合爆炸 -一个警示故事





5.00/5 (34投票s)
我经历了一次糟糕的面向对象设计的灾难性后果,尤其是当组合爆炸找上门来时会发生什么。
引言
多年前,我在英格兰的一家电信公司工作。那时,电信市场正在重塑“蛇油”生意。大量的资金涌入这些公司,我们有机会在一些令人惊叹的 HP-UX 机器上创建网络管理系统。这基本上意味着提供一个基于 GUI 的前端,后台运行着多个异步进程,它们通过 IPC 进行通信,并且都在进行复杂的、利用复用器和其他街道上的电信设备的工作。
那是一段美好的时光,我们的客户,也就是像我们一样的其他电信公司,可以从一个房间里控制他们的整个基础设施,那个拿着螺丝刀和货车的、在街头更换复用器的人终于被淘汰了!但是,就像上面提到的那个货车司机离开时一样,当他关上门在我们面前时,他低语了两个词,最终让我的部门的一切都停滞了……“组合爆炸……”,嗯,我确实听到了“爆炸”这个词。
背景
组合爆炸是面向对象设计可能陷入的一种状态。这是由面向对象层级结构的过度设计引起的。设计者最终在层级结构中创建了太多的类,因为他们认为扩展一个系统应该仅仅是添加新类,再加上一点额外的代码。这些代码写起来如此显而易见,以至于可以减少错误和歧义,从而提高质量。他们还认为,由于严格的合同约束,他们可以强制软件工程师编写正确的代码。所以,任何时候需要管理一种新型的某物,只需插入几个类,工程师就可以从类名推断出确切的行为,然后,奇迹就发生了!一个“奇迹成长”的软件解决方案,开发预算降低了十分之一,这意味着 QA 可以恢复到只有几个测试人员的规模,因为不会触及现有类,所以很少需要回归测试。
这种严格、压抑的设计对程序员来说很棒。他们可以从他们正在编码的类型(类名)中确切地推断出该类应该如何行为,即,我在 按钮 电话类中,因此我的绘制按钮的函数将执行此操作,而当我处于我的 拨号 电话类中时,我的绘制函数将执行那个操作,干净利落。但是,在插入另一种类型的基类时,可能会导致您不得不实现一整套派生类才能使您的设计继续工作,特别是如果您将它们设计为包含大量抽象函数(C++ 中的纯虚函数,或没有实现的函数,属于接口——基本上是一个契约)。
真实情况
因此,在上述电信公司中,我们使用面向对象的方式模拟了我们的复用器及其环境。我们有一个基于 GUI 的应用程序,其中为我们销售的每种类型的复用器都提供了一个类。我们有很多复用器,并且它们可能有不同的固件。每种复用器都是固定类型,因此无法从一种类型迁移到另一种类型。这很棒,因为不需要对持久的面向对象类进行复用器类型的迁移策略。但是固件可以升级,所以我们确实需要进行一些迁移。固件类型在现实生活中位于复用器控制器卡上,但我们认为,由于每个复用器只有一个复用器控制器卡,固件类型就可以仅仅是 Mux 类的一个属性,此外,这还节省了一个级别的函数间接调用,即通过 Mux->Card 调用来获取固件类型,我们现在可以在 Mux 类中直接知道固件类型。现在让我们谈谈我们最初的类。
我们有一个名为 Mux 的基类,然后 SDH 和 PDH 从中派生。这两种是发送网络数据流量的方法。从这两个基类出发,我们有了具体的类,即 SMA1
、SMA4
、SMA16
等。然而,很快(一年左右),我们发现代码中有很多针对固件类型的 C++ 软件开关。所以,例如,一个典型的函数会是这样的:
void SMA1::Draw()
{
if (firmware.IsA1.0() == true)
{
drawNormal ();
}
else if (firmware.IsA2.0() == true)
{
drawDoubleThick();
}
else if (firmware.IsA3.0() == true)
{
drawTrebleThick();
}
}
因此,Draw
函数将包含条件逻辑,该逻辑将正常绘制固件 1.0 的卡片,如果具有固件 2.0,则绘制两倍宽,对于 3.0,则绘制三倍宽。显然,这只是一个例子,代码并没有那么简单(我们使用了开关——哈哈,这只是个玩笑!)。
很容易看到这个函数并说“哇!这是糟糕的代码”,但代码就是这样演变成这种状态的。新加入团队的人,他们会搜索 1.0
grep 1.0 *.cpp > work_to_do_for_new_firmware_type.omg
grep 1.0 *.h >> work_to_do_for_new_firmware_type.omg
所有带有固件类型开关的函数都会被扩展,为 switch
语句增加一个 case,或者在 if
语句中添加一个 ||
(或) (如果新固件的行为与现有固件相同)。记住,没有人是在同一时间编写这个函数的。
公司的运作方式是典型的硬件制造商。请记住,当固件 3.0 出现时,我们软件管理团队有一个月的时间来支持它。固件团队几乎每个月都会发布新版本的固件,这受到贪婪的市场愿景和同行的压力驱动。硬件团队每年都会推出新硬件,因为创造新产品 altogether 的成本更高、风险更大,不如扩展现有产品。新产品需要管理风险,而我们都知道这并不怎么发生,最好坚持零风险,因为虽然没有回报,但也没有失败,对吧?
所以新功能大部分都添加到了固件中(如果可能的话),如果我们软件管理团队没有管理好这些新功能,那么客户就不会从花费更多钱中看到任何好处,我们的销售团队也无法为股东先生(他基本上坐拥十亿美元的无价值金字塔计划纸币)提高利润。所以我们有一项任务要完成,而且要快。于是我们做了,最终我们所有的函数都变成了上面那个样子。
提醒一下,固件是基类 MUX
的一个属性。
这是我们的设计样子
“让我们重构吧!”,我们异口同声地喊道,当然不是真的哭了,不,那是在之后的事了。
我们完全删除了固件属性类型,因为我们决定更充分地利用面向对象的本来用途。类本身会告诉我们类型,我们不需要有条件的代码去询问类型,我们可以通过编写代码的位置来推断出我们是什么。所以本质上,如果我在 SMA1
类中,我就知道我是一个 SMA1
,所以让我们相应地做事。我们改变了设计,增加了我们认为可以接受数量的类。现在我们的层级结构看起来是这样的(为了简洁起见,这只是一个子集):
所以我们在 SMA1
、SMA4
、SMA4c
和 SMA1c
上增加了新的类。由于我们将所有函数都设为虚函数,Draw
被专门为新的固件类型重写。所以 SMA1_FIRMWARE_VERSION::Draw
函数变成了:
void SMA1_1.0::Draw()
{
drawNormal ();
}
void SMA1_2.0::Draw()
{
drawDoubleThick();
}
void SMA1_3.0::Draw()
{
drawTrebleThick();
}
哇,看看我们减少了多少条件代码。这非常干净,每个人都可以安心休息一天,等等……
要实例化我们的类,我们有工厂函数。
然后发生了这件事
“这家伙想要什么?”
“他带来了好消息,我们非常成功,正在发布大量新产品,名为 SMA32
、SMA64
、VCTS2
、VCTS4
。本季度将有三种新固件,并且他们正在拆分复用器控制器卡,这意味着它们可以具有双固件模式。”
哈哈哈,所以我们现在将不得不拥有这个层级结构(同样,我不会花 4 个小时画出所有内容):
请注意,为了简洁起见,我省略了所有具体的类(您可以自己推断出哪些),我用 SMA4c
派生类来演示了这个概念。只有傻瓜才会填完其余的,而我们就是这样做的。我们已经跨过了卢比孔河,骰子已经掷下!无路可退,命运已定!
两年后,我们又有了六种类型的复用器,现在我们大约有 15 种固件类型,所以我们的层级结构看起来是这样的:
好的,明白了吗?是的,代码确实有效,但这难道就是 KISS - “保持简单愚蠢”的方式吗?是吗?
在这种情况下,理论上正确的做法
大章节标题总是让我好奇。我们接下来翻阅的书告诉我们避免深层层级结构!事实上,查拉图斯特拉喊道:“尽你所能避免继承。 favore 组合”,他说,“favore?”,我反问道。
所以我们分析了一下,提出了下一个解决方案。移除所有这些类,回到将复用器特定的代码放入复用器层级结构,并将固件特定的代码放入单独的层级结构,并使用组合。从 ISA(是一种)转变为 HASA(有一个)。所以 Mux
类有一个固件版本,而以前我们有一个 Mux ISA 固件版本(以及一个 mux 版本)。
所以层级结构看起来是这样的:
我们的设计要小得多,并成功地将我们糟糕的设计从复用器类型和固件类型合并的困境中解耦出来,将其拆分为独立于固件和复用器类型的层级结构。耦合量并不高,因为我们将固件特定的代码委托给了固件层级结构(想象一下如果我们回到在复用器类中说 Is1.0()
!又回到原点)。Draw 函数变成了:
void SMA1::Draw()
{
firmware->Draw();
}
我们有工厂函数,它们会根据用户在 GUI 中选择的字符串来构建复用器类型以及固件类型。所以我们的构造函数将按以下方式流动:
SMA1::SMA1(string firmwareType)
{
if (firmwareType=="1.0")
{
firmware = new firmware1_0();
}
}
所以我们现在有了两个层级结构。
现实很糟糕
显然,如果事情像这么简单,我就是在撒谎,对吧?是的,所以为了挽回我的声誉,让我们来讨论下一系列问题。我们发现,在现实中,有些东西是复用器类型和固件的组合,例如,一个 SMA1 LED
对于 firmware 1.0
是 blue
,对于 firmware
版本 2
是 green
,而对于 SMA4
LED,它们分别是黄色和白色。所以我们无法在固件层级结构中有一个像这样的函数:
colour SMA1::GetColourLED()
{
if (Firmware() == 1.0)
return colour::Blue;
else if (Firmware() == 2.0)
return colour::Green;
etc.
}
或者这样!
colour Firmware1_0::GetColourLED(
{
if (GetMuxType() == SMA1)
return colour::Blue;
else (GetMuxType() == SMA4)
return colour::Green;
}
因为这会把我们推回到最初的问题。
我们知道我们必须为这些东西再建立一个层级结构,所以我们试着后退一步说,“嗯,颜色是否与复用器类型或固件类型无关?”通常,这些东西都是如此,所以我们有一个 LED 层级结构。例如,LED 的颜色实际上代表了网络流量的速度,恰好绿色和蓝色(2兆和4兆)分别出现在 SMA1 1.0
和 2.0
固件上。所以,我们根据网络流量速度来模拟颜色。你懂的。
因此,电信管理系统中类爆炸的故事到此结束。我应该说这大约发生在 1989 年,那时情况有所不同,我们正在使用 Shlaer–Mellor(在 UML 出现之前),这是一个容易犯错的新兴行业。最后,我们都是优秀的工程师,愿意承认我们犯了巨大的错误。但实际上,最大的问题可能是相信了另外两群人:
市场营销和销售 - “不会再有新的固件发布了。我们将坚持每年一次,所以基本上只在新硬件发布时才发布。”
我们的管理层 - “别担心,我们可以在 3 个月内把所有这些东西扔掉,然后重新设计系统的整个那部分,现在只插入这最后一个新的 Mux 类型。”
这个故事的寓意是什么?如果某样东西看起来比应有的大了 50 倍,那它很可能就是。
感谢阅读!
历史
- 2017年1月22日:初始版本