Limitaciones de la statement de cambio de C #: ¿por qué?

Al escribir una instrucción switch, parece haber dos limitaciones sobre lo que puede activar en las sentencias case.

Por ejemplo (y sí, lo sé, si estás haciendo este tipo de cosas, probablemente signifique que tu architecture orientada a objetos (OO) es dudosa, ¡esto es solo un ejemplo artificial!),

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; } 

Aquí la instrucción switch () falla con ‘Un valor de un tipo integral esperado’ y las sentencias case fallan con ‘Se espera un valor constante’.

¿Por qué están vigentes estas restricciones y cuál es la justificación subyacente? No veo ninguna razón por la cual la instrucción switch deba sucumbir únicamente al análisis estático, y por qué el valor que se enciende debe ser integral (es decir, primitivo). ¿Cuál es la justificación?

Esta es mi publicación original, lo que provocó cierto debate … porque está mal :

La instrucción switch no es lo mismo que una statement big if-else. Cada caso debe ser único y evaluado estáticamente. La instrucción switch hace una twig de tiempo constante independientemente de la cantidad de casos que tenga. La instrucción if-else evalúa cada condición hasta que encuentre una que sea verdadera.


De hecho, la instrucción de cambio C # no siempre es una twig de tiempo constante.

En algunos casos, el comstackdor utilizará una instrucción de conmutación CIL, que en realidad es una twig de tiempo constante que utiliza una tabla de salto. Sin embargo, en casos dispersos como lo señala Ivan Hamilton, el comstackdor puede generar algo completamente diferente.

Esto es realmente bastante fácil de verificar al escribir varias instrucciones de conmutación C #, algunas dispersas, algunas densas, y mirando el CIL resultante con la herramienta ildasm.exe.

Es importante no confundir la instrucción de cambio C # con la instrucción de cambio CIL.

El interruptor CIL es una tabla de salto, que requiere un índice en un conjunto de direcciones de salto.

Esto solo es útil si los casos del interruptor C # son adyacentes:

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

Pero de poco uso si no lo son:

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

(Necesitaría una tabla ~ 3000 entradas de tamaño, con solo 3 espacios)

Con expresiones no adyacentes, el comstackdor puede comenzar a realizar verificaciones lineales if-else-if-else.

Con conjuntos de expresiones no adyacentes más grandes, el comstackdor puede comenzar con una búsqueda en árbol binario, y finalmente if-else-if-else en los últimos artículos.

Con conjuntos de expresiones que contienen grupos de elementos adyacentes, el comstackdor puede buscar árboles binarios y finalmente un interruptor CIL.

Esto está lleno de “mays” y “mights”, y depende del comstackdor (puede variar con Mono o Rotor).

Reproduje tus resultados en mi máquina usando casos adyacentes:

tiempo total para ejecutar un interruptor de 10 vías, 10000 iteraciones (ms): 25.1383
tiempo aproximado por interruptor de 10 vías (ms): 0.00251383

tiempo total para ejecutar un interruptor de 50 vías, 10000 iteraciones (ms): 26.593
tiempo aproximado por interruptor de 50 vías (ms): 0.0026593

tiempo total para ejecutar un conmutador de 5000 vías, 10000 iteraciones (ms): 23.7094
hora aproximada por conmutador de 5.000 vías (ms): 0.00237094

tiempo total para ejecutar un conmutador de 50000 vías, 10000 iteraciones (ms): 20.0933
tiempo aproximado por interruptor de 50000 vías (ms): 0.00200933

Luego también hice uso de expresiones de casos no adyacentes:

tiempo total para ejecutar un interruptor de 10 vías, 10000 iteraciones (ms): 19.6189
tiempo aproximado por interruptor de 10 vías (ms): 0.00196189

tiempo total para ejecutar un interruptor de 500 vías, 10000 iteraciones (ms): 19.1664
tiempo aproximado por interruptor de 500 vías (ms): 0.00191664

tiempo total para ejecutar un conmutador de 5000 vías, 10000 iteraciones (ms): 19.5871
Tiempo aproximado por conmutador de 5000 vías (ms): 0.00195871

Una statement de cambio de caso de 50,000 no adyacente no se comstackría.
“Una expresión es demasiado larga o compleja de comstackr cerca de ‘ConsoleApplication1.Program.Main (string [])’

