Programación Funcional en JavaScript: La evaluación perezosa

32649683842_e66726ea84_kImagen de Stefan K.

Terminada la parte de patrones funcionales, entramos en una miniserie de dos post sobre optimización funcional.

Puede que no te hayas percatado – o que si – pero la programación funcional nos va a suponer un reto a la hora de optimizar recursos. Otra de las grandes pegas que siempre le han puesto sus detractores se debe a que la programación funcional es un estilo bastante devorador de recursos y tiempo.

Yo sinceramente sería cauto. Si pensamos fríamente, cualquier tipo de abstracción que queramos realizar desde un lenguaje de programación va a minar nuestras posibilidades computacionales nos guste o no. La abstracción en lenguajes declarativos es bastante grande, pero no muy diferente a lo que a veces nos podamos encontrar en abstracciones imperativas. Como todo, hacer uso de ciertas técnicas va a ser una cuestión de equilibrio entre lo que nos conviene a nosotros y lo que le conviene al computador.

Por si esto no fuera poco para los detractores, como hemos ido viendo, JavaScript tiene menos funcionalidades funcionales de las que nos gustaría, lo que supone tener que realizar una gran cantidad de abstracciones ad hoc para que el comportamiento sea el correcto en el paradigma. Esto supone un sobreesfuerzo de recursos. Como veremos, esto es posible de paliar, pero tenemos que tener en cuenta que la programación funcional no es el maná, también tiene sus problemas (benditos problemas).

En fin, ¿Estudiáis un poco de optimización conmigo? ¿Si? Pues adelante:

La pila de funciones en JavaScript

Antes de ver porqué JavaScript, con un estilo funcional, puede presentar problemas de rendimiento, es importante que analicemos un poco el comportamiento interno de JavaScript.

JavaScript cuenta con una estructura de datos tipo LIFO denominada “pila de funciones de contexto” – también llamada pila de funciones o pila de contexto – En esta pila se van a ir guardando todas aquellas funciones que van siendo ejecutadas. Toda aplicación en JavaScript empieza con una pila inicializada con el contexto global. Este contexto global siempre se encuentra en la parte inferior de la pila, como es de esperar.

pila

Inicialización de la pila de contexto

A partir de este primer bloque de memoria, reservado para el contexto global, se van  almacenando los contextos de las funciones que van siendo ejecutadas. Cada función, como decimos, tiene su contexto. Este contexto es un registro – o frame – que ocupa unos 48 bytes si se encuentra vacío, es decir, si una función que se ejecuta no tiene ni variables locales, ni funciones declaradas en su interior.

Este registro consta de 3 partes:

  • Una referencia al contexto padre que ha ejecutado dicha función.
  • Un objeto que almacena el estado de dicha función formado por los argumentos de entrada, las variables locales de esa funciones y las funciones declaradas internamente.
  • Una referencia así mismo (Nuestro amigo ‘this’).

Teniendo estos datos, hay que tener en cuenta 4 puntos que pueden ser importantes en el futuro:

  • JavaScript es un lenguaje monohilo. Lo que supone que su ejecución sea síncrona.
  • Una aplicación en JavaScript solo puede contar con un único contexto global.
  • El número de contexto de funciones que puede ser almacenado es ‘ilimitado’ – el límite lo pone la memoria del sistema, lógicamente – algunos navegadores puede existir un límite preestablecido.
  • Cada ejecución de una función, puede incluir nuevos contextos a la pila, incluso si estas llamadas son recursivas.

Y si esto es así, tenemos un pequeño problema con ciertas técnicas que hemos desarrollado en un JavaScript funcional.

Una reflexión sobre la currificación y la recursividad

Si recordamos, JavaScript no tiene un soporte nativo de la currificación. Era un proceso que nosotros simulamos por medio del contexto léxico – clousures – y del uso de las funciones de primer orden.

Cuando currificamos una función en JavaScript, lo que estamos haciendo es incluir un gran número de funciones en la pila de contexto.

Recordemos un caso que teníamos en particular. Abstrajimos la librería Log4Js en una función para que pudiese ser configurada a nuestro antojo en todo momento, teniendo aislada la librería. El uso de la función era tal que así:

const logger = function (appender, layout, name, level, message)

