That's not how lexical scope works anywhere but in JavaScript. Or rather, it's the interaction between "normal" lexical scope and hoisting. In a "normal" lexically scoped language, if you tried:
function f() {
return x; // Syntax parsing fails here.
}
let x = 4;
return f();
you would get the equivalent of a ReferenceError for x when f() tried to use it (well, refer to it) at the commented line. But in JavaScript, this successfully returns 4, because `let` inherits the weird hoisting behavior of `var` and `function`. And it has to, because otherwise this would be really weird: function f1() { return x; }
let x = 4;
function f2() { return x; }
return Math.random() < 0.5 ? f1() : f2();
Would that have a 50/50 chance of returning the outer x? Would the engine have to swap which x is referred to in f1 when x gets initialized?TDZ is also terrible because the engines have to look up at runtime whether a lexical variable's binding has been initialized yet. This is one reason (perhaps the main reason?) why they're slower. You can't constant fold even a `const`, because `const v = 7` means at runtime "either 7 or nothing at all, not even null or undefined".
In my opinion, TDZ was a mistake. (Not one I could have predicted at the time, so no shade to the designers.) The right thing to do when introducing let/const would have been to make any capture of a lexical variable disable hoisting of the containing function. So the example from the article (trimmed down a little)
return Math.random() < 0.5 ? useX() : 1;
let x = 4;
function useX() { return x; }
would raise a ReferenceError for `useX`, because it has not yet been declared at that point in the syntactic scope. Same with the similar return Math.random() < 0.5 ? x : 1;
let x = 4;
which in current JavaScript also either returns 1 or throws a ReferenceError. I'm not against hoisting functions, and removing function hoisting would have not been possible anyway. The thing is, that's not "just a function", that's a closure that is capturing something that doesn't exist yet. It's binding to something not in its lexical scope, an uninitialized slot in its static environment. That's a weird special case that has to be handled in the engine and considered in user code. It would be better to just disallow it. (And no, I don't think it would be a big deal for engines to detect that case. They already have to compute captures and bindings.)Sadly, it's too late now.
That’s not the example I’m talking about. I mean where he defines `calculation` within the curly braces of the if statement, then says it “leaked out” because he can log it below the closing brace of the if statement. That’s a perfect example of the difference between lexical scope and block scope.
>>> The first example is not “terrible”
There are several examples in the blog, and only one is the first. It does not include the "terrible" descriptor after it. So your comment is kind of odd because it doesn't connect to the article at all.
If you mean the first example that's described as "terrible", that's the second example and it's the one with the leaking loop variable. It kind of is terrible, Python has the same problem (and many others, Python scoping rules are not good). C used to have that problem but they at least had the good sense to fix it.
You’re right about my mistake, I should have said “the second code snippet”.
> the difference between lexical scope and block scope
There isn't a difference between lexical scope and "block" scope. What I think you are referring to as "block" scope, is a subset of lexical scope. The difference between var and let/const is where the boundaries of the lexical scope is.
Ah, fair, I didn't actually pay attention to which example you were referring to. That example is specifically about `var` being terrible, not `let/const`.
I was really using your comment as a jumping off point for my rant.
I wouldn't describe `var` declarations as lexical, though. Sure, they have a lexical scope that they get hoisted up to cover, but hoisting is not "just lexical scope". It's unusual.
One thing about scope hoisting in JS is that it allows to simulate some aspects of functional programming, where the the order of execution does not necessarily math the order of declaration. I use this all the time to order code elements in a file in order of decreasing importance.
I wish there was explicit support for this though, maybe with a construct like <exression> where <antecedent>, etc, like what Haskell has, instead of having to hack it using functions and var
This is not some terrible decision that comes with only downsides. In fact there are quite a few upsides to the flexibility it brings compared to a language like Python that works as you describe.
It basically means you can always override anything, which allows for monkey patching and proxying and adapter patterns and circular imports… These are all nasty things to accidentally encounter, but they can also be powerful tools when used deliberately.
These hoisting tricks all play an important role in ensuring backwards compatibility. And they’re the reason why JavaScript can have multiple versions of the same package while Python cannot.
Actually, let/const do the opposite of adding flexibility. Simple example: if you have a REPL, it has to cheat (as in, violate the rules of the language) in order to do something sensible for let/const. Once you do `let x;`, you can never declare `x` in the REPL again. In fact, simple typing `console.log(v)` is ambiguous: will you enter `let v` some time in the future or not?
You can't monkey patch lexicals, that is much of their point. Any reference to a lexical variable is a fixed binding.
In practice, this comes up most often for me when I have a script that I want to reload: if there are any toplevel `let/const`, you can't do it. Even worse, `class C { ... }` is also a lexical binding that cannot be replaced or overridden. Personally, I normally use `var` exclusively at the toplevel, and `let/const` exclusively in any other scope. But `class` is painful -- for scripts that I really want to be able to reload, I use `var C = class { ... };` which is fugly but mostly works. And yet, I like lexical scoping anyway, and think it's worth the price. The price didn't have to be quite so high, is all. I would happily take the benefit of avoiding TDZ for the price of disabling hoisting in the specific situations where it no longer makes sense.
I agree that hoisting is a backwards compatibility thing. I just think that the minute optional lexical scoping entered the picture, hoisting no longer made sense. Either one is great, the combination is awful, but it's possible for them to coexist peacefully in a language if you forbid the problematic intersection. TDZ is a hack, a workaround, not peaceful coexistence. (TDZ is basically the same fix as I'm proposing, just done dynamically instead of statically. Which means that JS's static semantics depend on dynamic behavior, when the whole point of lexical scoping is that it's lexical.)
> if there are any toplevel `let/const`, you can't do it [monkeypatch it]
True, but you can at least wrap the entire scope and hack around it. It's not gonna be pretty or maintainable but you can avoid/override the code path that defines the let.
Anecdotally... I've monkeypatched a lot of JavaScript code and I've never been stopped from what I wanted to do, whereas with Python I've hit a dead-end in similar situations. Maybe there's some corner case that's unpatchable but I really think there is always a workaround by the ability to wrap the scope in a closure. Worst case you re-implement the entire logic and change the bit you care about.
None of the things you mentioned are clearly related to each other. “It basically means you can always override anything, which allows for monkey patching and proxying and adapter patterns and circular imports” is not true. “They’re the reason why JavaScript can have multiple versions of the same package while Python cannot” is definitely not true. (I’m not even sure if you’re referring to TDZ or hoisting or lexical scope or whatever other part of the context, but these things are unrelated to every option.)
The premise of “a language like Python that works as you describe” is wrong too, since Python doesn’t work like that (it has the same hoisting and TDZ concepts as JavaScript):
Would mutual recursion still work with this solution? E.g.
Yes, because those don't capture any lexicals, so they're hoisted as usual.
My solution would only stop hoisting closures that capture lexicals, with the idea that capturing a lexical means that the closure's identity is now tied to that lexical environment anyway so it's kind of bizarre to hoist it out to where part of its identity is missing.
But you were giving a simplified example, so I want to be sure I'm not dismissing your concern unfairly. This would not work:
Hm... you raise a good point, though. Should this work?: perhaps the proposal needs to be modified: instead of not hoisting lexical closures (functions that close over lexicals) at all, hoist them to the nearest lexical scope containing everything they close over. So the above would work, but this would still be an error: Not that I'm proposing anything at all, this is all wishful thinking. Though I've wondered if there could be a way to opt-in to this somehow, something like: (or `const function`, I suppose, but I don't know if there's a useful distinction.) Then these lexical functions would never need to consider the case where they capture uninitialized bindings. But that's kind of weird, in that `let f = () => { ... };` looks similar to `let function f() { ... }` but the former would still have to hoist. Bleagh. Oh well.It would work just like Lua then and require manually hoisting one of the declarations:
(Lua's named function statements are just syntatic sugar. For example `local function a()` is equivalent to `local a; a = function()`.)