148

在编写 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 语句必须屈服于静态分析,以及为什么要打开的值必须是整数(即原始值)。理由是什么?

4

17 回答 17

117

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

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

这仅在 C# 开关的 case 相邻时才有用:

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 次迭代(毫秒):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 次迭代(毫秒):19.1664
每个 500 路切换的近似时间(毫秒):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)(线性)。

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

如果您检查C# 语言规范(而不是 CIL 规范),您会发现“15.7.2 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
于 2008-09-07T08:47:03.857 回答
104

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

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


事实上,C# switch 语句并不总是一个恒定的时间分支。

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

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

于 2008-09-04T22:51:03.980 回答
24

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

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

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

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

此外,允许打开对象会破坏关于 switch 语句的基本假设。如果允许打开对象,则编译器将无法强制执行 switch 语句的两条规则(请参阅C# 版本 3.0 语言规范,§8.7.2):

  • 开关标签的值是恒定的
  • 开关标签的值是不同的(因此对于给定的开关表达式只能选择一个开关块)

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

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 贯穿非法的原因之一是 switch 语句可以任意重新排列。

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

于 2008-09-05T13:11:22.020 回答
10

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

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

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

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

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

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

编辑:lomaxx - 您对“typeof”运算符的理解不正确。“typeof”运算符用于获取类型的 System.Type 对象(与其超类型或接口无关)。检查具有给定类型的对象的运行时兼容性是“is”运算符的工作。在这里使用“typeof”来表达一个对象是无关紧要的。

于 2008-09-05T11:33:44.737 回答
10

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

于 2008-09-05T11:49:40.453 回答
7

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

现在使用 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));
}
于 2017-02-17T19:07:04.257 回答
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]);
于 2008-09-04T23:06:06.780 回答
6

我看不出为什么 switch 语句必须屈服于静态分析

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

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

允许动态 case 表达式可能会导致诸如以下 PHP 代码之类的怪事:

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

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

于 2009-06-04T20:40:15.803 回答
3

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

switch 语句的控制类型由 switch 表达式确定。如果 switch 表达式的类型是 sbyte、byte、short、ushort、int、uint、long、ulong、char、string 或枚举类型,那么这是 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

于 2008-09-04T22:54:32.800 回答
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 时,它具有键控而不是切换式跳转表的额外好处。

于 2010-03-07T16:27:38.307 回答
0

我想编译器无法自动将您的 switch 语句转换为:

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

但这并没有太大的收获。

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

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

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

于 2008-09-04T22:56:26.900 回答
0

根据switch 语句文档,如果有一种明确的方法可以将对象隐式转换为整数类型,那么它将被允许。我认为您期望一种行为,其中每个 case 语句都将替换为if (t == typeof(int)),但是当您重载该运算符时,这会打开一整罐蠕虫。如果您错误地编写了 == 覆盖,则当 switch 语句的实现细节发生变化时,行为将会发生变化。通过减少与整数类型和字符串的比较以及可以减少为整数类型(并且旨在)的那些东西,它们避免了潜在的问题。

于 2008-09-04T23:01:59.990 回答
0

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

严格来说,您完全正确,没有理由对其施加这些限制。有人可能会怀疑,原因是对于允许的情况,实现非常有效(正如 Brian Ensink ( 44921 ) 所建议的那样),但是如果我使用整数和一些随机情况,我怀疑实现是否非常有效(使用 if 语句) (例如 345、-4574 和 1234203)。无论如何,允许它对所有事物(或至少更多)并说它仅对特定情况(例如(几乎)连续数字)有效有什么害处。

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

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

于 2008-09-04T23:16:22.933 回答
0

写道:

“无论您有多少案例,switch 语句都会执行恒定时间分支。”

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

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

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

于 2008-09-04T23:28:34.857 回答
0

我认为 Henk 用“没有静态访问类型系统”的东西钉住了它

另一种选择是,数字和字符串的类型没有顺序。因此,类型切换不能构建二叉搜索树,只能构建线性搜索。

于 2008-09-05T03:30:30.657 回答
0

我同意这个评论,即使用表格驱动的方法通常更好。

在 C# 1.0 中这是不可能的,因为它没有泛型和匿名委托。新版本的 C# 具有完成这项工作的脚手架。为对象字面量加上符号也很有帮助。

于 2008-09-05T04:46:12.220 回答
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

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

于 2020-02-01T09:18:33.557 回答