Lambdas y variable hoisting, una combinación peligrosa en Javascript

No es la primera vez que escribo sobre errores que he cometido. Me parece una buena forma de analizarlos y ya lo hice en su momento hablando sobre algoritmos lentos, servidores web caídos o carreras críticas.

Hace poco he perdido bastante tiempo depurando código javascript aparentemente sencillo por no tener claros ciertos conceptos, pero empecemos por el principio.

Lambdas y bucles

Cuando creamos un cierre lambda (o closure, que es la terminología más frecuente en javascript), estamos definiendo una función que referencia variables del contexto exterior en que está definida la función.

En la frase anterior el punto clave es que lo que se captura en un cierre lambda son variables, no el valor de las variables.

¿Qué quiere decir esto? Que si tenemos un bucle, los efectos pueden no ser los deseados. En C# es un error frecuente:

var actions = new List<Action>();

for (var i = 0; i < 3; i++) {
	actions.Add(i => Console.WriteLine(i));
}

foreach (var action in actions)
	action();

// Esto escribirá en consola:
// 3
// 3
// 3

Cada cierre lambda que creamos referencia la misma variable i, cuyo valor se va modificando en el bucle, por lo que al ejecutar los actions, todos ellos muestran lo mismo, el valor que tiene la variable i en ese momento.

La solución en C# es muy sencilla: utilizar distintas variables para cada cierre lambda creando una variable nueva en cada iteración:

var actions = new List<Action>();

for (var i = 0; i < 3; i++) {
	var value = i;
	actions.Add(i => Console.WriteLine(value));
}

foreach (var action in actions)
	action();

// Esto escribirá en consola:
// 0
// 1
// 2

Bienvenido a Javascript: Variable hoisting

Si intentamos aplicar la misma técnica de introducir una variable local al bucle en Javascript nos llevaremos una desagradable sorpresa:

for (var i = 0; i < 3; i++) {
	var value = i;
	setTimeout(function() { console.log(value); }, 10);
}
// Esto muestra en consola:
// 3
// 3
// 3

La explicación a esto se llama variable hoisting (algo así como elevación de variables). En Javascript, el ámbito de definición de una variable es siempre la función en que está declarada, independientemente de la parte de la función en que se declare. Mientras que en el ejemplo de C# existía una nueva variable value en cada iteración, en javascript el código anterior es equivalente a:

var value;
for (var i = 0; i < 3; i++) {
	value = i;
	setTimeout(function() { console.log(value); }, 10);
}

La variable value es declarada al principio de la función y, por tanto, es reutilizada en cada iteración, con lo que esta técnica no nos sirve para resolver el problema.

Ese es el principal motivo por el que es tan frecuente en código javascript ver todas las variables declaradas al principio de la función en lugar de declararlas cerca de su primer uso, como es recomendable en otros lenguajes como C#.

¿Problemas con lambdas? Añade otra más

Dado que el ámbito de definición de una variable en javascript es la función que la contiene, la forma más sencilla de crear un nuevo ámbito es usar una función, como explican muy bien en este post.

Podemos reescribir el ejemplo anterior así:

for (var i = 0; i < 3; i++) {
	setTimeout((function(x) {
		return function() { console.log(x); };
	})(i), 10);
}
// Esto muestra en consola:
// 0
// 1
// 2

Estamos usando una immediately invoked function expression (expresión de función invocada inmediatamente), que no es más que una función anónima que definimos e invocamos directamente.

Esta función recibe como parámetro la variable sobre la cual queremos hacer el cierre, por lo que capturamos el valor de la variable y lo usamos para crear una nueva función que lo utilice.

¿Lioso? Un poco, no nos vamos a engañar, y más teniendo en cuenta la cantidad de cierres lambdas que se usan en un lenguaje dominado por operaciones asíncronas con callbacks como javascript.

Un par de alternativas curiosas

Si lo de usar funciones para devolver funciones no te convence, existe un par de alternativas como explican en esta respuesta de Stack Overflow.

En Javascript 1.7 se puede usar la palabra reservada let para definir un nuevo ámbito:

for (var i = 0; i < 3; ++i) {
   let (num = i) {
      setTimeout(function() { console.log(num); }, 10);
   }
}

El problema es que esto no está soportado en todos los navegadores, lo que hace que sea una solución poco portable.

Otra opción es utilizar la palabra reservada with, cuyo uso no está muy bien visto pero aquí nos puede echar una mano.

with nos permite referenciar las propiedades de un objeto sin necesidad de referenciar ese objeto para ahorrar código. Aprovechando eso, podemos reescribir el código anterior así:

for (var i = 0; i < 3; ++i) {
   with ({num: i}) {
      setTimeout(function() { console.log(num); }, 10);
   }
}

Ambas opciones son menos idiomáticas que el uso de expresiones de función autoinvocadas, por lo que, si tienes previsto leer y escribir código javascript, es mejor que te vayas acostumbrando a usar funciones para devolver funciones que devuelven…


Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos necesarios están marcados *

*

Puedes usar las siguientes etiquetas y atributos HTML: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>