Ir al contenido principal

Clausuras (closures)

Introducción

Las clausuras (o closures en inglés) son uno de los conceptos más poderosos y a la vez más confusos de JavaScript. Representan una característica fundamental del lenguaje que permite a las funciones "recordar" y acceder al ámbito léxico en el que fueron creadas, incluso cuando se ejecutan fuera de ese ámbito. Aunque pueda sonar complejo, las clausuras son una herramienta extremadamente útil que nos permite implementar patrones de diseño avanzados y crear código más elegante y modular. En este artículo, exploraremos qué son las clausuras, cómo funcionan, y veremos ejemplos prácticos de su aplicación en JavaScript.

Concepto de clausura en JavaScript

Una clausura se forma cuando una función accede a variables que están fuera de su propio ámbito, pero dentro del ámbito en el que la función fue declarada. En otras palabras, una clausura permite que una función "recuerde" y siga teniendo acceso al ámbito en el que se creó, incluso cuando esa función se ejecuta en un ámbito completamente diferente.

Para entender mejor este concepto, pensemos en una analogía: Imagina que una función es como una persona que lleva una mochila. Cuando esta persona (función) se crea en una habitación (ámbito), puede meter en su mochila objetos de esa habitación (variables del ámbito). Luego, aunque la persona salga de la habitación original y vaya a otros lugares (se ejecute en otros ámbitos), seguirá teniendo acceso a los objetos que guardó en su mochila.

Cómo se forman las clausuras

Para que se forme una clausura, necesitamos:

  1. Una función externa que contenga una variable local.
  2. Una función interna (anidada) que utilice esa variable local.
  3. La función interna debe ser devuelta o pasada fuera del ámbito de la función externa.

Veamos un ejemplo sencillo:

function crearSaludador(nombre) {
  // Variable local 'nombre' dentro de la función externa
  
  function saludar() {
    // Función interna que accede a 'nombre'
    console.log(`¡Hola, ${nombre}!`);
  }
  
  // Devolvemos la función interna
  return saludar;
}

// Creamos dos saludadores diferentes
const saludarAna = crearSaludador("Ana");
const saludarJuan = crearSaludador("Juan");

// Ejecutamos las funciones
saludarAna(); // ¡Hola, Ana!
saludarJuan(); // ¡Hola, Juan!

¿Qué está pasando aquí?

  1. La función crearSaludador recibe un parámetro nombre y define una función interna saludar.
  2. La función saludar utiliza la variable nombre de su ámbito contenedor.
  3. La función crearSaludador devuelve la función saludar (sin ejecutarla).
  4. Cuando llamamos a crearSaludador("Ana"), obtenemos una función que "recuerda" que nombre es "Ana".
  5. Cuando llamamos a crearSaludador("Juan"), obtenemos otra función que "recuerda" que nombre es "Juan".
  6. Aunque la ejecución de crearSaludador haya terminado, las funciones devueltas siguen teniendo acceso a sus respectivas variables nombre.

Este "recuerdo" del ámbito en el que fue creada la función es lo que llamamos clausura.

Acceso a variables externas

Una de las características clave de las clausuras es que pueden acceder a variables de cualquier ámbito en el que estén anidadas:

function exterior(a) {
  let b = 5;
  
  function intermedia(c) {
    let d = 8;
    
    function interior(e) {
      // Esta función tiene acceso a todas las variables:
      // a, b, c, d y e
      console.log(a, b, c, d, e);
    }
    
    return interior;
  }
  
  return intermedia;
}

const paso1 = exterior(1);
const paso2 = paso1(2);
paso2(3); // Muestra: 1 5 2 8 3

En este ejemplo, la función interior forma una clausura que incluye:

  • La variable a del ámbito de exterior
  • La variable b del ámbito de exterior
  • La variable c del ámbito de intermedia
  • La variable d del ámbito de intermedia
  • La variable e de su propio ámbito

Esto muestra cómo las clausuras pueden mantener múltiples capas de ámbito.

Mantenimiento del estado

