👉 Ver todas las notas
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.
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 convar
tienen hoisting. - si utilizamos
let
oconst
, 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.
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.
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.
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 objetowindow
). - 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 confunction
) 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.
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);
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?
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.
👉 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.
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.
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
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}`);
}
}
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.
Ver Three Techniques for Avoiding Global Variables in JavaScript Using Closure
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);
[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.
👉 Ver Programación Funcional en JS - Function Decorators
👉 Ver Programación Funcional en JS - Aplicaciones parciales y Currying