关于C#switch语句限制:C#switch语句限制 – 为什么?

C# switch statement limitations - why?

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  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()语句失败,应为"整型值",case语句失败,应为"常量值"。

为什么有这些限制,以及根本的理由是什么?我看不出为什么switch语句必须只服从静态分析,以及为什么被打开的值必须是整数(也就是说,原语)。理由是什么?


重要的是不要将C switch语句与CIL switch指令混淆。

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

只有当C开关的外壳相邻时,这才有用:

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

但如果没有,就没什么用了:

1
2
3
case 10: blah; break;
case 200: blah; break;
case 3000: blah; break;

(您需要一张3000个条目的表格,只有3个插槽)

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

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

如果表达式集包含相邻项的丛,编译器可以二叉树搜索,最后是CIL开关。

这满是"mays"和"mights",它依赖于编译器(可能与mono或rotor不同)。

我使用相邻的案例在我的机器上复制了您的结果:

total time to execute a 10 way switch, 10000 iterations (ms) : 25.1383
approximate time per 10 way switch (ms) : 0.00251383

total time to execute a 50 way switch, 10000 iterations (ms) : 26.593
approximate time per 50 way switch (ms) : 0.0026593

total time to execute a 5000 way switch, 10000 iterations (ms) : 23.7094
approximate time per 5000 way switch (ms) : 0.00237094

total time to execute a 50000 way switch, 10000 iterations (ms) : 20.0933
approximate time per 50000 way switch (ms) : 0.00200933

然后我还使用了非相邻的case表达式:

total time to execute a 10 way switch, 10000 iterations (ms) : 19.6189
approximate time per 10 way switch (ms) : 0.00196189

total time to execute a 500 way switch, 10000 iterations (ms) : 19.1664
approximate time per 500 way switch (ms) : 0.00191664

total time to execute a 5000 way switch, 10000 iterations (ms) : 19.5871
approximate time per 5000 way switch (ms) : 0.00195871

A non-adjacent 50,000 case switch statement would not compile.
"An expression is too long or complex to compile near 'ConsoleApplication1.Program.Main(string[])'

有趣的是,二叉树搜索比CIL开关指令显示得快一点(可能不是统计上的)。

布莱恩,你已经使用了"常数"这个词,从计算复杂性理论的角度来看,它有一个非常明确的含义。虽然简单的相邻整数示例可能产生被认为是o(1)(常量)的cil,但稀疏示例是o(对数),聚集示例介于两者之间,而小示例是o(n)(线性)。

这甚至不能解决字符串情况,在这种情况下,静态Generic.Dictionary可能会被创建,并且在第一次使用时会受到一定的开销。这里的性能将取决于Generic.Dictionary的性能。

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

在一天结束的时候,在现代系统中,C转换成整数表达式是一个亚微秒的操作,通常不值得担心。

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

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

1
  jmp     ds:300025F0[eax*4]

如果二叉树搜索包含:

1
2
3
4
5
6
7
  cmp     ebx, 79Eh
  jg      3000352B
  cmp     ebx, 654h
  jg      300032BB
  …
  cmp     ebx, 0F82h
  jz      30005EEE


这是我最初的帖子,引起了一些争论…因为它是错误的:

The switch statement is not the same
thing as a big if-else statement.
Each case must be unique and evaluated
statically. The switch statement does
a constant time branch regardless of
how many cases you have. The if-else
statement evaluates each condition
until it finds one that is true.

实际上,C switch语句并不总是一个常量时间分支。

在某些情况下,编译器将使用CIL switch语句,该语句实际上是使用跳转表的常量时间分支。然而,在伊万·汉密尔顿指出的少数情况下,编译器可能会完全生成其他东西。

通过编写各种C switch语句、一些稀疏语句、一些密集语句以及使用ildasm.exe工具查看结果CIL,实际上很容易验证这一点。


首先想到的是历史原因:

由于大多数C、C++和Java程序员不习惯拥有这样的自由,所以他们不需要这些自由。

另一个更为有效的原因是,语言复杂性会增加:

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

此外,允许打开对象会破坏关于switch语句的基本假设。管理switch语句的规则有两条,即如果允许打开对象,编译器将无法执行这些规则(请参见C版本3.0语言规范,?8.7.2):

  • 开关标签的值是常量
  • 开关标签的值是不同的(这样对于给定的开关表达式只能选择一个开关块)

在允许非常量case值的假设情况下,考虑下面的代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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;
    }
}

代码将做什么?如果案例陈述被重新排序怎么办?事实上,C使switch陷入非法状态的原因之一是switch语句可以任意重新排列。

这些规则是有原因的,这样程序员就可以通过查看一个case块来确定块输入的精确条件。当上述switch语句增长到100行或更多行(并且它将增长)时,这种知识是无价的。


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


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

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

  • 创建一个大型if-else语句
  • 使用MSIL开关指令(跳转表)
  • 构建一个generic.dictionary,在第一次使用时填充它,然后调用generic.dictionary<>::TryGetValue())对于要传递到msil开关的索引指令(跳转表)
  • 使用Aif-elses和msil的组合"开关"跳转

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

为了处理字符串大小写,编译器将使用a.equals(b)(可能还有a.getHashCode())结束(在某个时刻)。我认为编译器使用任何满足这些约束的对象都是很麻烦的。