Una de las aplicaciones más poderosas de las clausuras es el mantenimiento de estado entre diferentes llamadas a una función:

function crearContador() {
  let contador = 0; // Variable privada
  
  return {
    incrementar: function() {
      contador++;
      return contador;
    },
    decrementar: function() {
      contador--;
      return contador;
    },
    obtenerValor: function() {
      return contador;
    }
  };
}

const miContador = crearContador();
console.log(miContador.obtenerValor()); // 0
console.log(miContador.incrementar()); // 1
console.log(miContador.incrementar()); // 2
console.log(miContador.decrementar()); // 1

const otroContador = crearContador(); // Nuevo contador independiente
console.log(otroContador.obtenerValor()); // 0

En este ejemplo, crearContador devuelve un objeto con tres métodos que comparten el acceso a la misma variable contador. Cada vez que creamos un nuevo contador, se crea una nueva clausura con su propia variable contador.

Lo importante es que la variable contador no es accesible directamente desde fuera del ámbito de crearContador, lo que proporciona una forma de encapsulamiento.

Encapsulamiento de datos

Las clausuras nos permiten implementar el concepto de encapsulamiento en JavaScript, similar a lo que ofrecen las clases en otros lenguajes orientados a objetos:

function crearCuenta(propietario, saldoInicial) {
  let saldo = saldoInicial; // Variable "privada"
  
  return {
    // Métodos "públicos"
    depositar: function(cantidad) {
      saldo += cantidad;
      return `Depósito de ${cantidad}€ realizado. Nuevo saldo: ${saldo}€`;
    },
    retirar: function(cantidad) {
      if (cantidad > saldo) {
        return "Fondos insuficientes";
      }
      saldo -= cantidad;
      return `Retirada de ${cantidad}€ realizada. Nuevo saldo: ${saldo}€`;
    },
    consultarSaldo: function() {
      return `${propietario}, su saldo actual es de ${saldo}€`;
    }
  };
}

const cuentaAna = crearCuenta("Ana García", 1000);
console.log(cuentaAna.consultarSaldo()); // Ana García, su saldo actual es de 1000€
console.log(cuentaAna.depositar(500)); // Depósito de 500€ realizado. Nuevo saldo: 1500€
console.log(cuentaAna.retirar(200)); // Retirada de 200€ realizada. Nuevo saldo: 1300€
console.log(cuentaAna.consultarSaldo()); // Ana García, su saldo actual es de 1300€

// No podemos acceder directamente a la variable saldo
console.log(cuentaAna.saldo); // undefined

En este ejemplo, la variable saldo está encapsulada dentro de la clausura y solo se puede acceder a ella a través de los métodos proporcionados. Esto protege los datos y garantiza que solo puedan ser modificados de maneras específicas y controladas.

Patrones comunes con clausuras

Las clausuras se utilizan en muchos patrones de diseño en JavaScript. Veamos algunos de los más comunes:

1. Módulo (Module Pattern)

El patrón módulo utiliza clausuras para crear "módulos" con variables privadas y públicas:

const calculadora = (function() {
  // Variables privadas
  let memoria = 0;
  
  // Funciones privadas
  function validarNumero(n) {
    return typeof n === 'number';
  }
  
  // API pública
  return {
    sumar: function(n) {
      if (!validarNumero(n)) return "Error: se requiere un número";
      return memoria += n;
    },
    restar: function(n) {
      if (!validarNumero(n)) return "Error: se requiere un número";
      return memoria -= n;
    },
    obtenerMemoria: function() {
      return memoria;
    },
    resetear: function() {
      memoria = 0;
      return memoria;
    }
  };
})();

console.log(calculadora.obtenerMemoria()); // 0
console.log(calculadora.sumar(5)); // 5
console.log(calculadora.sumar(3)); // 8
console.log(calculadora.restar(2)); // 6
console.log(calculadora.resetear()); // 0

Este patrón es muy útil para organizar y encapsular código, evitando la contaminación del ámbito global.

2. Fábrica de funciones (Factory Pattern)

Las clausuras nos permiten crear funciones especializadas a partir de una función más general:

