undefinedschool / notes-fp-js

Notas sobre Programación Funcional en JS

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Programación Funcional con JavaScript

👉 Ver todas las notas

Contenido


Intro

A medida que la cantidad de líneas del código de nuestra aplicación va aumentando, debemos tener cuidado al hacer cambios y pensar a qué otras partes del código estamos afectando.

La forma más simple que conocemos para acotar el scope de cierto bloque de código y construir software son las funciones. Nos permiten, además de reutilizar, reducir el potencial impacto de nuestros cambios. Pero aún así, razonar y entender bien qué hace cada línea de nuestras funciones puede resultar complejo y no está garantizado que no provoque efectos por fuera del scope (aka side effects), por ejemplo, si utilizamos variables globales.

Además, el estilo con el que solemos escribir nuestras funciones suele ser indicar las instrucciones, paso a paso, que queremos que ejecute la computadora, es decir, un modo imperativo: hablamos de cómo hacer las cosas en lugar del qué (declarativo).

Imaginemos que podemos estructurar nuestro código en pequeñas piezas, individuales, independientes, auto-contenidas, donde

  • el resultado de la ejecución de cada pieza depende sólo de sus inputs
  • las únicas consecuencias o efectos provocados son sus outputs (no hay side effects)
  • cada una de estas piezas de código es declarativa, haciéndola más legible

Cómo podríamos lograr esto?

↑ Ir al inicio

Paradigma

La programación funcional es un paradigma de programación que nos sirve para estructurar, organizar y controlar la complejidad de nuestro código, favoreciendo un estilo más declarativo_, componiendo funciones puras para construir aplicaciones, evitando el uso de estado compartido, los datos mutables y los side effects, logrando así que el código resulte...

➕ legible
➕ declarativo
➕ simple (sólo tenemos valores y funciones)
➕ fácil de razonar
➕ fácil de debuggear (cada función es una unidad con input/output definido)
➕ fácil de testear, si usamos funciones puras
➕ fácil de extender (podemos agregar funcionalidades combinando otras ya existentes)
➕ fácil de refactorizar (nuevamente, sólo tenemos valores y funciones)
➕ performance, si paralelizamos la ejecución de código
side effects
➖ bugs

Para esto, vamos a utilizar

  • funciones pequeñas, puras: sólo dependen de sus inputs y no de otras partes del código
  • sin side effects: no hay consecuencias más allá del scope de la función y su output
  • composición: construimos nuestra aplicación a partir de estos bloques

Nuestra aplicación estará definida en términos de una función principal. La función principal se define a partir de otras funciones, que a su vez se definen a partir de otras funciones, etc, hasta llegar a valores de tipos primitivos como number o string.

El estado de nuestra aplicación fluye a través de funciones puras, en contraste con lo que sucede en la Programación Orientada a Objetos, donde el estado de las aplicaciones es usualmente compartido en objetos y manipulado a través de sus métodos.

Usar funciones puras y componerlas para resolver problemas más grandes son habilidades muy útiles que pueden ser utilizadas para simplificar esta complejidad.

👉 Tengamos en cuenta que simple no significa fácil: los problemas difíciles lo seguirán siendo, el paradigma funcional no va a cambiar esto, la simplificación viene dada porque los problemas resultan más fáciles de razonar, al descomponerlos en subproblemas. Estos problemas son mucho más sencillos de resolver de forma independiente y pueden componerse para llegar a la solución buscada.

JavaScript no es un lenguaje de programación funcional puro, pero tiene soporte para algunas características del paradigma. Existen lenguajes funcionales puros que compilan a JavaScript (y pueden utilizarse en frontend), como Elm, ClojureScript y PureScript

↑ Ir al inicio

Conceptos

Función

Función: un proceso que recibe determinado input y produce cierto output

Las funciones son procesos que reciben determinado input y producen cierto output.

Utilizamos funciones principalmente para:

  • mappear inputs a determinados outputs: una función recibe argumentos y retorna un valor, por lo que para cada input existe un output
  • procedimientos: una función puede invocarse para ejecutar una secuencia de instrucciones, conocida como procedimiento
  • I/O: una función puede comunicarse con otras partes del sistema/periféricos (requests HTTP, interacción con una DB, obtener input a través de la terminal, etc)

En el paradigma funcional, las funciones cumplen con las siguientes características:

↑ Ir al inicio

Aridad

Representa la cantidad de argumentos que recibe una función. Según la cantidad, una función puede ser

  • unaria: recibe 1 argumento
  • binaria: recibe 2 argumentos
  • ternaria: recibe 3 argumentos
  • etc...
const sum = (a, b) => a + b;

const arity = sum.length;
console.log(arity) // 2

// Si utilizamos `.length` sobre una función, nos devuelve la cantidad de parámetros de la misma

