getify / You-Dont-Know-JS

A book series on JavaScript. @YDKJS on twitter.
Other
179.92k stars 33.53k forks source link

`let` hoisting? #767

Closed dxu closed 8 years ago

dxu commented 8 years ago

Hi, thanks for taking the time to write such a great book! I'm working my way through it - in Chapter 3, you talk about hoisting let variables. Specifically:

However, declarations made with let will not hoist to the entire scope of the block they appear in. Such declarations will not observably "exist" in the block until the declaration statement.

{
   console.log( bar ); // ReferenceError!
   let bar = 2;
}

Even though the variable bar isn't initialized as undefined, it seems that they are still created.

Using the example from this article as an example,

let x = 'outer scope';
(function() {
    console.log(x); // Reference error
    let x = 'inner scope';
}());

Do you think it would be helpful to add a clarification regarding the meaning of "hoisting" (since x here is technically still being created at the top of the block - it's just not initialized and can't be accessed), or perhaps just include a similar example?

EDIT: removed extra console.log in the example that may have caused confusion

ghost commented 8 years ago

Hello,

Hoisting is the topic of the next chapter in that book:

https://github.com/getify/You-Dont-Know-JS/blob/master/scope%20%26%20closures/ch4.md

the scope application of hoisting starts in this part specifically:

https://github.com/getify/You-Dont-Know-JS/blob/master/scope%20%26%20closures/ch4.md#the-compiler-strikes-again

EDIT: phrasing

dxu commented 8 years ago

Thanks for the comment. If you take a look at the chapter you link to, as well as my example, you'll notice that the chapter on hoisting actually doesn't address the specifics of hoisting with the let and const bindings.

Hoisting is shown in the chapter as a step of the compiler that lifts var declarations to the top of the scope, as shown in this example:

console.log( a );
var a = 2;

into something that is "invisibly" interpreted during compilation as something more similar to

var a;
console.log( a );
a = 2;

According to this interpretation, it'd be easy for someone to make the assumption regarding the clause I quoted above:

However, declarations made with let will not hoist to the entire scope of the block they appear in. Such declarations will not observably "exist" in the block until the declaration statement.

