wandres.dev
RENDIMIENTO · el runtime de Elm

Html.lazy: no recalcular una vista cuando sus entradas no cambian, y los requisitos exactos

La memoización de vistas es la optimización más citada del ecosistema de Elm y también la que más veces se aplica sin efecto alguno, porque su contrato es estricto y no produce ningún aviso cuando se incumple. Esta lección expone qué guarda exactamente una envoltura perezosa, cómo decide el runtime que las entradas siguen siendo las mismas, y por qué esa decisión se toma por identidad de referencia y no por igualdad estructural. De ahí se derivan los requisitos concretos que hay que cumplir para que la memoización se aplique de verdad: la función envuelta debe ser un valor estable del módulo y no una lambda ni una aplicación parcial construida en el sitio; los argumentos deben ser valores primitivos o referencias que sobrevivan entre dibujados; no se pueden fabricar registros, listas ni tuplas en la llamada; y conviene pasar las porciones mínimas del modelo en lugar del modelo completo. Se estudian además los casos límite que sorprenden a quien ya cree dominarla, el coste que la propia envoltura introduce, la interacción con Html.map y el método para comprobar con mediciones que la optimización está surtiendo efecto.

⏱ 18 min

Hay optimizaciones que fallan ruidosamente y optimizaciones que fallan en silencio, y las segundas son mucho peores. Envolver una función de vista con lazy pertenece a la segunda categoría: si se cumplen sus condiciones, el runtime devuelve el árbol memorizado sin evaluar nada y la comparación se salta esa rama entera; si no se cumplen, el código compila igual, se comporta igual, produce exactamente la misma interfaz, y lo único que ocurre es que la función se evalúa cada vez y además se paga el coste añadido de guardar y comparar referencias inútilmente. No hay error de compilación, no hay advertencia, no hay traza en la consola. El resultado es un ecosistema lleno de llamadas a lazy que no memorizan nada y de programadores convencidos de haber optimizado su interfaz. La causa de este desajuste es que el contrato de la envoltura no se lee en su firma sino en su implementación: la firma promete el mismo tipo que la función original, y la condición real —qué significa que dos argumentos sean los mismos— vive en una decisión de implementación que hay que conocer. Esta lección la expone completa, deriva de ella los requisitos exactos y termina con el procedimiento para verificar que se están cumpliendo.

🎯 Al terminar esta lección sabrás
  • Explicar qué guarda una envoltura perezosa y en qué momento decide reutilizar el árbol memorizado.
  • Distinguir identidad de referencia de igualdad estructural y predecir cuál se aplica a cada tipo de argumento.
  • Enunciar los requisitos exactos que hacen efectiva la memoización y reconocer los patrones que la desactivan.
  • Decidir con mediciones dónde compensa envolver y comprobar que la envoltura está surtiendo efecto.

Qué guarda exactamente la envoltura

Una llamada envuelta no produce un árbol: produce un nodo de una forma especial que guarda tres cosas, la función, los argumentos con los que se la iba a llamar, y —una vez evaluada— el árbol que resultó. En el dibujado siguiente, cuando la comparación llega a esa posición y encuentra dos nodos perezosos, no baja a compararlos: compara la función del nodo viejo con la del nuevo y cada argumento con el suyo. Si todos coinciden, adopta el árbol ya calculado del nodo anterior y da por terminada la rama sin ejecutar una sola línea de la función de vista. Si alguno difiere, evalúa la función nueva y compara el resultado con el árbol anterior de la forma habitual.

El ahorro es doble y conviene separarlo porque no siempre se aprecia el segundo componente. Se ahorra la construcción de la descripción, que en una sección grande son miles de objetos reservados y descartados; y se ahorra la comparación de esa descripción con la anterior, que es un recorrido de la misma magnitud. En una lista de mil filas donde nada relevante cambió, la diferencia entre pagar los dos recorridos y pagar una comparación de dos referencias es exactamente la razón por la que existe esta herramienta.