En el ejemplo anterior, sum es una función binaria o una función con una aridad 2 y siempre deberá ser invocada con 2 argumentos.

También existen las funciones variádicas: son aquellas que pueden recibir una cantidad variable de argumentos.

👉 Es importante respetar la aridad de las funciones cuando estamos componiendo

↑ Ir al inicio

Transparencia referencial

Decimos que una expresión es referencialmente transparente si puede ser reemplazada por su valor, sin alterar el comportamiento del programa.

Por ejemplo, si tenemos la siguiente función

const greet = () => 'Hello World!';

cualquier invocación de greet() puede ser reemplazada por el string 'Hello World!' perfectamente, por lo tanto tiene transparencia referencial.

↑ Ir al inicio

Funciones First-Class

En un lenguaje de programación funcional, las funciones son First-Class Citizens (es decir, pueden tratarse como cualquier otro valor) y JavaScript cumple con esto.

↑ Ir al inicio

Higher-Order Functions

Si una función acepta otras funciones como argumentos (por ejemplo, cada vez que usamos callbacks en JS/Node) o retorna funciones, se dice que es una función de alto orden o Higher-Order Function (alcanza con que cumpla alguna de las 2 características).

Algunos métodos de Array, como map(), filter() y reduce() son funciones de alto orden.

Higher-order functions - Part 1 of Functional Programming in JavaScript

Ver Higher-order functions - Part 1 of Functional Programming in JavaScript

👉 Esta característica es la que nos va a permitir...

  • componer funciones.**
  • generalizar funciones, al poder pasar funciones por parámetro (pensar por ejemplo en lo que hacen map, filter y reduce)

↑ Ir al inicio

Declarativo vs Imperativo

Cuando utilizamos un enfoque imperativo, definimos todos los pasos necesarios para cumplir cierta tarea. Con un enfoque declarativo en cambio, le decimos a la computadora qué hacer y que la misma se encargue de resolver los detalles, abstrayéndonos de estos. Notemos que podemos manejar diferentes niveles de abstracción: JavaScript por si mismo ya es mucho más declarativo que el código máquina que termina produciendo el compilador/intérprete.

Por ejemplo, cuando estamos iterando arrays, el enfoque más imperativo sería utilizar for o while para definir los ciclos. for...of es más declarativo, ya que nos abstrae de ciertos detalles como la longitud, mientras que métodos como map, filter y reduce son más declarativos todavía, porque directamente toman el enfoque funcional.

Algunos lenguajes declarativos que ya conocemos y venimos utilizando son HTML y SQL.

↑ Ir al inicio

Side Effects

Decimos que una expresión o función tiene un side effect si, aparte de retornar un valor, interactúa de alguna forma (lee o escribe) con un estado externo a la misma (es decir, cualquier otra cosa que haga aparte de retornar un valor). Por ejemplo, leer o modificar una variable global son considerados side effects.

const differentEveryTime = new Date();

console.log('IO is a side effect!');

Las operaciones de I/O tienen side effects, porque su propósito es intercambiar información entre diferentes sistemas.

Una función que provoca side effects modifica el estado (ya sea interno de algún argumento o de una variable externa) o depende de un estado externo a su input. Una consecuencia de esto puede ser que código de otra parte del programa no esté al tanto de los cambios realizados, produciendo resultados inesperados en la ejecución o que la misma llamada a una función, en diferentes momentos pueda tener resultados diferentes. En estos casos, las funciones son impuras.

Los side effects incluyen:

  • leer/escribir de un disco (HD/SSD)
  • leer/escribir de la red (request HTTP)
  • leer inputs a través de la consola
  • loggear info a la consola
  • arrojar errores
  • modificar el DOM
  • mutar objetos/arrays pasados como argumentos

El paradigma funcional utiliza funciones puras y datos inmutables para evitar los side-effects.

↑ Ir al inicio

Estado compartido

Tener estado (variables) compartido hace que nuestra aplicación se vuelva más frágil (error-prone), difícil de razonar y eventualmente, de debuggear, ya que puede haber otras partes del código, módulos o incluso código externo, como dependencias u otro software que use nuestra aplicación, que puedan estar modificando este estado, volviendo más complejo el seguimiento de la evolución del mismo.

Podemos tener estado compartido en

  • Clases
  • Variables globales
  • Argumentos pasados por referencia (objetos)

Las funciones limitan los cambios realizados al estado del programa, evitando acceder a variables globales, reduciendo así los posibles side-effects. Es por esta razón que usamos funciones puras en el paradigma funcional.

↑ Ir al inicio

Inmutabilidad

