Skip to content

Latest commit

 

History

History
398 lines (268 loc) · 19.8 KB

README.md

File metadata and controls

398 lines (268 loc) · 19.8 KB

Closures

Contenido


Intro

Supongamos que tenemos el siguiente código, una función getEmployee que crea nuevos empleados.

function getEmployee(name, country) {
  return { name, country };
}
 
const employeeOne = getEmployee('Robin', 'Germany');
const employeeTwo = getEmployee('Markus', 'Canada');
 
const employees = [employeeOne, employeeTwo];

Agreguemos un id para identificar a cada empleado. Este valor debe ser asignado internamente, ya que una vez creado y retornado el objeto, no vamos a modificarlo.

function getEmployee(name, country) {
  let employeeNumber = 1;
  return { employeeNumber, name, country };
}
 
const employeeOne = getEmployee('Robin', 'Germany');
const employeeTwo = getEmployee('Markus', 'Canada');
 
const employees = [employeeOne, employeeTwo];

El problema que tenemos es que estamos repitiendo siempre el mismo id y debería ser único. Usualmente, los ids se van incrementando en 1 y nos gustaría hacer eso con el id de cada nuevo empleado. Pero una vez que la función getEmployee retorna, no tiene forma de trackear el valor actual de este id, para luego asignar el correspondiente. Notemos que la función es pura, sólo depende de sus inputs y no mantiene un historial de su estado interno luego de ejecutarse.

Entonces, algo que se nos puede ocurrir es mover este id fuera de la función, para que de esta forma pueda ser accedido y actualizado luego, con cada llamada a la función getEmployee.

let employeeNumber = 1;
 
function getEmployee(name, country) {
  return { employeeNumber: employeeNumber++, name, country };
}
 
const employeeOne = getEmployee('Robin', 'Germany');
const employeeTwo = getEmployee('Markus', 'Canada');
 
const employees = [employeeOne, employeeTwo];

Con este cambio, employeeNumber pasó de poder utilizarse sólo dentro del scope de la función a estar en el scope global. Esto nos puede traer algunos problemas... Ahora puede ser accedido y modificado desde cualquier parte.

let employeeNumber = 1;
 
function getEmployee(name, country) {
  return { 
    employeeNumber: employeeNumber++, 
    name, 
    country 
  };
}
 
const employeeOne = getEmployee('Robin', 'Germany');
employeeNumber = 50;
const employeeTwo = getEmployee('Markus', 'Canada');
 
const employees = [employeeOne, employeeTwo];

Nos gustaría poder seguir trackeando el estado interno, pero sin caer en los problemas que nos genera utilizar estado global (side effects). Modifiquemos el código de la sigueinte forma

function getEmployeeFactory() {
  let employeeNumber = 1;
  
  return (name, country) => ({
    employeeNumber: employeeNumber++, 
    name, 
    country 
  });
}
 
const getEmployee = getEmployeeFactory();
 
const employeeOne = getEmployee('Robin', 'Germany');
const employeeTwo = getEmployee('Markus', 'Canada');
 
const employees = [employeeOne, employeeTwo];

Ahora getEmployeeFactory pasa a ser una HOF (Higher-Order Function), ya que retorna una función.

Ya no es posible modificar el id desde afuera del scope de la función, porque dejó de pertenecer al scope global. El employeeNumber pasó a ser un estado interno que persiste en la función.

Nota: en este ejemplo, getEmployeeFactory() utiliza el patrón de diseño conocido como Factory Pattern.

Scope (breve repaso)

Es el contexto actual de ejecución, en el que los valores, variables y expresiones son visibles o pueden ser referenciadas, o lo que es lo mismo, el alcance, es decir, dónde pueden o no utilizarse.

