C # switch 语句的限制——为什么?

在编写 switch 语句时,对于 case 语句可以打开的内容似乎有两个限制。

例如(是的,我知道,如果您正在做这种事情,这可能意味着您的 面向对象(OO)架构是可疑的——这只是一个人为的例子!),

  Type t = typeof(int);


switch (t) {


case typeof(int):
Console.WriteLine("int!");
break;


case typeof(string):
Console.WriteLine("string!");
break;


default:
Console.WriteLine("unknown!");
break;
}

在这里,switch ()语句失败,表示“ A 值为期望的整型”,case 语句失败,表示“ A 值为期望的常量”。

为什么要实行这些限制,其根本原因是什么?我不明白为什么 switch 语句 已经仅仅屈服于静态分析,也不明白为什么打开的值必须是整数(即原始值)。理由是什么?

119778 次浏览

这是我最初的帖子,引发了一些争论... ... 因为这是错的:

Switch 语句不相同 事情就像一个大的 if-else 语句。 每种情况都必须是唯一的,并且必须进行评估 开关语句执行 一个不变的时间分支 你有多少个案子,还有别的吗 语句计算每个条件 直到找到真爱。


实际上,C # switch 语句是 没有总是一个常量时间分支。

在某些情况下,编译器将使用 CIL switch 语句,这实际上是一个使用跳转表的常量时间分支。然而,在 伊凡 · 汉密尔顿指出的稀疏情况下,编译器可能会生成完全不同的东西。

实际上,通过编写各种 C # switch 语句(有些是稀疏的,有些是密集的)并使用 ildasm.exe 工具查看生成的 CIL,很容易验证这一点。

这并不是原因,但 C # 规范8.7.2节声明了以下内容:

开关语句的控制类型由开关表达式确定。如果 switch 表达式的类型是 sbyte、 byte、 short、 usshort、 int、 uint、 long、 ulong、 char、 string 或 enum 类型,那么这就是 switch 语句的控制类型。否则,从开关表达式的类型到下列可能的治理类型之一,必须存在一个用户定义的隐式转换(6.4) : sbyte、 byte、 short、 usshort、 int、 uint、 long、 ulong、 char、 string。如果不存在此类隐式转换,或者存在多个此类隐式转换,则会发生编译时错误。

C # 3.0规范位于: Http://download.microsoft.com/download/3/8/8/388e7205-bc10-4226-b2a8-75351c669b09/csharp%20language%20specification.doc

我想编译器不能自动将 switch 语句翻译成以下内容并没有什么根本原因:

if (t == typeof(int))
{
...
}
elseif (t == typeof(string))
{
...
}
...

但这并没有什么好处。

关于整数类型的 case 语句允许编译器进行许多优化:

  1. 没有重复(除非你重复编译器检测到的大小写标签)。在您的示例中,由于继承,t 可能匹配多个类型。应该执行第一场比赛吗?全部吗?

  2. 编译器可以选择通过一个跳转表实现一个针对整数类型的 switch 语句,以避免所有的比较。如果您打开的枚举值为0到100个整数,那么它将创建一个包含100个指针的数组,每个指针对应一个 switch 语句。在运行时,它只是根据打开的整数值从数组中查找地址。与执行100次比较相比,这样可以获得更好的运行时性能。

根据 Switch 语句文档,如果有一个明确的方法来隐式地将对象转换为一个整数类型,那么它将是允许的。我认为您期望的行为是,对于每个 case 语句,它都将被替换为 if (t == typeof(int)),但是当您使该操作符超载时,这将打开一个完整的蠕虫罐头。如果不正确地编写 = = 覆盖,则当 switch 语句的实现细节发生更改时,行为将发生更改。通过减少对整数类型和字符串的比较,以及那些可以减少到整数类型(并且打算减少到整数类型)的内容,它们避免了潜在的问题。

谈到这个话题时,根据杰夫 · 阿特伍德(Jeff Atwood) Switch 语句是一种编程暴行的说法,尽量少用。