{ console.log( bar ); // ReferenceError! let bar = 2; }

For someone unfamiliar with the nuances around let, but familiar with the classical "hoisting" mechanics, they might inaccurately assume this means that in the following example:

let x = 'outer scope';
(function() {
    console.log(x); // Reference error
    let x = 'inner scope';
}());

console.log(x) will print the x of the outer scope ("outer scope"), because the phrasing:

declarations made with let will not hoist to the entire scope of the block they appear in

is misleading. x is still technically "hoisted", in the sense that something is being created that is identified by x at the top of the scope, it's just not initialized. As a result, console.log(x) will actually throw a Reference Error.

Let me know my explanation makes sense. You can also take a look at the article linked above - he explains in further detail about how the Temporal Dead Zone works.

EDIT: phrasing on last paragraph to more clearly outline what happens in the example.

creeperyang commented 8 years ago

@dxu Maybe the error message is clear enough. Reference error means the engine can not resolve the name x ----> not hoisted.

getify commented 8 years ago

Hoisting is not a real thing. It's a made up concept. It's a metaphor to describe what actually happens when the compiler finds declarations and associates them with the scope they belong to so they can be used in that scope.

When we say that this code:

x = 2;
var x;

is interpreted as this code via hoisting:

var x;
x = 2;

...what we're actually saying is not just that the x is added to the scope by the compiler, but that x is "initialized" so that it can be used in the entire scope. "Initialized" here has nothing to do with the x = 2 part, though it'd be tempting to assume such as obvious; it actually is an internal concept of the spec.

With var declarations, there is no way to distinguish between a declared variable and an initialized variable, because var declarations are automatically initialized at the beginning of the scope, so they are available for use throughout the scope. You cannot observe an "uninitialized" var declaration.

let declarations however are not initialized at the beginning of the scope; their initialization doesn't come in the block until the actual line where let x occurs (again, regardless of any = .. value assignment).

So to recap: the metaphor of hoisting is about both making a declaration attach to a scope at compile time before execution, and about initializing that variable to be available throughout the scope. Both are true of var declarations, but only the first one is true of let and const declarations. Therefore, let and const are in fact not hoisted.


To the confusion that you're pointing out around not being able to access the outer x while on the first line of the inner scope, you're absolutely correct that this can be confusing. It's just it's inaccurate to explain that situation as "not hoisting".

Whenever I teach, I only mention such a thing in passing as an informal explanation, which is kinda fudging with the truth a bit. Then again, the entire usage of "hoisting" to describe JS's compiler scoping behavior is fudging with the truth, because hoisting implies a reordering of code, which is not at all what happens.

The accurate explanation of the confusion you point out is that var has no TDZ (temporal dead zone) because the initialization comes immediately at the beginning of the scope, whereas let and const have a TDZ since the initialization is deferred until "later" when the actual declarator is encountered.

A variable cannot be used while it's in its TDZ, which is why you're able to observe the inaccessibility of an very-much-already-existent-but-not-yet-initialized inner x in your example. The ReferenceError in that sense is not entirely accurate: it's not that x hasn't been declared, it's that it hasn't been initialized yet.

I cover TDZ in more detail in subsequent books in the series ("Types & Grammar" and "ES6 & Beyond"). At the time of the writing of this book, I didn't have as full an understanding of TDZ and its implications on how scopes work as I do now, which is why it's not mentioned here. It's a planned issue to address TDZ in the second edition of the book, whenever I get around to that effort.

felixsanz commented 8 years ago

@getify let and const are hoisted as far as i know. Here is an example:

function foo(value) {  
  var x = 1
  if (true) {
    /*
     * TDZ for x
     */
    console.log(x) // ReferenceError: x is not defined
    let x = 2 // here TDZ ends and x get its value
    console.log(x) // => 2
  }
}
foo()

This demostrates that let x is hoisted to the top of the if block, because if it weren't hoisted, we will not get any ReferenceError and we would get 1 instead (from var x). But the hoisting concept creates an undefined reference at the top of the if block.

We can't use it and get undefined like we would do with var because TDZ doesn't let us access that reference until assignment, no matter if it is or not initialized because of hoisting.

I'm missing something?

fkling commented 8 years ago

@felixsanz Hoisting is used to describe the creation of the binding and the initialization. I think even members of the committee do not consider let and const to be hoisted. Hoisting really implies the initialization part. There probably isn't a dedicated term for the "binding creation before code execution" part.

I also only considered the mere fact that the bindings are created as hoisting, but that's not technically true. Otherwise, why wouldn't let and const be _HoistableDeclaration_s?

getify commented 8 years ago

@felixsanz

let and const are hoisted as far as i know.

Nope.

This demostrates that let x is hoisted to the top of the if block, because if it weren't hoisted, we will not get any ReferenceError

Nope. Please re-read my message above where I correct this misunderstanding.

Quoting myself:

hoisting ... actually saying is not just that the x is added to the scope by the compiler, but that x is "initialized" so that it can be used in the entire scope.

What @fkling said is correct.

felixsanz commented 8 years ago

@getify Why you say that hoisting means adding the variable to the scope and ALSO initializing it so it can be used in the entire scope? I mean, where do you get that definition from?

Hoisting is the act of hoist, which means in the dictionary "raising" or "lifting". And this is true since console.log(x) in my code produces a ReferenceError. So it's raised/lifted. It's not initialized i agree with that, but it's hoisted :/

Some source for what hoisting really means in programming would be nice, because if it's a concept/idea, then everyone can have its own definition (and that doesn't mean you are wrong, but means that we both could be right).

felixsanz commented 8 years ago

Also:

http://www.ecma-international.org/ecma-262/7.0/#sec-evaldeclarationinstantiation

5 -> d -> ii -> 1

NOTE: The environment of with statements cannot contain any lexical declaration so it doesn't need to be checked for var/let hoisting conflicts.

"let hoisting".

dxu commented 8 years ago

I think we're arguing about semantics and wording here. I think everyone's agreed on how this actually works - it's just the interpretation of what "hoisting" is strictly referring to. There's definitely a difference between the var hoisting of the past and the new TDZ behavior with let, so there is a case to be argued that the act of "hoisting" should refer to both preinitialization and initialization. Regardless, I don't think it's worth arguing about, at least not in this context.

Thanks for the great work @getify! I look forward to finishing up the rest of the book. Thanks for all your contributions to the community, I really enjoyed your talk at Forward 3 last year.

getify commented 8 years ago

I mean, where do you get that definition from?

https://twitter.com/awbjs/status/434044880180871168

(@allenwb is the editor of the ES6 spec, so I trust his authority on the terms/definitions.)

creeperyang commented 8 years ago

Thanks for @getify 's nice explanation. I get the key points:

  1. Hoisting includes both declare and initialize.
  2. Only initialized variable can be used in a scope.
  3. var do both declare and initialize, the two cannot be split for var.
  4. let do firstly declare in the top of the scope, and do initialize when encounter the let xxx statements.

The fourth point is important that

  1. When a scope has let x, outer x will be covered.
  2. Only when encounter the real let x, x is then initialized and can be used. Before all usage will lead to error because of TDZ.