En JavaScript tenemos 3 tipos de scope: global, por función y por bloque, definidos según una jerarquía en la que los scopes hijos (child scopes) tienen acceso a los scopes padres (parent scopes), pero no al revés.

  • global: variables que se crean fuera de las funciones y que son accesibles desde cualquier punto.
  • por función: variables locales a una función, que sólo pueden accederse dentro de la misma.
  • por bloque: variables locales a un bloque, que sólo pueden accederse dentro del mismo (por ejemplo, los índices en un ciclo for).

El scope de una variable depende de cómo la definamos:

  • si utilizamos var, se define como scope a la función que contiene a la variable (y este pasa a ser global en el caso de definir una variable suelta). Además, las variables definidas con var tienen hoisting.
  • si utilizamos let o const, se define un scope por bloque de código.

Siempre que sea posible, es recomendable evitar utilizar variables globales: podemos tener colisiones de nombres (por ejemplo, si estamos utilizando módulos o importando alguna librería externa) y estamos generando side-effects, que vuelven menos mantenible y más frágil a nuestro código.

👉 En JavaScript, al invocar una función se crea un nuevo contexto local de ejecución (y el correspondiente scope local).

Tener closures es una feature importante porque permite controlar qué queda dentro y fuera del scope de una función y qué variables son compartidas entre funciones que se encuentren bajo el mismo scope (pensemos en funciones definidas dentro de otras). Entender cómo las variables y funciones se relacionan entre sí es clave para entender lo que sucede en nuestro código, tanto en el paradigma funcional como en el de objetos.

Se recomienda usar la herramienta JavaScript Visualizer para entender mejor las fases de cada contexto.

Scope léxico

En JavaScript, las funciones tienen su propio ámbito léxico, lo cual significa que depende de cómo son declaradas en el código y no de cuándo o cómo se ejecutan. Léxico hace referencia a dónde (en qué parte del código) fue definida la función.

Determina a qué datos (variables, estado) una función tiene acceso cuando la invocamos.

Similar a lo que sucede con la Prototype Chain, si una variable no se encuentra definida en el scope local de una función, se busca entre sus argumentos, luego en su scope léxico más cercano y así sucesivamente, hasta llegar al scope global, donde esta variable puede estar definida o no, arrojando un ReferenceError en este caso.

Contexto de ejecución

El contexto es toda la información necesaria para ejecutar una función, como por ejemplo las variables disponibles para ser utilizadas (scope). Cada vez que invocamos una función en JavaScript, se crea un nuevo contexto de ejecución local, que es pusheado al Call Stack, para trackear la evolución del mismo, pasando a ser el contexto de ejecución activo. Cuando una función retorna, el contexto se elimina (pop) del stack.

También existe el contexto de ejecución global, que siempre está presente y se crea cuando el engine de JS comienza a analizar nuestro código.

Podemos diferenciar 2 fases del contexto, creación y ejecución.

Fase de Creación

En el contexto de ejecución global, en esta fase

  • se crea un objeto global (window en el caso del browser, global si estamos en Node).
  • se setea el valor al que hace referencia this (que, en el caso del browser, apunta al objeto window).
  • se reserva memoria para las variables y funciones de nuestro programa.
  • se asigna el valor undefined por default a todas las variables y se cargan las declaraciones de funciones (funciones definidas con function) en memoria.

Siguiendo con el último ítem, en esta fase, el engine de JS analiza todo el código, buscando instanciar todas las variables y funciones que creemos. Esto posibilita, por ejemplo, que funcione el hoisting

console.log(a);
var a = 'Hola mundo';

Cuando el engine de JS lee el código anterior, reserva memoria para esa variable a y le asigna el valor undefined (a todas las variables se les asigna este valor por default).

En el contexto de ejecución global, en cambio, sucede prácticamente lo mismo sólo que en lugar de crearse un objeto global, se crea el objeto arguments, que contiene los valores de los argumentos pasados a la función.

👉 No se ejecuta ninguna función en este punto, sólo se crean cosas.

Fase de Ejecución