你通常可以用一张表来完成同样的任务,例如:

var table = new Dictionary<Type, string>()
{
{ typeof(int), "it's an int!" }
{ typeof(string), "it's a string!" }
};


Type someType = typeof(int);
Console.WriteLine(table[someType]);

我实际上对 C # 一无所知,但我怀疑,要么是因为在其他语言中使用了这种切换,而没有考虑让它变得更通用,要么是开发人员认为扩展它不值得。

严格地说,你是绝对正确的,没有理由把这些限制。有人可能会怀疑,原因是对于允许的情况,实现是非常有效的(如布莱恩恩辛克(44921)建议) ,但我怀疑实现是非常有效的(w.r.t. 如果-语句) ,如果我使用整数和一些随机情况(例如345,-4574和1234203)。在任何情况下,允许它适用于所有情况(或者至少更多情况) ,并且说它只适用于特定情况(例如(几乎)连续数字) ,这有什么坏处呢。

但是,我可以想象,由于一些原因,比如 lomaxx (44918)给出的原因,可能需要排除类型。

编辑:@Henk (44970) : 如果字符串是最大共享的,具有相同内容的字符串也将是指向相同内存位置的指针。然后,如果你能确保用例中使用的字符串连续地存储在内存中,你就可以非常有效地实现交换机(例如,执行顺序为2个比较,一个加法和两个跳转)。

写道:

“ switch 语句执行一个常量时间分支,而不管您有多少个案例。”

由于该语言允许在 switch 语句中使用 绳子类型,我推测编译器无法为这种类型的常量时间分支实现生成代码,因此需要生成 if-then 样式。

@ mweerden-啊,我明白了,谢谢。

我在 C # 和方面没有很多经验。NET,但似乎语言设计人员不允许静态访问类型系统,除非在狭窄的情况下。类型关键字返回一个对象,因此只能在运行时访问该对象。

我觉得亨克用“无静态访问类型系统”的方法说得很好

另一种选择是,在数字和字符串可以存在的地方,类型没有顺序。因此,类型开关不能构建一个二叉查找树,只能进行线性搜索。

我同意 此评论的观点,使用表驱动的方法通常更好。

在 C # 1.0中这是不可能的,因为它没有泛型和匿名委托。 新版本的 C # 有脚手架来实现这个功能,有对象文字的符号也很有帮助。

大多数情况下,这些限制是因为语言设计者的缘故。潜在的理由可能是与语言历史、理想或编译器设计的简化兼容。

编译器可以(而且确实)选择:

  • 创建一个大的 if-else 语句
  • 使用 MSIL 开关指令(跳转表)
  • 构建一个 Generic.Dictionary < string,int32 > ,在第一次使用时填充它,然后调用 Generic. Dictionary < > : : TryGetValue () 用于传递给 MSIL 开关的索引 指令(跳转表)
  • 使用 If-else 和 MSIL 的组合 “开关”跳出来了

Switch 语句不是一个常量时间分支。编译器可能会找到捷径(使用散列桶等) ,但是更复杂的情况会生成更复杂的 MSIL 代码,有些情况会比其他情况更早扩展。

要处理 String 大小写,编译器最终将使用.Equals (b)(可能还有.GetHashCode ())。我认为对于编译器来说,使用任何满足这些约束的对象都是微不足道的。

至于对静态大小写表达式的需求... ... 如果大小写表达式不是确定的,那么其中一些优化(哈希、缓存等)将不可用。但我们已经看到,有时编译器只是选择简单的 if-else-if-else 路径..。

编辑: 洛马克斯-您对“ typeof”操作符的理解不正确。“ typeof”运算符用于获取 System。类型的类型对象(与其超类型或接口无关)。检查对象与给定类型的运行时兼容性是“ is”操作符的工作。在这里使用“ typeof”表示对象是不相关的。

