C#switch语句限制-为什么?


140

编写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()语句以“期望整数类型的值”失败,而case语句以“期望常数的值”失败。

为什么要设置这些限制?其基本依据是什么?我看不出有任何理由switch语句具有屈服于只有静态的分析,以及为什么值在接通必须是完整的(即原语)。理由是什么?



启用内置类型的另一种方法是使用TypeCode Enum
Erik Philips

Answers:


98

这是我的原始帖子,引发了一些争论... 因为它是错误的

switch语句与大型if-else语句不同。每个案例都必须是唯一的并进行静态评估。无论您有多少种情况,switch语句都会执行恒定的时间分支。if-else语句评估每个条件,直到找到一个满足条件的条件。


实际上,C#switch语句并不总是恒定时间分支。

在某些情况下,编译器将使用CIL switch语句,该语句实际上是使用跳转表的恒定时间分支。但是,在稀疏情况下,如Ivan Hamilton指出的,编译器可能会完全生成其他内容。

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


4
如其他答案(包括我的答案)中所述,此答案中的主张不正确。我建议删除(如果只是为了避免实施这种(可能是常见的)误解)。
mweerden's

请在下面查看我的帖子,在该帖子中,我认为结论将表明switch语句执行恒定时间分支。
Brian Ensink

伯恩,非常感谢您的答复。请参阅Ivan Hamilton的回复((48259)[ beta.stackoverflow.com/questions/44905/#48259])。简而言之:您正在谈论的是(CIL的)switch 指令,它switch与C#的语句不同。
mweerden's

我也不相信编译器在打开字符串时也会生成固定时间的分支。
德鲁·诺阿克斯

这仍然适用于C#7.0中的switch case语句中的模式匹配吗?
达伦·奥尔森

114

重要的是不要将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开关。

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

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

执行10向切换的总时间,10000次迭代(毫秒):25.1383
每10向切换的近似时间(毫秒):0.00251383

执行50向切换的总时间,10000次迭代(毫秒):26.593
每50向切换的近似时间(毫秒):0.0026593

执行5000次切换的总时间,10000次迭代(毫秒):23.7094
每5000次切换的近似时间(毫秒):0.00237094

执行50000路切换的总时间,10000次迭代(毫秒):20.0933
每50000路切换的大约时间(毫秒):0.00200933

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

执行10向切换的总时间,10000次迭代(毫秒):19.6189
每10向切换的近似时间(毫秒):0.00196189

执行500路切换的总时间,10000次迭代(ms):19.1664
每500路切换的大约时间(ms):0.00191664

执行5000次切换的总时间,10000次迭代(毫秒):19.5871
每5000次切换大约的时间(毫秒):0.00195871

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

有趣的是,二叉树搜索的速度比CIL切换指令的显示速度快(可能在统计上不是)。

Brian,您使用过“ 常数 ” 一词,从计算复杂性理论的角度来看,它具有非常明确的含义。简单的相邻整数示例可能会产生被认为是O(1)(常数)的CIL,而稀疏示例是O(log n)(对数),聚类示例位于两者之间的某个地方,小示例是O(n)(线性)。

这甚至无法解决Generic.Dictionary<string,int32>可能会创建静态字符串的String的情况,并且在首次使用时会遭受一定的开销。此处的性能取决于的性能Generic.Dictionary

如果检查C#语言规范(不是CIL规范),您会发现“ 15.7.2 switch语句”没有提到“恒定时间”,或者底层实现甚至使用CIL switch指令(请谨慎假设这样的事情)。

归根结底,在现代系统上针对整数表达式的C#切换是亚微秒级的操作,通常不值得担心。


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

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

  jmp     ds:300025F0[eax*4]

二叉树搜索充满以下内容:

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

您的实验结果使我有些惊讶。你和布莱恩交换了吗?他的结果的确显示了尺寸的增加,而你的却没有。我缺少什么?无论如何,感谢您的明确答复。
mweerden

这么小的操作很难准确地计算出时序。我们没有共享代码或测试程序。我不明白为什么他的时间在相邻案件中会增加。我的速度快了10倍,因此环境和测试代码可能相差很大。
伊万·汉密尔顿

23

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

由于大多数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;
    }
}

该代码将做什么?如果案例陈述被重新排序怎么办?确实,C#将切换失败定为非法的原因之一是可以任意重新排列switch语句。

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


2
注意开关的重新排序。如果案件不包含任何代码,则掉线是合法的。例如,情况1:情况2:Console.WriteLine(“ Hi”); 打破;
Joel McBeth

10

顺便说一下,具有相同基础架构的VB允许更灵活的Select Case语句(上述代码将在VB中运行),并且在可能的情况下仍会生成有效的代码,因此必须仔细考虑基于技术约束的论点。


1
Select Case连接VB非常灵活,超级节省时间。我很想念它。
爱德华多·莫尔蒂尼

@EduardoMolteni然后切换到F#。相比之下,它使Pascal和VB的开关看起来像是白​​痴孩子。
a安2015年

10

通常,由于语言设计师的缘故,这些限制已经到位。基本的理由可能是与languange历史,理想或简化编译器设计兼容。

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

  • 创建一个大的if-else语句
  • 使用MSIL切换指令(跳转表)
  • 生成Generic.Dictionary <string,int32>,在首次使用时进行填充,然后调用Generic.Dictionary <> :: TryGetValue()获取索引以传递给MSIL切换指令(跳转表)
  • 使用if-elses和MSIL“ switch”跳转的组合

switch语句不是恒定时间分支。编译器可能会找到捷径(使用哈希桶等),但是更复杂的情况将生成更复杂的MSIL代码,其中某些情况比其他情况更早分支出来。