Es en ésta fase donde se asignan valores en JS y se comienza a ejecutar el código, línea por línea.

Volviendo al ejemplo anterior, ahora a la variable a se le asigna 'Hola mundo'

console.log(a);
var a = 'Hola mundo';

Si quisiéramos imprimir el valor 'Hola mundo', lo que deberíamos hacer es invertir el orden en el código:

var a = 'Hola mundo';
console.log(a);

FP y Funciones

En el paradigma funcional (en adelante FP, por Functional Programming), las funciones son el bloque fundamental que utilizamos para construir nuestras aplicaciones. Si bien vimos que tienen muchas ventajas, también nos encontramos con ciertas limitaciones. Por ejemplo, no tienen memoria: se olvidan de todo el historial de ejecución cada vez que retornan un valor y no tenemos acceso a un estado global, algo que nos resultaría muy útil.

Pero cómo podríamos tener esta funcionalidad sin caer en los problemas (side effects generados) de utilizar, por ejemplo, variables globales?

Closures

Un closure es la combinación de una función y un ambiente o estado ligado, determinado por su scope léxico (el entorno donde fue definida). Es decir, un closure permite que una función tenga acceso a un scope externo (variables, estado) definido fuera de la misma, similar a lo que ocurre con las variables globales, pero controlando los side effects 😎.

En JavaScript, los closures se crean cada vez que se crea una función, por lo que todas las funciones definen closures (por default, el scope ligado es el global). En los lenguajes que no tienen closures en cambio, las variables (estado) local sólo existen durante la ejecución de la función.

Un closure almacena el estado de una función (tiene un ambiente de variables ligado), aún después de que la misma haya retornado. En decir, la función definida en el closure tiene memoria del entorno (estado) en el que fue definida.

Learn Closures In 7 Minutes

Ver Learn Closures In 7 Minutes

👉 Por lo tanto, un closure termina siendo un tipo especial de objeto que combina lo siguiente:

  • una función
  • el entorno en el cual la función fue definida (scope léxico)
    • el entorno consiste de cualquier variable local o función definida dentro de este scope en el momento en el que se define el closure.

Closures y el contexto de ejecución

Dijimos anteriormente que la invocación de una función creaba un nuevo contexto de ejecución. Esto implica que

  • cada variable declarada dentro de este scope es local.
  • el scope externo a la función no tiene acceso a las variables locales.
  • el scope local puede acceder al scope externo, gracias a los closures.

Part 1: JavaScript the Hard Parts: Closure, Scope & Execution Context

Ver Part 1: JavaScript the Hard Parts: Closure, Scope & Execution Context

Al crear un closure, se enlaza una función a un entorno de variables (determinado por su scope léxico).

Una función definida dentro de otra y luego retornada, mantiene el acceso a este entorno a través de una propiedad oculta [[scope]] (recordemos que las funciones son objetos!) que persiste aún cuando la función es retornada. De esta forma, la función retornada (closure) va a buscar las referencias a variables y otros objetos primero en su scope local y luego en el entorno ligado, antes de pasar a buscar en el scope global.

Decimos que la función interna (closure) crea una clausura sobre el contexto de ejecución de la función externa

Creando un closure

Para definir un closure, alcanza con definir una función dentro de otra: tenemos una función que retorna una función, por lo tanto se trata de una Higher-Order Function.

En el siguiente ejemplo, la constante name puede ser accedida desde la función salute().

const salute = () => {
  const name = "Sarah";
  
  const showName = () => {
    alert(name);
  }
  
  showName();
}

salute();

Si le agregamos parámetros a la función externa que el closure (función interna) puede utilizar, este se vuelve mucho más versátil. Por ejemplo

const sayHi = name =>
  () => `Hi, ${name}!`
const saluteCaro = sayHi('Caro');
const saluteLeo = sayHi('Leo');
const saluteLucas = sayHi('Lucas');