Lo que es divertido aquí, es que la búsqueda de árbol binario parece un poco (probablemente no estadísticamente) más rápida que la instrucción de cambio CIL.

Brian, has usado la palabra ” constante “, que tiene un significado muy definido desde la perspectiva de la teoría de la complejidad computacional. Mientras que el ejemplo entero simple simplista puede producir CIL que se considera O (1) (constante), un ejemplo disperso es O (log n) (logarítmico), los ejemplos agrupados se encuentran en algún punto intermedio, y los ejemplos pequeños son O (n) (lineal )

Esto ni siquiera aborda la situación de String, en la que se puede crear un Generic.Dictionary y sufrirá una sobrecarga definitiva en el primer uso. El rendimiento aquí dependerá del rendimiento de Generic.Dictionary .

Si comprueba la especificación del lenguaje C # (no la especificación CIL) encontrará que “15.7.2 La statement del interruptor” no hace mención de “tiempo constante” o que la implementación subyacente utiliza incluso las instrucciones del interruptor CIL (tenga mucho cuidado al asumir tales cosas).

Al final del día, un interruptor C # contra una expresión entera en un sistema moderno es una operación de sub-microsegundo, y normalmente no vale la pena preocuparse por eso.


Por supuesto, estos tiempos dependerán de las máquinas y las condiciones. No prestaría atención a estas pruebas de tiempo, las duraciones de microsegundos de las que hablamos son eclipsadas por cualquier código “real” que se ejecute (y debe incluir algún “código real”; de lo contrario, el comstackdor optimizará la twig), o inestabilidad en el sistema. Mis respuestas se basan en el uso de IL DASM para examinar el CIL creado por el comstackdor de C #. Por supuesto, esto no es definitivo, ya que las instrucciones reales que ejecuta la CPU las crea el JIT.

He comprobado las instrucciones finales de la CPU realmente ejecutadas en mi máquina x86, y puedo confirmar un interruptor de conjunto adyacente simple haciendo algo como:

  jmp ds:300025F0[eax*4] 

Donde una búsqueda de árbol binario está llena de:

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

La primera razón que viene a la mente es histórica :

Como la mayoría de los progtwigdores C, C ++ y Java no están acostumbrados a tener tales libertades, no los exigen.

Otra razón, más válida, es que la complejidad del lenguaje boostía :

En primer lugar, ¿se deben comparar los objetos con .Equals() o con el operador == ? Ambos son válidos en algunos casos. ¿Deberíamos introducir nueva syntax para hacer esto? ¿Deberíamos permitirle al progtwigdor que introduzca su propio método de comparación?

Además, permitir el encendido de objetos rompería las suposiciones subyacentes sobre la statement de cambio . Hay dos reglas que rigen la statement de conmutación que el comstackdor no podría aplicar si se permitiera que los objetos estuvieran activados (consulte la especificación del lenguaje C # versión 3.0 , §8.7.2):

  • Que los valores de las tags de cambio son constantes
  • Que los valores de las tags de los conmutadores son distintos (de modo que solo se puede seleccionar un bloque de conmutadores para una expresión de conmutación dada)

Considere este ejemplo de código en el caso hipotético de que se permitieron valores de casos no constantes:

 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; } } 

¿Qué hará el código? ¿Qué ocurre si las declaraciones de casos se reordenan? De hecho, una de las razones por las cuales C # hizo que el switch se volcara ilegal es que las declaraciones de cambio podrían ser arbitrariamente reorganizadas.

Estas reglas existen por una razón, de modo que el progtwigdor puede, al observar un bloque de casos, saber con certeza la condición precisa bajo la cual se ingresa el bloque. Cuando la statement de interruptor antes mencionada crece en 100 líneas o más (y lo hará), dicho conocimiento es invaluable.

En su mayoría, esas restricciones están en su lugar debido a los diseñadores de idiomas. La justificación subyacente puede ser la compatibilidad con la historia de languange, los ideales o la simplificación del diseño del comstackdor.