为了处理String的情况,编译器将最终使用a.Equals(b)(可能还使用a.GetHashCode())(在某些时候)。我认为对于编译器而言,使用满足这些约束的任何对象都是不明智的。

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

编辑:lomaxx-您对“ typeof”运算符的理解不正确。“ typeof”运算符用于获取类型的System.Type对象(与它的超类型或接口无关)。检查具有给定类型的对象的运行时兼容性是“ is”操作员的工作。在此使用“ typeof”表示对象是不相关的。


6

根据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]);

7
您是在认真引用某人的现有Twitter帖子而没有证据吗?至少链接到可靠来源。
伊万·汉密尔顿

4
它来自可靠的来源;有问题的Twitter帖子来自您正在查找的网站的作者Jeff Atwood。:-)如果您感到好奇,Jeff会在该主题上发布一些博客文章。
Judah Gabriel Himango,2009年

我相信这是全部BS-不论Jeff Atwood是否写过。有趣的是switch语句非常适合处理状态机,以及其他根据enum类型的值更改代码流的示例。当您打开enum类型变量时,Intellisense会自动填充switch语句也不是巧合。
乔纳森·莱因哈特

@JonathonReinhart是的,我认为这很重要-处理多态代码的方法比使用switch语句更好。他并不是说您不应该编写状态机,只是您可以使用漂亮的特定类型来做同样的事情。当然,在F#之类的语言可以轻松覆盖相当复杂的状态的语言中,这要容易得多。以您的示例为例,您可以在状态成为类型一部分的情况下使用区分联合,然后将其替换为switch模式匹配。或使用接口。
a安2015年

旧的答案/问题,但我会认为(如果我错了,请纠正我)Dictionary会比优化的switch语句慢得多……?
保罗

6

我看不出switch语句只必须接受静态分析的任何原因

没错,这不是必须的,实际上许多语言都使用动态switch语句。但是,这意味着对“ case”子句进行重新排序可以更改代码的行为。

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

允许动态的大小写表达式可能会导致诸如以下PHP代码这样的怪异现象:

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

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


1
那就是我对PHP的热爱(现在我正在过渡到C#),这就是自由。有了它,就可以自由编写错误的代码,但这是我在C#中真正想念的东西
Silkfire

5

微软终于听到你了!

现在,使用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));
}

3

这不是原因,但是C#规范第8.7.2节指出以下内容:

switch语句的控制类型由switch表达式建立。如果switch表达式的类型是sbyte,byte,short,ushort,int,uint,long,ulong,char,string或enum-type,则这就是switch语句的控制类型。否则,从switch表达式的类型到以下可能的控制类型之一,必须存在一个用户定义的隐式转换(第6.4节):sbyte,byte,short,ushort,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


3

犹大的上述回答给了我一个主意。您可以使用以下命令“伪造” OP的开关行为Dictionary<Type, Func<T>

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时,它具有被键控而不是开关式跳转表的附加好处。


0

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

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

但这并没有太大的收获。

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

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

  2. 编译器可以选择通过跳转表在整数类型上实现switch语句,以避免所有比较。如果打开的枚举的整数值为0到100,则它将创建一个包含100个指针的数组,每个switch语句一个。在运行时,它仅基于打开的整数值从数组中查找地址。与执行100次比较相比,这提供了更好的运行时性能。


1
这里要注意的一个重要复杂性是.NET内存模型具有某些强有力的保证,使您的伪代码不精确地等同于(假设的,无效的C#),switch (t) { case typeof(int): ... }因为您的翻译意味着,如果变量t 必须两次从内存中获取t != typeof(int),而后者会(可能)始终只读取t 一次值。这种差异会破坏依赖那些出色保证的并发代码的正确性。有关更多信息,请参见Joe Duffy 在Windows上
Glenn Slayden

0

根据switch语句文档,如果存在将对象隐式转换为整数类型的明确方法,则将允许该方法。我认为您期望的行为是,对于每个case语句都将其替换为if (t == typeof(int)),但是当您使该运算符重载时,它将打开一堆蠕虫。如果您错误地编写了==覆盖,当switch语句的实现详细信息更改时,该行为也会更改。通过减少对整数类型和字符串的比较以及可以简化为整数类型(并且打算这样做)的那些事物,它们避免了潜在的问题。


0

写道:

“无论您有多少种情况,switch语句都会执行恒定的时间分支。”

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

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

我在C#和.NET方面没有很多经验,但是似乎语言设计人员不允许在狭窄情况下对类型系统进行静态访问。该typeof运算所以这是仅在运行时访问的关键词返回一个对象。


0

我认为Henk用“不能随意访问类型系统”来钉牢它

另一个选择是没有顺序键入数字和字符串。因此,类型开关将无法构建二进制搜索树,而只能构建线性搜索。


0

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

在C#1.0中,这是不可能的,因为它没有泛型和匿名委托。新版本的C#具有使此工作起作用的支架。使用对象文字表示法也有帮助。


0

我几乎不了解C#,但是我怀疑这两种切换只是在其他语言中发生时才采取的,而没有考虑使其更通用,或者开发人员认为扩展它不值得。

严格来说,您是绝对正确的,没有理由对此施加任何限制。有人可能会怀疑,原因是在允许的情况下,实现非常有效(如Brian Ensink(44921所建议)),但是我怀疑如果我使用整数和一些随机情况,实现是否非常有效(wrt if语句)。 (例如345,-4574和1234203)。在任何情况下,将其用于所有内容(或至少更多内容)并说仅对特定情况(例如(几乎)连续数)有效是什么危害?

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

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


0

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

您可以在此处阅读有关此新功能的更多信息。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.