Introducción
Es frecuente que conceptos que llevan desde siempre entre nosotros se pongan de moda de un día a otro. No son precisamente nuevos, pero es gracias a nuevas tecnologías, técnicas o tendencias, que vuelven a estar en boca de todos o sean redescubiertos.
Ocurrió por ejemplo con el modelo MVC (creado en los años 70), o con la actualmente renacida Programación Funcional (teorizada en 1930 e implementada en los años 60). Y es precisamente, dentro de este último contexto, donde nos encontramos con las Funciones Puras en Javascript: un concepto perfectamente familiar para quienes practican ese paradigma funcional pero poco documentado más allá del mismo.
Echémosle un vistazo…
Teoría básica y ejemplo rápido
Una forma muy rápida y poco precisa de definir a las Funciones Puras sería decir que son aquellas que operan utilizando solo los parámetros de entrada sin recurrir a ningún otro elemento fuera de ellas.
De la anterior aproximación, podemos derivar fácilmente -y de un modo ya formal- una definición más académica:
En programación, las Funciones Puras son aquellas que cumplen con dos requisitos básicos:
- Dado unos parámetros de entrada de idéntico valor, la función siempre devolverá el mismo resultado.
- El cómputo de la función, su lógica, no implica ningún efecto observable colateral fuera de ella.
Veamos ahora de forma igualmente simple, un ejemplo infantil de función pura:
function pureFoo ( a, b ) { return a + b; } console.info( pureFoo( 2, 4 ) ); // 6 console.info( pureFoo( 3, 6 ) ); // 9 console.info( pureFoo( 2, 4 ) ); // 6 |
La función anterior, que únicamente tiene como objetivo devolver la suma de sus dos argumentos de entrada (no hacemos ningún tipo de comprobación para mantenerla simple), cumple con los dos requisitos enunciados más arriba:
- Por un lado, podemos observar e intuir que siempre que llamemos a esta función con los mismos parámetros, el resultado será idéntico. Al no requerir o depender de ningún estado o valor fuera de ella, la salida debe ser siempre la misma para valores dados iguales.
- Por otro, el cálculo que realiza nuestra función no modifica durante el mismo nada fuera de ella. Eso es extensible a las mismas variables que recibe como entrada: ni a ni b son ‘mutadas’ durante el proceso.
NOTA: He usado el término ‘mutar’ para acercarnos un poco más a la definición clásica ya que, efectivamente, un aspecto fundamental de las funciones puras es la inmutabilidad de cualquier valor fuera de ella, ¡incluidos sus argumentos de entrada!.
Podemos encontrar otros ejemplos de funciones puras en el propio lenguaje de forma nativa. Sería el caso de la mayoría de métodos del objeto Math:
console.info( Math.min( 0, 150, 30, 20, -8, -200 ) ); // -200 console.info( Math.max( 0, 150, 30, 20, -8, -200 ) ); // 150 console.info( Math.sqrt( 16 ) ); // 4 |
De nuevo, siempre que ejecutemos las llamadas anteriores con los mismos parámetros de entrada, el resultado será el mismo independientemente del contexto.
Veamos otro ejemplo, pero ahora con una función no pura:
function nonPureFoo ( a ) { var inputValue = $( '#userInput' ).val(); return a + inputValue; } |
Creo que queda perfectamente clara la diferencia en este ejemplo: ahora nuestra función requiere de un elemento externo para devolver un resultado. En concreto, estamos accediendo a lo que parece ser un campo de formulario con jQuery, obteniendo su valor, y sumándolo al parámetro de entrada. Esa dependencia impide que podamos garantizar el mismo resultado para una misma entrada. Estamos dependiendo aquí del valor externo del campo #userInput.
Si volvemos al objeto Math, también encontramos que tenemos un método ‘no puro’ disfrazado:
console.info( Math.random() ); // 0.014685770236463336 console.info( Math.random() ); // 0.7129402960991972 |
En este caso, aunque el método/función se limita a operar con los parámetros de entrada y no modificamos nada fuera de su ámbito, la salida difiere entre llamada y llamada. Algo similar obtenemos cuando utilizamos funciones que involucran tiempo real:
console.info( new Date().toLocaleTimeString() ); // 12:00:00 |
Ejemplo no tan obvio
En el anterior caso, vimos que no cumplíamos la primera regla aunque sí la segunda. Veamos ahora el ejemplo contrario:
function getFirstArrayElement ( arr ) { return arr.splice( 0, 1 ); } console.info( getFirstArrayElement( [ 'la', 'donna', 'e', 'mobile' ] ) ); // [ 'la' ] console.info( getFirstArrayElement( [ 'cual', 'piuma', 'al', 'vento' ] ) ); // [ 'cual' ] console.info( getFirstArrayElement( [ 'la', 'donna', 'e', 'mobile' ] ) ); // [ 'la' ] |
Esta sencilla función nos devolvería el primer elemento de un array. Por la salida que tenemos en la consola, parece que estamos cumpliendo la primera regla: el resultado siempre es el mismo. Tampoco parece que estemos usando nada fuera de nuestra función para realizar el cómputo: nos limitamos al array de entrada…
Pero tenemos un problema con la segunda regla que ya habrán visto la mayoría de los lectores: sí que tenemos aquí una mutación ‘colateral’ en los parámetros de entrada. El método splice, como otros muchos cuando manejamos arrays, modifica el valor de entrada para producir un resultado. Podemos verlo fácilmente si utilizamos nuestra anterior función de esta otra forma:
var arr = [ 'la', 'donna', 'e', 'mobile' ]; console.info( getFirstArrayElement( arr ) ); // [ 'la' ] console.info( arr ); // [ 'donna', 'e', 'mobile' ] |
Comprobamos así que el valor de entrada, arr ha sido modificado dentro de nuestra función violando con ello la regla de la inmutabilidad.
NOTA: Aquellos lectores que tengan una formación matemática, se habrán percatado que cuando hablamos de Funciones Puras en programación, no estamos refiriendo básicamente al concepto matemático que tenemos de las mismas. En concreto, sería Leibniz en el S.XVII quien acuñaría los términos «función», «variable», «constante» y «parámetro» y Euler quien introduciría su notación f(x). Al hilo de todo esto, existe documentación abundante en OpenLibra que podéis consultar: Libros sobre funciones
AJAX / operaciones asíncronas
Cuando hablamos de operaciones asíncronas, al igual que en el caso anterior, volvemos a un escenario no puro. Este tipo de dependecias provocan lo que conocemos como una carrera de condiciones y eventos: disponibilidad de las redes, latencias, entradas o aleatoriedad que impiden garantizar un resultado de forma inequívoca.
Beneficios de las funciones puras
Ahora que tenemos claro qué son las Funciones Puras, las ventajas de trabajar con ellas son obvias:
Ausencia de efectos colaterales; inmutabilidad
No modificamos nada fuera de nuestra función y, por tanto, nos limitamos a un único e identificable scope. Esto supone en última instancia que los resultados son siempre reproducibles a partir de una idéntica entrada lo que nos lleva al directamente al siguiente punto.
Testing/Debug
Las Funciones Puras, por definición, son fáciles de testear. Al no requerir de un contexto, podemos concetrarnos en evaluar el tándem entrada/salida de forma aislada. En una metodología TDD, o de tests unitarios, el procedimiento es muy simple. Un ejemplo con QUnit y nuestra sencilla función anterior resalta este punto:
QUnit.test( 'Testing Sum', function( assert ) { assert.ok( pureFoo( 1, 3 ) === 4, 'Passed' ); } ); |
La misma prueba, con el ejemplo anterior ‘no puro’, requeriría al framework de testing como mínimos acceso al DOM y a jQuery.
En el caso de las operaciones asíncronas, todos conocemos la necesidad de recurrir a mocks y a stubs para poder verificar un comportamiento adecuado de nuestras aplicaciones.
Autodocumentación
El hecho de tener toda la lógica de una función encerrada entre sus llaves, permite identificar mejor su funcionamiento y preveer sus resultados. Esto, de nuevo, se complementa con el punto anterior: no debemos olvidar que, en una aplicación, sus tests deben funcionar como documentación de aquellos métodos que analizan.
Paralelización y Web Workers
Otra ventaja quizá menos obvia a simple vista, es la paralelización. Las funciones puras pueden así distribuirse entre distintos hilos de ejecución para aliviar altos ciclos de computación. Esto es posible gracias a la ausencia de dependencias y a que ningún otro componente principal se verá afectado. Esto es una clara y magnífica oportunidad para recurrir a los Web Workers.
De nuevo, volveremos sobre este tema más adelante 🙂
Memoization
El concepto de la memorización es una de las herramientas más útiles cuando se busca optimizar un código. La identificar funciones recurrente, ‘cachear’ su resultado y reutilizarlo más adelante en lugar de volver a repetir la operación de nuevo. Nos moveríamos aquí en el terreno de las funciones recursivas de alto coste computacional, o los algoritmos de órdenes superiores al cuadrático.
Las funciones puras, al ser ‘referencialmente transparentes’, posibilitan guardar el resultado para un conjunto de parámetros dados evitando así la necesidad de volver a calcularlo más adelante si fuera necesario.
Para profundizar en este concepto de la memoization, recomiendo el siguiente artículo del maestro Addy Osmani: Faster JavaScript Memoization For Improved Application Performance
Programación Funcional
Y… son la puerta a la Programación Funcional en Javascript. Llegado el momento de hablar sobre este paradigma perfectamente compatible con la Programación Orientada a Objetos, este breve artículo servirá de referencia cuando tratemos de nuevo este tipo de funciones.
Un ejemplo más complejo
Si nos quedáramos en el ejemplo de una función que solo suma no le veríamos a esto la gracia… Así que vamos a probar con algo más complejo.
Esta función es un incrementador la cual, dada una entrada, nos devuelve el siguiente valor (sucesivo) lógico. Está tomada de la biblioteca Slang, por lo que cualquier mejora que queráis hacer sobre la misma, podéis hacerla en su repositorio en GitHub.
/** * Successor * * Returns the successor to string. The successor is * calculated by incrementing characters starting from * the rightmost alphanumeric (or the rightmost character * if there are no alphanumerics) in the string. * Incrementing a digit always results in another * digit, and incrementing a letter results in another * letter of the same case. * * If the increment generates a carry, the character to * the left of it is incremented. This process repeats * until there is no carry, adding an additional character * if necessary. * * @param {string} input The string */ function incrementator ( input ) { var alphabet = 'abcdefghijklmnopqrstuvwxyz', length = alphabet.length, result = input, i = input.length; while( i >= 0 ) { var last = input.charAt( --i ), next = '', carry = false; if ( isNaN( last ) ) { index = alphabet.indexOf( last.toLowerCase() ); if ( index === -1 ) { next = last; carry = true; } else { var isUpperCase = last === last.toUpperCase(); next = alphabet.charAt( ( index + 1 ) % length ); if ( isUpperCase ) { next = next.toUpperCase(); } carry = index + 1 >= length; if ( carry && i === 0 ) { var added = isUpperCase ? 'A' : 'a'; result = added + next + result.slice( 1 ); break; } } } else { next = +last + 1; if ( next > 9 ) { next = 0; carry = true } if ( carry && i === 0 ) { result = '1' + next + result.slice( 1 ); break; } } result = result.slice( 0, i ) + next + result.slice( i + 1 ); if ( ! carry ) { break; } } return result; } console.info( successor( 'abcd' ) ); // 'abce' console.info( successor( 'THX1138' ) ); // 'THX1139' console.info( successor( '<<koala>>' ) ); // '<<koalb>>' console.info( successor( '1999zzz' ) ); // '2000aaa' console.info( successor( 'ZZZ9999' ) ); // "AAAA0000" |
Como podemos ver, este fragmento cumple con todos los requisitos de una función pura: la lógica necesaria para computar la salida se limita a su scope, y solo requiere de sus parámetros de entrada (en este caso uno). No tiene efectos colaterales, ni modifica/muta ningún estado/valor. Otra regla que podemos comprobar es que, dada una misma entrada, siempre obtendríamos el mismo resultado.
De igual modo, testear esta función es relativamente sencillo dada la ausencia de dependencias, de escenarios o contextos externos, o estados fuera de sí misma. Si ojeamos sus propios tests, encontramos las afirmaciones necesarias:
assert.equal( 'abce', successor( 'abcd' ) ); assert.equal( 'THX1139', successor( 'THX1138' ) ); assert.equal( '<<koalb>>', successor( '<<koala>>' ) ); assert.equal( '2000aaa', successor( '1999zzz' ) ); assert.equal( 'AAAA0000', successor( 'ZZZ9999' ) ); |
Conclusión
Este artículo tan teórico es una introducción necesaria a otros temas más complejos como lo serían la mencionada paralelización, el paradigma funcional o el testing. En futuros artículos lo utilizaré como referencia a este concepto que, aunque sencillo, no siempre ha sido correctamente documentado.
Muy bueno!!! me alegro que estes publicando nuevamente =)
Muyy bueno Carlos, gracias por tu tiempo 🙂