El comstackdor puede (y lo hace) elegir:

  • crear una gran statement if-else
  • utilizar una instrucción de cambio MSIL (tabla de salto)
  • comstackr un Generic.Dictionary , rellenarlo en el primer uso, y llamar a Generic.Dictionary <> :: TryGetValue () para que un índice pase a una instrucción de cambio de MSIL (tabla de salto)
  • use una combinación de saltos “interruptor” de if-elses & MSIL

La instrucción switch NO ES una twig de tiempo constante. El comstackdor puede encontrar atajos (utilizando cubos de hash, etc.), pero los casos más complicados generarán un código MSIL más complicado con algunos casos ramificándose antes que otros.

Para manejar el caso String, el comstackdor terminará (en algún punto) usando a.Equals (b) (y posiblemente a.GetHashCode ()). Creo que sería trival para el comstackdor usar cualquier objeto que satisfaga estas restricciones.

En cuanto a la necesidad de expresiones de casos estáticos … algunas de esas optimizaciones (hashing, caching, etc.) no estarían disponibles si las expresiones de casos no fueran deterministas. Pero ya hemos visto que a veces el comstackdor simplemente escoge el camino simplista if-else-si-else de todos modos …

Editar: lomaxx : su comprensión del operador “typeof” no es correcta. El operador “typeof” se utiliza para obtener el objeto System.Type para un tipo (nada que ver con sus supertipos o interfaces). Comprobar la compatibilidad en tiempo de ejecución de un objeto con un tipo dado es el trabajo del operador “es”. El uso de “typeof” aquí para express un objeto es irrelevante.

Por cierto, VB, que tiene la misma architecture subyacente, permite Select Case mucho más flexibles (el código anterior funcionaría en VB) y aún produce código eficiente donde esto es posible, por lo que el argumento por restricción técnica debe considerarse cuidadosamente.

No veo ninguna razón por la cual la statement del interruptor tenga que succomb al análisis estático solamente

Es cierto que no tiene por qué ser así y, en realidad, muchos lenguajes utilizan sentencias de cambio dynamic. Sin embargo, esto significa que reordenar las cláusulas de “caso” puede cambiar el comportamiento del código.

Hay alguna información interesante detrás de las decisiones de diseño que entraron en “cambiar” aquí: ¿Por qué la statement del conmutador C # está diseñada para no permitir la caída, pero aún así requiere un descanso?

Permitir expresiones dinámicas de casos puede llevar a monstruosidades como este código PHP:

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

que francamente debería simplemente usar la statement if-else .

¡Microsoft finalmente te escuchó!

Ahora con C # 7 puedes:

 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(""); break; case null: throw new ArgumentNullException(nameof(shape)); } 

Mientras que en el tema, de acuerdo con Jeff Atwood, la statement de cambio es una atrocidad de progtwigción . Úselos con moderación.

A menudo puede realizar la misma tarea usando una tabla. Por ejemplo:

 var table = new Dictionary() { { typeof(int), "it's an int!" } { typeof(string), "it's a string!" } }; Type someType = typeof(int); Console.WriteLine(table[someType]); 

Esta no es una razón por la cual, pero la especificación de C # sección 8.7.2 establece lo siguiente:

El tipo de gobierno de una instrucción de conmutación se establece mediante la expresión de conmutación. Si el tipo de expresión del interruptor es sbyte, byte, short, ushort, int, uint, long, ulong, char, string o enum-type, entonces ese es el tipo de gobierno de la instrucción switch. De lo contrario, debe existir exactamente una conversión implícita definida por el usuario (§6.4) del tipo de expresión del interruptor a uno de los siguientes tipos de gobierno posibles: sbyte, byte, short, ushort, int, uint, long, ulong, char, string . Si no existe tal conversión implícita, o si existe más de una conversión implícita, se produce un error en tiempo de comstackción.

La especificación C # 3.0 se encuentra en: http://download.microsoft.com/download/3/8/8/388e7205-bc10-4226-b2a8-75351c669b09/CSharp%20Language%20Specification.doc

La respuesta de Judá anterior me dio una idea. Puede “falsear” el comportamiento del interruptor del OP anterior usando un Dictionary :

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

Esto le permite asociar el comportamiento con un tipo en el mismo estilo que la instrucción switch. Creo que tiene la ventaja adicional de tener una clave en lugar de una tabla de salto de estilo conmutador cuando se comstack en IL.

