¿Es una buena práctica reemplazar la división con la multiplicación cuando sea posible?

68

Cuando necesito división, por ejemplo, verificación de condición, me gustaría refactorizar la expresión de división en multiplicación, por ejemplo:

Versión original:

if(newValue / oldValue >= SOME_CONSTANT)

Nueva versión:

if(newValue >= oldValue * SOME_CONSTANT)

Porque creo que puede evitar:

  1. División por cero

  2. Desbordamiento cuando oldValue es muy pequeño

¿Es eso correcto? ¿Hay algún problema con este hábito?

    
pregunta mmmaaa 03.01.2018 - 02:52

9 respuestas

71

Dos casos comunes a considerar:

Aritmética de enteros

Obviamente, si está utilizando aritmética de enteros (lo que trunca) obtendrá un resultado diferente. Aquí hay un pequeño ejemplo en C #:

public static void TestIntegerArithmetic()
{
    int newValue = 101;
    int oldValue = 10;
    int SOME_CONSTANT = 10;

    if(newValue / oldValue > SOME_CONSTANT)
    {
        Console.WriteLine("First comparison says it's bigger.");
    }
    else
    {
        Console.WriteLine("First comparison says it's not bigger.");
    }

    if(newValue > oldValue * SOME_CONSTANT)
    {
        Console.WriteLine("Second comparison says it's bigger.");
    }
    else
    {
        Console.WriteLine("Second comparison says it's not bigger.");
    }
}

Salida:

First comparison says it's not bigger.
Second comparison says it's bigger.

Aritmética de punto flotante

Aparte del hecho de que la división puede producir un resultado diferente cuando se divide por cero (genera un error, mientras que la multiplicación no lo hace), también puede dar como resultado errores de redondeo ligeramente diferentes y un resultado diferente. Ejemplo simple en C #:

public static void TestFloatingPoint()
{
    double newValue = 1;
    double oldValue = 3;
    double SOME_CONSTANT = 0.33333333333333335;

    if(newValue / oldValue >= SOME_CONSTANT)
    {
        Console.WriteLine("First comparison says it's bigger.");
    }
    else
    {
        Console.WriteLine("First comparison says it's not bigger.");
    }

    if(newValue >= oldValue * SOME_CONSTANT)
    {
        Console.WriteLine("Second comparison says it's bigger.");
    }
    else
    {
        Console.WriteLine("Second comparison says it's not bigger.");
    }
}

Salida:

First comparison says it's not bigger.
Second comparison says it's bigger.

En caso de que no me creas, aquí hay un Fiddle que puedes ejecutar y ver por ti mismo.

Otros idiomas pueden ser diferentes; sin embargo, tenga en cuenta que C #, al igual que muchos idiomas, implementa una biblioteca de punto flotante IEEE estándar (IEEE 754) , por lo que debería obtener los mismos resultados en otros tiempos de ejecución estandarizados.

Conclusión

Si está trabajando greenfield , probablemente esté bien.

Si está trabajando en un código heredado, y la aplicación es una aplicación financiera o de otro tipo que realiza operaciones aritméticas y que debe proporcionar resultados consistentes, tenga mucho cuidado al cambiar las operaciones. Si es necesario, asegúrese de tener pruebas unitarias que detecten cualquier cambio sutil en la aritmética.

Si solo estás haciendo cosas como contar elementos en una matriz u otras funciones computacionales generales, probablemente estarás bien. Sin embargo, no estoy seguro de que el método de multiplicación haga que tu código sea más claro.

Si está implementando un algoritmo para una especificación, no cambiaría nada, no solo por el problema de los errores de redondeo, sino también para que los desarrolladores puedan revisar el código y asignar cada expresión a la especificación para asegurarse de que haya No hay fallas de implementación.

    
respondido por el John Wu 03.01.2018 - 05:43
25

Me gusta tu pregunta ya que potencialmente cubre muchas ideas. En general, sospecho que la respuesta es depende , probablemente de los tipos involucrados y el posible rango de valores en su caso específico.

Mi instinto inicial es reflexionar sobre el estilo , es decir. Su nueva versión es menos clara para el lector de su código. Me imagino que tendría que pensar por un segundo o dos (o quizás más) para determinar la intención de su nueva versión, mientras que su versión anterior queda clara de inmediato. La legibilidad es un atributo importante del código, por lo que hay un costo en su nueva versión.

Tienes razón en que la nueva versión evita una división por cero. Ciertamente, no es necesario agregar una guarda (en la línea de if (oldValue != 0) ). ¿Pero tiene esto sentido? Su versión anterior refleja una relación entre dos números. Si el divisor es cero, entonces tu relación es indefinida. Esto puede ser más significativo en su situación, es decir. en este caso, no debe generar un resultado.