Decimos que los datos son inmutables si nunca cambian (no pueden modificarse). En el paradigma funcional, los datos son inmutables. Utilizar valores inmutables facilita mucho razonar sobre el código de nuestra aplicación, ya que no modificaremos accidentalmente el estado de la misma, por lo que es recomendable aplicar esta característica del paradigma siempre que podamos.

De ahora en más entonces, vamos a llamar mutación al cambio o alteración de valores y side effect al resultado de esta acción. Recordemos que, idealmente, las funciones que utilicemos deben ser puras, es decir, que no generan ningún tipo de side effect.

Las variables entonces, pasan a ser constantes, no pueden modificarse: una vez creada una variable con cierto valor, la única forma que tenemos de modificar el mismo es creando una nueva variable con el nuevo valor.

Por ejemplo, si queremos modificar un array, para agregar un nuevo ítem al mismo, creamos un nuevo array concatenando el array anterior con el nuevo ítem.

const originalArray = [1, 2, 3];
const newArray = [...originalArray, 4];

Ejemplo: agregar un ítem a un array sin modificar el original, utilizando spread operator

👉 La única forma de modificar datos es creando copias modificadas

↑ Ir al inicio

Inmutabilidad, const y objetos en JS

Si estamos utilizando valores primitivos (number, string, boolean, etc), definir una variable como const la vuelve inmutable, es decir, no podemos modificar el valor de la misma (reasignar) una vez creada.

const cantModifyThis = 1;
cantModifyThis = 2;

// ❌ Uncaught TypeError: Assignment to constant variable.

En cambio, si estamos utilizando objetos, tengamos en cuenta que en JavaScript, los objetos siempre se pasan por referencia, es decir, si una función muta/modifica un objeto que recibe como argumento, está mutando un estado externo fuera de su scope. Los tipos primitivos en cambio, se pasan por valor/copia.

Usar const en el caso de un objeto, solo impide la reasignación, es decir, una vez definida la variable, no podemos cambiar a qué objeto hace referencia usando el operador de asignación =. Pero nada nos impide modificar el objeto internamente, modificar los valores de sus propiedades/métodos o incluso agregar/eliminar alguna.

const mutableData = {
  x: 1
};

// intentando reasignar
mutableData = { x: 2 };
// ❌ Uncaught TypeError: Assignment to constant variable.

// pero si modificamos una propiedad...
mutableData.x = 2;

console.log(mutableData);
// 😰 { x: 2}

👉 Para evitar mutar el estado, es importante tratar el input de las funciones como inmutable.[2]

Si queremos trabajar con objetos inmutables podemos utilizar el método Object.freeze()[4] o la librería Immer.js

En el paradigma funcional, nunca deberíamos modificar un objeto directamente, sino crear uno nuevo con los cambios necesarios, a partir del original.

Para esto, es muy útil utilizar el operador spread para copiar las propiedades del objeto original y modificar sólo las que cambian.

const original = {
  name: 'JavaScript',
  age: 25,
  color: 'yellow'
}

const modifiedCopy = {
  ...original,
  color: 'blue'
}

console.log(original);
console.log(modifiedCopy);

↑ Ir al inicio

Funciones puras

Decimos que una función es pura si

  • el valor de retorno está determinado únicamente por su input (mismo input => mismo output), sin importar cuántas veces la llamemos[1]
  • es predecible (por el ítem anterior)
  • no modifica ningún estado interno (argumentos) ni interactúa con ningún estado externo (no leen ni modifican valores fuera de su scope), es decir, no provocan side effects
  • son referencialmente transparentes.

Es decir, dado el mismo input, retorna siempre el mismo output (es determinística). Esto hace que las funciones sean auto-contenidas, y predecibles facilitando la composición y el testeo de las mismas.

Por ejemplo, la siguiente función

const sum = (a, b) => a + b;

es pura, ya que no tiene side-effects y para los mismos valores de a y b, el resultado será siempre el mismo, mientras que getId

const SECRET = 42; 

const getId = a => SECRET * a;

es impura, ya que accede a la variable global SECRET. Si SECRET fuera modificada, getId retornaría un valor diferente para el mismo input, por lo tanto no puede ser una función pura.

La siguiente función

let id_count = 0;

const getId = () => ++id_count;

también es impura, por las siguientes razones:

  • está accediendo a una variable por fuera de su scope
  • crea un side-effect al modificar una variable externa

Otros beneficios de utilizar funciones puras

  • Las funciones puras son más robustas: el orden de ejecución no tienen ningún impacto en el sistema.
  • Las operaciones realizadas con funciones puras pueden ser paralelizadas.
  • Más fáciles de testear (unit tests), ya que no debemos considerar ningún contexto o estado externo, sólo el input y el output de la función.

↑ Ir al inicio

Ejercicios

  1. Cómo afectan las funciones Array.slice() y Array.splice() al array original?