function crearMultiplicador(factor) {
  return function(numero) {
    return numero * factor;
  };
}

const duplicar = crearMultiplicador(2);
const triplicar = crearMultiplicador(3);
const multiplicarPorDiez = crearMultiplicador(10);

console.log(duplicar(5)); // 10
console.log(triplicar(5)); // 15
console.log(multiplicarPorDiez(5)); // 50

3. Memoización

La memoización es una técnica de optimización que consiste en almacenar en caché los resultados de funciones costosas:

function crearCalculadoraFibonacci() {
  // Caché para almacenar resultados ya calculados
  const cache = {};
  
  function fibonacci(n) {
    // Si ya hemos calculado este valor, lo devolvemos directamente
    if (n in cache) {
      return cache[n];
    }
    
    // Caso base
    if (n <= 1) {
      return n;
    }
    
    // Cálculo recursivo
    const resultado = fibonacci(n - 1) + fibonacci(n - 2);
    
    // Almacenamos el resultado en caché para futuras llamadas
    cache[n] = resultado;
    
    return resultado;
  }
  
  return fibonacci;
}

const fib = crearCalculadoraFibonacci();

console.time('Primer cálculo');
console.log(fib(40)); // 102334155
console.timeEnd('Primer cálculo'); // Primer cálculo: tiempo considerable

console.time('Segundo cálculo');
console.log(fib(40)); // 102334155 (inmediato, desde caché)
console.timeEnd('Segundo cálculo'); // Segundo cálculo: tiempo mínimo

La clausura permite que la función fibonacci mantenga acceso al objeto cache entre llamadas, lo que hace que llamadas repetidas con los mismos argumentos sean mucho más rápidas.

Casos de uso prácticos

Veamos algunos casos de uso prácticos donde las clausuras son especialmente útiles:

1. Manejadores de eventos

Las clausuras son muy útiles para los manejadores de eventos en el navegador:

function configurarBoton(id, mensaje) {
  const boton = document.getElementById(id);
  
  boton.addEventListener('click', function() {
    // Esta función tiene acceso a 'mensaje' gracias a la clausura
    alert(mensaje);
  });
}

configurarBoton('botonSaludo', '¡Hola!');
configurarBoton('botonDespedida', 'Hasta pronto');

Cada evento de clic está asociado con su propio mensaje, gracias a las clausuras.

2. Iteradores personalizados

Las clausuras nos permiten crear iteradores que mantienen su estado interno:

function crearIterador(array) {
  let indice = 0;
  
  return {
    siguiente: function() {
      if (indice < array.length) {
        return { valor: array[indice++], terminado: false };
      } else {
        return { terminado: true };
      }
    }
  };
}

const colores = ['rojo', 'verde', 'azul'];
const iterador = crearIterador(colores);

console.log(iterador.siguiente()); // { valor: 'rojo', terminado: false }
console.log(iterador.siguiente()); // { valor: 'verde', terminado: false }
console.log(iterador.siguiente()); // { valor: 'azul', terminado: false }
console.log(iterador.siguiente()); // { terminado: true }

3. Control de acceso a APIs

Las clausuras pueden utilizarse para limitar la tasa de llamadas a una API:

function crearControladorAPI(limitePorMinuto) {
  let llamadas = 0;
  let ultimoReset = Date.now();
  
  return function(funcionAPI) {
    const ahora = Date.now();
    
    // Resetear contador después de un minuto
    if (ahora - ultimoReset > 60000) {
      llamadas = 0;
      ultimoReset = ahora;
    }
    
    // Verificar si estamos dentro del límite
    if (llamadas < limitePorMinuto) {
      llamadas++;
      return funcionAPI();
    } else {
      return "Límite de API excedido. Intente nuevamente más tarde.";
    }
  };
}

// Simulación de función de API
function consultarAPI() {
  return "Datos de la API";
}

const apiLimitada = crearControladorAPI(3);

// Primeras 3 llamadas funcionan
console.log(apiLimitada(consultarAPI)); // "Datos de la API"
console.log(apiLimitada(consultarAPI)); // "Datos de la API"
console.log(apiLimitada(consultarAPI)); // "Datos de la API"