saluteCaro();  // 'Hi, Caro!'
saluteLeo();   // 'Hi, Leo!'
saluteLucas(); // 'Hi, Lucas!'

Otro ejemplo

function outerFunction(outerVariable) {
  return function innerFunction(innerVariable) {
    console.log(`Outer variable: ${outerVariable}`);
    console.log(`Inner variable: ${innerVariable}`);
  }
}

Algunas aplicaciones

Definir variables y propiedades privadas

Una aplicación común de los cloures es darle privacidad a algunas partes de la interfaz de un objeto (definir propiedades o métodos privados).

Esto nos va a permitir escribir código que se base más en las interfaces (encapsulación) que en la implementación, resultando en aplicaciones más robustas, ya que los detalles de implementación son más propensos a cambiar con el tiempo que las interfaces.

Recordemos que sólo podemos acceder a variables locales estando dentro del scope de la función externa/contenedora. No podemos acceder al estado desde un scope externo, salvo a través de los métodos privilegiados. En JavaScript, cualquier método expuesto, definido dentro de un closure, es un método privilegiado.

En el siguiente ejemplo, accountBalance es una variable global que puede ser accedida (y modificada) externamente, algo que queremos evitar!

let accountBalance = 0;

function manageBankAccount() {
  return {
    deposit(amount) {
      accountBalance += amount;
    },
    withdraw(amount) {
      // ... safety logic
      accountBalance -= amount;
    }
  };
}

Si utilizamos closures, limitamos el scope de la variable y por lo tanto sólo puede ser modificada a través del objeto

function manageBankAccount(initialBalance) {
  let accountBalance = initialBalance;
    
  return {
    getBalance() {
      return accountBalance
    },
    deposit(amount) { 
      accountBalance += amount; 
    },
    withdraw(amount) {
      if (amount > accountBalance) return 'You cannot draw that much!';

      accountBalance -= amount;
    }
  }
};

const accountManager = manageBankAccount(0);

accountManager.deposit(1000);
accountManager.withdraw(500);
accountManager.getBalance(); // 500

Como vemos, los closures nos permiten encapsular datos o comportamiento.

Evitar las variables globales

Three Techniques for Avoiding Global Variables in JavaScript Using Closure

Ver Three Techniques for Avoiding Global Variables in JavaScript Using Closure

Uso más eficiente de la memoria

Una ventaja indirecta de evitar las variables globales utilizando closures, es que evitamos la polución del scope global, teniendo menos funciones, variables y valores viviendo continuamente en la memoria global de nuestro programa, resultando en una aplicación más eficiente.

Por ejemplo, cada vez que invocamos produceChocolate estamos creando y destruyendo continuamente el array chocolateFactory, porque pertenece al scope local de la función.

const produceChocolate = index => {  
  const chocolateFactory = new Array(7000).fill('chocolate');
 
  return chocolateFactory[index];
}

produceChocolate(1);
produceChocolate(996);
produceChocolate(6784);

En cambio, podemos utilizar closures, para crear el array sólo 1 vez.

const produceChocolateEfficiently = () => {
  const chocolateFactory = new Array(7000).fill('chocolate');
  
  // chocolateFactory is stored in the closure memory, 
  // because it has reference in execution scope of inner function below
  return (index) => chocolateFactory[index];
}

const getProduceChocolateEfficiently = produceChocolateEfficiently();

getProduceChocolateEfficiently(1243);
getProduceChocolateEfficiently(6832);
getProduceChocolateEfficiently(345);

Funciones con estado (feat. React Hooks)

[WIP]

Los objetos no son la única forma que tenemos de encapsular datos. También podemos utilizar closures para crear funciones con estado (stateful), cuyos valores de retorno dependan de este estado interno.

FP: Function Decorators

👉 Ver Programación Funcional en JS - Function Decorators

FP: Aplicaciones parciales y Currying

👉 Ver Programación Funcional en JS - Aplicaciones parciales y Currying