Piekło zamarzło! Przyszedł dzień, w którym Comandeer posypuje głowę popiołem i przyznaje się do błędu.

Mea culpa

W swojej książce, JavaScript. Programowanie zaawansowane, w rozdziale 2., na stronie 32 stwierdzam:

Jak zatem widać, nowa metoda deklaracji zmiennych działa w sposób, o który od wieków upominali się programiści JS: zmienne ograniczone zostały do bloków, w których je zadeklarowano. Co więcej, żeby już całkowicie “unormalnić” ten obszar JS-a, zrezygnowano z hoistingu (wynoszenia).

Ten fragment (a zwłaszcza drugie zdanie) jest niepoprawny i wynika to ze zbytniego uproszczenia, jakiego się dopuściłem.

Temporal Dead Zone, czyli hoisting bez hoistingu

Doskonale wiadomo, że zmienne deklarowane przy pomocy var są hoistowane (wynoszone). Rozpatrzmy taki, klasyczny, przykład:

( function() {
	console.log( typeof a ); // undefined

	var a = 1;
}() );

Jak widać, można pobrać typ zmiennej przed jej deklaracją. Umożliwia to właśnie mechanizm hoistingu, który wszystkie deklaracje zmiennych “wynosi” na sam początek danego scope, zostawiając na miejscu jedynie przypisanie wartości do zmiennej. Powyższy przykład jest zatem widziany przez parser JS mniej więcej tak:

( function() {
	var a;
	console.log( typeof a ); // undefined

	a = 1;
}() );

Gdy zamienimy var na let zauważymy, że zachowanie skryptu się znacząco zmienia:

( function() {
	console.log( typeof a ); // Uncaught ReferenceError: a is not defined

	let a = 1;
}() );

Wniosek, który się nasuwa, jest oczywisty: zmienne deklarowane przez let nie są hoistowane. I gdybyśmy poprzestali na tego typu przykładzie, faktycznie można by tak przyjąć. Spójrzmy jednak na ciut inną sytuację:

( function() {
	var a = 1;

	( function() {
		console.log( typeof a ); // undefined
		var a = 'hublabubla';
	}() );
}() );

W tym przykładzie po raz kolejny pojawia się undefined zamiast number. Dlaczego? Bo zmienne są hoistowane na górę najbliższego scope. W tym wypadku wewnętrzna funkcja stanowi osobny scope, stąd przesłania liczbową zmienną z zewnętrznego scope.

Jeśli założymy, że zmienne deklarowane przy pomocy let faktycznie nie są hoistowane, to w przykładzie z let powinniśmy uzyskać number (bo deklaracja let jest dopiero po console.log, zatem do tego czasu powinna być dostępna zmienna z wyższego scope). Sprawdźmy:

( function() {
	let a = 1;

	( function() {
		console.log( typeof a ); // Uncaught ReferenceError: a is not defined
		let a = 'hublabubla';
	}() );
}() );

Co tu się stało? Ano, nadzialiśmy się na tzw. Temporal Dead Zone (Czasowo Martwa Strefa). Choć nazwa brzmi strasznie, sam mechanizm aż tak straszny nie jest. Składa się on tak naprawdę z dwóch elementów:

  • Zmienne deklarowane przy pomocy let (ale także const) są hoistowane na górę najbliższego scope.
  • Od początku scope aż do miejsca faktycznej deklaracji zmiennej istnieje TDZ, uniemożliwiając dostęp do zmiennej.

Mechanizm ten wprowadzono dla const, aby uniemożliwić nadpisanie stałej wewnątrz danego scope i przeniesiono następnie to zachowanie także dla let – dla zachowania spójności.

Po więcej informacji o TDZ odsyłam – jak zawsze – do odpowiedniego fragmentu twórczości Rauschmayera.

Mea maxima culpa

Jak widać, TDZ w większości przypadków zachowuje się tak, jakby zmienne let i const nie były hoistowane. Stąd od biedy fragment w mojej książce mógłby brzmieć:

Co więcej, żeby już całkowicie “unormalnić” ten obszar JS-a, zmienne te w przeważającej większości przypadków zachowują się, jakby nie podlegały hoistingowi [tutaj przypis wspominający o TDZ i odsyłający do Exploring JS].

Posypuję głowę popiołem i przyznaję: pominięcie opisu TDZ było sporym przeoczeniem z mojej strony. Jedyne, co mogę zrobić na swoje usprawiedliwienie, to zacytować Konrada:

Język kłamie głosowi, a głos myślom kłamie;