a) slice es puro, splice es impuro.
b) slice es impuro, splice es puro.
c) ambas son puras.
d) ambas son impuras.

  1. Justificar si Math.random() es una función pura o impura.
  2. Justificar cuáles de las siguientes funciones son puras o no:

a)

const greet = name => `Hi, ${name}`;

greet('Brianne') // 'Hi, Brianne'

b)

window.name = 'Brianne';

const greet = () => `Hi, ${window.name}`;

greet() // "Hi, Brianne"

c)

let greeting = '';

const greet = name => greeting = `Hi, ${name}`;

greet('Brianne');
greeting // "Hi, Brianne"

d)

const arr = [2, 4, 6];

const doubleValues = arr => {
  for(let value of arr)  {
    value = value * 2;
  }
}

doubleValues(arr);
arr; // [4, 8, 12]

doubleValues(arr);
arr; // [8, 16, 24]

e)

const arr = [2, 4, 6];

const doubleValues = arr =>
  arr.map(value => value * 2);

doubleValues(arr); // [4, 8, 12]
doubleValues(arr); // [4, 8, 12]
doubleValues(arr); // [4, 8, 12]

f)

const start = {};

const addNameToObject = (obj, val) => {
  obj.name = val;
  
  return obj;
}

g)

const arr = [1, 2, 3, 4];

function addToArr (arr,val) {
  arr.push(val);

  return arr;
}

addToArr(arr, 5);
arr; // [1, 2, 3, 4, 5]

h)

const arr = [1, 2, 3, 4];

function addToArr (arr, val) {
  const newArr = [...arr, val];

  return newArr;
}

addToArr(arr, 5);
arr; // [1, 2, 3, 4, 5]

↑ Ir al inicio

Composición de funciones

El rol de una función es tomar un valor inicial y transformarlo en otro. La composición nos permite combinar funciones, para que podamos aplicar una serie de transformaciones hasta alcanzar un valor final.

La composición consiste entonces en utilizar el resultado de una función (output) como argumento (input) de otra función.

Podemos utilizar la composición cuando coinciden la cantidad y el tipo de retorno de una función con el tipo de argumento de otra, es decir, respetamos la aridad.

De esta forma, combinando 2 o más funciones simples (funciones que, en lo posible, hagan 1 sola cosa) podemos crear funciones más complejas.

Es el mecanismo que nos provee el paradigma para reutilizar y evitar la duplicación de código (DRY).

const f = x => x + 1;
const g = y => y * 2;
const h = z => z ** 3; 
const number = 3;

const result = f(g(h(number)));

Ejemplo: composición de funciones usando la notación matemática

Podemos pensar también a la composición de funciones como el hecho de ejecutar una serie de operaciones para resolver un problema más complejo.

pipe(
  getPlayerName,
  getFirstName,
  properCase,
  addUserLabel,
  createUserTemplate
)([{name: 'John Bonham', score: 77}]);

Ejemplo: usando pipe()

El paradigma de programación funcional utiliza funciones puras como la unidad primaria de composición: son los bloques con los que vamos a construir nuestra aplicación.

Para facilitar la composición, es recomendable que las funciones que utilicemos...

  • tengan un propósito bien definido, es decir, hagan 1 sola cosa
  • sean lo suficientemente genéricas

↑ Ir al inicio

compose

Miremos el ejemplo anterior y pensemos qué pasaría si tuviéramos que componer muchas funciones...

const f = ...;
const g = ...;
const h = ...;
const i = ...;
const j = ...;
const k = ...;
const x = 3;

const result = f(g(h(i(j(k(x))))));

Terminaríamos con cada vez más funciones anidadas, algo que podríamos llamar Composition Hell 🤔

Utilizando reduceRight (para procesar los valores de adentro hacia afuera), podemos escribir una función de composición para obtener el mismo resultado.

const f = x => x + 1;
const g = y => y * 2;
const h = z => z ** 3; 
const number = 3;

const compose = (...fns) => 
  x => fns.reduceRight((acc, fn) => fn(acc), x);
  
const enhance = compose(f, g, h);

enhance(number);

Ejemplo: función de composición

👉 compose aplica la composición leyendo los argumentos (que en este caso son funciones) de DERECHA a IZQUIERDA, ya que se basa en el orden que usamos cuando componemos funciones en matemáticas, es decir, de adentro hacia afuera. Conviene utilizarlo cuando resulta más natural pensar en términos de la composición matemática, ya que el orden de evaluación es de adentro hacia afuera. También es muy útil en desarrollo de UIs, por ejemplo cuando queremos componer componentes.

compose

Este patrón es muy común en la programación funcional y podemos implementarlo utilizando el método compose de la librería utilitaria Ramda

Un tip muy útil para debuggear es utilizar funciones para tracear el input/output de cada función

