Limitaciones de la declaración de cambio de C #: ¿por qué?

140

Al escribir una declaración de cambio, parece haber dos limitaciones sobre lo que puede activar en las declaraciones de caso.

Por ejemplo (y sí, lo sé, si estás haciendo este tipo de cosas, probablemente significa que tu arquitectura orientada a objetos (OO) es dudosa, ¡este 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 'Se espera un valor de tipo integral' y las declaraciones de caso fallan con 'Se espera un valor constante'.

¿Por qué existen estas restricciones y cuál es la justificación subyacente? No veo ninguna razón por la cual la declaración del interruptor tiene que sucumbir solo al análisis estático, y por qué el valor que se debe activar debe ser integral (es decir, primitivo). ¿Cuál es la justificación?

ljs
fuente
1
Ver posible solución alternativa ¿Existe una alternativa mejor que esta para 'activar el tipo'?
Michael Freidgeim
Otra opción para activar los tipos incorporados es utilizar la enumeración TypeCode .
Erik Philips

Respuestas:

98

Esta es mi publicación original, que provocó un debate ... porque está mal :

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


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

En algunos casos, el compilador usará una declaración de cambio CIL que es, de hecho, una rama de tiempo constante usando una tabla de salto. Sin embargo, en casos escasos como lo señaló Ivan Hamilton, el compilador puede generar algo completamente distinto.

En realidad, esto es bastante fácil de verificar escribiendo varias instrucciones de cambio de C #, algunas dispersas, otras densas y mirando el CIL resultante con la herramienta ildasm.exe.

Brian Ensink
fuente
44
Como se señaló en otras respuestas (incluida la mía), las afirmaciones hechas en esta respuesta no son correctas. Recomendaría la eliminación (aunque solo sea para evitar aplicar esta idea errónea (probablemente común)).
mweerden
Consulte mi publicación a continuación, donde muestro, en mi opinión de manera concluyente, que la declaración de cambio tiene una rama de tiempo constante.
Brian Ensink
Muchas gracias por tu respuesta, Brian. Consulte la respuesta de Ivan Hamilton ((48259) [ beta.stackoverflow.com/questions/44905/#48259] ). En resumen: estás hablando de la switch instrucción (del CIL) que no es lo mismo que la switchdeclaración de C #.
mweerden
Tampoco creo que el compilador genere ramificaciones de tiempo constante al encender cadenas.
Drew Noakes
¿Sigue siendo aplicable con la coincidencia de patrones en declaraciones de mayúsculas y minúsculas en C # 7.0?
B. Darren Olson
114

Es importante no confundir la declaración de cambio de C # con la instrucción de cambio de 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 poca utilidad si no lo son:

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

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

Con expresiones no adyacentes, el compilador puede comenzar a realizar comprobaciones lineales if-else-if-else.

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

Con conjuntos de expresiones que contienen grupos de elementos adyacentes, el compilador puede buscar en árbol binario y finalmente un interruptor CIL.

Esto está lleno de "mays" y "mights", y depende del compilador (puede diferir con Mono o Rotor).

Repliqué sus 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 conmutador de 50 vías, 10000 iteraciones (ms): 26.593
tiempo aproximado por conmutador de 50 vías (ms): 0.0026593

tiempo total para ejecutar un interruptor de 5000 vías, 10000 iteraciones (ms): 23.7094
tiempo aproximado por interruptor de 5000 vías (ms): 0.00237094

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

Luego también lo hice usando expresiones de casos no adyacentes:

tiempo total para ejecutar un conmutador de 10 vías, 10000 iteraciones (ms): 19.6189
tiempo aproximado por conmutador 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 declaración de cambio de caso no adyacente de 50,000 no se compilaría.
"Una expresión es demasiado larga o compleja para compilar cerca de 'ConsoleApplication1.Program.Main (string [])'

Lo curioso 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 de 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 adyacente 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 Generic.Dictionary<string,int32>se puede crear una estática , y sufrirá una sobrecarga definitiva en el primer uso. El rendimiento aquí dependerá del rendimiento de Generic.Dictionary.

Si marca la Especificación del lenguaje C # (no la especificación CIL), encontrará "15.7.2 La declaración de cambio" no menciona el "tiempo constante" o que la implementación subyacente incluso utiliza la instrucción de cambio CIL (tenga mucho cuidado de asumir tales cosas).

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


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 estamos hablando están eclipsadas por cualquier código "real" que se esté ejecutando (y debe incluir algún "código real", de lo contrario, el compilador optimizará la ramificación), o nerviosismo 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 son creadas por el JIT.

Verifiqué las instrucciones finales de la CPU realmente ejecutadas en mi máquina x86, y puedo confirmar que un simple interruptor de configuración adyacente haga 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
Ivan Hamilton
fuente
Los resultados de tus experimentos me sorprenden un poco. ¿Cambiaste la tuya con la de Brian? Sus resultados muestran un aumento de tamaño, mientras que los suyos no. Me falta algo? En cualquier caso, gracias por la respuesta clara.
mweerden
Es difícil calcular con precisión el tiempo con una operación tan pequeña. No compartimos código ni procedimientos de prueba. No puedo ver por qué sus tiempos deberían aumentar para los casos adyacentes. Los míos fueron 10 veces más rápidos, por lo que los entornos y el código de prueba pueden variar mucho.
Ivan Hamilton
23

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

Como la mayoría de los programadores de 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 una nueva sintaxis para hacer esto? ¿Deberíamos permitir que el programador introduzca su propio método de comparación?

Además, permitir encender objetos rompería los supuestos subyacentes sobre la declaración de cambio . Hay dos reglas que rigen la declaración de cambio que el compilador no podría aplicar si se permitiera encender los objetos (consulte la especificación del lenguaje C # versión 3.0 , §8.7.2):

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

Considere este ejemplo de código en el caso hipotético de que se permitieron valores de caso 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é pasa si se reordenan las declaraciones del caso? De hecho, una de las razones por las cuales C # hizo ilegal la interrupción del cambio es que las declaraciones de cambio podrían reorganizarse arbitrariamente.

Estas reglas están en su lugar por una razón, para que el programador pueda, al mirar un bloque de casos, saber con certeza la condición precisa bajo la cual se ingresa el bloque. Cuando la declaración de cambio antes mencionada crece en 100 líneas o más (y lo hará), dicho conocimiento es invaluable.

Antti Kissaniemi
fuente
2
De nota sobre el cambio de orden. El incumplimiento es legal si el caso no contiene código. Tal como, Caso 1: Caso 2: Console.WriteLine ("Hola"); descanso;
Joel McBeth
10

Por cierto, VB, que tiene la misma arquitectura subyacente, permite Select Casedeclaraciones 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.

Konrad Rudolph
fuente
1
El Select Caseen VB es muy flexible y ahorra mucho tiempo. Lo extraño mucho.
Eduardo Molteni
@EduardoMolteni Cambie a F # entonces. En comparación, hace que los interruptores de Pascal y VB parezcan niños idiotas.
Luaan
10

En su mayoría, esas restricciones están vigentes debido a los diseñadores de idiomas. La justificación subyacente puede ser la compatibilidad con la historia, 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
  • use una instrucción de cambio de MSIL (tabla de salto)
  • construir un Generic.Dictionary <string, int32>, llenarlo en el primer uso y llamar a Generic.Dictionary <> :: TryGetValue () para que un índice pase a una instrucción de cambio MSIL (tabla de salto)
  • use una combinación de saltos if-elses y saltos de "cambio" de MSIL

La declaración de cambio NO ES una rama de tiempo constante. El compilador puede encontrar atajos (usando cubos hash, etc.), pero los casos más complicados generarán un código MSIL más complicado con algunos casos que se ramifican antes que otros.

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

En cuanto a la necesidad de expresiones de caso estáticas ... algunas de esas optimizaciones (hashing, almacenamiento en caché, etc.) no estarían disponibles si las expresiones de caso no fueran deterministas. Pero ya hemos visto que a veces el compilador simplemente elige el camino simplista if-else-if-else de todos modos ...

Editar: lomaxx : su comprensión del operador "typeof" no es correcta. El operador "typeof" se usa para obtener el objeto System.Type para un tipo (nada que ver con sus supertipos o interfaces). Verificar 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.

Ivan Hamilton
fuente
6

Mientras habla sobre el tema, según Jeff Atwood, la declaración de cambio es una atrocidad de programación . Úsalos 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]);
Judá Gabriel Himango
fuente
77
¿Estás citando seriamente la publicación de Twitter de alguien sin evidencia? Al menos enlace a una fuente confiable.
Ivan Hamilton
44
Es de una fuente confiable; la publicación de Twitter en cuestión es de Jeff Atwood, autor del sitio que estás viendo. :-) Jeff tiene un puñado de publicaciones de blogs sobre este tema si tienes curiosidad.
Judá Gabriel Himango
Creo que es BS total, ya sea que Jeff Atwood lo haya escrito o no. Es curioso cuán bien se presta la instrucción switch para manejar máquinas de estado y otros ejemplos de cambio de flujo de código en función del valor de un enumtipo. Tampoco es una coincidencia que intellisense llene automáticamente una declaración de cambio cuando se activa una variable de un enumtipo.
Jonathon Reinhart
@JonathonReinhart Sí, creo que ese es el punto: hay mejores formas de manejar el código polimórfico que usar la switchdeclaración. No está diciendo que no debas escribir máquinas de estado, solo que puedes hacer lo mismo usando tipos específicos agradables. Por supuesto, esto es mucho más fácil en lenguajes como F # que tienen tipos que pueden cubrir fácilmente estados bastante complejos. Para su ejemplo, podría usar uniones discriminadas donde el estado se convierte en parte del tipo y reemplazar el switchcon coincidencia de patrones. O use interfaces, por ejemplo.
Luaan
Antigua respuesta / pregunta, pero habría pensado que (corríjame si me equivoco) Dictionaryhabría sido considerablemente más lento que una switchdeclaración optimizada ...?
Paul
6

No veo ninguna razón por la cual la declaración de cambio tiene que sucumbir solo al análisis estático

Es cierto que no tiene a, y muchos lenguajes de hecho emplean sentencias switch 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 de cambio C # está diseñada para no permitir fallos , pero aún así requiere un descanso?

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

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

que francamente solo debería usar la if-elsedeclaración.

Roman Starkov
fuente
1
Eso es lo que me encanta de PHP (ahora que estoy haciendo la transición a C #), es la libertad. Con eso viene la libertad de escribir código incorrecto, pero esto es algo que realmente extraño en C #
silkfire
5

¡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));
}
dimaaan
fuente
3

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

