Algunas consideraciones sobre el hoisting

¿Qué es realmente?

Mucho se ha escrito sobre el hoisting en JavaScript. Es uno de los conceptos que al principio confunden más. Yo mismo escribí hace ya algún tiempo un post sobre hoisting en el blog de recursos de CampusMvp. Este post pretende entrar en más detalles.

Doy por supuesto que ya sabes que es el hoisting en JavaScript, pero bueno no está de más comentar la definición que es más fácil que te encuentres: el hoisting es básicamente poder acceder a una variable antes de declararla, ya que realmente las declaraciones se mueven al principio del ámbito. Realmente, no nos engañemos, los problemas con el hoisting vienen realmente porque en JavaScript las variables declaradas con vartienen solo dos ámbitos: o son locales (a la función que las define) o son globales. No existe la visibilidad de bloque para las variables declaradas con var.

Supongamos el siguiente ejemplo:

function foo() {
    var item={v:'value'};
    for (var idx in [0]) {
        var item = {i: idx};
        console.log(item);
    }
    console.log(item);
}
foo();

Podemos pensar que esto imprimirá por la consola primero {i:0} y luego {v:'value'} pero la realidad es que imprime dos veces {i:0}. La razón de eso es triple:

  1. No hay ámbito de bloque en var, por lo tanto la variable item declarada dentro del for es realmente visible en toda la función.
  2. El hoisting coloca la declaración de la variable item declarada dentro del for al inicio de su ámbito, que es la función.
  3. No es error en JavaScript declarar una misma variable dos veces.

A todos los efectos es como si el código anterior fuese equivalente a:

function foo() {
    var item;	// variable item de "fuera" del for
    var item;  //  variable item de "dentro" del for
    item={v:'value'};
    for (var idx in [0]) {
        item = {i: idx};
        console.log(item);
    }
    console.log(item);
}
foo();

Pero esas normas cambian con el uso de let. En concreto let modifica el primero y el tercero de los puntos mencionados anteriormente. Por un lado con let tenemos ámbito de bloque y por otro es un error declarar dos veces una misma variable declarada con let.

Pero… ¿hay hoisting si se usa let?

El hoisting permite utilizar una variable antes de declararla porque se mueven las declaraciones al principio de su ámbito. Si hacemos una prueba rápida parece que efectivamente let no tiene hoisting:

function foo() {
   a = 10;
   let a;
   console.log(a);
}

Si ejecutamos este código nos da un error. Concretamente nos indica que la variable a no está definida en la primera línea. Efectivamente la definimos justo después. Así que parece que sí, que no hay hoisting (observa que el mismo código usando var funciona). Pero… las cosas no son tan sencillas. Veamos otro ejemplo:

let a = 20;
function foo() {
   console.log(a);
   let a = 10;
   console.log(a);
}

¿Cual es el resultado de ejecutar este código? Antes de responder recuerda que en JavaScript si se declara una variable local con el mismo nombre que una global, la visibilidad de la variable local pasa por encima de la variable global. Esto significa que una vez declarada la variable local a dentro de la función foo, el identificador a hace referencia a dicha variable local, y no a la global del mismo nombre. Sabiendo eso y asumiendo que no hay hoisting parece claro que el código debe imprimir los valores 20 y 10 por la consola. El primer console.log imprimirá el valor de la variable global (en este punto todavía no hemos definido la variable local), mientras que el segundo console.log imprimirá 10, que es el valor de la variable local que ya hemos definido. Todo eso suena muy bien, salvo que no es cierto. Este código da un error en JavaScript. ¿Y qué error crees que da? Pues… que la variable a no está definida.

¿Como se puede entender eso? Pues la manera de explicarlo es asumiendo que las declaraciones con let tienen hoisting. Así este mismo código se podría interpretar como:

let a = 20;
function foo() {
   let a;
   console.log(a);
   a = 10;
   console.log(a);
}

Pero ¡eh espera! este otro código no da error. De hecho funciona y imprime undefined y 10 por la consola. Esto es porque los valor por defecto de una variable es undefined por lo que el primer console.log(a) imprime el valor por defecto de la variable local a. Pero… entonces, ¿hay hoisting o no? ¿Qué ocurre exactamente? Bueno, lo que ocurre es que cuando declaramos una variable con let es como si la declaración se moviese al principio (como el hoisting) pero el valor por defecto de la variable no es undefined. Y si no es undefined ¿cual es? Pues ninguno. Y ninguno significa literalmente ninguno. La variable no tiene valor y por lo tanto acceder a ella da un error. En JavaScript decimos que la variable está en su zona muerta (dead zone). Hasta que no realizamos una asignación a la variable, esta no sale de su “zona muerta” y por lo tanto podemos acceder a ella.