// Cuarta llamada es bloqueada
console.log(apiLimitada(consultarAPI)); // "Límite de API excedido. Intente nuevamente más tarde."

Problemas potenciales (memory leaks)

Aunque las clausuras son poderosas, pueden causar problemas de memoria si no se utilizan correctamente. El principal problema es el de las fugas de memoria (memory leaks), que ocurren cuando se mantienen referencias a grandes objetos que ya no se necesitan:

function crearProblema() {
  // Un array grande que ocupa mucha memoria
  const datosGrandes = new Array(1000000).fill('dato');
  
  return function() {
    // Esta función solo necesita un elemento, pero mantiene
    // una referencia a todo el array
    console.log(datosGrandes[0]);
  };
}

const funcionProblematica = crearProblema();
funcionProblematica(); // Muestra "dato", pero mantiene todo el array en memoria

Cómo evitar fugas de memoria:

  1. Liberar referencias cuando ya no sean necesarias:
let funcionConClausura = crearFuncion();
funcionConClausura(); // Usar la función

// Cuando ya no necesitemos la función
funcionConClausura = null; // Liberar la referencia
  1. Seleccionar solo los datos necesarios:
function crearSolucion() {
  const datosGrandes = new Array(1000000).fill('dato');
  
  // Solo guardamos el dato que necesitamos
  const primerDato = datosGrandes[0];
  
  return function() {
    // Esta función solo mantiene una referencia al primer elemento
    console.log(primerDato);
  };
}
  1. Tener cuidado con las clausuras en bucles:
// Problema común con clausuras en bucles
function configurarBotones() {
  const botones = document.querySelectorAll('button');
  
  for (var i = 0; i < botones.length; i++) {
    botones[i].addEventListener('click', function() {
      console.log('Botón ' + i + ' pulsado');
      // Problema: Todos los botones mostrarán el mismo número,
      // porque 'i' final será el último valor del bucle
    });
  }
}

// Solución 1: Usar let en lugar de var
function configurarBotones() {
  const botones = document.querySelectorAll('button');
  
  for (let i = 0; i < botones.length; i++) {
    // 'let' crea una nueva variable i para cada iteración
    botones[i].addEventListener('click', function() {
      console.log('Botón ' + i + ' pulsado'); // Funciona correctamente
    });
  }
}

// Solución 2: Usar una IIFE para cada iteración
function configurarBotones() {
  const botones = document.querySelectorAll('button');
  
  for (var i = 0; i < botones.length; i++) {
    (function(indice) {
      botones[indice].addEventListener('click', function() {
        console.log('Botón ' + indice + ' pulsado'); // Funciona correctamente
      });
    })(i);
  }
}

Resumen

Las clausuras son un concepto fundamental en JavaScript que permite a las funciones recordar y acceder al ámbito en el que fueron creadas, incluso cuando se ejecutan en un ámbito diferente. Se forman cuando una función interna hace referencia a variables de una función externa y esa función interna es devuelta o pasada a otro contexto.

Las clausuras nos permiten:

  • Mantener estado entre llamadas a funciones
  • Encapsular datos y funcionalidad (similar a clases en otros lenguajes)
  • Implementar patrones como el patrón módulo, fábricas de funciones y memoización
  • Crear manejadores de eventos con datos específicos
  • Controlar el acceso a recursos

Sin embargo, debemos tener cuidado con las posibles fugas de memoria, especialmente cuando las clausuras mantienen referencias a grandes objetos o cuando se utilizan en bucles.

Dominar las clausuras es esencial para escribir JavaScript avanzado y es uno de los conceptos que distingue a los programadores experimentados. Con la práctica y comprendiendo los ejemplos de este artículo, podrás utilizar este poderoso mecanismo para crear código más elegante, modular y eficiente.

En el próximo artículo, exploraremos otro tema relacionado con las funciones: las funciones anidadas y cómo interactúan con los conceptos de ámbito y clausuras que hemos aprendido.