multiple - Limitaciones de la declaración de cambio de C#: ¿por qué?
switch case c++ (16)
No veo ninguna razón por la cual la declaración 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 dinámico. 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 declaración 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 declaración if-else
.
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 arquitectura object-oriented (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?
escribió:
"La instrucción switch hace una rama 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 compilador no puede generar código para una implementación de rama 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.
¡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("<unknown shape>");
break;
case null:
throw new ArgumentNullException(nameof(shape));
}
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.
De acuerdo con la documentación de la declaración 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 declaración 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.
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 compilador.
El compilador puede (y lo hace) elegir:
- crear una gran declaración if-else
- utilizar una instrucción de cambio MSIL (tabla de salto)
- compilar un Generic.Dictionary <string, int32>, 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 rama de tiempo constante. El compilador 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 compilador terminará (en algún punto) usando a.Equals (b) (y posiblemente a.GetHashCode ()). Creo que sería trival para el compilador 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 compilador 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 expresar un objeto es irrelevante.
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 compilador puede comenzar a realizar verificaciones lineales if-else-if-else.
Con conjuntos de expresiones no adyacentes más grandes, el compilador 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 compilador puede buscar árboles binarios y finalmente un interruptor CIL.
Esto está lleno de "mays" y "mights", y depende del compilador (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.00251383tiempo total para ejecutar un interruptor de 50 vías, 10000 iteraciones (ms): 26.593
tiempo aproximado por interruptor de 50 vías (ms): 0.0026593tiempo 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.00237094tiempo 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.00196189tiempo total para ejecutar un interruptor de 500 vías, 10000 iteraciones (ms): 19.1664
tiempo aproximado por interruptor de 500 vías (ms): 0.00191664tiempo total para ejecutar un conmutador de 5000 vías, 10000 iteraciones (ms): 19.5871
Tiempo aproximado por conmutador de 5000 vías (ms): 0.00195871Una declaración de cambio de caso de 50,000 no adyacente no se compilaría.
"Una expresión es demasiado larga o compleja de compilar 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<string,int32>
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 declaración 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 compilador optimizará la rama), o inestabilidad en el sistema. Mis respuestas se basan en el uso de IL DASM para examinar el CIL creado por el compilador 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
Esta es mi publicación original, lo que provocó cierto debate ... porque está mal :
La instrucción switch no es lo mismo que una declaración big if-else. Cada caso debe ser único y evaluado estáticamente. La instrucción switch hace una rama 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 rama de tiempo constante.
En algunos casos, el compilador utilizará una instrucción de conmutación CIL, que en realidad es una rama de tiempo constante que utiliza una tabla de salto. Sin embargo, en casos dispersos como lo señala Ivan Hamilton, el compilador 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.
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 compilació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
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 genéricos ni delegados 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.
La primera razón que viene a la mente es histórica :
Como la mayoría de los programadores 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 aumentarí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 sintaxis para hacer esto? ¿Deberíamos permitirle al programador que introduzca su propio método de comparación?
Además, permitir el encendido de objetos rompería las suposiciones subyacentes sobre la declaración de cambio . Hay dos reglas que rigen la declaración de conmutación que el compilador 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 etiquetas de cambio son constantes
- Que los valores de las etiquetas 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 programador puede, al observar un bloque de casos, saber con certeza la condición precisa bajo la cual se ingresa el bloque. Cuando la declaración de interruptor antes mencionada crece en 100 líneas o más (y lo hará), dicho conocimiento es invaluable.
La respuesta de Judá anterior me dio una idea. Puede "falsear" el comportamiento del interruptor del OP anterior usando un 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());
});
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 compila en IL.
Mientras que en el tema, de acuerdo con Jeff Atwood, la declaración de cambio es una atrocidad de programación . Úselos con moderación.
A menudo puede realizar la misma tarea usando una tabla. Por ejemplo:
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]);
Por cierto, VB, que tiene la misma arquitectura 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.
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 ( lomaxx ).
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 suma y dos saltos).
Supongo que no hay una razón fundamental por la que el compilador no pueda traducir automáticamente su declaración de cambio a:
if (t == typeof(int))
{
...
}
elseif (t == typeof(string))
{
...
}
...
Pero no hay mucho ganado por eso.
Una declaración de caso sobre tipos integrales permite al compilador realizar una serie de optimizaciones:
No hay duplicación (a menos que duplique las etiquetas de los casos, que el compilador detecta). En su ejemplo, t podría coincidir con varios tipos debido a la herencia. ¿Debería ejecutarse la primera coincidencia? ¿Todos ellos?
El compilador 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.