But I'm still confused about the meaning of declare and initialize.

I thought the declared variable (has value undefined) can be used and initialized variable means the variable has value.

getify commented 8 years ago

But I'm still confused about the meaning of declare and initialize.

I thought the declared variable (has value undefined) can be used and initialized variable means the variable has value.

Yeah, the problem is nuances and conflations of words.

From the code author's perspective, "declaring" is the var x part and "initializing" is the x = 2 part. But from the perspective of the spec/engine, these shift. "Declaring" is like registering a variable to a scope, "initializing" is reserving space/memory/binding for that variable so it can be used (and giving it its initial undefined value), and "assigning" is giving it a value explicitly in code.

Declaring always happens at time of compilation, and its effect can be seen whenever a scope is first entered. Initializing for var happens at the beginning of the scope, whereas it happens at the site of the declarator for let and const. Initialization is what gives a value its initial undefined value. Assignment then is when you actually use = to assign something to it.

aric87 commented 8 years ago

Here's the real question. What is the benefit of changing the behavior of let and const from the way var works?

getify commented 8 years ago

Mostly to prevent code that does silly things like:

x = 2;
var x;

By far, most developers would agree this is poor coding style. So, the language applies social pressure to devs to convince them to stop doing that so theu can use the shiny new toys.

allenwb commented 8 years ago

From the code author's perspective, "declaring" is the var x part and "initializing" is the x = 2 part. But from the perspective of the spec/engine, these shift. "Declaring" is like registering a variable to a scope, "initializing" is reserving space/memory/binding for that variable so it can be used (and giving it its initial undefined value), and "assigning" is giving it a value explicitly in code.

The spec. uses the phrase "create a binding" to mean "registering a variable in a scope". A binding is an association between a name and a value. Bindings are created for all the names declarated within the scope when the scope is first entered. When a binding is created it does not yet have a value associated with it. It is "uninitialized". Determination of when a binding actually gets initialized depends upon how its name was declared and the kind of scope that contains itl.

allenwb commented 8 years ago

Mostly to prevent code that does silly things like:

x =2;
var x;

Except, the above is legal. The real motivation for temporal deed zones is:

console.log(k);
x=42;
const k=x;;
console.log(k);

Should the first log be legal and if so what value does it see for k? const k is intended to mean: all accesses to k will have the same value which is the value of it's initialization expression. If the first log reported that the value of k was undefined and the second log report that its value is 42 then the value of k would have observably changed, invalidating the const invariant. So, the spec. says that a runtime exception occurs if k is accessed before it is initialized. The period of time between between the creation of the binding for k and the initialization of k is its temporal dead zone. T

For consistency, all new declarations introduced by ES6 follow the same initialization rule as const.

getify commented 8 years ago

Except, the above is legal.

Of course the above is legal. I intentionally used a var because that code snippet was the bad legacy thing (relying on variable hoisting) that developers were doing that they shouldn't have been doing. So when they change to x = 2; let x;, now they'll get a compiler error to tell them not to do it.

panzerdp commented 8 years ago

@getify Thank you for the nice details about what's hoisted and what's not. To continue & detail the explanations, I created an interesting blog post: https://dmitripavlutin.com/variables-lifecycle-and-why-let-is-not-hoisted/

getify commented 8 years ago

:+1

MattGoldwater commented 8 years ago

Interesting discussion here. I agree that at this point people are basically arguing about the semantics of what the term hoisting means. MDN states here that let is hoisted which will only add to the confusion. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let

Telanx commented 7 years ago

@getify I can't agree more. in my opnion, hoisting is only a concept which was created for us to better unstand how var expression was evaluated

console.log(a)  // undefined
var a = 1
console.log(a)  // 1

equals to

var a
console.log(a)
a = 1

but the compiler really just replace the code? i'm not sure. Since let and const are either the same nor exactly opsite to var due to TDZ. so the concept hoisting may not be suitable for let/const.

ghost commented 5 years ago

Is 'hoisting' a valid term/concept in specification, now? Is this tweet https://twitter.com/awbjs/status/434044880180871168 written at 2014 would be old answer?


Update: The links below are very helpful discussion for me. Refer to them. https://twitter.com/awbjs/status/1133756684340420609 https://twitter.com/awbjs/status/1149390012120612864

getify commented 5 years ago

Just for the record... I was wrong in this thread years ago. "Hoisting" is attaching the binding, only, not whether it's initialized. Let and const "hoist" but they aren't initialized. My apologies for the confusion caused. I know better now.

ghost commented 5 years ago

@getify So, If the hoisting means what you said, should explanation below be updated?