Nuestra implementación currificada era de esta forma:

const logger = function (appender) {
    return function (layout) {
        return function (name) {
            return function (level) {
                return function (message) {
                    ...
                }
            }
        }
    }
};

Si dibujamos la pila de contexto, tendremos algo como esto:

llamada-log4js

El problema viene de que en realidad esto no es así y que para hacer uso de la función, el número de funciones que se ejecutan y se almacenan es algo más parecido a esto:

untitled-diagram-1

Si decíamos, que almacenar la ejecución de una función vacía es de unos 48 bytes, estamos aumentando el uso de memoria en unos 240 bytes.

Con los ordenadores que hay hoy en día esto puede parecer una memez, pero imaginad ahora que hacéis un uso intensivo de la currificación, que nos encontramos en un juego que necesita crear muchas interacciones por segundo, quizá estemos usando unos recursos que son muy valiosos para otros usos. Simplemente, tengámoslo en cuenta.

En una solución recursiva nos pasa lo mismo. Decíamos anteriormente que una ejecución incluye información en la pila incluso cuando esta ejecución es recursiva (al final hay que almacenar el estado y contexto de cada ejecución), por tanto, si contamos con una solución como la siguiente:

function longest(str, arr) {
    // caso base
    if (R.isEmpty(arr) {
        return str; 
    } else {
        let currentStr = R.head(arr).length >= str.length 
            ? R.head(arr) 
            : str;
        return longest(currentStr, R.tail(arr));
    }
}

Tenemos una representación de la pila bastante parecida a la siguiente cuando por fín llegamos al caso base:

untitled-diagram-2

Digo lo mismo, la recursividad es una solución muy elegante y concisa, pero tengamos en cuenta que supone un gasto de recursos. El algoritmo anterior podía haber sido resuelto por medio de los métodos funcionales como son filter, reduce o map, métodos que se encuentran optimizados por los navegadores para su uso.

Sin embargo, si el uso de la recursividad es la única forma de atajar un problema, podemos tener en cuenta que hay diferentes formas de resolver un problema de manera recursiva. Una de estas formas es por medio de la recursividad terminal que puede evitar los problemas de desbordamiento de la pila.

Entendiendo la evaluación perezosa

Hemos aprendido que la ejecución de funciones hace crecer nuestra pila, por tanto, es importante ejecutar aquellas funciones en el momento idóneo y solo cuando sean necesarias. También es importante reducir el número de evaluaciones en medida de que sea posible. La evaluación perezosa es una técnica que nos puede ayudar bastante, ya no solo a almacenar menos funciones en la pila de contexto, sino a realizar cómputos sobre aquellos datos necesarios.

Veamos un ejemplo sencillo para que entendamos la evaluación perezosa. Dado un rango de número que metemos en un array, queremos quedarnos con los 3 primeros. Esto podría ser algo parecido a esto:

const getArrayThree = R.pipe(R.range(1, 10), R.take(3));

Cuando ejecutamos esta función, la evaluación normal de JavaScript es la siguiente:

  • Primero se ejecuta ‘R.range’ y nos genera un ‘array’ de con 9 posiciones del 1 al 9: [1…9].
  • Lo siguiente es que ‘R.take’ se quede con los 3 primeros devolviendo [1, 2, 3]

¿No sería mejor encontrar algún mecanismo que evitara la creación de un array tan largo que luego no va a ser usado. La evaluación perezosa es una técnica que se encarga de esto. Es capaz de conocer más del contexto y hacer un simple ‘R.range’ de solo 3 posiciones. De esta forma ahorramos tiempos y recursos.

Otro caso muy común donde la evaluación perezosa puede ayudarnos en con el caso de la mónada Maybe. Si nos acordamos, podemos hacer algo de este estilo:

Maybe.of(student).getOrElse(createStudent());

No sabemos si ‘student’ tiene un estudiante o no, por tanto, para qué vamos a ejecutar todavía la creación de un estudiante que quizá todavía no sea necesario. En imperativo el caso anterior se resuelve rápido, ya que hasta que no se comprueba el ‘if’ no se crea un usuario:

var student = find('03132232L');

if (!student) {
    student = createStudent();
}

$('#student').append(student);

En programación funcional, tenemos solución para estos dos casos:

Evitando ejecuciones con el combinador alt

Si recordamos, por medio de combinadores conseguimos combinar ciertas funciones puras y composiciones que permitían controlar el flujo de una aplicación de una forma declarativa.

Existía uno, el combinador alt que nos va a permitir evitar ciertas ejecuciones innecesarias. En vez de hacer uso de la mónada Maybe para controlar la condición de si existe usuario o no, vamos hacer uso de lo siguiente:

const alt = R.curry((fn1, fn2, val)  => fn1(val) || fn2(val));

Ahora podemos hacer esto gracias a este combinador :

const printStudent = R.pipe(
    alt(find, createStudent), 
    $('#student').append
);

printStudent('03132232L');

La función ‘createStudent’ no se ejecuta hasta que ‘find’ no devuelva vacío, hemos delegado la ejecución al combinador. Hemos conseguido reducir el número de llamadas cuando no sean necesarias, ahora son bajo demanda.

Aprovecharse de la combinación de atajos

Tenemos la suerte de contar con librerías funcionales como Lodash o Rambda. Librerías muy funcionales que cuentan con mucho trabajo de optimización. Librerías como esta se aprovechan mucho de la combinación de atajos – o shortcut fusion.

Estos atajos se basan en las reglas matemáticas y lógicas para poder realizar los mismos resultados de una manera más optima. Lo bueno que tiene la transparencia referencial es que las reglas que hemos estudiado en álgebra, lógica o matemáticas se pueden aplicar.

Por ejemplo,  ‘map f . map g’ puede ser sustituido por  ‘map (f . g)’ obteniendo ambas mísmo resultados, pero siendo más óptimo o por ejemplo, ‘filter p . filter q’ puede ser sustituido por ‘filter (x -> q(x) && p(x))’ y el resultado no variaría.

Lógicamente esto solo nos funcionará con funciones puras que no tienen efectos laterales.

Conclusión

Puede que las optimizaciones no sean importantes en nuestro día a día porque contemos con recursos suficientes para que nuestro programa funcional se ejecute correctamente., puede que no sea lo primero en lo que pensemos a la hora de diseñar un sistema, sin embargo, es importante que tengamos en cuenta estas técnicas por si algún día las necesitemos.

Con el grado de interacción que cada vez tienen los usuarios con la web y la gran cantidad de videojuegos que se empiezan a ejecutar en navegadores, es importante que pensemos de una forma óptima. Todos aquellos cómputos innecesarios y toda la memoria a consumir que podamos evitar a nuestros ordenadores, a la larga, siempre será de agradecer.

Aunque quizá estas técnicas son relativamente poco efectivas en cuanto a rendimiento, pueden ser útiles y nos sensibilizan a pensar si lo que hacemos está bien o mal para el sistema. Programar y desarrollar suponen un gran cantidad de disciplinas que tienen que tenerse en cuenta.

En el próximo capítulo, hablaremos de otra técnica importante a la hora de ahorrar recursos y tiempos, se trata de la memoización, una técnica de optimización muy socorrida en filosofías funcionales.

Por ahora es todo. Nos leemos 🙂

Anteriores posts de Programación Funcional en JavaScript:

Introducción

  1. La Programación Funcional en JavaScript
  2. Programación Funcional en JavaScript: Los Objetos
  3. Programación Funcional en JavaScript: Las funciones

El control de flujo funcional

  1. Programación Funcional en JavaScript: Los métodos funcionales
  2. Programación Funcional en JavaScript: La recursividad

La modularidad funcional

  1. Programación Funcional en JavaScript: La aridad y las tuplas
  2. Programación Funcional en JavaScript: La currificación
  3. Programación Funcional en JavaScript: La composición
  4. Programación Funcional en JavaScript: Los combinadores

Los patrones funcionales

  1. Programación Funcional en JavaScript: Los funtores
  2. Programación Funcional en JavaScript: La mónada Maybe
  3. Programación Funcional en JavaScript: La mónada Either
  4. Programación Funcional en JavaScript: La mónada IO

La optimización funcional

  1. Programación Funcional en JavaScript: La evaluación perezosa
  2. Programación Funcional en JavaScript: La memoización
Anuncios

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s