Supongo que no hay una razón fundamental por la que el comstackdor no pueda traducir automáticamente su statement de cambio a:

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

Pero no hay mucho ganado por eso.

Una statement de caso sobre tipos integrales permite al comstackdor realizar una serie de optimizaciones:

  1. No hay duplicación (a menos que duplique las tags de los casos, que el comstackdor detecta). En su ejemplo, t podría coincidir con varios tipos debido a la herencia. ¿Debería ejecutarse la primera coincidencia? ¿Todos ellos?

  2. El comstackdor puede elegir implementar una instrucción switch sobre un tipo integral mediante una tabla de salto para evitar todas las comparaciones. Si está encendiendo una enumeración que tiene valores enteros de 0 a 100, entonces crea una matriz con 100 punteros, uno para cada instrucción de cambio. En tiempo de ejecución simplemente busca la dirección de la matriz en función del valor entero que se enciende. Esto hace que el rendimiento del tiempo de ejecución sea mucho mejor que realizar 100 comparaciones.

De acuerdo con la documentación de la statement de cambio si hay una forma inequívoca de convertir implícitamente el objeto a un tipo integral, entonces estará permitido. Creo que esperas un comportamiento en el que para cada statement de caso sea reemplazado por if (t == typeof(int)) , pero eso abriría toda una lata de gusanos cuando sobrecargues a ese operador. El comportamiento cambiará cuando cambien los detalles de implementación de la instrucción switch si escribió su == override incorrectamente. Al reducir las comparaciones con los tipos integrales y la cadena y las cosas que pueden reducirse a tipos integrales (y están destinados a) evitan problemas potenciales.

escribió:

“La instrucción switch hace una twig de tiempo constante independientemente de la cantidad de casos que tenga”.

Como el lenguaje permite utilizar el tipo de cadena en una instrucción switch, supongo que el comstackdor no puede generar código para una implementación de twig de tiempo constante para este tipo y necesita generar un estilo if-then.

@mweerden – Ah, ya veo. Gracias.

No tengo mucha experiencia en C # y .NET, pero parece que los diseñadores de idiomas no permiten el acceso estático al sistema de tipos, excepto en circunstancias limitadas. La palabra clave typeof devuelve un objeto, por lo que solo se puede acceder a él en tiempo de ejecución.

Creo que Henk lo definió con el concepto de “no acceso stático al sistema de tipos”

Otra opción es que no haya ningún orden para los tipos donde pueden estar los números y las cadenas. Por lo tanto, un cambio de tipo no puede construir un árbol binario de búsqueda, solo una búsqueda lineal.

Estoy de acuerdo con este comentario de que usar un enfoque basado en tablas es a menudo mejor.

En C # 1.0 esto no fue posible porque no tenía generics ni delegates anónimos. Las nuevas versiones de C # tienen el andamiaje para hacer que esto funcione. Tener una notación para los literales de los objetos también ayuda.

Prácticamente no tengo conocimiento de C #, pero sospecho que cualquiera de los conmutadores se tomó simplemente como ocurre en otros lenguajes sin pensar en hacerlo más general o el desarrollador decidió que extenderlo no valía la pena.

Estrictamente hablando, usted tiene toda la razón en que no hay ninguna razón para poner estas restricciones en él. Uno podría sospechar que la razón es que para los casos permitidos la implementación es muy eficiente (como sugiere Brian Ensink ( 44921 )), pero dudo que la implementación sea muy eficiente (declaraciones if si) uso enteros y algunos casos aleatorios (por ejemplo, 345, -4574 y 1234203). Y en cualquier caso, ¿cuál es el daño al permitirlo todo (o al menos más) y decir que solo es eficiente para casos específicos (como (casi) números consecutivos).

Sin embargo, puedo imaginar que uno podría querer excluir tipos debido a razones como la dada por lomaxx ( 44918 ).

Editar: @Henk ( 44970 ): si las cadenas se comparten al máximo, las cadenas con el mismo contenido también serán punteros a la misma ubicación de memoria. Luego, si puede asegurarse de que las cadenas utilizadas en los casos se almacenan consecutivamente en la memoria, puede implementar el interruptor de manera muy eficiente (es decir, con la ejecución en el orden de 2 comparaciones, una sum y dos saltos).