Buf… que lío, ¿verdad? Permíteme que te de otra definición de hoisting. Básicamente podemos decir que un lenguaje no tiene hoisting si dentro de un mismo ámbito de visibilidad un mismo identificador puede hacer referencia a dos variables distintas. Observa que eso no ocurre en JavaScript. Dentro del ámbito de visibilidad definido por foo el identificador a siempre hace referencia a la variable local a, aunque esta variable la declaremos con let en medio de la función. Por lo tanto podemos decir que sí, que en JavaScript todas las declaraciones tienen hoisting, aunque en la práctica el comportamiento de let se asemeje mucho a no tener hoisting debido a que las variables están por defecto en su “zona muerta”.

¿Como tratan esto otros lenguajes?

Empecemos viendo C#. En C# las cosas son muy parecidas a JavaScript, el siguiente código no compila en C#:

class Program
{
    static int a = 20;
    static void Main(string[] args)
    {
        Console.WriteLine(a);
        int a = 10;
        Console.WriteLine(a);
    }
}

El error que nos da es CS0844 Cannot use local variable ‘a’ before it is declared. The declaration of the local variable hides the field ‘Program.a’. Es decir es como si tuviéramos hoisting pero claro, el hecho de que no compile hace que no podamos hablar de hoisting como tal. Pero si que podemos ver como declarar la variable local a hace que en todo este ámbito (función Main el identificador a se refiera a la local, incluso antes de declarla). Una cosa interesante a observar es que si eliminamos el primer Console.WriteLine el código compila y funciona. Cosa lógica, ya que entonces declaramos la variable local a antes de su primer acceso (lo que es obligado por C#).

Comento el hecho de que la “compilación” nos impide hablar de hoisting como tal para recalcar el hecho de que en JavaScript acceder a una variable que está en su “zona muerta” no es un error de parsing si no de ejeución. Quiero recalcar esto, porque los errores de parsing serían lo más “cercano a la compilación en JavaScript”. Para ver la diferencia entre un error de parsing y uno de ejecución, es que los primeros impiden tan siquiera que se ejecute nada de código. P. ej. esto en JavaScript es un error de parsing:

console.log('hola');
let a = 10;
console.log('adiós');
let a = 20;

Si ejecutas esto no se imprime nada por la consola, ni tan siquiera “hola”. Redeclarar una variable con let es un error de parsing. Por otro lado este código:

console.log('hola');
console.log(a);
let a = 10;
console.log('adiós');

Da un error pero se imprime “hola” por la consola, es decir el error es de ejecución. ¡Es una diferencia sutil pero importante!

Volvamos a C#. C# tiene ámbito de bloque, ¿qué ocurre si creamos un bloque interno a Main y definimos otra variabla a en él? Pues en este caso el código ya ni compila:

static void Main(string[] args)
{
    int a = 10;
    {
        int a = 20;
        Console.WriteLine(a);
    }
    Console.WriteLine(a);
}

Observa que en este caso el código ya no compila incluso aunque la declaración de la variable interna a esté al inicio de su ámbito de visibilidad. El error ahora es distinto pero igualmente explicativo: A local or parameter named ‘a’ cannot be declared in this scope because that name is used in an enclosing local scope to define a local or parameter.

Veamos ahora mi lenguaje preferido. ¿Como se comporta C++ en este aspecto?

#include <iostream>

auto a = 20;
int main()
{
	std::cout << a << "\n";
	auto a = 10;
	std::cout << a << "\n";
}

Este código imprime primero 20 y luego 10. Es decir, el primer std::cout imprime el valor de la variable global a, mientras que el segundo std::cout imprime el valor de la variable local a, ya que en este punto ya está definda. Observa que se trata de un comportamiento radicalmente distinto al de JavaScript y al de C#. Al igual que C#, C++ tiene ámbito de bloque:

#include <iostream>

auto a = 20;
int main()
{
	std::cout << a << "\n";
	auto a = 10;
	{
		std::cout << a << "\n";
		auto a = 30;
		std::cout << a << "\n";
	}
	std::cout << a << "\n";
}

Supongo que ya no te sorprende si te digo que este código funciona y imprime por pantalla 20, 10, 30 y 10. El tercer std::cout imprime 30 ya que antes ya se ha declarado la variable interna a. Por lo tanto en C++ un mismo identificador dentro de un mismo bloque de visibilidad puede referenciar a variables distintas.

¡Y hasta aquí este post! Como se puede observar las cosas nunca son tan sencillas como parecen. Si lees que let y const no tienen hoisting en JavaScript, pues bueno… ahora ya sabes que es un “sí, pero no” :)