import Html.Lazy exposing (lazy, lazy2, lazy3)


view : Model -> Html Msg
view model =
    div []
        [ lazy cabecera model.usuario
        , lazy2 panelTareas model.filtro model.tareas
        , lazy pie model.version
        ]


-- Existen variantes hasta ocho argumentos: lazy, lazy2 ... lazy8

Los requisitos exactos, uno a uno

La comparación de la función y de los argumentos se hace por identidad de referencia. Para los valores primitivos eso coincide con lo que uno espera, porque dos números iguales, dos cadenas iguales o dos booleanos iguales son indistinguibles para esa comprobación. Para todo lo demás —registros, listas, uniones con datos, diccionarios, tuplas— la comprobación exige que sea literalmente el mismo objeto en memoria, no uno con el mismo contenido. De esa única regla salen todos los requisitos, y por eso conviene enunciarlos como consecuencias y no como una lista arbitraria que memorizar.

El primero afecta a la función. Debe ser un valor definido en el nivel superior del módulo, porque solo entonces su referencia es la misma en cada dibujado. Una lambda escrita en la llamada se construye de nuevo cada vez y falla siempre; una función definida dentro de un let de la vista, igual; y una aplicación parcial hecha en la llamada, también, porque aplicar parcialmente construye un cierre nuevo. Este último caso es el que más engaña, porque parece idéntico a pasar una función y no lo es.

El segundo afecta a los argumentos. Deben ser primitivos o referencias que sobrevivan intactas entre dibujados, lo cual significa en la práctica campos que se leen directamente del modelo. Fabricar el argumento en la llamada —un registro de configuración, una lista construida con List.map, una tupla, un valor filtrado— produce un objeto nuevo cada vez y desactiva la memoización con total seguridad. Si hace falta transformar los datos, la transformación tiene que ocurrir dentro de la función envuelta, no antes de entrar en ella.

-- Falla: la lambda es nueva en cada dibujado
lazy (\ts -> panelTareas model.filtro ts) model.tareas

-- Falla: aplicar parcialmente construye un cierre nuevo
lazy (panelTareas model.filtro) model.tareas

-- Falla: el registro se construye aqui mismo
lazy panel { titulo = "Tareas", items = model.items }

-- Falla: la lista es nueva aunque su contenido sea igual
lazy panelTareas (List.filter .activa model.tareas)

-- Funciona: funcion del modulo y argumentos tomados del modelo
lazy2 panelTareas model.filtro model.tareas
🔗

Función estable

Definida en el nivel superior del módulo. Nada de lambdas, nada de definiciones locales, nada de aplicación parcial en la llamada.

🧊

Argumentos intactos

Primitivos o campos leídos del modelo. Si el valor se fabrica en la llamada, la referencia es nueva y no hay memoización.

✂️

Porciones mínimas

Pasar el modelo entero hace que cualquier cambio en cualquier campo invalide la memoria. Pasa solo lo que la función usa.

🧭

Posición fija

Si el nodo padre se sustituye, el nodo perezoso se descarta con él. La memoria vive en el árbol, no en un registro global.

El tercer requisito no es una condición de corrección sino de eficacia, y es el que más rendimiento decide. Como una actualización de registro produce un registro nuevo, pasar el modelo completo a una función envuelta garantiza que la memoización se descarte en cuanto cambie cualquier campo, incluso uno que la sección envuelta no mire. Pasar los dos o tres campos concretos que la función necesita, usando la variante numerada correspondiente, hace la comparación a la vez más barata y más certera. Esta disciplina tiene un efecto colateral que compensa por sí solo aunque el rendimiento no mejore: obliga a declarar de qué depende realmente cada sección de la interfaz, y esa declaración suele descubrir dependencias accidentales que nadie había advertido.

💡
El caso límite que sorprende a quien ya cree dominarla