至于需要静态case表达式…如果case表达式不具有确定性,那么其中一些优化(散列、缓存等)将不可用。但我们已经看到,有时编译器只会选择简单化的if-else,无论如何…

编辑:lomaxx-您对"typeof"运算符的理解不正确。"typeof"运算符用于获取类型的System.Type对象(与其父类型或接口无关)。检查对象与给定类型的运行时兼容性是"is"运算符的工作。这里使用"typeof"来表示对象是不相关的。


I don't see any reason why the switch statement has to succomb to static analysis only

是的,它不必这样做,而且许多语言实际上都使用动态开关语句。然而,这意味着重新排序"case"子句会改变代码的行为。

在这里,进入"转换"的设计决策背后有一些有趣的信息:为什么C switch语句设计为不允许失败,但仍然需要休息?

允许动态case表达式可能会导致诸如以下php代码之类的畸形:

1
2
3
4
5
6
7
8
switch (true) {
    case a == 5:
        ...
        break;
    case b == 10:
        ...
        break;
}

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


微软终于听到了!

现在有了C 7,您可以:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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));
}

在这个话题上,根据杰夫·阿特伍德的说法,switch语句是一种编程的暴行。谨慎使用。

通常可以使用表来完成相同的任务。例如:

1
2
3
4
5
6
7
8
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]);


犹大的回答给了我一个主意。您可以使用Dictionary来"伪造"上面的操作转换行为:

1
2
3
4
5
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规范第8.7.2节规定了以下内容:

The governing type of a switch statement is established by the switch expression. If the type of the switch expression is sbyte, byte, short, ushort, int, uint, long, ulong, char, string, or an enum-type, then that is the governing type of the switch statement. Otherwise, exactly one user-defined implicit conversion (§6.4) must exist from the type of the switch expression to one of the following possible governing types: sbyte, byte, short, ushort, int, uint, long, ulong, char, string. If no such implicit conversion exists, or if more than one such implicit conversion exists, a compile-time error occurs.

C 3.0规范位于:http://download.microsoft.com/download/3/8/8/388e7205-bc10-4226-b2a8-75351c669b09/csharp%20语言%20规范.doc


我同意这种观点,即使用表驱动方法通常更好。

在C 1.0中,这是不可能的,因为它没有泛型和匿名委托。新版本的C有脚手架使这项工作。拥有对象文本的符号也是有帮助的。


我认为编译器无法自动将switch语句转换为以下内容没有根本原因:

1
2
3
4
5
6
7
8
9
if (t == typeof(int))
{
...
}
elseif (t == typeof(string))
{
...
}
...

但是没有什么好处。

整型的case语句允许编译器进行一些优化:

  • 没有重复(除非您复制了编译器检测到的大小写标签)。在您的示例中,由于继承,T不能匹配多个类型。是否应执行第一个匹配?都是吗?

  • 编译器可以选择通过跳转表在整型上实现switch语句,以避免所有比较。如果要打开整数值为0到100的枚举,则它将创建一个包含100个指针的数组,每个switch语句一个指针。在运行时,它只是根据打开的整数值从数组中查找地址。这使得运行时性能比执行100个比较要好得多。


  • 根据switch语句文档,如果有一种明确的方法可以将对象隐式转换为整型,那么将允许这样做。我认为您期望的行为是,对于每个case语句,它将被if (t == typeof(int))替换,但当您超载该运算符时,这将打开一整罐蠕虫。如果您不正确地编写了==override,当switch语句的实现详细信息更改时,行为将发生更改。通过减少与整型和字符串以及那些可以简化为整型(并且打算这样做)的东西的比较,它们可以避免潜在的问题。


    我对C几乎一无所知,但我怀疑这两种语言中的任何一种都只是简单地将其作为其他语言中的开关,而没有考虑使其更通用,或者开发人员认为扩展它是不值得的。

    严格地说,你完全正确,没有理由对它施加这些限制。有人可能会怀疑,原因是对于允许的情况,实现是非常有效的(如Brian Ensink(44921)),但如果我使用整数和一些随机情况(如345、-4574和1234203),我怀疑实现是非常有效的(w.r.t.if语句)。在任何情况下,允许它做任何事情(或者至少更多),并且说它只对特定的情况(比如(几乎)连续的数字)有效,这有什么害处呢?

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

    编辑:@henk(44970):如果最大限度地共享字符串,则内容相同的字符串也将成为指向相同内存位置的指针。然后,如果您可以确保在案例中使用的字符串连续存储在内存中,那么您可以非常有效地实现开关(即,执行顺序为2个比较、一个加法和两个跳转)。


    我认为Henk用"禁止静态访问类型系统"这个词来形容它。

    另一种选择是,在数字和字符串可以输入的地方没有顺序。因此,类型切换不能构建二进制搜索树,只能进行线性搜索。


    wrote:

    "The switch statement does a constant time branch regardless of how many cases you have."

    < /块引用>

    由于语言允许在switch语句中使用字符串类型,因此我假定编译器无法为此类型生成持续时间分支实现的代码,需要生成if-then样式。

    @我明白了。谢谢。

    我在C和.NET方面没有太多的经验,但似乎语言设计者不允许静态访问类型系统,除非在狭窄的环境中。typeof关键字返回一个对象,因此只能在运行时访问该对象。