La protección contra el desbordamiento es discutible. Si sabes que newValue siempre es mayor que oldValue , entonces quizás puedas hacer ese argumento. Sin embargo, puede haber casos en los que (oldValue * SOME_CONSTANT) también se desbordará. Así que no veo mucho beneficio aquí.

Puede haber un argumento para que obtengas un mejor rendimiento porque la multiplicación puede ser más rápida que la división (en algunos procesadores). Sin embargo, tendría que haber muchos cálculos como estos para lograr una ganancia significativa, es decir. cuidado con la optimización prematura.

Reflexionando sobre todo lo anterior, en general, no creo que haya mucho que ganar con su nueva versión en comparación con la versión anterior, en particular dada la reducción en la claridad. Sin embargo, puede haber casos específicos en los que exista algún beneficio.

    
respondido por el dave 03.01.2018 - 03:22
22

No.

Probablemente lo llamaría optimización prematura , en un sentido amplio, independientemente de si está optimizando para rendimiento , como la frase generalmente se refiere, o cualquier otra cosa que se pueda optimizar, como edge-count , líneas del código , o incluso más ampliamente, cosas como "diseño".

La implementación de ese tipo de optimización como un procedimiento operativo estándar pone en riesgo la semántica de su código y, potencialmente, oculta los bordes. Es posible que los casos de borde que considere adecuados para eliminar en forma silenciosa deban tratarse explícitamente de todos modos . Y, es infinitamente más fácil depurar problemas alrededor de bordes ruidosos (aquellos que lanzan excepciones) sobre los que fallan en silencio.

Y, en algunos casos, es incluso ventajoso "des-optimizar" por motivos de legibilidad, claridad o explicación. En la mayoría de los casos, sus usuarios no notarán que ha guardado algunas líneas de código o ciclos de CPU para evitar el manejo de casos extremos o el manejo de excepciones. El código incómodo o que falla silenciosamente, por otra parte, afectará a las personas, al menos a sus compañeros de trabajo. (Y también, por lo tanto, el costo de construir y mantener el software.)

El valor predeterminado para cualquier cosa es más "natural" y se puede leer con respecto al dominio de la aplicación y al problema específico. Mantenlo simple, explícito e idiomático. Optimice según sea necesario para obtener ganancias significativas o para alcanzar un umbral de uso legítimo.

También tenga en cuenta: los compiladores a menudo optimiza la división para ti de todos modos, cuando sea seguro hacerlo.

    
respondido por el svidgen 03.01.2018 - 06:45
12

Utilice el que tenga menos errores y tenga más sentido lógico.

Generalmente , la división por una variable es una mala idea, ya que, por lo general, el divisor puede ser cero.
La división por una constante por lo general depende solo de cuál es el significado lógico.

Aquí hay algunos ejemplos para mostrar que depende de la situación:

División buena:

if ((ptr2 - ptr1) >= n / 3)  // good: check if length of subarray is at least n/3
    ...

Multiplicación mala:

if ((ptr2 - ptr1) * 3 >= n)  // bad: confusing!! what is the intention of this code?
    ...

Multiplicación buena:

if (j - i >= 2 * min_length)  // good: obviously checking for a minimum length
    ...

División mala:

if ((j - i) / 2 >= min_length)  // bad: confusing!! what is the intention of this code?
    ...

Buena multiplicación:

if (new_length >= old_length * 1.5)  // good: is the new size at least 50% bigger?
    ...

División mala:

if (new_length / old_length >= 2)  // bad: BUGGY!! will fail if old_length = 0!
    ...
    
respondido por el Mehrdad 03.01.2018 - 10:28
3

Hacer cualquier cosa "siempre que sea posible" rara vez es una buena idea.

Su prioridad número uno debe ser la corrección, seguida de la legibilidad y el mantenimiento. Reemplazar ciegamente la división con la multiplicación siempre que sea posible a menudo fallará en el departamento de corrección, a veces solo en casos raros y, por lo tanto, difíciles de encontrar.

Haz lo correcto y lo más legible. Si tiene pruebas sólidas de que escribir el código de la manera más legible causa un problema de rendimiento, entonces puede considerar cambiarlo. Cuidados, matemáticas y revisiones de códigos son tus amigos.

    
respondido por el gnasher729 03.01.2018 - 19:47
1

Con respecto a la legibilidad del código, creo que la multiplicación es en realidad más legible en algunos casos. Por ejemplo, si hay algo que debe verificar si newValue ha aumentado un 5 por ciento o más por encima de oldValue , entonces 1.05 * oldValue es un umbral contra el cual probar newValue , y es natural escribir

    if (newValue >= 1.05 * oldValue)