El tipo de gobierno de una instrucción switch se establece mediante la expresión switch. Si el tipo de la expresión de cambio es sbyte, byte, short, ushort, int, uint, long, ulong, char, string o un tipo de enumeración, entonces ese es el tipo de gobierno de la declaración de cambio. De lo contrario, debe existir exactamente una conversión implícita definida por el usuario (§6.4) del tipo de expresión de cambio a uno de los siguientes tipos posibles de gobierno: 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

Markus
fuente
3

La respuesta de Judá anterior me dio una idea. Puede "fingir" el comportamiento de cambio del OP 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 el beneficio adicional de estar codificado en lugar de una tabla de salto de estilo de conmutador cuando se compila en IL.

Dave Swersky
fuente
0

Supongo que no hay una razón fundamental por la cual el compilador no pudo traducir automáticamente su declaración de cambio a:

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

Pero no se gana mucho con eso.

Una declaración de caso sobre tipos integrales permite al compilador realizar una serie de optimizaciones:

  1. No hay duplicación (a menos que duplique etiquetas de caso, que el compilador detecta). En su ejemplo, t podría coincidir con varios tipos debido a la herencia. ¿Se debe ejecutar el primer partido? ¿Todos ellos?

  2. El compilador puede optar por implementar una instrucción switch sobre un tipo integral mediante una tabla de salto para evitar todas las comparaciones. Si está activando una enumeración que tiene valores enteros de 0 a 100, 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 activa. Esto hace que el rendimiento en tiempo de ejecución sea mucho mejor que realizar 100 comparaciones.