Hay un patrón que cumple todos los requisitos aparentes y aun así no memoriza: envolver una función cuyo argumento se recalcula en update en cada mensaje aunque su contenido no cambie. Si en cada actualización escribes algo como una lista ordenada de nuevo o un diccionario reconstruido, el campo del modelo cambia de referencia constantemente y la envoltura nunca acierta. La corrección no está en la vista sino en la actualización: conservar el valor anterior cuando el resultado sería idéntico, o guardar en el modelo la forma ya derivada en lugar de recomputarla. La memoización de la vista solo puede ser tan estable como lo sean las referencias que el modelo le ofrece.

Cuándo compensa y cómo comprobar que se aplica

La envoltura no es gratuita: guarda referencias, añade una forma de nodo más al árbol y paga una comparación en cada dibujado, además de mantener vivo el árbol memorizado en memoria mientras el nodo exista. Aplicada a una función barata, el remedio cuesta más que la enfermedad y el código queda peor sin ganancia mensurable. La regla es envolver secciones cuya evaluación sea sustancial y cuyas entradas cambien con menos frecuencia que el modelo en conjunto, que es justo la combinación en la que el ahorro es grande y el acierto probable.

flowchart TD
D[Comparacion llega a un nodo perezoso] --> F[Misma funcion por referencia]
F -->|No| E[Evaluar la vista de nuevo]
F -->|Si| G[Comparar los argumentos]
G -->|Alguno distinto| E
G -->|Todos iguales| R[Reutilizar el arbol memorizado]
R --> S[Rama saltada por completo]
E --> C[Comparar el arbol resultante]
style G fill:#89b4fa,color:#11111b
style R fill:#a6e3a1,color:#11111b
style S fill:#cba6f7,color:#11111b

Hay además una consideración de memoria que rara vez se menciona. El árbol memorizado permanece vivo mientras el nodo perezoso exista, de modo que envolver cientos de secciones grandes mantiene en memoria cientos de descripciones completas que en otro caso se habrían recolectado enseguida. En una aplicación de escritorio eso no se nota; en un dispositivo con poca memoria, sí, y el síntoma es desconcertante porque la interfaz va fluida hasta que empieza a ir a saltos por presión del recolector. La regla que evita el problema es la misma que evita el desperdicio: envolver pocas secciones y grandes, nunca muchas y pequeñas.

Comprobar que surte efecto no admite conjeturas y es fácil. El método directo consiste en dejar temporalmente una traza en la función envuelta y ver si deja de aparecer cuando cambian partes del modelo ajenas a ella; recuerda que esa traza debe retirarse antes de compilar en modo optimizado, porque las funciones de depuración están prohibidas allí. El método definitivo es el perfilador del navegador: se graba una interacción representativa, se localiza la función en el desglose de tiempo y se confirma que su número de invocaciones cae. Si no cae, el fallo está en la lista de requisitos anterior y casi siempre es el segundo o el tercero.

Cómo convive con el resto del árbol

La memoria de un nodo perezoso vive en el árbol y no en un registro global, lo cual tiene una consecuencia que explica muchos fracasos aparentemente inexplicables: si el nodo padre se sustituye, el nodo perezoso desaparece con él y la próxima evaluación no encuentra nada que reutilizar. Basta con que un ancestro cambie de etiqueta según una condición para que toda la memoización que hubiera por debajo se pierda en cada cambio de esa condición. Antes de investigar los argumentos conviene por tanto comprobar que la posición del nodo en el árbol es estable.

La combinación con listas con clave sigue la misma lógica y admite dos disposiciones que no son intercambiables. Envolver la función que produce el contenedor entero evita reconstruir la descripción de la lista cuando ni el filtro ni los datos han cambiado, y es lo apropiado cuando la lista se recalcula por motivos ajenos a ella. Envolver la vista de cada fila, en cambio, permite que una modificación de un solo elemento deje intactas las descripciones de las demás, y es lo apropiado cuando las filas son costosas y cambian de una en una. Aplicar las dos a la vez es legítimo y frecuente.

