¿Por qué usar un enfoque OO en lugar de una declaración de "cambio" gigante?

58

Estoy trabajando en una tienda .Net, C # y tengo un compañero de trabajo que sigue insistiendo en que deberíamos usar declaraciones de Switch gigantes en nuestro código con muchos "Casos" en lugar de enfoques más orientados a objetos. Su argumento consistentemente se remonta al hecho de que una instrucción Switch se compila en una "tabla de salto de 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).

Honestamente, no tengo un argumento en contra de esto ... porque no sé de qué diablos está hablando.
¿Tiene razón?
¿Está él solo hablando por el culo?
Solo trato de aprender aquí.

    
pregunta James P. Wright 25.05.2011 - 17:15

17 respuestas

48

Probablemente es un viejo pirata informático C y sí, él habla fuera de sí. .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 todas las funciones una vez antes de que se estén utilizando. Por lo tanto, si algunos casos nunca son afectados durante el ciclo de vida de un programa, no se incurre en costos al compilar estos JIT. De todos modos, si la velocidad no es un problema, no debería haber optimizaciones. Escribir para el programador primero, para el compilador segundo. Su compañero de trabajo no se convencerá fácilmente, por lo que demostraría empíricamente que el código mejor organizado es en realidad más rápido. Elegiría uno de sus peores ejemplos, los reescribiría de una mejor manera y luego me aseguraré de que su código sea más rápido. Cereza-pick si es necesario. Luego ejecútalo unos cuantos millones de veces, perfílelo y muéstralo. Eso debería enseñarle bien.

EDIT

Bill Wagner escribió:

Artículo 11: Entender la atracción de las funciones pequeñas (efectiva C # Segunda edición) 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 un código de máquina para cada método (o grupo de métodos, cuando se trata de la alineació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 candidatos para el alineamiento. No es solo una pequeñez, el flujo de control más simple es igual de importante. Menos ramas de control dentro de las funciones hacen que sea más fácil para el compilador JIT registrar variables. No es solo una buena práctica escribir un 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 instrucción de cambio es más rápida y mejor que un grupo de instrucciones if / else, porque una comparación es logarítmica y otra lineal. enlace

Bueno, mi enfoque favorito para reemplazar una declaración de cambio enorme es con un diccionario (o, a veces, incluso con una matriz si estoy activando enumeraciones o entradas pequeñas) que asigna valores a funciones a las que se llama 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. Así que ... con matrices y diccionarios, la búsqueda tomará un tiempo constante, y se desperdiciará poca memoria adicional.

Todavía no estoy convencido de que la declaración de cambio sea mejor.

    
respondido por el Job 25.05.2011 - 17:24
38

A menos que su colega pueda proporcionar una prueba, que esta alteración proporciona un beneficio real de mensurable 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 se debe hacer, después se detectan cuellos de botella. La optimización prematura es la raíz de todos los males .

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? ".

    
respondido por el back2dos 25.05.2011 - 17:33
27

¿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 cantidad minúscula de aceleración que pueda obtener al hacer algo de una manera totalmente insana haga una gran diferencia para su cliente. Ni siquiera me gustaría luchar contra este en el frente de la velocidad, este tipo claramente no va a escuchar ningún argumento sobre el tema.

Sin embargo, el objetivo del juego es la capacidad de mantenimiento, y una declaración de cambio gigante no es incluso un poco mantenible, ¿cómo explica las diferentes rutas a través del código a un nuevo tipo? ¡La documentación tendrá que ser tan larga como el propio código!

Además, tienes la incapacidad total para realizar pruebas de unidad de manera efectiva (demasiadas rutas posibles, sin mencionar la probable falta de interfaces, etc.), lo que hace que tu código sea aún menos mantenible.

[En el lado interesado: el JITter funciona mejor con 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.]

    
respondido por el Ed James 25.05.2011 - 17:22
14

Alejate de la sentencia de cambio ...

Este tipo de declaración de cambio debe rechazarse como una plaga porque viola el Open Closed Principle . Obliga al equipo a realizar cambios en el código existente cuando es necesario agregar una nueva funcionalidad, en lugar de agregar un nuevo código.

    
respondido por el Dakotah North 25.05.2011 - 17:24
8

He sobrevivido a la pesadilla conocida como la máquina masiva de estados finitos manipulada por declaraciones de conmutación masivas. Peor aún, en mi caso, el FSM abarcaba tres DLL de C ++ y era bastante claro que el código fue escrito por alguien versado en C.

Las métricas que debes cuidar son:

  • Velocidad de hacer un cambio
  • Velocidad de encontrar el problema cuando sucede

Me dieron la tarea de agregar una nueva característica a ese conjunto de DLL, y pude convencer a la administración de que me llevaría tanto tiempo reescribir las 3 DLL como una DLL orientada a objetos como sería para mí Para parchear el mono y el jurado instalar 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 ampliar. De hecho, una tarea que normalmente tomaría una semana para asegurarse de que no rompió nada terminaría tomando unas pocas horas.

Entonces, ¿qué hay de los tiempos de ejecución? No hubo aumento o disminución de la velocidad. Para ser justos, los controladores del sistema aceleraron nuestro rendimiento, por lo que si la solución orientada a objetos fuera más lenta, no lo sabríamos.

¿Qué pasa con las declaraciones de cambio masivo para un idioma OO?

  • El flujo de control del programa se retira del objeto al que pertenece y se coloca fuera del objeto
  • Muchos puntos de control externo se traducen en muchos lugares que necesita revisar
  • No está claro dónde se almacena el estado, especialmente si el conmutador está dentro de un bucle
  • La comparación más rápida es que no hay comparación en absoluto (puede evitar la necesidad de muchas comparaciones con un buen diseño orientado a objetos)
  • Es más eficiente iterar a través de sus objetos y llamar siempre el mismo método en todos los objetos que cambiar su código en función del tipo de objeto o enumeración que codifica el tipo.
respondido por el Berin Loritsch 25.05.2011 - 20:32
8

No compro el argumento de rendimiento; todo se trata de la capacidad de mantenimiento del código.

PERO: a veces , una declaración de cambio gigante es más fácil de mantener (menos código) que un grupo de pequeñas clases que anulan funciones virtuales de una clase base abstracta. Por ejemplo, si tuviera que implementar un emulador de CPU, no implementaría la funcionalidad de cada instrucción en una clase separada; simplemente lo metería en un gigante en el código de operación, posiblemente llamando a las funciones de ayuda para obtener instrucciones más complejas. / p>

Regla de oro: si el interruptor se realiza de alguna manera en el TIPO, probablemente debería usar la herencia y las funciones virtuales. Si el interruptor se realiza en un VALOR de un tipo fijo (por ejemplo, el código de operación de la instrucción, como se indica anteriormente), está bien dejarlo como está.

    
respondido por el zvrba 26.05.2011 - 09:36
5

No puedes convencerme de que:

void action1()
{}

void action2()
{}

void action3()
{}

void action4()
{}

void doAction(int action)
{
    switch(action)
    {
        case 1: action1();break;
        case 2: action2();break;
        case 3: action3();break;
        case 4: action4();break;
    }
}

Es significativamente más rápido que:

struct IAction
{
    virtual ~IAction() {}
    virtual void action() = 0;
}

struct Action1: public IAction
{
    virtual void action()    { }
}

struct Action2: public IAction
{
    virtual void action()    { }
}

struct Action3: public IAction
{
    virtual void action()    { }
}

struct Action4: public IAction
{
    virtual void action()    { }
}

void doAction(IAction& actionObject)
{
    actionObject.action();
}

Además, la versión OO es más fácil de mantener.

    
respondido por el Martin York 25.05.2011 - 20:09
4

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 en particular no tenga que preocuparse por este tipo de microoptimización, o no usaría .net en primer lugar. Para cualquier cosa que no sea aplicaciones integradas muy restringidas, o trabajo intensivo de CPU, siempre debe dejar que el compilador se ocupe de la optimización. Concéntrate en escribir código limpio y mantenible. Casi siempre es de gran valor que unas pocas décimas de nano-segundo en tiempo de ejecución.

    
respondido por el Luke Graham 25.05.2011 - 17:25
3

Una de las principales razones para usar clases en lugar de declaraciones de cambio es que las instrucciones de cambio tienden a llevar a 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 tiene que desproteger y editar ese enorme archivo en lugar de un archivo de clase más pequeño diferente

    
respondido por el Homde 25.05.2011 - 17:28
3

una declaración de cambio en el código OOP es un fuerte indicio de las clases faltantes

inténtalo de ambas maneras y ejecuta algunas pruebas de velocidad simples; lo más probable es que la diferencia no sea significativa. Si son y el código es crítico en el tiempo , entonces mantenga la instrucción de cambio

    
respondido por el Steven A. Lowe 25.05.2011 - 17:34
3

Normalmente odio la palabra, "optimización prematura", pero esto apesta. Vale la pena señalar que Knuth utilizó esta famosa cita en el contexto de presionar para usar goto enunciados con el fin de acelerar el código en las áreas críticas . Esa es la clave: ruta crítica .

Estaba sugiriendo usar goto para acelerar el código, pero advirtió contra aquellos programadores que querrían hacer este tipo de cosas basadas en corazonadas y supersticiones para códigos que ni siquiera son críticos.

Para favorecer las declaraciones switch tanto como sea posible uniformemente 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 "penny-wise y pound- "Programador tonto que pasa todo el día luchando para mantener su código" optimizado "que se convirtió en una pesadilla de depuración como resultado de intentar ahorrar centavos de más. Dicho código rara vez se puede mantener y mucho menos, incluso, es eficiente en primer lugar.

  

¿Tiene razón?

Él es correcto desde la perspectiva de la eficiencia muy básica. Ningún compilador, que yo sepa, puede optimizar el código polimórfico que involucra objetos y el envío dinámico mejor que una instrucción switch. Nunca terminará con una LUT o una tabla de salto a código en línea desde el 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, llamar a Base.method() no le permite al compilador saber qué función terminará siendo llamada si method es virtual, no está sellada, y puede ser anulada. Dado que no sabe qué función se llamará por adelantado, no puede optimizar la llamada de función y utilizar más información para tomar decisiones de optimización, ya que no sabe realmente a qué función se llamará en La hora en que se está compilando 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 completamente a la persona que llama y a la persona que llama, o al menos optimizan a la persona que llama para que trabaje de manera más eficiente con la persona que llama. No pueden hacerlo si no saben a qué función se va a llamar de antemano.

  

¿Está simplemente hablando por el culo?

El uso de este costo, que a menudo equivale a centavos, para justificar que se convierta en un estándar de codificación aplicado de manera uniforme generalmente es 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 los problemas menores de rendimiento en estándares de codificación aplicados de manera uniforme en toda la base de código sin tener en cuenta el mantenimiento en absoluto.

Sin embargo, me ofende un poco la cita del "viejo hacker C" que se usa en la respuesta aceptada, ya que soy uno de ellos. 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, he encontrado y trabajado con ellos también. Pero esos tipos nunca miden cosas como la falta de predicción de la rama o las fallas de caché, creen que lo saben mejor y basan sus nociones de ineficiencia en una base de código de producción compleja basada en supersticiones que hoy no son ciertas y que a veces nunca son verdaderas. Las personas que realmente han trabajado 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 degradante de 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 bucle muy ajustado y crítico para el rendimiento. En ese caso, terminamos ahorrando 10 millones de dólares. No vale la pena afeitar centavos cuando tiene una función llamada dos veces por la cual el cuerpo solo cuesta miles de dólares. No es aconsejable pasar su tiempo regateando centavos durante la compra de un automóvil. Vale la pena regatear sobre centavos si está comprando un millón de latas de refresco de 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 intentan regatear centavos sin importar lo que estén comprando, no es un optimizador experto.

    
respondido por el user204677 05.01.2016 - 12:43
2

Parece que su compañero de trabajo está muy preocupado por el rendimiento. Puede ser que, en algunos casos, una estructura de caso / conmutador grande se realice más rápido, pero esperamos que ustedes hagan un experimento haciendo pruebas de tiempo en la versión OO y la versión de conmutador / caso. Supongo que la versión OO tiene menos código y es más fácil de seguir, entender y mantener. Yo abogaría por la versión OO primero (ya que el mantenimiento / legibilidad debería ser inicialmente más importante), y solo considerar la versión del conmutador / caso solo si la versión OO tiene serios problemas de rendimiento y se puede demostrar que un conmutador / caso hará un cambio. mejora significativa.

    
respondido por el FrustratedWithFormsDesigner 25.05.2011 - 17:23
2

Una ventaja de mantenimiento del polimorfismo que nadie ha mencionado es que podrá estructurar su código mucho mejor usando la herencia si siempre está cambiando en la misma lista de casos, pero a veces varios casos se manejan de la misma manera y en algún momento no lo son

Por ejemplo. Si está cambiando entre Dog , Cat y Elephant , y algunas veces Dog y Cat tienen el mismo caso, puede hacer que ambos hereden de una clase abstracta DomesticAnimal y poner esas funciones en el clase abstracta.

También me sorprendió que varias personas usaran un analizador como un ejemplo de donde no usarías el polimorfismo. Para un analizador tipo árbol, este es definitivamente el enfoque equivocado, pero si tiene algo como ensamblaje, donde cada línea es un tanto independiente, y comience con un código de operación que indique cómo debe interpretarse el resto de la línea, usaría totalmente el polimorfismo y una fábrica. Cada clase puede implementar funciones como ExtractConstants o ExtractSymbols . He utilizado este enfoque para un intérprete BASIC de juguete.

    
respondido por el jwg 25.07.2013 - 08:37
0

"Debemos olvidarnos de las pequeñas eficiencias, digamos que aproximadamente el 97% de las veces: la optimización prematura es la raíz de todo mal"

Donald Knuth

    
respondido por el thorsten müller 25.05.2011 - 21:04
0

Incluso si esto no fuera malo para el mantenimiento, 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 conmutación), por lo que incluso en C ++ el rendimiento debería ser aproximadamente igual. En C #, donde todas las llamadas de función son virtuales, la instrucción de cambio debería ser peor, ya que tiene la misma sobrecarga de llamada de función virtual en ambas versiones.

    
respondido por el Dirk Holsopple 28.08.2012 - 16:09
0

