Estoy trabajando en una tienda .Net, C # y tengo un compañero de trabajo que sigue insistiendo en que debemos usar declaraciones gigantes de Switch en nuestro código con muchos "Casos" en lugar de enfoques más orientados a objetos. Su argumento se remonta constantemente al hecho de que una declaración Switch se compila en una "tabla de salto de la CPU" y, por lo tanto, es la opción más rápida (aunque en otras cosas se le dice a nuestro equipo que no nos importa la velocidad).
Sinceramente, no tengo un argumento en contra de esto ... porque no sé de qué diablos está hablando.
¿Tiene razón?
¿Solo está hablando por el culo?
Solo trato de aprender aquí.
c#
.net
switch-statement
James P. Wright
fuente
fuente
Respuestas:
Probablemente sea un viejo hacker de C y sí, está hablando por el culo. .Net no es C ++; el compilador .Net sigue mejorando y la mayoría de los hacks inteligentes son contraproducentes, si no hoy, en la próxima versión .Net. Las funciones pequeñas son preferibles porque .Net JIT-s cada función una vez antes de que se esté utilizando. Entonces, si algunos casos nunca se ven afectados durante un LifeCycle de un programa, entonces no se incurre en costos al compilarlos en JIT. De todos modos, si la velocidad no es un problema, no debería haber optimizaciones. Escriba para el programador primero, para el compilador segundo. Su compañero de trabajo no se convencerá fácilmente, por lo que probaría empíricamente que un código mejor organizado es realmente más rápido. Elegiría uno de sus peores ejemplos, los reescribiría de una mejor manera y luego me aseguraría de que su código sea más rápido. Recoge cerezas si es necesario. Luego ejecútelo unos millones de veces, perfílelo y muéstrelo.
EDITAR
Bill Wagner escribió:
Punto 11: Comprenda la atracción de las funciones pequeñas (segunda edición efectiva de C #) Recuerde que traducir su código C # en código ejecutable por máquina es un proceso de dos pasos. El compilador de C # genera IL que se entrega en ensamblajes. El compilador JIT genera código de máquina para cada método (o grupo de métodos, cuando se trata de la inclusión), según sea necesario. Las funciones pequeñas hacen que sea mucho más fácil para el compilador JIT amortizar ese costo. Las funciones pequeñas también son más propensas a ser candidatas para la inserción. No es solo la pequeñez: el flujo de control más simple también es importante. Menos ramas de control dentro de las funciones facilitan que el compilador JIT registre variables. No es solo una buena práctica escribir código más claro; es cómo se crea un código más eficiente en tiempo de ejecución.
EDIT2:
Entonces ... aparentemente una declaración de cambio es más rápida y mejor que un montón de declaraciones if / else, porque una comparación es logarítmica y otra es lineal. http://sequence-points.blogspot.com/2007/10/why-is-switch-statement-faster-than-if.html
Bueno, mi enfoque favorito para reemplazar una declaración de cambio enorme es con un diccionario (o, a veces, incluso una matriz si estoy activando enumeraciones o entradas pequeñas) que asigna valores a funciones que se llaman en respuesta a ellas. Hacerlo obliga a uno a eliminar una gran cantidad de desagradable estado de espagueti compartido, pero eso es algo bueno. Una declaración de cambio grande suele ser una pesadilla de mantenimiento. Entonces ... con matrices y diccionarios, la búsqueda llevará un tiempo constante, y habrá poca memoria extra desperdiciada.
Todavía no estoy convencido de que la declaración de cambio sea mejor.
fuente
A menos que su colega pueda proporcionar pruebas de que esta alteración proporciona un beneficio medible real en la escala de toda la aplicación, es inferior a su enfoque (es decir, polimorfismo), que en realidad proporciona tal beneficio: mantenibilidad.
La microoptimización solo debe hacerse, después de que los cuellos de botella se hayan inmovilizado. La optimización prematura es la raíz de todo mal .
La velocidad es cuantificable. Hay poca información útil en "el enfoque A es más rápido que el enfoque B". La pregunta es " ¿Cuánto más rápido? ".
fuente
¿A quién le importa si es más rápido?
A menos que esté escribiendo software en tiempo real, es poco probable que la minúscula cantidad de aceleración que pueda obtener al hacer algo de una manera completamente loca haga mucha diferencia para su cliente. Ni siquiera trataría de luchar contra este en el frente de la velocidad, este tipo claramente no va a escuchar ninguna discusión sobre el tema.
Sin embargo, el objetivo del juego es la mantenibilidad, y una declaración de cambio gigante ni siquiera es ligeramente mantenible, ¿cómo se explican las diferentes rutas a través del código a un nuevo tipo? ¡La documentación tendrá que ser tan larga como el código mismo!
Además, tiene la incapacidad total para realizar pruebas unitarias de manera efectiva (demasiadas rutas posibles, sin mencionar la probable falta de interfaces, etc.), lo que hace que su código sea aún menos mantenible.
[En el lado del interés: el JITter funciona mejor en métodos más pequeños, por lo que las declaraciones de cambio gigantes (y sus métodos inherentemente grandes) dañarán su velocidad en ensamblajes grandes, IIRC.]
fuente
Aléjese de la declaración de cambio ...
Este tipo de declaración de cambio debe evitarse como una plaga porque viola el Principio Abierto Cerrado . Obliga al equipo a realizar cambios en el código existente cuando se necesita agregar una nueva funcionalidad, en lugar de simplemente agregar un nuevo código.
fuente
He sobrevivido a la pesadilla conocida como la máquina de estados finitos masiva manipulada por declaraciones de cambio masivas. Peor aún, en mi caso, el FSM abarcó tres DLL de C ++ y era bastante claro que el código fue escrito por alguien versado en C.
Las métricas que debe preocupar son:
Se me asignó la tarea de agregar una nueva característica a ese conjunto de archivos DLL, y pude convencer a la gerencia de que me tomaría tanto tiempo reescribir los 3 archivos DLL como un archivo DLL orientado a objetos como lo haría para mí parchear y el jurado manipula la solución en lo que ya estaba allí. La reescritura fue un gran éxito, ya que no solo era compatible con la nueva funcionalidad, sino que era mucho más fácil de extender. De hecho, una tarea que normalmente tomaría una semana para asegurarse de no romper nada terminaría en unas pocas horas.
Entonces, ¿qué hay de los tiempos de ejecución? No hubo aumento o disminución de la velocidad. Para ser justos, nuestro rendimiento se vio limitado por los controladores del sistema, por lo que si la solución orientada a objetos fuera más lenta, no lo sabríamos.
¿Qué hay de malo con las declaraciones de cambio masivo para un lenguaje OO?
fuente
No compro el argumento de rendimiento; Se trata de la mantenibilidad del código.
PERO: a veces , una declaración de interruptor gigante es más fácil de mantener (menos código) que un grupo de clases pequeñas que anulan las funciones virtuales de una clase base abstracta. Por ejemplo, si implementara un emulador de CPU, no implementaría la funcionalidad de cada instrucción en una clase separada, simplemente la introduciría en un swtich gigante en el código de operación, posiblemente llamando a funciones auxiliares para obtener instrucciones más complejas.
Regla general: si el cambio se realiza de alguna manera en el TIPO, probablemente debería usar la herencia y las funciones virtuales. Si el cambio se realiza en un VALOR de un tipo fijo (por ejemplo, el código de operación de la instrucción, como se indicó anteriormente), está bien dejarlo como está.
fuente
No puedes convencerme de que:
Es significativamente más rápido que:
Además, la versión OO es más fácil de mantener.
fuente
Tiene razón en que el código de máquina resultante probablemente será más eficiente. El compilador esencial transforma una instrucción switch en un conjunto de pruebas y ramas, que serán relativamente pocas instrucciones. Existe una alta probabilidad de que el código resultante de enfoques más abstractos requiera más instrucciones.
SIN EMBARGO : es casi seguro que su aplicación particular no necesita preocuparse por este tipo de micro-optimización, o no estaría usando .net en primer lugar. Para cualquier cosa que no sea aplicaciones embebidas muy limitadas o trabajo intensivo de CPU, siempre debe dejar que el compilador se encargue de la optimización. Concéntrese en escribir código limpio y fácil de mantener. Esto es casi siempre de gran valor que unas pocas décimas de nano-segundo en tiempo de ejecución.
fuente
Una razón importante para usar clases en lugar de declaraciones de cambio es que las declaraciones de cambio tienden a generar un archivo enorme que tiene mucha lógica. Esto es tanto una pesadilla de mantenimiento como un problema con la administración de la fuente, ya que debe revisar y editar ese archivo enorme en lugar de archivos de clase más pequeños
fuente
una declaración de cambio en el código OOP es una fuerte indicación de las clases faltantes
pruébelo en ambos sentidos y ejecute algunas pruebas de velocidad simples; Lo más probable es que la diferencia no sea significativa. Si lo son y el código es de tiempo crítico, entonces mantenga la declaración de cambio
fuente
Normalmente odio la palabra "optimización prematura", pero esto apesta. Vale la pena señalar que Knuth usó esta famosa cita en el contexto de presionar para usar
goto
declaraciones para acelerar el código en áreas críticas . Esa es la clave: caminos críticos .Estaba sugiriendo usar
goto
para acelerar el código, pero advirtió contra aquellos programadores que desearían hacer este tipo de cosas basadas en corazonadas y supersticiones de código que ni siquiera es crítico.Favorecer las
switch
declaraciones lo más uniformemente posible a lo largo de una base de código (ya sea que se maneje o no una carga pesada) es el ejemplo clásico de lo que Knuth llama el programador "centavo y tonto" que pasa todo el día luchando por mantener su "optimizado" "código que se convirtió en una pesadilla de depuración como resultado de intentar ahorrar centavos por libras. Dicho código rara vez se puede mantener y mucho menos incluso eficiente en primer lugar.Es correcto desde la perspectiva de eficiencia muy básica. Que yo sepa, ningún compilador puede optimizar el código polimórfico que involucra objetos y el despacho dinámico mejor que una declaración de cambio. Nunca terminará con una LUT o una tabla de salto al código en línea del código polimórfico, ya que dicho código tiende a servir como una barrera optimizadora para el compilador (no sabrá a qué función llamar hasta el momento en que el envío dinámico ocurre).
Es más útil no pensar en este costo en términos de tablas de salto, sino más en términos de la barrera de optimización. Para el polimorfismo, la llamada
Base.method()
no permite que el compilador sepa qué función realmente se llamará simethod
es virtual, no está sellada y puede anularse. Dado que no sabe qué función se va a llamar de antemano, no puede optimizar la llamada a la función y utilizar más información para tomar decisiones de optimización, ya que no sabe qué función se va a llamar en la hora en que se compila el código.Los optimizadores están en su mejor momento cuando pueden mirar en una llamada de función y hacer optimizaciones que aplanan por completo a la persona que llama y a la persona que llama, o al menos optimizan a la persona que llama para trabajar de manera más eficiente con la persona que llama. No pueden hacer eso si no saben qué función se llamará de antemano.
Usar este costo, que a menudo equivale a centavos, para justificar convertirlo en un estándar de codificación aplicado de manera uniforme es generalmente muy tonto, especialmente para lugares que tienen una necesidad de extensibilidad. Eso es lo principal que debe tener en cuenta con los optimizadores prematuros genuinos: quieren convertir las preocupaciones menores de rendimiento en estándares de codificación aplicados de manera uniforme en una base de código sin tener en cuenta el mantenimiento en absoluto.
Sin embargo, me ofende un poco la cita del "viejo pirata informático C" utilizada en la respuesta aceptada, ya que soy uno de esos. No todos los que han estado codificando durante décadas a partir de un hardware muy limitado se han convertido en un optimizador prematuro. Sin embargo, también me he encontrado y trabajado con ellos. Pero esos tipos nunca miden cosas como predicciones erróneas de sucursales o errores de caché, piensan que saben mejor y basan sus nociones de ineficiencia en una base de código de producción compleja basada en supersticiones que hoy en día no son ciertas y a veces nunca lo fueron. Las personas que han trabajado genuinamente en campos críticos para el rendimiento a menudo entienden que la optimización efectiva es una priorización efectiva, y tratar de generalizar un estándar de codificación que degrada el mantenimiento para ahorrar centavos es una priorización muy ineficaz.
Los centavos son importantes cuando tienes una función barata que no hace tanto trabajo, que se llama mil millones de veces en un ciclo muy apretado y crítico para el rendimiento. En ese caso, terminamos ahorrando 10 millones de dólares. No vale la pena afeitarse centavos cuando tiene una función llamada dos veces para la cual el cuerpo solo cuesta miles de dólares. No es aconsejable pasar el tiempo regateando centavos durante la compra de un automóvil. Vale la pena regatear centavos si está comprando un millón de latas de refresco a un fabricante. La clave para una optimización efectiva es comprender estos costos en su contexto adecuado. Alguien que intenta ahorrar centavos en cada compra y sugiere que todos los demás intenten regatear centavos sin importar lo que compren, no es un optimizador experto.
fuente
Parece que su compañero de trabajo está muy preocupado por el rendimiento. Es posible que en algunos casos una estructura de caja / interruptor grande funcione más rápido, pero espero que ustedes hagan un experimento haciendo pruebas de tiempo en la versión OO y la versión de caja / interruptor. Supongo que la versión OO tiene menos código y es más fácil de seguir, comprender y mantener. Primero abogaría por la versión OO (ya que el mantenimiento / legibilidad debería ser inicialmente más importante), y solo consideraría la versión del switch / case solo si la versión OO tiene serios problemas de rendimiento y se puede demostrar que un switch / case hará un mejora significativa.
fuente
Una ventaja de mantenibilidad del polimorfismo que nadie ha mencionado es que podrá estructurar su código de manera mucho más agradable utilizando la herencia si siempre cambia a la misma lista de casos, pero en algún momento varios casos se manejan de la misma manera y otras veces no son
P.ej. si está cambiando entre
Dog
,Cat
yElephant
, y algunas vecesDog
yCat
tiene el mismo caso, puede hacer que ambos hereden de una clase abstractaDomesticAnimal
y poner esas funciones en la clase abstracta.Además, me sorprendió que varias personas usaran un analizador sintáctico como ejemplo de dónde no usarías el polimorfismo. Para un analizador similar a un árbol, este es definitivamente el enfoque incorrecto, pero si tiene algo como el ensamblaje, donde cada línea es algo independiente, y comienza con un código de operación que indica cómo debe interpretarse el resto de la línea, usaría totalmente el polimorfismo y una fabrica. Cada clase puede implementar funciones como
ExtractConstants
oExtractSymbols
. He usado este enfoque para un intérprete BASIC de juguete.fuente
"Deberíamos olvidarnos de las pequeñas eficiencias, digamos alrededor del 97% del tiempo: la optimización prematura es la raíz de todo mal"
Donald Knuth
fuente
Incluso si esto no fuera malo para la mantenibilidad, no creo que sea mejor para el rendimiento. Una llamada de función virtual es simplemente una indirección adicional (lo mismo que el mejor caso para una declaración de cambio), por lo que incluso en C ++ el rendimiento debería ser aproximadamente igual. En C #, donde todas las llamadas a funciones son virtuales, la instrucción switch debería ser peor, ya que tiene la misma sobrecarga de llamadas a funciones virtuales en ambas versiones.
fuente
Su colega no está hablando por fuera, en lo que respecta al comentario sobre las tablas de salto. Sin embargo, usar eso para justificar escribir código incorrecto es donde sale mal.
El compilador de C # convierte las declaraciones de cambio con solo unos pocos casos en una serie de if / else, por lo que no es más rápido que usar if / else. El compilador convierte declaraciones de cambio más grandes en un Diccionario (la tabla de salto a la que se refiere su colega). Consulte esta respuesta a una pregunta de Stack Overflow sobre el tema para obtener más detalles .
Una declaración de cambio grande es difícil de leer y mantener. Un diccionario de "casos" y funciones es mucho más fácil de leer. Como eso es en lo que se convierte el interruptor, usted y su colega deberían usar los diccionarios directamente.
fuente
No necesariamente está hablando por el culo. Al menos en las
switch
declaraciones C y C ++ se puede optimizar para saltar tablas, mientras que nunca he visto que suceda con un despacho dinámico en una función que solo tiene acceso a un puntero base. Por lo menos, este último requiere un optimizador mucho más inteligente que busque mucho más código circundante para descubrir exactamente qué subtipo se está utilizando desde una llamada de función virtual a través de un puntero / referencia base.Además de eso, el despacho dinámico a menudo sirve como una "barrera de optimización", lo que significa que el compilador a menudo no podrá insertar código y asignar registros de manera óptima para minimizar los derrames de pila y todas esas cosas elegantes, ya que no puede entender qué Se llamará a la función virtual a través del puntero base para alinearla y hacer toda su magia de optimización. No estoy seguro de que quiera que el optimizador sea tan inteligente e intente optimizar las llamadas de función indirectas, ya que eso podría conducir a que muchas ramas de código tengan que generarse por separado en una pila de llamadas dada (una función que las llamadas
foo->f()
tendrían para generar código de máquina totalmente diferente de uno que llamabar->f()
a través de un puntero base, y la función que llama a esa función tendría que generar dos o más versiones de código, y así sucesivamente, la cantidad de código de máquina que se genera sería explosiva, tal vez no sea tan malo con un JIT de seguimiento que genera el código sobre la marcha mientras se rastrea a través de rutas de ejecución activas).Sin embargo, como muchas respuestas se han hecho eco, esa es una mala razón para favorecer una gran cantidad de
switch
declaraciones, incluso si es sin duda más rápida en alguna cantidad marginal. Además, cuando se trata de microeficiencias, las cosas como la ramificación y la alineación generalmente tienen una prioridad bastante baja en comparación con cosas como los patrones de acceso a la memoria.Dicho eso, salté aquí con una respuesta inusual. Quiero defender la mantenibilidad de las
switch
declaraciones sobre una solución polimórfica cuando, y solo cuando, sepa con certeza que solo habrá un lugar que necesite realizar elswitch
.Un buen ejemplo es un controlador de eventos central. En ese caso, generalmente no tiene muchos lugares para manejar eventos, solo uno (por qué es "central"). Para esos casos, no se beneficia de la extensibilidad que proporciona una solución polimórfica. Una solución polimórfica es beneficiosa cuando hay muchos lugares que harían el
switch
enunciado analógico . Si sabe con certeza que solo habrá uno, unaswitch
declaración con 15 casos puede ser mucho más simple que diseñar una clase base heredada por 15 subtipos con funciones anuladas y una fábrica para instanciarlas, solo para luego usarse en una función en todo el sistema En esos casos, agregar un nuevo subtipo es mucho más tedioso que agregar unacase
declaración a una función. En todo caso, abogaría por la mantenibilidad, no por el rendimiento,switch
declaraciones en este caso peculiar donde no se beneficia de la extensibilidad en absoluto.fuente