const trace = msg => x => (console.log(msg, x), x);
const bookTitles = {
  'The Culture Code',
  'Designing Your Life',
  'Algorithms to Live By'
}

const slugify = compose(
  map(join('-')),
  trace('after split'),
  map(split(' ')),
  trace('after lowercase'),
  map(lowerCase),
  trace('before lowercase')
)(bookTitles);

↑ Ir al inicio

pipe

Además del compose, otro patrón muy común en la programación funcional para componer funciones es el pipe. Utilizando reduce, podemos escribir una función de composición para obtener el mismo resultado.

const pipe = (...fns) => 
  x => fns.reduce((acc, fn) => fn(acc), x);

👉 pipe aplica la composición leyendo los argumentos (que en este caso son funciones) de IZQUIERDA a DERECHA, por lo que el orden en el que le pasemos las funciones será el orden en el que las evalúe. Conviene utilizarlo cuando resulta más natural pensar la composición como una serie de tareas a ejecutar a partir de un valor inicial. Resulta muy útil, por ejemplo, para eliminar el uso de variables intermedias que sólo existen con el fin de almacenar valores temporales entre una operación y la siguiente.

pipe

Este patrón es muy común en la programación funcional y también podemos implementarlo utilizando el método pipe de la librería utilitaria Ramda

↑ Ir al inicio

Pipeline operator

Existe un operador (aún en fase experimental, por lo que necesitamos Babel para poder utilizarlo), el Pipeline operator que permite escribir de forma mucho más legible la composición de funciones, utilizando el output de una expresión como input de la siguiente.

Volviendo el ejemplo original y utilizando el pipeline, podríamos reescribirlo de la forma

const f = x => x + 1;
const g = y => y * 2;
const h = z => z ** 3; 
const number = 3;

3
  |> h 
  |> g 
  |> f;

Ver ejemplo usando el Pipeline operator en Codepen

↑ Ir al inicio

Ejercicio

Escribir las siguientes funciones

  • scream: recibe un string y retorna el mismo string convertido a mayúsculas
  • exclaim: recibe un string y retorna el mismo string con un signo de exclamación (!) al final
  • repeat: recibe un string y retorna el mismo string, repetido 2 veces y separado por 1 espacio

y componerlas sobre el string 'I like coffee', utilizando primero scream, luego exclaim y por último repeat.

Resolverlo de tres formas, con la composición más trivial (f(g(x))) y luego aplicando los refactors 1 y 2 mencionados anteriormente.

⚡ Solución v1
const scream = str => str.toUpperCase();
const exclaim = str => `${str}!`;
const repeat = str => `${str} ${str}`;

const str = 'I like coffee';
const result = repeat(exclaim(scream(str)));
⚡ Solución v2
const scream = str => str.toUpperCase();
const exclaim = str => `${str}!`;
const repeat = str => `${str} ${str}`;

const str = 'I like coffee';

const enhance =  compose(scream, explain, repeat);
enhance(str);
⚡ Solución v3
const scream = str => str.toUpperCase();
const exclaim = str => `${str}!`;
const repeat = str => `${str} ${str}`;

const str = 'I like coffee';

str
  |> scream 
  |> exclaim 
  |> repeat;

↑ Ir al inicio

reduce

Es una de las HOF más versátiles que existen

const reducer = (accumulator, currentValue) => accumulator + currentValue;

reduce recibe un callback al que llamamos reducer, que define cómo vamos a combinar 2 valores (acumulador y valor actual) para obtener otro, que será el input de la próxima iteración

Gracias a reduce podemos, por ejemplo

  • implementar cualquier método de Array (incluyendo map y filter)
  • componer funciones: es la base de compose y pipe

Por lo tanto se trata de una función muy importante dentro del paradigma funcional.

Point-Free Style

En la programación imperativa, cuando realizamos algún tipo de operación sobre alguna variable, siempre vamos a encontrar referencias a la misma en cada paso.

En el paradigma funcional en cambio (y en JavaScript), muchas veces es frecuente operar con argumentos implícitos, es decir, que no están identificados. Por ejemplo, en el siguiente código

const toSlug = pipe(
  split(' '),
  map(toLowerCase),
  join('-'),
  encodeURIComponent
);

console.log(toSlug('JS rlz'));

pipe() tiene un parámetro implícito (el string). Esta forma de escribir las funciones se conoce como Point-Free.

👉 Otro caso común es utilizar Point-Free para reemplazar funciones anónimas y escribir código más legible y declarativo. Además, al definir funciones con un nombre, nos va a permitir testear estas funciones.

Por ejemplo

// utilizando un callback anónimo
const arr = [1, 2, 3];

arr.map(x => x * 2);
// utilizando point-free
const arr = [1, 2, 3];
const double = x => x * 2;