Su colega no está hablando desde afuera, en lo que respecta al comentario sobre las tablas de salto. Sin embargo, usar eso para justificar la escritura de un código incorrecto es donde se equivoca.

El compilador de C # convierte sentencias de conmutación con solo unos pocos casos en una serie de if / else's, por lo que no es más rápido que usar if / else. El compilador convierte las declaraciones de cambio más grandes en un Diccionario (la tabla de saltos a la que se refiere su colega). Consulte esta respuesta a una pregunta de desbordamiento de pila sobre el tema para obtener más detalles .

Una instrucció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 cambio, sería aconsejable que usted y su colega utilicen los diccionarios directamente.

    
respondido por el David Arno 05.01.2016 - 13:33
0

No está necesariamente hablando fuera de su culo. Al menos en C y C ++% las declaraciones de switch se pueden optimizar para saltar tablas, mientras que nunca he visto que esto ocurra con un envío dinámico en una función que solo tiene acceso a un puntero base. Como mínimo, este último requiere un optimizador mucho más inteligente que busque mucho más código circundante para averiguar 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 envío dinámico a menudo sirve como una "barrera de optimización", lo que significa que el compilador a menudo no podrá en línea codificar y asignar registros de manera óptima para minimizar los derrames de pila y todas esas cosas sofisticadas, ya que no puede Averigüe qué función virtual se va a llamar a través del puntero base para alinearla y hacer toda su magia de optimización. No estoy seguro de que desee que el optimizador sea tan inteligente e intente optimizar las llamadas a funciones indirectas, ya que esto podría llevar a que muchas ramas de código se generen por separado en una pila de llamadas determinada (una función que llama a foo->f() tendría que generar un código de máquina totalmente diferente de uno que llame a bar->f() a través de un puntero base, y la función que llame 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 ser generado sería explosivo (tal vez no sea tan malo con un JIT de rastreo que genera el código sobre la marcha, ya que se está rastreando a través de rutas de ejecución activas).

Sin embargo, como se han repetido muchas respuestas, esa es una mala razón para favorecer un cargamento de declaraciones de switch , incluso si es indiscutiblemente más rápido por una cantidad marginal. Además, cuando se trata de microeficiencias, las cosas como la bifurcación y la alineación suelen tener una prioridad bastante baja en comparación con las cosas como los patrones de acceso a la memoria.

Dicho esto, salté aquí con una respuesta inusual. Quiero defender el mantenimiento de las declaraciones switch sobre una solución polimórfica cuando, y solo cuando, esté seguro de que solo habrá un lugar que necesite realizar el switch .

Un ejemplo principal es un controlador de eventos central. En ese caso, generalmente no tiene muchos lugares que manejen 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 la declaración analógica switch . Si está seguro de que solo va a haber uno, una declaración switch con 15 casos puede ser mucho más simple que diseñar una clase base heredada por 15 subtipos con funciones sobrescritas y una fábrica para crear instancias, solo para luego usarlas. En una función en todo el sistema. En esos casos, agregar un nuevo subtipo es mucho más tedioso que agregar una declaración case a una función. En cualquier caso, defendería la capacidad de mantenimiento, no el rendimiento, de switch en este caso peculiar en el que no se beneficia de la extensibilidad en absoluto.

    
respondido por el user204677 10.12.2017 - 02:16

Lea otras preguntas en las etiquetas