Azjatka otoczona przez kolorowe bąbelki mydlane.

Zakresy – Powtórka przed ReactJS #7

Bez zrozumienia zakresów nie obejdzie się podczas pisania nawet najprostszych programów. Tak pozornie banalne zagadnienie zawiera wiele niuansów, które wprowadzają w dezorientację wielu początkujących. Większość z owych niuansów odchodzi w niepamięć po zrezygnowaniu z wykorzystywania var na rzecz let i const. Czy jest to wystarczający powód, aby pominąć temat? W żadnym wypadku. Dogłębne opanowanie mechaniki zakresów jest bardzo często sprawdzane podczas rozmów kwalifikacyjnych. Wystarczy zrozumieć kilka prostych zasad, żeby znacznie zwiększyć szansę na zdobycie wymarzonej pracy.

Jak działa zakres?

Silnik JavaScript, podczas wykonywania kodu, musi na bieżąco wyszukiwać wartości, które kryją się pod nazwami zmiennych czy funkcji.

Kyle Simpson, w książce YDKJS: Scope & Closures wyróżnia dwa rodzaje wyszukiwania – LHS i RHS. W zależności od kontekstu wyrażenia, silnik wykonuje jedno z nich.

LHS (left-hand side) – wyszukiwanie lewostronne, które następuje gdy zmienna znajduje się po lewej stronie operacji przypisania.
Wszystko sprowadza się do odszukania adresu pamięci należacego do zmiennej i umieszczeniu w nim nowej wartości.

foo = 42;

Nie interesuje nas obecna zawartość foo – po prostu chcemy wrzucić do kontenera o tym identyfikatorze wartość 42.

Wyszukiwanie RHS ogranicza się do podglądania wartości pod wybranym adresem pamięci.

console.log(foo);

Silnik sprawdza wartość przechowywaną przez foo, po czym przekazuje ją do metody log obiektu console. Nie ma tutaj żadnej operacji przypisania, więc mamy do czynienia z referencją prawostronną.

Silnik ma bardzo dużo pracy. Musi delegować część zadań, aby wywiązywać się ze wszystkich obowiązków w jak najkrótszym czasie. Podczas wyszukiwania silnik konsultuje się z zakresami. Każdy z nich posiada spis zamieszkujących go identyfikatorów. W pierwszej kolejności silnik odpytuje zakres, w którym natrafił na analizowane wyrażenie. Jeżeli nie otrzyma odpowiedzi, to przechodzi do zakresu otaczającego. Według takiego algorytmu silnik przemieszcza się, aż do zakresu globalnego. Jeżeli nadal nie uda mu się odnaleźć poszukiwanego identyfikatora, to podejmuje decyzję jak zareagować na taką sytuację. Decyzja jest zależna od dwóch trybów działania silnika – strict i non-strict mode.

Zacznijmy od pierwszego scenariusza. Silnik z surowym nastawieniem daje do zrozumienia, że ma dość i raczy programistę jednym z dwóch błędów.

ReferenceError informuje, że wyszukiwanie zakończyło się niepowodzeniem. Identyfikatora nie odnaleziono w żadnym z przeanalizowanych zakresów.

'use strict';

var a = 2;

function foo() {
  b = 3;
}

foo(); // Uncaught ReferenceError: b is not defined
[js]
<code>TypeError</code> oznacza, że silnik odnalazł wartość ale doszło do próby wykonania nielegalnej/niemożliwej operacji. Często spotykaną sytuacją w praktyce jest próba wywołania <code>undefined</code>, kiedy spodziewamy się wyszukania wartości funkcyjnej.
[js]
'use strict';

var c = {
  functionWithoutMissingCharacters() {
    console.log('kwakwa');
  }
}

c.functionWithMissingCharacters(); // Uncaught TypeError: c.functionWithMissingCharacters is not a function

Natomiast jeżeli zapomnimy o wyrażeniu ‚use strict’ i silnik będzie pracował w trybie nierygorystycznym to musimy liczyć się z działaniami dywersyjnymi.

Nieudane wyszukiwanie LHS zakończy się zadeklarowaniem zmiennej w zakresie globalnym.

'use strict';

var a = 2;

function foo() {
  b = 3;
}

foo();
console.log(b); // 3

Na całe szczęście, w przypadku porażki z wyszukiwaniem RHS, silnik zachowuje się w ten sam sposób co w trybie rygorystycznym.

Czym jest zakres?

Skoro już mamy jakiekolwiek pojęcie o tym jak silnik współpracuje z zakresen, to możemy przejść do ulubionej części każdego wpisu. Szanowni Państwo, czas na… definicję.