arr.map(double);

↑ Ir al inicio

Recursión

Cuando una función se invoca a si misma, se la conoce como función recursiva.

La recursión es una técnica de programación en la que la solución de un problema depende de las soluciones de sus subproblemas. Los subproblemas consisten básicamente en variantes más pequeñas y sencillas del problema original, hasta llegar eventualemente a algún caso trivial, que llamaremos caso base.

Aparte del caso base, para asegurarnos de que enventualmente llegamos a él (y la función retorna un valor), cada llamada recursiva debe ser invocada con una instancia más simple (y diferente) del problema.

En algunas ocasiones, los algoritmos recursivos resultan legibles y simples de entender que sus versiones iterativas.

Un algoritmo recursivo está compuesto de

  • caso(s) base: definen las condiciones para terminar y retornar la solución a un subproblema.
  • caso recursivo: hacemos la llamada recursiva con una variante más simple del problema original.

Notar que cada vez que llamamos a la función recursiva, los argumentos deberían cambiar y converger al caso base

En el siguiente ejemplo, la función sumRange devuelva la suma de los valores de 1 a n

function sumRange(n) {
  // caso base
  if (n === 1) return 1;
  
  // llamada recursiva
  return n + sumRange(n - 1);
}

Todo algoritmo recursivo debe tener al menos un caso base y retornar un valor, sino nunca va a terminar y generamos un stack overflow!

Recursión y ciclos

En el paradigma funcional buscamos evitar los side effects. Al iterar utilizando un ciclo for o while, estamos reutilizando los resultados de la iteración previa en la siguiente y reasignando valores, como los índices. Es por eso que para iterar utilizamos algoritmos recursivos, para usar siempre funciones puras y valores inmutables.

👉 Ver más detalles en Introduction to Recursion

↑ Ir al inicio

Closures

En programación funcional, los closures nos permiten utilizar currying y aplicaciones parciales de funciones.

👉 Ver Notas sobre Closures

↑ Ir al inicio

Function Decorators

Son funciones que nos permiten 'editar' o 'modificar' el comportamiento de otra función, sin reescribirla. Notar que esto último es una forma de decir, ya que no podemos modificar el cuerpo de una función una vez creada y guardada.

Para esto, se crea una nueva función, que recibe como argumento a la función que queremos editar y utilizamos closures para mantener el estado interno.

Por ejemplo, si quisiéramos 'modificar' el comportamiento de una función para que este se ejecute una sola vez, podemos utilizar la función once definida a continuación.

once es lo que se conoce como function decorator o simplemente decorator.

function once(decoratedFn) {
  let counter = 0;

  function innerFn(input) {
    if (counter === 1) return 'Nope.';

    counter++;
    return decoratedFn(input);
  }

  return innerFn;
}

const multiplyBy2 = x => x * 2;
// _decorando_ `multiplyBy2` para que se ejecute 1 sola vez
const multiplyBy2Once = once(multiplyBy2);

multiplyBy2Once(2); // 4;
multiplyBy2Once(5); // 'Nope.'

En este otro ejemplo, logging es un decorator que le aplicamos a la función writeSomething, para loguear un cierto texto antes y después.

function writeSomething(name) {
  console.log(`Hello ${name}`);
}

function logging(wrapped) {
  return function() {
    console.log('Starting...');
    const result = wrapped(...arguments);
    console.log('Finished!');

    return result;
  }
}

const wrapped = logging(writeSomething);

Otro ejemplo. En este caso, el decorator time toma el tiempo de ejecución.

function loop(times) {
  const arr = new Array(times);

  arr.forEach(x => x);
}

function time(decoratedFn) {
  return function() {
    console.time('time');
    wrapped(...arguments);
    console.timeEnd('time');
  }
}

const decoratedFn = time(loop);

👉 De esta forma, los decorators nos permiten reutilizar funciones ya existentes para extender o generar nuevas funcionalidades.

Más detalles en Exploring EcmaScript Decorators.

Aplicaciones parciales y Currying

Aplicación parcial

Vimos que la composición de funciones resulta muy útil para reutilizar funciones existentes, pero requiere que todas las funciones se comporten de la misma manera. Por ejemplo, tomar 1 input y retornar 1 output. Qué pasa si intentamos componer una función que retorno 1 valor con otra que espera recibir 2? Tenemos un problema de aridad, no coinciden.

Una posible solución, para poder reutilizar y evitar escribir nuevas funciones, es convertir (decorar) esta función en una función que acepte de a 1 argumento y que se ejecute por completo recién cuando tenga todos los argumentos necesarios.

👉 Esto se conoce como aplicación parcial.