顺便说一下,VB 具有相同的底层架构,允许更加灵活的 Select Case语句(上面的代码在 VB 中可以工作) ,并且在可能的情况下仍然生成高效的代码,因此必须仔细考虑技术约束的影响。

我想到的第一个原因是 历史性的:

因为大多数 C、 C + + 和 Java 程序员都不习惯拥有这种自由,所以他们不需要这种自由。

另一个更合理的原因是,语言的复杂性会增加:

首先,对象应该与 .Equals()比较还是与 ==操作符比较?两者在某些情况下都是有效的。我们是否应该引入新的语法来实现这一点?我们应该允许程序员介绍他们自己的比较方法吗?

此外,允许打开对象将 打破有关 switch 语句的基本假设。如果允许对象被打开,编译器将无法强制执行两条规则来管理 switch 语句(参见 C # 3.0语言规范,8.7.2) :

  • 开关标签的值为 不变
  • 开关标签的值为 很明显(因此只能为给定的开关表达式选择一个开关块)

假设允许非常量大小写值,请考虑下面的代码示例:

void DoIt()
{
String foo = "bar";
Switch(foo, foo);
}


void Switch(String val1, String val2)
{
switch ("bar")
{
// The compiler will not know that val1 and val2 are not distinct
case val1:
// Is this case block selected?
break;
case val2:
// Or this one?
break;
case "bar":
// Or perhaps this one?
break;
}
}

代码会做什么?如果对 case 语句进行了重新排序怎么办?事实上,C # 使 switch fall-through 非法的原因之一是 switch 语句可以任意重新排列。

这些规则之所以存在是有原因的——这样程序员就可以通过查看一个 case 块,确切地知道输入块的精确条件。当前面提到的 switch 语句增长到100行或更多行(并且它会增长到100行以上)时,这些知识是非常宝贵的。

不要将 C # switch 语句与 CIL switch 指令混淆,这一点很重要。

CIL 开关是一个跳转表,它需要一组跳转地址的索引。

这只有在 C # 交换机的情况相邻时才有用:

case 3: blah; break;
case 4: blah; break;
case 5: blah; break;

但如果它们不是,那就没什么用了:

case 10: blah; break;
case 200: blah; break;
case 3000: blah; break;

(您需要一个大约3000个条目的表,只使用了3个插槽)

对于不相邻的表达式,编译器可能会开始执行线性 if-else-if-else 检查。

对于较大的非相邻表达式集,编译器可以从二叉树搜索开始,最后是 if-else-if-else 最后几个项。

当表达式集包含相邻项的簇时,编译器可以进行二叉树搜索,最后是 CIL 开关。

这里充满了“可能”和“可能”,并且它取决于编译器(可能与 Mono 或 Rotor 不同)。

我在我的机器上复制了你的结果用的是相邻的病例:

执行10路交换机的总时间,10000次迭代(ms) : 25.1383 每个10路开关的近似时间(ms) : 0.00251383

执行50路交换机的总时间,10000次迭代(ms) : 26.593 每个50路开关的近似时间(ms) : 0.0026593

执行5000路交换机的总时间,10000次迭代(ms) : 23.7094 每5000路开关的近似时间(ms) : 0.00237094

执行50000路开关的总时间,10000次迭代(ms) : 20.0933 每50000路开关的大约时间(ms) : 0.00200933

然后我还使用了非邻接格表达式:

执行10路开关的总时间,10000次迭代(ms) : 19.6189 每个10路开关的近似时间(ms) : 0.00196189

执行500路交换机的总时间,10000次迭代(ms) : 19.1664 每500路开关的近似时间(ms) : 0.00191664

执行5000路交换机的总时间,10000次迭代(ms) : 19.5871 每5000路开关的近似时间(ms) : 0.00195871

不相邻的50,000个 case switch 语句将无法编译。 ”表达式太长或太复杂,无法在‘ Console Application1.Program. Main (string [])’附近编译

这里有趣的是,二叉树搜索看起来比 CIL 开关指令快一点(可能不是统计上的)。

布莱恩,你用了“ 不变”这个词从计算复杂性理论的角度来看它有着非常明确的含义。简单相邻整数示例可能产生被认为是 O (1)(常数)的 CIL,稀疏示例是 O (log n)(对数) ,聚类示例介于两者之间,小示例是 O (n)(线性)。

这甚至没有解决 String 的情况,在这种情况下可能会创建一个静态 Generic.Dictionary<string,int32>,并且在第一次使用时会承受一定的开销。这里的性能将取决于 Generic.Dictionary的性能。

如果您检查 C # 语言规范(不是 CIL 规范) 您会发现“15.7.2 The switch 语句”没有提到“常量时间”,或者底层实现甚至使用了 CIL switch 指令(要非常小心地假设这种情况)。

最后,在现代系统中,针对整数表达式的 C # 开关是一个次微秒操作,通常不值得担心。


当然,这些时间将取决于机器和条件。我不会注意这些计时测试,我们谈论的微秒持续时间与任何正在运行的“真正”代码(你必须包含一些“真正的代码”,否则编译器会优化分支)或系统中的抖动相比相形见绌。我的答案是基于使用 IL DASM检查 C # 编译器创建的 CIL。当然,这并不是最终的,因为 CPU 运行的实际指令是由 JIT 创建的。

我已经检查了在我的 x86机器上实际执行的最终 CPU 指令,并且可以确认一个简单的相邻设置开关执行以下操作:

  jmp     ds:300025F0[eax*4]

二叉树搜索充满:

  cmp     ebx, 79Eh
jg      3000352B
cmp     ebx, 654h
jg      300032BB
…
cmp     ebx, 0F82h
jz      30005EEE

我不明白为什么 switch 语句必须屈从于静态分析

的确,它不使用 ,而且许多语言实际上都使用动态 switch 语句。然而,这意味着重新排序“ case”子句可以改变代码的行为。

在设计决策的背后有一些有趣的信息,在这里进入“开关”: 为什么 C # switch 语句的设计不允许失败,但仍然需要中断?

允许动态大小写表达式可能导致如下 PHP 代码这样的怪物:

switch (true) {
case a == 5:
...
break;
case b == 10:
...
break;
}

坦率地说,它应该只使用 if-else语句。

Judah 上面的回答给了我一个想法,你可以使用 Dictionary<Type, Func<T>来“伪造”OP 的开关行为:

Dictionary<Type, Func<object, string,  string>> typeTable = new Dictionary<Type, Func<object, string, string>>();
typeTable.Add(typeof(int), (o, s) =>
{
return string.Format("{0}: {1}", s, o.ToString());
});

这允许您将行为与与 switch 语句相同样式的类型相关联。我相信它有一个额外的好处被键控,而不是一个开关风格的跳转表时,编译为 IL。

微软终于听到你的声音了!

现在有了 C # 7,你可以:

switch(shape)
{
case Circle c:
WriteLine($"circle with radius {c.Radius}");
break;
case Rectangle s when (s.Length == s.Height):
WriteLine($"{s.Length} x {s.Height} square");
break;
case Rectangle r:
WriteLine($"{r.Length} x {r.Height} rectangle");
break;
default:
WriteLine("<unknown shape>");
break;
case null:
throw new ArgumentNullException(nameof(shape));
}

C # 8允许您使用 switch 表达式优雅而紧凑地解决这个问题:

public string GetTypeName(object obj)
{
return obj switch
{
int i => "Int32",
string s => "String",
{ } => "Unknown",
_ => throw new ArgumentNullException(nameof(obj))
};
}

因此,你会得到:

Console.WriteLine(GetTypeName(obj: 1));           // Int32
Console.WriteLine(GetTypeName(obj: "string"));    // String
Console.WriteLine(GetTypeName(obj: 1.2));         // Unknown
Console.WriteLine(GetTypeName(obj: null));        // System.ArgumentNullException

你可以阅读更多关于 给你的新特性。