Pero ten cuidado con los números negativos cuando refaccionas las cosas de esta manera (ya sea reemplazando la división con la multiplicación, o reemplazando la multiplicación con la división). Las dos condiciones que consideraste son equivalentes si se garantiza que oldValue no sea negativo; pero supongamos que newValue es en realidad -13.5 y oldValue es -10.1. Entonces

newValue/oldValue >= 1.05

se evalúa como verdadero , pero

newValue >= 1.05 * oldValue

se evalúa como falso .

    
respondido por el David K 03.01.2018 - 13:01
1

Observe el famoso artículo División por enteros invariantes que utilizan la multiplicación .

¡El compilador está realmente haciendo la multiplicación, si el entero es invariante! No es una división. Esto sucede incluso para no poder de 2 valores. El poder de las 2 divisiones utiliza obviamente cambios de bits y, por lo tanto, son aún más rápidos.

Sin embargo, para enteros no invariantes, es su responsabilidad optimizar el código. Asegúrese antes de optimizar que realmente está optimizando un cuello de botella genuino, y que la corrección no se sacrifica. Tenga cuidado con el desbordamiento de enteros.

Me importa la microoptimización, por lo que probablemente echaría un vistazo a las posibilidades de optimización.

Piense también en las arquitecturas en las que se ejecuta su código. Especialmente ARM tiene una división extremadamente lenta; necesita llamar a una función para dividir, no hay instrucción de división en ARM.

Además, en arquitecturas de 32 bits, la división de 64 bits no está optimizada, ya que < descubrió .

    
respondido por el juhist 03.01.2018 - 16:51
1

Retomando su punto 2, de hecho evitará el desbordamiento de un oldValue muy pequeño. Sin embargo, si SOME_CONSTANT también es muy pequeño, entonces su método alternativo terminará con un flujo insuficiente, donde el valor no se puede representar con precisión.

Y a la inversa, ¿qué sucede si oldValue es muy grande? Tienes los mismos problemas, justo al revés.

Si desea evitar (o minimizar) el riesgo de desbordamiento / subdesbordamiento, la mejor manera es verificar si newValue tiene la magnitud más cercana a oldValue o a SOME_CONSTANT . A continuación, puede elegir la operación de división adecuada, ya sea

    if(newValue / oldValue >= SOME_CONSTANT)

o

    if(newValue / SOME_CONSTANT >= oldValue)

y el resultado será más preciso.

Para dividir por cero, en mi experiencia, esto casi nunca es apropiado para ser "resuelto" en las matemáticas. Si tiene una división por cero en sus comprobaciones continuas, es casi seguro que tiene una situación que requiere un análisis y cualquier cálculo basado en estos datos carece de sentido. Una verificación explícita de división por cero es casi siempre el movimiento apropiado. (Tenga en cuenta que digo "casi" aquí, porque no pretendo ser infalible. Solo noté que no recuerdo haber visto una buena razón para esto en los 20 años de escribir software integrado, y seguir adelante .)

Sin embargo, si tiene un riesgo real de desbordamiento / subdesbordamiento en su aplicación, probablemente esta no sea la solución correcta. Lo más probable es que, en general, debería verificar la estabilidad numérica de su algoritmo, o tal vez simplemente pasar a una representación de mayor precisión.

Y si no tiene un riesgo comprobado de desbordamiento / subdesbordamiento, entonces no se preocupa por nada. Eso significa que usted literalmente necesita demostrar que lo necesita, con números, en los comentarios junto al código que explican a un mantenedor por qué es necesario. Como ingeniero principal que revisa el código de otras personas, si me encontrara con alguien que esté haciendo un esfuerzo extra por esto, personalmente no aceptaría nada menos. Esto es lo opuesto a la optimización prematura, pero generalmente tendría la misma causa raíz: obsesión con los detalles que no hacen una diferencia funcional.

    
respondido por el Graham 05.01.2018 - 13:50
0

Encapsule la aritmética condicional en métodos y propiedades significativos. La buena denominación no solo le dirá lo que "A / B" significa , la comprobación de parámetros & el manejo de errores puede ocultarse perfectamente allí también.

Es importante destacar que, dado que estos métodos se componen de una lógica más compleja, la complejidad extrínseca sigue siendo muy manejable.

Yo diría que la sustitución por multiplicación parece una solución razonable porque el problema está mal definido.

    
respondido por el radarbob 04.01.2018 - 19:07

Lea otras preguntas en las etiquetas