Introducción
Una de los primeras cosas que aprendimos hace ya más de décadas cuando íbamos descubriendo poco a poco el lenguaje BASIC fue el comando LET para asignar valores a nuestras variables. Es patente que la retroinformática está de moda y que la Historia está destinada a repetirse una y otra vez porque será a finales de este 2014 cuando el nuevo ECMAScript 6 resucite esta palabra reservada para precisamente eso: asignar valor a nuestras variables en Javascript.
Veamos qué cosas implica esta nueva forma de declarar variables…
El contexto léxico
Cuando un programador llega por primera vez a Javascript, especialmente si proviene de otro lenguaje como Java, se encuentra con lo arbitrario que parece el contexto al que se asocian las variables… En Javascript, cuando definimos una variable con la palabra reservada ‘var’, su visibilidad viene marcada por la ‘closure’ en la que se encuentra. Y las closures en este lenguaje las delimitan las funciones.
function foo () { var bar = 'Hello World'; console.info( bar ); // Hello World } console.info( bar ); // ReferenceError: bar is not defined |
Este ejemplo es muy intuitivo: si preguntamos por ‘bar’ dentro de la función, el intérprete nos devuelve el valor correspondiente. Si lo hacemos desde fuera, obtenemos un error por indefinición. Poca novedad hasta aquí.
Sin embargo, este otro ejemplo puede no ser tan natural para ciertos desarrolladores:
for( var x = 0; x < 1000; x++ ) { // Awesome code goes here... } console.info( x ); // 1000 |
En ese último caso, la variable ‘x’ que hemos usado como contador, ‘trasciende’ más allá del bucle para continuar existiendo fuera de él. En términos de Javascript tiene sentido ya que como hemos dicho, las closures, las marcan las funciones y en un bucle for no hay función. Sin embargo, desde un punto de vista de arquitectura del software, no tiene sentido que la variable contador del bucle siga existiendo más allá contaminando el contexto global.
De igual forma en Javascript aunque es poco frecuente, podemos estructurar nuestro código utilizando bloques. Ya lo vimos en un viejo artículo de este mismo blog, encontrándonos con el mismo problema:
{ var foo = 'Hello World', bar = 'Goodbye Lenin', concat, result; concat = function ( ...params ) { return params; } result = concat( foo, bar ); console.info( result ); } console.info( result ); // ["Hello World", "Goodbye Lenin"] |
Como vemos, aunque tenemos un bloque que claramente delimita nuestro código, las variables que creamos dentro tienen visibilidad más allá de ese contexto léxico. De nuevo, se asocian a su closure por encima (ya sea una función o el mismo contexto global).
Por tanto, llegados a este punto podemos decir que hasta ahora, Javascript ha tenido una importante carencia a la hora de asignar variables a contextos léxicos en lugar de closures. En realidad, lo que queremos decir con el contexto léxico es aquel delimitado por llaves en lugar de funciones, lo cual tiene mucho sentido en estructuras de tipo bucles, condicionales, bloques, etc…
LET asigna valor únicamente al contexto entre llaves
Pues esa es la idea: let permite asignar valor a nuestras variables con visibilidad / validez únicamente dentro del contexto léxico (llaves) en el que se ha definido:
for ( let x = 0; x < 2; x++ ) { console.info( x ); // 0, 1 } console.info( x ); // ReferenceError: x is not defined |
Esa es la idea: la variable contador no tiene porqué vivir más allá del bucle para el que ha sido creada… igual ocurre con cualquier otra estructura de bloque (un if, un switch…):
if ( true ) { let y = 'Hello World'; console.info( y ); // 'Hello World' } console.info( y ); // ReferenceError: y is not defined |
De nuevo vemos como el bloque del if que está marcado por llaves, delimita la visibilidad de las variables declaradas con let.
Esto significa tanto una mejor optimización del código como un uso más eficiente del recolector de basura del intérprete Javascript.
Múltiples declaraciones
Esta nueva fórmula let permite, al igual que var, encadenar múltiples asignaciones de forma consecutiva separadas por comas:
{ let foo = 'Hello World', bar = 'Goodbye Lenin'; console.info( foo, bar ); } console.info( foo, bar ); |
Declaraciones todo terreno
Como ya hemos mencionado, let es la nueva forma de declarar variables y, dado que en Javascript se puede asignar como valor cualquier otro objeto, todo lo que sigue a continuación es perfectamente válido:
{ let // A primitive fooPrimitive = new Boolean( true ), // A falsy value fooFalsy = null, // An array fooArr = [ 'Hello World', 'Goodbye Lenin' ], // An object fooObj = { keyOne: 'value', keyTwo: 'value' }, // A constructor FooConstructor = function () { this.foo = 'paramOne'; this.bar = 'paramTwo'; }, // An immediately-Invoked Function Expression fooIIFE = ( function () { let foo = function () { console.info( 'Hello from fooIIFE' ); }; return { foo: foo } } )(); console.info( fooPrimitive, fooFalsy, fooArr, fooObj, new FooConstructor(), fooIIFE.foo() ); } |
A priori, no encontramos ninguna restricción o diferencia con respecto a var.
Estructuración de las declaraciones
Como en el caso de las closures tradiciones con var, una variable definida dentro de un bloque léxico con let tiene preferencia sobre una variable externa con igual nombre:
var foo = 'Goodbye Lenin'; if ( foo ) { let foo = 'Hello World'; console.info( foo ); // 'Hello World' } console.info( foo ); // Goodbye Lenin |
Aquí vemos que aunque la variable ‘foo’ se ha definido fuera del bloque if como global, al redefinirse dentro de su contexto léxico con let, su nuevo valor tiene preferencia. Una vez salimos de las llaves, volvemos a su valor dentro de la closure.
Hoisting
El hoisting es una peculiaridad del lenguaje que ya vimos en su momento, pero que básicamente quiere decir que una variable declarada ‘sube’ o ‘flota’ hasta lo más alto de la función (closure) en la que se encuentre independientemente de dónde la declaremos:
var foo = function () { console.log( bar ); var bar = 'Hello World'; } foo(); // undefined |
Ojo al ejemplo anterior; aunque pregunto por ‘bar’ antes de declararla, Javascript no me da un error indicando que la variable no está definida, sino que me dice que no tiene valor… Esto es así porque, por detrás, el intérprete está reescribiendo el código anterior de la siguiente forma:
var foo = function () { var bar; console.log( bar ); bar = 'Hello World'; } foo(); // undefined |
Javascript ‘observa’ que hay una declaración de una variable dentro de la función y, antes de ejecutarla, sube la declaración de esta variable hasta arriba del todo, aunque no le dé el valor hasta que llegue al punto del código en el que nosotros indicamos la declaración. Por eso, cuando el console pregunta por bar, realmente si está definida, aunque no tiene valor. Ese particular comportamiento de Javascript es lo que llamamos Hoisting.
¿Y que ocurre con let y el hoisting? Se conserva.
Quizá habría sido interesante aprovechar esta nueva estructura para corregir ese comportamiento y volverlo más natural o intuitivo, pero quizá eso provocaría más dudas por lo inconsistente de ambas fórmulas (let y var). Sea como fuere, la cuestión a tener en cuenta es esa: con let, continuamos sufriendo el hoisting.
Integridad declarativa
Otra funcionalidad importante que si se ha incorporado a este tipo de declaración, es la integridad declarativa dentro un bloque. Eso quiere decir que, no es posible declarar dos veces la misma variable dentro de un mismo contexto léxico:
{ let foo = 'Hello World'; let foo = 'Goodbye Lenin'; } // TypeError: redeclaration of variable foo |
Esto también es extensible si usamos var dentro de un bloque:
{ let foo = 'Hello World'; var foo = 'Goodbye Lenin'; } // TypeError: redeclaration of variable foo |
Como podemos observar, esto permite cierta integridad que no era posible con ‘var’ y nos salvaguarda de re declarar una variable accidentalmente.
Anidación
Como ya hemos visto antes, pero lo rematamos ahora, cada bloque crea su propio contexto y eso nos permite redefinir de nuevo las variables del anterior:
{ let foo = 'block-1'; console.info( foo ); // block-1 { let foo = 'block-2'; console.info( foo ); // block-2 { let foo = 'block-3'; console.info( foo ); // block-3 { let foo = 'block-4'; console.info( foo ); // block-4 } } } } |
El ejemplo anterior es autoexplicativo: podemos redefinir indefinidamente la misma variable en cada nivel de anidación. En la práctica, nunca tendremos un código con tanta profundidad, pero ilustra bien este supuesto.
Compatibilidad
Si echamos un vistazo a la magnífica tabla de compatibilidad que ha elaborados Kangax, let es compatible a día de hoy con todos los navegadores salvo IE<1 .
IE 10 | IE 11 | Firefox | Chrome | Safari | Opera |
---|---|---|---|---|---|
NO | SI | SI (desde v.24) | SI (desde v.30) | SI (desde v.6) | SI (desde v.12) |
Conclusión
El estándar ECMAScript 6 ha sabido redefinir de forma muy inteligente algunos conceptos básicos del lenguaje. En particular, asociar variables a contextos léxicos es una nueva forma de estructurar nuestros códigos más eficientemente: evitamos que variables declaradas para un determinado bloque de código tengan que continuar siendo accesibles en otros donde ya no tienen sentido. El caso más claro de esto es, como hemos visto, las variables de tipo contador en bucles o cualquier otra que sirva de apoyo dentro de un condicional.
Es cierto que puede resultar confuso en un primer momento pararnos a pensar si la variable que queremos definir en un momento dado tiene que tener visibilidad solo ‘a nivel de llaves’ o si debe por el contrario extenderse a la ‘closure’ tradicional de la función. Será cuestión de práctica pero tras unos pocos ejemplos, lo asumiremos de forma natural y sabremos escoger la mejor opción.
Hola Carlos como siempre muy buena info, bien explicada, let va a servir bastante, saludos!
Como vi en un artículo anterior, hay un cacao con el recolector de basura. Las referencias locales (con var o let) están en el stack, que se libera al acabar el contexto de ejecución. Si es un valor, se elimina directamente. Si es un puntero, el objeto se almacena en el montículo, que va a su bola y libera objetos cuando le da la gana.
Da lo mismo que las definas con var, con let, que las hagas null al final del contexto o que le des cualquier valor. El puntero al objeto se elimina del stack y el objeto se queda en el montículo hasta que el recolector de basura decida darle una pasada.
Saludos
Hola Miguel;
precisamente uno de los aspectos que más se está trabajando en las nuevas implementaciones de Javascript es el recolector de basura; aspecto al que le dedicaré un extenso artículo en el futuro.
Con ‘let’, la idea es que como ocurre con el «patrón Nullify», el intérprete ejecute las rutinas de limpieza a la salida del «scope» o bloque, algo similar a lo que está ahora ocurriendo con el ámbito de las closures.
No es que una referencia (puntero como lo llamas) se quede ahí hasta que el recolector ‘decida’ pasar, sino que el intérprete va evaluando los bloques estructurales y liberando memoria siempre que entiende que ya no son necesarias.
De todos modos, estamos sobre ‘arenas movedizas’ en cuanto que toda esta implementación se está trabajando a día de hoy de forma activa tanto desde el estándar ECMA como desde sus interpretaciones por parte de los motores Javascript.
Saludos.
Hola Carlos,
Efectivamente, el intérprete va ejecutando los bloques estructurales en la pila, y liberando las referencias (32 o 64 bit por referencia). Pero esto se hace inmediatamente al acabar la ejecución. Esté definida con var, con let, se asigne null o se le asigne cualquier cosa, en cuanto se acaba la ejecución, las referencias se liberan de la pila, de forma inmediata. No hay ninguna rutina de limpieza, es el trabajo natural de una pila de ejecución en cualquier lenguaje imperativo.
Los objetos son otra cosa, están en el heap y se liberarán cuando al garbage collector le de la gana. Son cosas completamente separadas.
No creo que se pueda decir que son arenas movedizas. Todos los recolectores de basuras ECMAScript son prácticamente iguales. Trabajan con el mismo modelo: reference count desde los objetos Root y posterior liberación. Únicamente varían las estrategias que utilizan (organización del Heap, intervalo de recolección, …), no solo entre diferentes VMs, sino también entre diferentes versiones de la misma VM.
Un saludo
Creo que no estamos hablando de lo mismo 😉
Por un lado, no se habla de recolectores ‘ECMAScript’; ECMA es un estándar; los que implementan los recolectores son los intérpretes; en este caso, los motores Javascript como V8, SpiderMonkey o Chakra.
Tampoco debe hablarse sobre cómo trabajan los recolectores en lenguajes imperativos, sino cómo lo hacen dependiendo del nivel al que opera dicho lenguaje: C es un lenguaje imperativo que puede considerarse de bajo nivel por muchas de sus caraterísticas, especialmente las relacionadas con punteros. Javascript es también claramente imperativo, pero de alto nivel. Pese a que ambos son imperativos, el nivel a que operan implica que sus recolectores funcionan de forma muy diferente.
En Javascript, un recolector debe interpretar mediante algoritmos cuándo un objeto puede ser liberado de la memoria; y eso lo hace en tiempo de ejecución, no solo después (que como es lógico, también lo hace). Ese algoritmo, o aproximación (que en realidad se enriquece de patrones como la concurrencia o el procesado en paralelo), lo que trata de hayar son las referencias externas que posee un determinado objeto (hablamos siempre de objetos porque en Javascript, todo valor es un objeto) en un momento de ejecución concreto. De ahí se suele tomar la frase de que en Javascript, el recolector de basura no mira ‘cuando un objeto ya no es necesario’, sino ‘cuando un objeto ya no es referenciado por otro’. Estas referencias incluyen toda la cadena prototípica de dicho objeto, por lo que no siempre variando su valor (bien por otro, bien por directamente null) conseguimos eliminar dichas referencias.
Es en ese punto donde intervienen patrones como el Nullify que tratan no solo de anular el valor de una variable en un punto concreto de ejecución, sino que además, buscan eliminar las referencias cruzadas ya sean estas directas o a través de su prototipo (entiéndase aquí también la ‘propiedad secreta’ __proto__ de cada objeto).
Tras la evaluación del recolector, y una vez detectados los bloques de memoria que referencian a objetos ‘que ya no van a volver a ser invocados, ni mantienen referencias con otros’, en definitiva, ‘que ya no son accesibles’, se liberan. Ese proceso no es arbitrario; no es ‘cuando al recolector le dé la gana’, sino que es más bien ‘cuando puede’. Los pases del recolector se suceden a lo largo de las closures y los bloques léxicos (flujo natural de una aplicación), además de cuando advierte que se está agotando la memoria (por ejemplo los sucesivos cálculos que puede estar generando un setInterval). También habría que incluir aquí las precompilaciones que hacen los motores modernos del código antes de ejecutarlo, pero eso excedería esta respuesta demasiado. Como es previsible, y es después de lo anterior, hay ocasiones en las que las referencias se mantienen por una mala arquitectura del código y el recolector no puede liberar nada: es lo que conocemos como memory-leaks y que se resuelven con el temido desbordamiento de pila.
Ese sería a grosso modo (demasiado y por eso que quiera tratarlo profundamente en un post) el funcionamiento de los recolectores desde hace unos años (2012) a esta parte en Javascript.
Saludos!!
Hola Carlos,
Sigo pensando que confundes algunos conceptos. Para no perder perspectiva, decir que lo único incorrecto en el artículo es la relación entre var, let y la recolección de basura. Todo lo demás está perfectamente explicado.
Algunas notas por si ayudan a aclarar las cosas.
— el nivel a que operan implica que sus recolectores funcionan de forma muy diferente.
C no parece un buen ejemplo porque la gestión de memoria la hace el programador. Lo que si te es cierto es que JVM, AVM, SpiderMonkey o V8 utilizan como base el mismo algoritmo para GC.
— y eso lo hace en tiempo de ejecución, no solo después (que como es lógico, también lo hace)
El tiempo de ejecución transcurre desde que carga el programa hasta que se cierra. No se si querrías decir otra cosa, pero es bastante confuso. Lo mismo para la mención a concurrencia, no se que tiene que ver en todo esto mas allá de que obviamente el recolector funciona en otro hilo de ejecución.
— Ese algoritmo lo que trata de hallar son las referencias externas que posee un determinado objeto en un momento de ejecución concreto.
Solo la primera parte del algoritmo. Una pasada a todos los objetos empezando por los Root (entre otros los propotype). La recolección es una segunda fase.
— Los pases del recolector se suceden a lo largo de las clousures y los bloques léxicos
Eso no es cierto, no tiene nada que ver con la ejecución. Los pases se producen cuando el GC puede/quiere. Un buen profiler te indicará cuando eso se produce, está siempre fuera del flujo de ejecución.
— memory-leaks y que se resuelven con el temido desbordamiento de pila.
Un memory leak no tiene nada que ver con la pila. Los objetos nunca se almacenan en la pila, sino en el montículo. Cuando no se tiene claras estas dos estructuras es muy difícil entender el modelo de memoria de una VM.
Un saludo!
Genial; ahora si estamos llegando a un consenso 🙂
Tus matizaciones son todas correctas; era la idea que quería transmitir. Solo comento alguna cosilla suelta:
— El tema de la concurrencia me refería a que de un tiempo a esta parte, el algoritmo del GC no se ha modificado en sí mismo sino que lo han hecho aspectos relacionados con su implementación (recolección en paralelo/concurrente/incremental).
— Los pases del GC se producen cuando puede/quiere. Es cierto que es un proceso controlado por el intérprete y que éste sabe mejor que nadie cuándo debe lanzarse y esa implementación varía de un motor a otro. En V8, por lo que sé, cuando el Crankshaft después de hacer la precompilación previa de todo el código analiza con el profiler los puntos calientes para optimizar, va llamando al GC para que recoja lo que ya no le es necesario. Entendía que actuaba también tras pasar por los bloques y closures (el MDN lo sugiere así en sus notas técnicas), pero nunca lo he comprobado directamente. Me lo anoto 🙂
Aunque queda offtopic, el GC también es posible de lanzarlo de forma manual durante el tiempo de ejecución. Con Node y V8 es muy sencillo y a veces útil para pruebas (además de para juegos y WebGL donde lo he visto en alguna ocasión). Para ello se expone la función V8::HEAP->CollectAllGarbage con –expose_gc lo que te permite más tarde acceder de forma programática al recolector con gc().
— Memory-leaks y desbordamiento. Completamente cierto. Estaba pensando en referencias circulares y algoritmos recursivos que acaban desbordando pilas; pero eso no es problema del intérprete, sino de un mal código.
Muchas gracias por todos tus comentarios y aclaraciones; está quedando un post muy interesante hasta el momento 🙂
Un saludo!!
Hola Carlos,
gracias por el artículo, interesante el uso que se le puede dar a let.
Solamente un apunte, seguramente se deba a que escribiste esto en junio y lo han cambiado, pero según mozilla con let no habrá hoisting:
«In ECMAScript 6, let does not hoist the variable to the top of the block. If you reference a variable in a block before the let declaration for that variable is encountered, this results in a ReferenceError, because the variable is in a «temporal dead zone» from the start of the block until the declaration is processed.»
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let
Supongo que habrá mil cosas que revisar hasta marzo de 2015 (que es cuando dicen que estará 100% operativa la versión 6).
Saludos y gracias de nuevo!