W językach programowania zakres to zbiór zasad dotyczących przechowywania oraz wyszukiwania zmiennych wewnątrz programu. Istnieją dwa rodzaje zakresów: leksykalny oraz dynamiczny. JavaScript wykorzystuje zakres leksykalny. Ten rodzaj zakresu, rozpowszechniony w praktycznie każdym języku opartym o C, opiera się o lokalizację funkcji i zmiennych w czasie kompilacji. Alternatywnym rozwiązaniem jest zakres dynamiczny, który działa w oparciu o czas wykonywania.

Przypuścmy, że JavaScript daje nam możliwość przełączania się pomiędzy zakresem leksykalnym i dynamicznym.

let a = 2;

function bar() {
  console.log(a);
}

function foo() {
	let a = 1;
	bar();
}
'use lex scope'
foo(); // 2
'use dynamic scope'
foo(); // 1

Zakres dynamiczny wyszukuje zmienne w oparciu o miejsce wywołania funkcji. Z tego względu ma dostęp do a zadeklarowanego wewnątrz foo. Zakres leksykalny, z którym mamy do czynienia poza tym jakże pouczającym przykładem, analizuje kod w oparciu o miejsce deklaracji funkcji. Najbliższym zakresem z miejsca deklaracji bar jest zakres globalny. Zmienna o identyfikatorze a posiada w nim wartość 2. Zakres foo znajduje się poza ścieżką wyszukiwania.

Zakres blokowy

Teraz zastanówmy się jakie mamy możliwości, jeżeli chodzi o tworzenie nowych zakresów. Bloki kodu takie jak instrukcja warunkowa if czy pętla for nie tworzą nowego zakresu (chyba że zadeklarujemy zmienną przy użyciu słowa kluczowego let/const). Tak samo wygląda to w Pythonie i Ruby, podczas gdy zakres blokowy możemy znaleźć w Javie i C.

Podsumowując, o zakresie zmiennych zadeklarowanych za pomocą słowa kluczowego var decyduje wyłącznie funkcja otaczająca.

var a = 1; // Zakres = Funkcja otaczająca = global
if (true) {
  var a = 2; // Zakres = Funkcja otaczająca = global
}
console.log(a); // 2

Taka mechanika była źródłem frustracji wśród osób przyzwyczajonych do zakresu blokowego występującego w Javie czy C#. W zakresie blokowym, każdy fragment kodu otoczony klamrami jest nowym zakresem. Na szczęście, wyrażenia let i const pozwalają na korzystanie z tego dobrodziejstwa.

let a = 1; // Zakres = Funkcja otaczająca = global
if (true) {
  let a = 2; // Zakres = Blok instrukcji warunkowej
}
console.log(a); // 1

Wyjątek: jedynym miejscem, gdzie mamy do czynienia z zakresem blokowym w JS pre-ES6, jest klauzula try/catch.

Function test()
 var x = 'outside';
try {
throw 'exception'
} catch (x) {
  x = 'inside'
  console.log(x); // 'inside'
}
console.log(x); // 'outside'
}

Czym są zakresy zagnieżdżone?

Jak zapewne zauważyliście podczas analizy poprzednich przykładów, zakresy zagnieżdżone są jak małe bąbelki zawarte w większych bąblach, zakresach otaczających. Silnik rozpoczyna wyszukiwanie zmiennej w aktywnym bąblu i zawsze biegnie na zewnątrz, w kierunku zakresu globalnego.

Taki mechanizm umożliwia zacieniowania zmiennej. Jeżeli dwie zmienne posiadają taką samą nazwę to zmienna w zakresie zagnieżdżonym będzie cieniowała tę z zakresu otaczającego.

let a = 'global scope';

function foo() {
  let a = 'foo scope';
  
  function bar() {
    let a = 'bar scope';
    
    function baz() {
      console.log(a); // 'bar scope'
    }
  }
  console.log(a); // 'foo scope'
}

console.log(a); // 'global scope'

Czym jest wynoszenie?

Teraz przejdziemy do jednego z najciekawszych mechanizmów. Jego największą zaletą jest możliwość tworzenia wielu podchwytliwych przykładów ;).

Inicjalizację zmiennej przy użyciu var warto traktować jak operacje dwuczłonową. Składa się z samej deklaracji i poźniejszego przypisania wartości. Silnik niejawnie przenosi deklarację na początek zakresu funkcji otaczajacej, a przypisanie zostawia na swoim miejscu. Tym właśnie jest wynoszenie (ang. hoisting). Oznacza to, że zmienna jest dostępna w zasięgu całej funkcji, ale wartość jest przypisywana do zmiennej dopiero w miejscu wystąpienia wyrażenia var.