However, declarations made with let will not hoist to the entire scope of the block they appear in. Such declarations will not observably "exist" in the block until the declaration statement.


Update:

See https://github.com/getify/You-Dont-Know-JS/issues/1132 together.

IOW, let and const declarations do in fact "hoist" their variable declarations to the entire enclosing scope (just like var), but they remain uninitialized (unlike var) and thus cannot be accessed earlier (from an execution perspective, not a code location perspective) in the scope than their declaration point.

getify commented 5 years ago

For clarity on my position:

Screenshot_20190711-134757__01

ghost commented 5 years ago

For the clarity, sorry, i don't fully understand your meaning. What's your meaning of "scope is entered"? At compile time? By whom?

getify commented 5 years ago

"scope is entered" is referring not to compile time but to runtime execution... each time a scope is executed. parsing/compilation sets up the plan for a scope, but this is just a plan. the scope itself isn't created (memory reserved, etc) until it's executed.

ghost commented 5 years ago

Thanks!! To sum up, the phases to execute declarations(var, let and const) are divided into declaration phase which is a plan for a scope performed at compile time and initialization phase which is a performed at runtime, assigning a value to variable(constant) at those variables declaration site. The gap between those phases is called as temporal dead zone.

getify commented 5 years ago

Yes, and additionally, there is no "gap" for var; they're auto-initialized as soon as the scope is instantiated.

ghost commented 5 years ago

You are right! So temporal dead zone doesn't exist in case of var.

earthnoob commented 5 years ago

The spec. uses the phrase "create a binding" to mean "registering a variable in a scope". A binding is an association between a name and a value. Bindings are created for all the names declarated within the scope when the scope is first entered. When a binding is created it does not yet have a value associated with it. It is "uninitialized". Determination of when a binding actually gets initialized depends upon how its name was declared and the kind of scope that contains itl.

I don't believe I have fully understood the "binding" in "create a binding" part, since I had seen it popping up in places like const declaration, in which someone pointed out that it creates an "immutable binding".

getify commented 5 years ago

"Immutable binding" is, IMO, a bit misleading. I would say: const creates an immutable assignment.

allenwb commented 5 years ago

As commonly used in programming language literature, a "binding" is an association, within an environment, between a name and some entity. In JS, most bindings are to computational "values", statement labels are also a kind of binding.

An immutable binding is one whose entity association never changes. A mutable binding us one whose entity association can change.

Mutability of bindings is an orthogonal concept to mutability of entities (eg values). A JS let or var declaration creates a mutable binding. The associated entity (at some point in time) might be either an immutable value (a number, string, etc) or a mutable value (most objects). A const declaration creates an immutable binding, but just like a let/var the associated entity might be either mutable or immutable.

allenwb commented 5 years ago

BTW, extra credit question.

In JS, a binding can usually be thought of as associating a name with a value. Some language have features that require bindings that associate a name with a storage cell. For example, call-by-reference parameters. This enables multiple names to refer to the same "variable". What features in modern JS create bindings to storage cells rather than values.

getify commented 5 years ago

Shrugs. I suppose I don't really see it useful to use the word "binding" to talk about the "assignment of a value", why not just talk about that as "assignment". To me, "binding" is more about the attachment between a variable name and the scope it's in... its entry in the lexical environment.

But whatever. It's not really worth nitpicking over these words.

What features in modern JS create bindings to storage cells rather than values.

What comes to mind: arguments, and also the identifier(s) imported from an ES6 module.

allenwb commented 5 years ago

In general, I think it's fine to talk about "assignment of a value to a variable". Talking about changing a binding is a way to define what is "assignment" means.

Right on import, but also applies to export. Export also binds to storage cells.

pygy commented 4 years ago

For the sake of completeness, here's a Lua sample. Lua local variables are let-like (lexical, per block, and mutable), but without hoisting.

The scope starts at the declaration and last until either the end of the block or until it is shadowed by an identically named variable.

local a = 5

local function factory()
  local function outer()
    return a
  end

  local a = 6
  local function firstInner()
    return a
  end

  local a = 7
  local function secondInner()
    return a
  end

  return {outer = outer, firstInner = firstInner, secondInner = secondInner}
end

o = factory()
print(o.outer()) --> 5
print(o.firstInner()) --> 6
print(o.secondInner()) --> 7

IMO let is better from an ergonomics standpoint. Lua is like that because it has a single pass parser/bytecode compiler, and implementation simplicity is factored in the language design.

getify commented 4 years ago

Firefox had a let block form, like:

let (x = 2) {
   // ..
}

I think this was a million times better than the let declaration form we got. Unfortunately, TC39 rejected the let block (in favor of just { let x = 2; .. }).