leanmind logo leanmind text logo

Blog

TDD Avanzado

Las herramientas que se necesitan para aplicar TDD en el mundo real en cualquier proyecto.

Legibilidad VS Optimización

Por Carlos Blé Jurado

En las décadas de los 70, 80 e incluso 90, las máquinas tenían muy poca memoria RAM (unos pocos kilobytes al principio) y una velocidad de CPU de pocos megahertz (8 MHz en el caso del Intel 8086). Un procesador Intel i7 de hoy en día, supera los 5GHz. Se han superado las prestaciones en muchos órdenes de magnitud. Sin embargo, hoy se sigue educando a los futuros programadores para que escriban un código óptimo en consumo de CPU y memoria, como si las máquinas tuvieran las prestaciones de hace 30 años.

Cuando las máquinas tenían pocas prestaciones en cuanto a hardware, los compiladores también eran muy limitados comparados con los de hoy en día. Los lenguajes de programación más usados eran de bajo nivel (sobre todo se usaba Ensamblador y después C) y los programadores tenían que gestionar la memoria y optimizar al máximo su código, porque ninguna otra herramienta lo iba a hacer por ellos. En la década de los 80, los compiladores empezaban a implementar suficientes optimizaciones, como para que el lenguaje Ensamblador fuese perdiendo popularidad. Cuando aparecieron máquinas virtuales de proceso como la JVM, los compiladores experimentaron una revolución, eran capaces de optimizar el código mejor que los propios programadores usuarios del lenguaje (Java, por ejemplo). Cualquier optimización que uno pretenda hacer en Java para ganar unos pocos ciclos de CPU, es insignificante en términos de velocidad y a veces incluso contraproducente. El compilador lo hará mejor. Otra cosa es cambiar el diseño de los algoritmos para que en lugar de tener una complejidad cuadrática, pasen a ser logarítmicos, esto sí puede tener un impacto notable si estamos trabajando con cantidades de datos ingentes (si estamos hablando de buscar en 1000 registros en memoria, tampoco se va a notar nada).

Cuándo optimizar

¿Merece la pena intentar escribir código eficiente en consumo de CPU y memoria hoy en día? Hay algunos casos en los que sí, pero son muy pocos. Aquí algunos:

Si no eres David Brevik, ni estás trabajando en el siguiente browser más rápido del mercado, ¡olvídate de intentar optimizar tu código! Porque el código que “parece óptimo”, es menos legible, costará más trabajo entenderlo. Digo que “parece óptimo”, porque ni siquiera medimos si hay ganancia. El primer paso para conseguir código óptimo es medir, hacer comparativas (benchmarks) para saber de forma empírica cuál es el que mejor rinde. Cuando diseñaron Chrome, seguro que no dejaron el código que de primeras les parecía más rápido, sino que hicieron comparativas midiendo consumos. Si de verdad te preocupa el rendimiento, utiliza métricas, no ofusques el código solo por la intuición de que estás ganando en velocidad (ya que seguramente no es cierto).

El cuello de botella está en el acceso a datos

Recuerdo que esta frase se la escuché por primera vez al bueno de Esteban Manchado. El cuello de botella, hoy en día, está en la latencia de la red y del disco. Una petición de lectura mediante una API REST va a tardar más de 100 milisegundos, fácilmente serán 200, 300 o más. Imagina que el backend trae 10.000 registros de la base de datos y luego en código hacemos alguna operación que filtra, ordena o de alguna forma recorre esos registros, consumiendo CPU antes de devolverlos por la red ¿Qué importancia tiene la optimización de nuestro código en esta operación en memoria?.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
import org.junit.Test;

import java.util.Arrays;
import java.util.Objects;

import static org.assertj.core.api.Assertions.*;

public class BenchmarkTests {

    static class Item implements Comparable<Item> {
        private final String textField;
        private final double numericField;