Por ejemplo, si tenemos la función sum. Qué pasaría si sólo conocemos un argumento y no podemos o no queremos cambiar la implementación de la función? Podemos utilizar la aplicación parcial y ejecutar el resto de la función después.

const sum = (a, b) => a + b;

const partial = a => b => sum(a, b);

const suspendedFn = partial(2);
// cuando tengo el otro argumento, completo la ejecución
const result = suspendedFn(3);
console.log(result); // 5

Otro ejemplo

const multiply = (a, b) => a * b;

function prefill(fn, prefilledValue) {
  const inner = liveInput => fn(liveInput, prefilledValue);
  
  return inner;
}

const multiplyBy2 = prefill(multiply, 2);
const result = multiplyBy2(5);
Currying

👉 Currificar una función significa convertir (utilizando aplicaciones parciales) 1 función de aridad n en n funciones de aridad 1 (unarias). Es decir, reestructurar una función de forma tal que reciba 1 argumento, luego retorne otra función que reciba el siguiente argumento, etc.

Por ejemplo

// un-curried version
const add = (x, y) => x + y;

// curried version
const curriedAdd = x =>
  y => x + y;

curriedAdd(1)(2); // 3

Esto nos va a permitir componer cualquier función, independientemente de la cantidad de argumentos!

Ejercicios

Funciones Puras

Utilizar funciones puras (incluyendo las funciones auxiliares, si las hay) para resolver los siguientes problemas:

  1. Agregar un ítem al final de un array, sin modificar el original (no podemos utilizar push).
  2. Agregar un ítem al inicio de un array, sin modificar el original (no podemos utilizar push).
  3. Eliminar el primer elemento de un array, sin modificar el original
  4. Eliminar el último elemento de un array, sin modificar el original
  5. Eliminar los duplicados de un array, utilizando Array.filter().
  6. Eliminar los duplicados de un array, utilizando Array.reduce().
  7. Eliminar los duplicados de un array, utilizando Set.
  8. Implementar la función isPalindrome, para chequear si una palabra es palíndromo[3]
  9. Implementar la función map() de Array usando reduce().
  10. Implementar la función filter() de Array usando reduce().
  11. Implementar la función isAnagram: (string, string) -> boolean, que recibe 2 palabras (o frases, puede haber espacios) y retorna true si estas son anagramas (es decir, están formadas por las mismas letras, en la misma cantidad).

Ejemplos de anagramas:

  • saco / cosa
  • certificable / rectificable
  • enfriamiento / refinamiento
  • anagramas / A ganar más

↑ Ir al inicio

Higher-Order Functions

  1. Dado el siguiente código, qué problemas encontramos en el mismo? Cómo podemos mejorarlo/refactorizarlo? (usar siempre funciones puras)
const copyArrayAndMultiplyBy2 = array => {
  const output = [];
  
  for (const elem of array) {
    output.push(elem * 2);
  }
  
  return output;
}

const copyArrayAndDivideBy2 = array => {
  const output = [];
  
  for (const elem of array) {
    output.push(elem / 2);
  }
  
  return output;
}

const copyArrayAndAdd2 = array => {
  const output = [];
  
  for (const elem of array) {
    output.push(elem + 2);
  }
  
  return output;
}

const arr = [1, 2, 3];

copyArrayAndMultiplyBy2(arr);
copyArrayAndDivideBy2(arr);
copyArrayAndAdd2(arr);
  1. Implementar la función (pura) generateUnorderedList, que dado un array de números de tamaño n, genera una lista desordenada (ul) con n items (li), donde cada item de esta lista será un valor del array, realizando las operaciones necesarias en el DOM. Por ejemplo, a partir del array [1, 2, 3, 4, 5], debería obtener la lista
<ul>
  <li>1</li>
  <li>2</li>
  <li>3</li>
  <li>4</li>
  <li>5</li>
</ul>

Crear un index.html para ver el resultado. Implementar las funciones auxiliares (puras) necesarias y luego componer utilizando pipe() de Ramda.

  1. Implementar la función capitalize: string -> string, que recibe un texto y devuelve el mismo con todas sus palabras teniendo la primer letra en mayúscula.

Ejemplo:

'The quick brown fox jumps over the lazy dog' -> 'The Quick Brown Fox Jumps Over The Lazy Dog'

Composición de Funciones

  1. Descomponer la función capitalize del ejercicio anterior (ítem 3) en pequeñas funciones (puras) que hagan 1 cosa y componerlas utilizando pipe() de Ramda.

  2. El siguiente código calcula el costo de comprar algo online. Reescribirlo, utilizando composición de funciones puras. Para componer, vamos a utilizar compose y pipe() de Ramda (implementar las 2 versiones)

const ITEM_PRICE = 10;

// tax (6%) + shipping (10)
const calculateTotal = (baseCost) => (1.06 * baseCost) + 10;
  