var text = 'outside';
function logIt(){
    console.log(text);
    var text = 'inside';
};
logIt(); // undefined

Uwzględnijmy mechanizm wynoszenia i zastanówmy się w jaki sposób silnik postrzega powyższy przykład.

var text = 'outside';
function logIt(){
    var text;
    console.log(text);
    text = 'inside';
};
logIt();

Teraz wszystko powinno być jasne.

Najskuteczniejszym sposobem na tego rodzaju niespodzianki jest całkowita rezygnacja z var na rzecz let i const. Jeżeli z jakiegoś powodu nie możemy sobie pozwolić na takie luksusy, to wypada samodzielnie umieszczać deklaracje na szczycie zakresu. Imitowanie działania silnika zwiększy czytelność kodu i pozwali uniknąć pomyłek związanych z przeoczeniem wynoszenia.

Warto wiedzieć: wyniesione deklaracje funkcji mają pierwszeństwo nad deklaracjami zmiennych.

foo(); // 1

var foo;

function foo() {
        console.log( 1 );
}

foo = function() {
        console.log( 2 );
};

Praktyczny przykład z rozmowy kwalifikacyjnej

Teraz zróbmy użytek z całej omówionej powyżej teorii. Rozpracujemy prosty, lecz podchwytliwy przykład. Jego wariacja pojawia się na każdej liście ‚Top 10 JavaScript Questions’.

function wrapElements(arr) {
  var result = [ ];
  for (var i = 0;  i < arr.length; i++) {
     result[i] = function() { return arr[i]; };
  }
  return result;
}

var wrapped = wrapElements([10, 20, 30]);
var foo = wrapped[0];
foo() // undefined

Do zmiennej i odwołujemy się za pomocą referencji. W momencie wywołania foo(), pętla jest od dawna wykonana, więc i ma wartość 3. Jako że przekazaliśmy do funkcji tablicę o [10, 20, 30], to nic dziwnego, że pod arr[3] kryje się undefined.

Aby poradzić sobie z tego typu problemem bez użycia let, musimy wykorzystać natychmiastowo-wywoływane wyrażenie funkcyjne (ang. immediately-invoked function expression aka IIFE). Jest to nadzwyczaj popularna praktyka pozwalająca na imitowanie zakresu blokowego (więcej informacji tutaj). IIFE tworzy nowy zakres przy każdej iteracji pętli. W ten sposób uzyskujemy dostęp do trzech oddzielnych egzemplarzy zmiennej i.

function wrapElements(arr) {
  var result = [];
  for (var i = 0;  i < arr.length; i++) {
(function(j) {
     result[j] = function() { return arr[j]; };
})(i);
  }
  return result;
}

var wrapped = wrapElements([10, 20, 30]);
var foo = wrapped[0];
foo() // 10

Korzystając z nowoczesnego JavaScript sprawa jest dużo prostsza. Wystarczy zadeklarować i przy pomocy słowa kluczowego let. Przy każdej iteracji powstaje zakres blokowy.

function wrapElements(arr) {
  var result = [];
  for (let i = 0;  i < arr.length; i++) {
     result[i] = function() { return arr[i]; };
  }
  return result;
}

var wrapped = wrapElements([10, 20, 30]);
var foo = wrapped[0]
foo() // 10

Podsumowanie

Miałem w planach omówienie większej ilości przykładów, ale praktycznie każdy z nich opiera się na zaprezentowanych powyżej patentach. Pozostało mi życzyć powodzenia na nadchodzących rozmowach kwalifikacyjnych ;).

Zależy Ci na skutecznym utrwalaniu wiedzy? Zapraszam do zapoznania się z moim sposobem. Jego opis znajdziesz w poprzednim wpisu, który pobił wszelkie dotychczasowe rekordy popularności – Anki, czyli jak zapamiętuję WSZYSTKO czego się uczę.

Zapraszam do zostawiania lajków na fanpage’u strony i śledzenia mojego prywatnego profilu na Twitterze. Każde kliknięcie to niesamowita porcja motywacji i pozytywnej energii, której nigdy za dużo – w końcu nauka programowania nigdy się nie kończy :).

Do zobaczenia za tydzień, zabierzemy się za domknięcie tematu zakresów.

Źródła

Zdjęcie tytułowe autorstwa: Alejandro Alvarez

Marcin Czarkowski

Cześć! Ja nazywam się Marcin Czarkowski, a to jest AlgoSmart - blog, na którym dzielę się wiedzą o ReactJS oraz JavaScript. Tworzę poradniki na YouTube i jestem współtwórcą przeprogramowani.pl