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 var
tienen 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:
|
|
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:
- No hay ámbito de bloque en
var
, por lo tanto la variableitem
declarada dentro delfor
es realmente visible en toda la función. - El hoisting coloca la declaración de la variable
item
declarada dentro delfor
al inicio de su ámbito, que es la función. - 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:
|
|
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:
|
|
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:
|
|
¿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:
|
|
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#:
|
|
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:
|
|
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:
|
|
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:
|
|
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?
|
|
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:
|
|
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” :)