calculateTotal(ITEM_PRICE);
  1. Agregar al ítem anterior la función applySaleDiscount: (number, number) -> number, que recibe un costo inicial y un porcentaje de descuento y retorna el valor final con el descuento aplicado. Componer esta función utilizando pipe, para calcular el costo de una compra online con un 10% de descuento.

↑ Ir al inicio

Recursión

  1. Implementar la función recursiva length: Array -> number, para calcular la longitud de un array.

Bonus: resolver el ejercicio sin utilizar Array.length.

  1. Implementar la función recursiva productOfArray: Array -> number, que recibe un array de enteros y retorna el producto de los mismos. Si el array es vacío, retornar 0.

  2. Implementar la función recursiva contains: (Object, value) -> boolean, que devuelve true si un objeto contiene cierto valor. Notar que el objeto puede tener a su vez objetos anidados como propiedades, por ejemplo

const nestedObject = {
  data: {
    info: {
      stuff: {
        thing: {
          moreStuff: {
            magicNumber: 44
          }
        }
      }
    }
  }
}

contains(nestedObject, 44);    // true
contains(nestedObject, "foo"); // false
  1. Implementar la función recursiva search: Array -> number, que devuelve el índice de un elemento en un array. Si el valor no se encuentra, retornar -1.
search([1,2,3,4,5],5;)  // 4
search([1,2,3,4,5],15); // -1

Closures

  1. Implementar la función createFunctionPrinter, que acepta un input (string) y retorna una función. Cuando la función creada es llamada, debe loguear el input utilizado cuando la función fue creada.
const printSample = createFunctionPrinter('sample');
const printHello = createFunctionPrinter('hello');

printSample(); //should console.log('sample');
printHello(); //should console.log('hello');
  1. Implementar la función addX, que retorna una función que incrementa el input en X.
const addTwo = addX(2);

addTwo(1); // debe retornar 3
addTwo(2); // debe retornar 4
addTwo(3); // debe retornar 5

const addByThree = addX(3);
addThree(1); // debe retornar 4
addThree(2); // debe retornar 5
  1. Dado el siguiente código, identificar los side effects y luego refactorizarlo para eliminar los mismos y que las funciones travelToTheFuture y travelToThePast sólo sean accesibles como métodos del objeto timeMachine.
const currentYear = 2020;

function travelToTheFuture(years) {
  const newCurrentYear = currentYear + years;
  
  return newCurrentYear;
}

function travelToThePast(years) {
  const newCurrentYear = currentYear - years;
  
  return newCurrentYear;
}
  1. Implementar la función russianRoulette: number -> Function, que acepta un número n y retorna una función. La función retornada no tiene argumentos y va a retornar el string 'Click.' las primeras n - 1 veces que es invocada. En la siguiente invocación (n, la enésima), la función retornada va a retornar el string 'BANG!'. Luego, en cada invocación posterior, la función retornada va a retornar el string 'Reload to play again'.

  2. Implementar la función average, que no recibe argumentos y retorna una función (que puede recibir un número como su único argumento o ningún argumento directamente). Cuando la función retornada es invocada con un número, el output debe ser el promedio de todos los números que se le pasaron a la función (incluyendo valores duplicados). Cuando la función retornada es invocada sin argumentos, debe retornar el promedio actual. Si la función retornada es invocada sin argumentos antes de que se le pase cualquier número, debe retornar 0.

Reduce

  1. Implementar la función allTestPassed, que recibe un array de funciones evaluadoras que definen alguna condición sobre un input (c/u retorna un booleano) y un valor. Usando reduce, retornar un booleano indicando si el valor pasa o no todos los tests (funciones evaluadoras).

  2. Implementar la función movieSelector, que recibe un array de objetos conteniendo información acerca de películas (id, título y puntaje). Encadenar invocaciones de map, filter y reduce para retornar un array que contenga sólo aquellar películas con un puntaje mayor a 5.

Currying

  1. Completar el código de la siguiente función de forma tal que utilice currying para sumar x, y y z.
function add(x) {
  // completar
}

add(10)(20)(30);

Lecturas recomendadas:

↑ Ir al inicio


1 Esto se que se conoce como idempotencia.

2 Se conoce como mutator a los métodos/funciones que modifican los objetos recibidos como argumentos, y accesor a las funciones que retornan un nuevo valor, basado en el input.

3 Un palíndromo es una palabra o frase que se lee igual para adelante y para atrás.

4 Tener en cuenta que Object.freeze() funciona a nivel shallow, es decir, sólo congela el primer nivel y no los objetos anidados, que siguen siendo referencias. Una posible solución para esto es utilizar el método cloneDeep() de lodash.

About

Notas sobre Programación Funcional en JS

License:MIT License