-- Capa exterior: evitar reconstruir la lista entera
lazy2 listaTareas model.filtro model.tareas


-- Capa interior: cada fila decide por su cuenta
conClave : Tarea -> ( String, Html Msg )
conClave tarea =
    ( String.fromInt tarea.id, lazy fila tarea )


-- Pasar constructores como etiquetadores: son valores estables del modulo
Html.map DesdeTabla (lazy tabla model.datos)
📝
Los etiquetadores también se comparan por referencia

Un nodo de transformación guarda la función que aplica a los mensajes que salen de su interior, y esa función se compara igual que todo lo demás. Escribir una lambda como etiquetador crea un valor nuevo en cada dibujado y añade trabajo en una posición donde nadie lo busca, mientras que pasar un constructor del tipo de mensajes, que es un valor estable del módulo, no cuesta nada. La regla práctica es la misma que rige la envoltura perezosa, y no es casualidad: en este runtime, cualquier cosa que se construya durante la evaluación de la vista pierde su identidad entre un dibujado y el siguiente.

Sustituir una llamada por su resultado anterior solo es legítimo si la pureza es una ley y no una costumbre

Detrás de esta herramienta hay una identidad matemática de aspecto inocente: si una función es pura, la expresión que la aplica a unos argumentos y el valor que produjo la última vez con esos mismos argumentos son intercambiables. Eso es la transparencia referencial, se enuncia en una línea y suele presentarse como una elegancia teórica sin consecuencias prácticas. Aquí se ve que la consecuencia práctica es enorme, porque toda la memoización de vistas consiste exactamente en aplicar esa identidad y nada más. Y se ve también por qué no es transferible sin más a un entorno donde la pureza sea una costumbre en lugar de una ley. En cuanto una función de vista puede leer una variable de módulo, consultar el reloj, mirar el ancho de la ventana o depender del orden en que se la llamó, el intercambio deja de estar justificado: los argumentos pueden ser los mismos y el resultado correcto distinto, con lo que la optimización no se limita a no ahorrar, sino que produce una interfaz equivocada, y lo hace de forma intermitente y difícil de reproducir. Por eso en otros entornos la memoización de componentes viene rodeada de advertencias, de listas de dependencias que hay que mantener a mano y de herramientas de análisis que intentan adivinar si alguien rompió el contrato; toda esa maquinaria existe para reconstruir a posteriori una garantía que el lenguaje no da. En Elm no hay advertencias porque no hay nada que advertir: la vista no puede leer nada que no le hayan pasado. El único riesgo que queda es el de no ahorrar, que es un problema de rendimiento y no de corrección, y esa asimetría es toda la diferencia. Conviene retener la forma general del argumento, porque reaparece en cachés, en cálculos incrementales y en paralelización: la pureza no acelera nada por sí misma, lo que hace es autorizar transformaciones que sin ella habría que prohibir. Su valor no se mide en lo que permite escribir, sino en lo que permite reescribir sin miedo.

⚔️ Haz que la memoización se aplique de verdad
  1. Explica qué tres cosas guarda un nodo perezoso y qué compara la reconciliación al encontrarse dos de ellos.
  2. Reproduce los cuatro patrones que desactivan la memoización y determina en cada uno qué comparación concreta falla.
  3. Envuelve una sección costosa pasando el modelo entero y luego solo sus campos; mide ambas versiones y explica la diferencia.
  4. Construye un caso donde la vista cumple todos los requisitos y aun así no memoriza porque el modelo recalcula un campo cada vez.
  5. Aplica la envoltura a una función trivial y demuestra con el perfilador que empeora el resultado.
  6. Argumenta por qué esta optimización sería insegura en un lenguaje con vistas impuras y qué tendría que suplirla allí.