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

有帮助吗?

解决方案

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

开关语句与大的IF-ELSE语句不同。每种情况必须是唯一的,并在静态上进行评估。无论您有多少案例,开关语句都会执行恒定的时间分支。IF-ELSE语句评估每个条件,直到找到真实的条件为止。


事实上,C#的switch语句是 不是 始终是恒定时间分支。

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

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

其他提示

重要的是不要将 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 路切换的大约时间(毫秒):0.00237094

执行 50000 路切换、10000 次迭代的总时间 (ms):20.0933
每 50000 路切换的大约时间(毫秒):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 路切换的大约时间(毫秒):0.00195871

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

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

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

这甚至没有解决字符串的情况,其中静态 Generic.Dictionary<string,int32> 可能会被创建,并且在第一次使用时会遭受一定的开销。这里的性能将取决于 Generic.Dictionary.

如果您检查 C# 语言规范 (不是CIL规格)您会发现“ 15.7.2开关语句”不会提及“恒定时间”,或者基础实现甚至使用CIL Switch指令(请非常小心地假设此类内容)。

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


当然,这些时间取决于机器和条件。我不会关注这些计时测试,我们谈论的微秒持续时间与正在运行的任何“真实”代码相形见绌(并且您必须包含一些“真实代码”,否则编译器将优化分支),或者系统中的抖动。我的答案基于使用 伊达斯曼 检查 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

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

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

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

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

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

  • 开关标签的值是 持续的
  • 开关标签的值是 清楚的 (这样对于给定的 switch 表达式只能选择一个 switch 块)

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

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 切换指令(跳转表)
  • 建立一个通用u003Cstring,int32>,在首次使用时填充它,然后致电generic.dictionary <> :: trygetValue()以将索引传递到MSIL Switch指令(跳表)
  • 使用IF-ELSES和MSIL“开关”跳跃的组合

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

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

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

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

杰夫·阿特伍德 (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]);

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

确实,事实并非如此 事实上,许多语言确实使用动态 switch 语句。然而,这意味着重新排序“case”子句可以改变代码的行为。

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

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

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

坦率地说,应该使用 if-else 陈述。

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

现在使用 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.7.2 节声明如下:

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

C# 3.0 规范位于:http://download.microsoft.com/download/3/8/8/388e7205-bc10-4226-b2a8-75351c669b09/CSharp%20Language%20Specification.doc

犹大上面的回答给了我一个想法。您可以使用上面的“伪造”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 时可以使用键控而不是开关式跳转表。

我认为编译器无法自动将 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 语句的实现细节发生更改时,行为也会发生变化。通过减少与整数类型和字符串以及那些可以简化为整数类型(并且旨在简化为整数类型)的比较,它们可以避免潜在的问题。

写道:

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

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

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

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

我认为 Henk 解决了“无法静态访问类型系统”的问题

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

我同意 这条评论 使用表驱动的方法通常更好。

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

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

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

然而,我可以想象,由于诸如 lomaxx 给出的原因,人们可能想要排除类型(44918).

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

许可以下: CC-BY-SA归因
不隶属于 StackOverflow
scroll top