Rob Walker
fuente
1
Una complejidad importante a tener en cuenta aquí es que el modelo de memoria .NET tiene ciertas garantías sólidas que hacen que su pseudocódigo no sea exactamente equivalente al ( C # hipotético e inválido ) switch (t) { case typeof(int): ... }porque su traducción implica que la variable t debe recuperarse de la memoria dos veces si t != typeof(int), mientras que este último sería (supuestamente) siempre lea el valor de t exactamente una vez . Esta diferencia puede romper la corrección del código concurrente que se basa en esas excelentes garantías. Para obtener más información sobre esto, vea Programación concurrente de
Glenn Slayden
0

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 se permitirá. Creo que está esperando un comportamiento en el que para cada enunciado de caso se reemplazaría if (t == typeof(int)), pero eso abriría una lata completa de gusanos cuando se sobrecarga ese operador. El comportamiento cambiaría cuando los detalles de implementación para la instrucción switch cambiaran si escribiera su == override incorrectamente. Al reducir las comparaciones a tipos integrales y cadenas y aquellas cosas que pueden reducirse a tipos integrales (y están destinadas a hacerlo) evitan posibles problemas.

fryguybob
fuente
0

escribió:

"La declaración de cambio hace una rama de tiempo constante independientemente de cuántos casos tenga".

Dado que el lenguaje permite que el tipo de cadena se use en una declaración de cambio, 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 lenguaje 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 en tiempo de ejecución.

Henk
fuente
0

Creo que Henk lo logró con la cuestión de "no tener acceso estático al sistema de tipos"

Otra opción es que no hay un orden de tipos donde pueden estar los números y las cadenas. Por lo tanto, un interruptor de tipo no podría construir un árbol de búsqueda binario, solo una búsqueda lineal.

BCS
fuente
0

Estoy de acuerdo con este comentario en que usar un enfoque basado en tablas suele ser mejor.

En C # 1.0 esto no fue posible porque no tenía delegados genéricos y anónimos. Las nuevas versiones de C # tienen el andamiaje para que esto funcione. Tener una notación para literales de objetos también es útil.

HS.
fuente
0

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

Estrictamente hablando, tiene toda la razón en que no hay razón para ponerle restricciones. Uno podría sospechar que la razón es que, para los casos permitidos, la implementación es muy eficiente (como lo sugiere Brian Ensink ( 44921 )), pero dudo que la implementación sea muy eficiente (wrt if-declaraciones) si uso números enteros y algunos casos aleatorios (por ejemplo, 345, -4574 y 1234203). Y, en cualquier caso, cuál es el daño al permitirlo para 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 excluir tipos debido a razones como la que da 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 conmutador de manera muy eficiente (es decir, con la ejecución en el orden de 2 comparaciones, una suma y dos saltos).

mweerden
fuente
0

C # 8 le permite resolver este problema de manera elegante y compacta usando una expresión de interruptor:

public string GetTypeName(object obj)
{
    return obj switch
    {
        int i => "Int32",
        string s => "String",
        { } => "Unknown",
        _ => throw new ArgumentNullException(nameof(obj))
    };
}

Como resultado, obtienes:

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

Puede leer más sobre la nueva característica aquí .

smolchanovsky
fuente