近 50 年来,switch 语句(也称为 case 语句)一直是编程的重要组成部分。然而,近年来,一些人声称 switch 语句已经过时。还有人甚至将 switch 语句标记为代码异味。
1952 年,Stephen Kleene 在他的论文《元数学导论》中提出了 switch 语句。第一个值得注意的实现是 1958 年的 ALGOL 58。后来,switch 语句被纳入了影响深远的 C 编程语言,众所周知,它影响了大多数现代编程语言。
快进到今天,几乎每种语言都有 switch 语句。然而,少数语言省略了 switch 语句。最著名的是 Smalltalk。
这引起了我的好奇心,为什么 switch 语句被排除在 Smalltalk 之外?
Andy Bower,Dolphin Smalltalk 的创建者/倡导者之一分享了他对 Smalltalk 为什么排除 switch 语句的看法:
当我第一次从 C++ 来到 Smalltalk 时,我无法理解一个号称功能完整的语言怎么不支持 switch/case 构造。毕竟,当我第一次从 BASIC 升级到”结构化编程”时,我认为 switch 是最好的东西。然而,因为 Smalltalk 不支持 switch,我不得不寻找并理解如何克服这个缺陷。正确的答案当然是使用多态性,让对象本身分派到正确的代码片段。然后我意识到这根本不是”缺陷”,而是 Smalltalk 强制我进行比我在 C++ 中习惯的更细粒度的面向对象设计。如果有 switch 语句可用,我需要花费更长的时间来学习这一点,或者更糟的是,我可能仍然在 Smalltalk 中使用 C++/Java 伪对象风格编程。
我会主张在正常的面向对象编程中,没有真正的需要 switch 语句。有时,当与非面向对象的世界交互时(比如接收和分派不是对象而只是整数的 WM_XXXX Windows 消息),switch 语句会很有用。在这些情况下,有替代方案(比如从字典分派),它们出现的次数不足以保证包含额外的语法。
Andy 说得对吗?没有 switch 语句我们会更好吗?其他语言也会因为排除 switch 语句而受益吗?
为了阐明这个问题,我整理了 switch 语句、字典和多态性之间的比较。我们称之为对比。愿最好的实现获胜!
每个实现都有一个方法,接受一个参数(一个整数),并返回一个字符串。我们将使用圈复杂度和可维护性指数来检查每个实现。然后我们将对所有三个实现进行整体评估。
代码。
Switch 语句
| 可维护性指数 | 72 |
|---|---|
| 圈复杂度 | 6 |
public class SwitchWithFourCases
{
public string SwitchStatment(int color)
{
var colorString = "Red";
switch (color)
{
case 1:
colorString = "Green";
break;
case 2:
colorString = "Blue";
break;
case 3:
colorString = "Violet";
break;
case 4:
colorString = "Orange";
break;
}
return colorString;
}
}
字典
| 可维护性指数 | 73 |
|---|---|
| 圈复杂度 | 3 |
public class DictionaryWithFourItems
{
public string Dictionary(int color)
{
var colorString = "Red";
var colors = new Dictionary<int, string> {{1, "Green"}, {2, "Blue"}, {3, "Violet"}, {4, "Orange"}};
var containsKey = colors.ContainsKey(color);
if (containsKey)
{
colorString = colors[color];
}
return colorString;
}
}
多态性
| 总可维护性指数 | 94 |
|---|---|
| 总圈复杂度 | 15 |
接口
| 可维护性指数 | 100 |
|---|---|
| 圈复杂度 | 1 |
public interface IColor
{
string ColorName { get; }
}
工厂
| 可维护性指数 | 76 |
|---|---|
| 圈复杂度 | 4 |
public class ColorFactory
{
public string GetColor(int color)
{
IColor defaultColor = new RedColor();
var colors = GetColors();
var containsKey = colors.ContainsKey(color);
if (containsKey)
{
var c = colors[color];
return c.ColorName;
}
return defaultColor.ColorName;
}
private static IDictionary<int, IColor> GetColors()
{
return new Dictionary<int, IColor>
{
{1, new GreenColor()},
{2, new BlueColor()},
{3, new VioletColor()},
{4, new OrangeColor()},
{5, new MagentaColor()}
};
}
}
实现
| 可维护性指数 | 97 |
|---|---|
| 圈复杂度 | 2 |
public class BlueColor : IColor
{
public string ColorName => "Blue";
}
public class RedColor : IColor
{
public string ColorName => "Red";
}
public class GreenColor : IColor
{
public string ColorName => "Green";
}
public class MagentaColor : IColor
{
public string ColorName => "Magenta";
}
public class VioletColor : IColor
{
public string ColorName => "Violet";
}
结果
在我深入研究结果之前,让我们定义圈复杂度和可维护性指数:
- 圈复杂度是逻辑分支的度量。数字越低越好。
- 可维护性指数衡量代码的可维护性。它的范围在 0 到 100 之间。数字越高越好。
| 圈复杂度 | 可维护性指数 | |
|---|---|---|
| Switch 语句 | 6 | 72 |
| 字典 | 3 | 73 |
| 多态性 | 15 | 94 |
我们将首先检查圈复杂度。
圈复杂度的结果很直接。字典实现最简单。这意味着它是最好的解决方案吗?不,当我们评估可维护性指数时我们会看到。
大多数人会像我一样认为,圈复杂度最低的实现是最可维护的——还能有其他方式吗?
在我们的场景中,圈复杂度最低的实现并不是最可维护的。实际上在我们的场景中,情况恰恰相反。最复杂的实现是最可维护的!思维爆炸!
如果你还记得,可维护性指数分数越高越好。长话短说,多态性具有最好的可维护性指数分数——但它也具有最高的圈复杂度。这是怎么回事?这似乎不对。
为什么最复杂的实现是最可维护的?要回答这个问题,我们必须理解可维护性指数。
可维护性指数由 4 个指标组成:圈复杂度、代码行数、注释数量和 Halstead 体积。前三个指标相对众所周知,但最后一个,Halstead 体积,相对不为人知。像圈复杂度一样,Halstead 体积试图客观地衡量代码复杂性。
简单来说,Halstead 体积衡量代码中的活动部分数量(变量、系统调用、算术、编码构造等)。活动部分越多,复杂性越高。活动部分越少,复杂性越低。这解释了为什么多态实现在可维护性指数上得分很高;这些类几乎没有活动部分。看待 Halstead 体积的另一种方式是它衡量”活动部分”密度。
软件如果不改变,还有什么意义呢?为了反映现实世界,我们引入了变化。我为每个实现添加了一个新颜色。
以下是修订后的结果。
| 圈复杂度 | 可维护性指数 | |
|---|---|---|
| Switch 语句 | 7 | 70 |
| 字典 | 3 | 73 |
| 多态性 | 17 | 95 |
switch 语句和多态方法的圈复杂度都增加了一个单位,但有趣的是,字典没有增加。起初我对此感到困惑,但后来我意识到字典将颜色视为数据,而其他两个实现将颜色视为代码。我来说说关键问题。
将注意力转向可维护性指数,只有一个(switch 语句)的可维护性下降了。多态性的可维护性分数提高了,但复杂性也增加了(我们希望它下降)。如我上面提到的,这是违反直觉的。
我们的比较表明,从复杂性的角度来看,字典可以无限扩展。多态方法是迄今为止最可维护的,并且随着添加更多场景似乎可维护性会增加。当添加新场景时,switch 语句的复杂性增加,可维护性下降。即使在我们添加新场景之前,它也具有最差的圈复杂度和可维护性指数度量。
来自谷歌的 Jem Finch 分享了他对 switch 语句缺点的看法:
1. 多态方法实现在词汇上彼此隔离。可以添加、删除、修改变量等,而不会有任何风险影响 switch 语句另一分支中的不相关代码。
2. 多态方法实现保证返回到正确的位置,假设它们终止。在 C/C++/Java 等穿透式语言中的 switch 语句需要容易出错的”break”语句来确保它们返回到 switch 之后的语句,而不是下一个 case 块。
3. 编译器可以强制多态方法实现的存在,如果多态方法实现缺失,编译器将拒绝编译程序。Switch 语句不提供这样的完整性检查。
4. 多态方法分派可以在不访问(或重新编译)其他源代码的情况下扩展。向 switch 语句添加另一个 case 需要访问原始分派代码,不仅在一个地方,而是在相关枚举被切换的每个地方。
5. ……你可以独立于切换装置测试多态方法。大多数像作者给出的示例那样切换的函数将包含其他无法单独测试的代码;另一方面,虚拟方法调用可以。
6. 多态方法调用保证常数时间分派。不需要足够聪明的编译器将自然是线性时间构造(带穿透的 switch 语句)转换为常数时间构造。
不幸的是,或幸运的是,取决于你的立场,大多数语言都有 switch 语句,它们不会很快消失。考虑到这一点,了解编译 switch 语句时幕后发生的事情是很好的。
可能发生三种 switch 语句优化:
- If-elseif 语句 – 当 switch 语句有少量 case 或稀疏 case(非递增值,如 10、250、1000)时,它被转换为 if-elseif 语句。
- 跳转表 – 在较大的相邻 case 集合(1、2、3、4、5)中,编译器将 switch 语句转换为跳转表。跳转表本质上是一个哈希表,带有指向内存中函数的指针(想象 goto 语句)。
- 二分查找 – 对于大量稀疏 case,编译器可以实现二分查找来快速识别 case,类似于数据库中的索引工作方式。在 case 数量大且稀疏和相邻的特殊情况下,编译器将使用三种优化的组合。
总结
在面向对象的世界中,1952 年提出的 switch 语句是软件工程师的主要工具。一个值得注意的例外是 Smalltalk,其设计者选择排除 switch 语句。
与等效的替代实现(字典和多态性)相比,switch 语句的表现不如。
switch 语句会继续存在,但正如我们的比较所示,有更好的替代方案。
这些实现可在 Github 上获得。
作者:Chuck Conway 是一位 AI 工程师,拥有近 30 年的软件工程经验。他构建实用的 AI 系统——内容管道、基础设施代理和解决实际问题的工具——并分享他沿途的学习成果。在社交媒体上与他联系:X (@chuckconway) 或访问他的 YouTube 和 SubStack。