        public Item(String textField, double numericField) {
            this.textField = textField;
            this.numericField = numericField;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof Item)) return false;
            Item item = (Item) o;
            return numericField == item.numericField &&
                    Objects.equals(textField, item.textField);
        }

        @Override
        public int hashCode() {
            return Objects.hash(textField, numericField);
        }

        @Override
        public int compareTo(Item item) {
            return Double.compare(item.numericField, this.numericField);
        }
    }
    @Test
    public void iterating_an_array_costs_pretty_much_nothing (){
        int arraySize = 10000;
        Item[] items = createArray(arraySize);
        long timeBefore = System.currentTimeMillis();
        double sum = sum(items); // iteration, filter, reduce...
        long timeAfter = System.currentTimeMillis();
        assertThat(timeAfter - timeBefore).isLessThan(5);
        // 1ms on my i7
    }
    @Test
    public void it_sorts_very_fast_using_merge_sort (){
        int arraySize = 10000;
        Item[] items = createArray(arraySize);
        long timeBefore = System.currentTimeMillis();
        Arrays.sort(items); // mergesort
        long timeAfter = System.currentTimeMillis();
        assertThat(timeAfter - timeBefore).isLessThan(100);
        // 50ms on my i7
    }
    @Test
    public void bubble_sort_is_slower_as_its_complexity_is_cuadratic(){
        int arraySize = 10000;
        Item[] items = createArray(arraySize);
        long timeBefore = System.currentTimeMillis();
        bubbleSort(items);
        long timeAfter = System.currentTimeMillis();
        assertThat(timeAfter - timeBefore).isLessThan(1100);
        // 1000ms on my i7
    }

    private double sum(Item[] items) {
        double sum = 0;
        for (Item item : items) {
            sum = sum + item.numericField;
        }
        return sum;
    }

    private void bubbleSort(Item[] items) {
        for (int i = 0; i < items.length; i++){
            for (int j = 0; j < items.length -1; j++){
                if (items[j].compareTo(items[j + 1]) > 0){
                    Item tmp = items[j];
                    items[j] = items[j + 1];
                    items[j + 1] = tmp;
                }
            }
        }
    }

    private Item[] createArray(int arraySize) {
        Item[] items = new Item[arraySize];
        for (int i = 0; i < items.length; i++){
            items[i] = initRandomRecord();
        }
        return items;
    }

    private Item initRandomRecord(){
        return new Item(
                String.valueOf(Math.random() * 1000), Math.random() * 1000);
    }
}

Este código es un pequeño experimento para ver lo que tardan en mi pc distintas operaciones. Recorrer un array de 10.000 elementos y leer una propiedad de cada elemento (lo que sería hacer un filtrado simple), tarda menos de 1 milisegundo. Ordenar el array con merge sort tarda unos 50 milisegundos y ordenarlo con bubble sort tarda unos 1000 milisegundos ¿Tiene sentido que intentes optimizar el código que filtra datos en memoria? Es evidente que no. Sin embargo, tiene sentido entender de complejidad de los algoritmos para darse cuenta de que operaciones cuadráticas como el bubble sort tienen un impacto notable, aunque de nuevo dependerá del volumen de datos que estamos moviendo. Si son 200 registros (y no se esperan que aumente el orden de magnitud), no te molestes.

A veces, escribimos código oscuro en nombre de la optimización, por ejemplo, para convertir un entero en un string:

    let text = number + "";

Aun cuando es mucho más explícito lo siguiente:

    let text = number.toString();

¿Cuántos miles o millones de veces tendría que ejecutarse esa conversión para que llegásemos a notar algún tipo de diferencia de rendimiento?, ¿lo notaríamos alguna vez?

Antes de la aparición del motor V8, la forma de escribir JavaScript podía tener diferencias notables en aplicaciones web que realizasen una cantidad considerable de cómputo. El lenguaje estaba en plena evolución y los navegadores también, por lo que en determinados casos valía la pena conocer los hacks de rendimiento del lenguaje en cada navegador. Hoy en día, no me encuentro con casos en los que siga haciendo falta “optimizar” en JavaScript, sobre todo porque cada vez más programamos con ES6 o superiores, con TypeScript y con otros lenguajes que terminan compilando a JavaScript de una forma optimizada.

El código es más fácil de entender cuanto mejor refleja la intención de quien lo escribió, para lo cual se requiere ser muy explícitos escribiéndolo.

Mejor código legible que óptimo

Es preferible programar para que el código sea legible que para que sea óptimo, aunque no está de más conocer la complejidad de los algoritmos que podemos usar en librerías, para que escojamos el mejor. Por desgracia, hay que escoger entre legible y óptimo, porque son características bastante excluyentes.

Cuando no estamos seguros del volumen de cómputo que a futuro podemos necesitar, yo prefiero quedarme con el código que es suficientemente bueno para hoy, aunque mañana se quede corto. Si tengo la idea de que es muy probable que haya que mejorar el rendimiento en el futuro, prefiero anotarlo en el documento de gestión de deuda técnica y dejar el código simple. Prefiero un enfoque lean con buena planificación y estrategia, que la sobreingenieria prematura.

Publicado el 19/01/2021 por

¿Quieres más? te invitamos a suscribirte a nuestro boletín para avisarte cada vez que recopilemos contenido de calidad que compartir.

Si disfrutas leyendo nuestro blog, ¿imaginas lo divertido que sería trabajar con nosotros? ¿te gustaría?

Impulsamos el crecimiento profesional de tu equipo de developers