Deklaracje let i const – zmienne w zasięgu blokowym

Standard ECMA Script 6 wprowadził nowy element składni – możliwość deklarowania zmiennych w zasięgu blokowym słowami let lub const. W praktyce jednak nie zawsze jest to takie proste jakby się z pozoru wydawało.

Zaczniemy od analizy deklaracji zmiennych blokowych słowem let. Zmienne deklarowane słowem const są często określane jako stałe, lecz w praktyce nie są to do końca takie same stałe jak w językach C++, Java itp. co dokładniej omówimy w dalszej części artykułu. Powiemy też, dlaczego w pewnych przypadkach zarówno let jak i const tak na prawdę nadal będą traktowane jako zmienne o zasięgu funkcyjnym (deklaracje var).

Zasięg zmiennych przed ECMA Script 6

W starszych środowiskach, nie obsługujących ES6+ istnieje jedynie zasięg funkcyjny. Oznacza to, że każda zmienna zadeklarowana wewnątrz funkcji jest widoczna tylko w tej funkcji, oraz w jej funkcjach wewnętrznych (tutaj wchodzimy już w tematykę tzw. domknięć). Zmienna nie jest natomiast widoczna po za funkcją „główną”. Wyjątek stanowią zmienne globalne, które są widoczne w całym zakresie, czyli we wszystkich funkcjach, choć tak na prawdę to jest to również związane z teorią domknięć w JavaScript.

Do ECMA Script 5 włącznie zmienne można było deklarować wyłącznie z użyciem słowa var. Brak jawnego „var” powoduje deklarowanie zmiennej w zasięgu globalnym, chyba, że pracujemy w trybie ścisłym (‚use strict’).

Przeanalizujmy poniższy kod aby ugruntować sobie wiedzę z zakresu zasięgu zmiennych deklarowanych słowem var:

var a = 1;        //zmienna globalna
function func1() {
    var a = 5,    //zmienna lokalna w func
        b = 10;   //zmienna lokalna w func
    a = b;
    console.log(a);
}
function func2() {
   console.log(a); //odwołanie do zmiennej globalnej
}
function func3() {
    a = 20;
    console.log(a);
}
func1();  //10
func2();  //1    
a;        //1
func3();  //20
a;        //20
func1();  //10
func2();  //20

Na początku zadeklarowaliśmy zmienną „a” jako zmienną globalną o wartości 1. Następnie wewnątrz funkcji func1 zadeklarowaliśmy dwie zmienne, z których jedna ma taką samą nazwę jak zmienna globalna. Nastąpiło w tym momencie przysłonięcie zmiennej globalnej „a” i wewnątrz funkcji func1 zmienna „a” odnosi się do zmiennej lokalnej o wartości początkowej 5.

Z kolei w drugiej funkcji func2 nie deklarujemy żadnych zmiennych lokalnych, więc odwołanie do zmiennej „a” powoduje odniesienie się do zmiennej z zakresu nadrzędnego, czyli w naszym wypadku z zakresu globalnego.

Należy jednocześnie pamiętać, że w języku JavaScript do zmiennych z zakresu nadrzędnego odwołujemy się poprzez referencję, co umożliwia ich zmianę. To właśnie następuje w funkcji func2, w której modyfikujemy globalną zmienną „a”. Gdy następnie wywołamy funkcję func3() spowodujemy wykonanie tej modyfikacji, co potwierdza późniejsze ponowne sprawdzenie wartości zmiennej globalnej „a”. Zmiana ta jest trwała, co potwierdza ponowne wywołanie funkcji func2(), natomiast funkcja func1() w dalszym ciągu odnosi się do swojej zmiennej lokalnej, przysłaniającej zmienną globalną „a”.

Wyjątkiem od omawianych tutaj reguł jest blok try…catch, gdzie do bloku catch przekazywany jest obiekt błędu (najczęściej odczytywany później pod zmienną e lub err), dostępny wyłącznie w lokalnym zakresie bloku catch:

function fn() {
    try {
        throw Error('blad aplikacji');
    } catch (err) {
        console.log(err.message);
    }
}
fn();        //'blad aplikacji'
err.message; //ReferenceError

Deklaracje zmiennych blokowych słowem let i const

Zanim do składni JavaScript wprowadzono instrukcje let i const zakres blokowy musieliśmy imitować poprzez stosowanie natychmiastowych wyrażeń funkcyjnych IIFE, co tak na prawdę sprowadzało się po prostu do stworzenia kolejnego, dodatkowego zakresu funkcyjnego.

Na szczęście obecnie mamy już do dyspozycji instrukcje pozwalające jawnie deklarować zmienne ograniczone do zasięgu blokowego, czyli wyznaczonego przez parę nawiasów klamrowych {}.

Najlepiej widać to na przykładzie instrukcji warunkowej IF, wewnątrz której wielu programistów stosowało deklaracje zmiennych z użyciem instrukcji var, co tak na prawdę nie powodowało stworzenia zmiennej o zasięgu bloku instrukcji IF.

// Rozwiązanie przed wprowadzeniem let i const
// Które w praktyce nie tworzyło zmiennej blokowej
// Lecz zmienną globalną, na jednym poziomie z var a;
var a = 1;
if (a === 1) {
   var b = 5;
}

a; //1
b; //5

A teraz spójrzmy jak w powyższy kod zmieni się, gdy instrukcje var zamienimy na jawne deklaracje blokowe let:

let a = 1;
if (a === 1) {
    let b = 5;
}

a; //1
b; //ReferenceError

Tym razem po wyjściu z bloku IF nie możemy się odwołać do zmiennej b będącej zmienną o zasięgu blokowym.

Kilka słów o hoistingu deklaracji var, let i const

W wielu językach programowania istnieje zasada, aby zmienne deklarować dopiero w miejscu, gdzie faktycznie są potrzebne. Nie miała one jednak zastosowania do języka JavaScript, gdyż tak na prawdę wszystkie instrukcje var z danego zasięgu funkcyjnego są przenoszone na jego początek.

function fn() {
    var a = 1;
    //dalszy kod funkcji...
    for (var i = 0; i < 5; i++) {
        //kod wykonywany w pętli...
    }
}

//Powyższy kod tak na prawdę interpretowany jest jako:
function fn() {
    var a = 1,
        i;
    //dalszy kod funkcji...
    for (i = 0; i < 5; i++) {
        //kod wykonywany w pętli...
    }
}

Mimo, że teoretycznie zmienną „i” zadeklarowaliśmy dopiero w pętli for, to i tak instrukcja var zostanie przeniesiona na początek funkcji fn, jednakże bez przypisania wartości. Wartość „0” zostanie przypisana dopiero w momencie definiowania pętli for, zmienna „i” istnieje jednak już wcześniej.

W przypadku deklaracji zmiennych instrukcją let lub const są one deklarowane faktycznie w miejscu użycia słowa let/const. Co więcej, nie tylko nie są przenoszone na początek funkcji, ale również nie są przenoszone na początek bloku, który stanowi ich zasięg:

if (true) {
   console.log(a);
   let a = 5;
} //ReferenceError

Powyższy kod spowoduje zgłoszenie wyjątku ReferenceError związanego z brakiem zadeklarowanej zmiennej a. Aby móc z niej skorzystać, deklarację let musimy umieścić przed wywołaniem zmiennej „a”, najlepiej na samym początku bloku {}:

if (true) {
   let a = 5;
   console.log(a);
} //5

Z tego względu osobiście uważam, że nie do końca poprawnym stwierdzeniem jest „zakres blokowy” dla deklaracji let i const, lecz takie nazewnictwo zostało już ogólnie przyjęte w społeczności JavaScript więc pewnie pozostanie już na długo. Warto jednak zdawać sobie sprawę z braku zjawiska hoistingu, czyli przenoszenia deklaracji jeśli używamy słów let lub const.

Uważny czytelnik zauważy, że często mówię o deklarowaniu zmiennych przez let lub const lecz w przykładach omawiam jak dotąd wyłącznie let. Informacje omówione do tej pory są w zasadzie takie same dla let i const, lecz tzw. stałe const w JavaScript są dość nietypowymi „stałymi” dlatego ich omówieniem zajmemy się w dalszej części artykułu.

Deklaracja let w pętli for

Wiedząc już jak działa zasięg zmiennych var oraz let możemy pochylić się nad jeszcze jednym problemem, który często sprawiał kłopoty początkującym programistom JavaScript.

Mowa tutaj o pętli for, wewnątrz której wywołujemy funkcje, które przyjmują jako argument iterowaną zmienną (najczęściej „i”). Omówimy to na przykładzie globalnej metody setTimeout, która ma spowodować opóźnione wyświetlanie w konsoli kolejnych liczb.

for (var i = 1; i <= 5; i++) {
    setTimeout(function(){
        console.log(i);
    },500);
} 
//6
//6
//6
//6
//6

Po wykonaniu pętli for w konsoli zobaczymy pięć razy cyfrę 6. Spodziewaliśmy się zapewne nieco innego wyniku, wyświetlającego cyfry kolejno od 1 do 5. Co więc się tutaj stało?

Otóż funkcja anonimowa wywoływana w metodzie setTimeout pobiera zmienną „i” poprzez tzw. domknięcie, jednakże zmienna początkowo ma wartość 1, ale po zakończeniu pętli otrzymuje wartość 6. Pętla kończy się w momencie gdy „i” przyjmuje wartość 5, ale wywoływana jest jeszcze ostatnia instrukcja i++ co sprawia, że ostatecznie zmienna i ma wartość 6 i ona właśnie zostaje przekazana do wszystkich wywołań setTimeout.

Zauważ również, że wbrew naszym oczekiwaniom opóźnienie czasowe występuje tylko jeden raz, po czym wyświetlane są wszystkie cyfry. Jest to związane z tym, że każde wywołanie setTimeout mamy przecież ustawione na to samo opóźnienie czasowe, wynoszące 500ms! Aby faktycznie wyświetlać kolejno elementy tablicy musimy zmodyfikować ten zapis np. na formę „i*500”. Nie gwarantuje nam to wykonania funkcji dokładnie po upływie 500,1000,1500 itp. milisekund, lecz zagadnienie to związane jest z jednowątkowym charakterem JS i wykracza poza ramy tego artykułu.

Przed wprowadzeniem deklaracji let radzono sobie z tym problemem poprzez wykorzystanie dodatkowej funkcji natychmiastowej IIFE:

for (var i = 1; i <= 5; i++) {
    (function(j) {
        setTimeout(function(){
            console.log(j);
        },j*500);
    })(i);
} 
//1
//2
//3
//4
//5

Tym razem otrzymaliśmy kolejne cyfry tak jak miało być. musieliśmy jednak nieco skomplikować kod naszej pętli. Zobaczmy co się stanie, jeśli w poprzedniej wersji pętli for instrukcję var zamienimy na let:

for (let i = 1; i <= 5; i++) {
    setTimeout(function(){
        console.log(i);
    },i*500);
} 
//1
//2
//3
//4
//5

Voila! Tym razem również otrzymujemy kolejne cyfry bez zmiany długości kodu. Pytanie co się tutaj stało? Otóż instrukcja let użyta wewnątrz pętli for zachowuje się nieco inaczej niż w innych miejscach kodu.

W tym wypadku powoduje ona przy każdej iteracji deklarowanie nowej zmiennej „i”, której wartość jest równa wartości po zakończeniu poprzedniej iteracji, czyli po wywołaniu i++ co jest realizowane właśnie na końcu każdego przebiegu pętli for. Mamy tu jednak za każdym razem NOWE zmienne, a nie jedną zmienną „i” o zwiększającej się wartości. Oznacza to, że każde wywołanie setTimeout otrzymuje oddzielną zmienną „i” o kolejno zwiększających się wartościach.

Należy jednak uważać, gdyż takie zachowanie deklaracji let występuje wyłącznie dla deklaracji umieszczonych wewnątrz pętli for (lub for…in, for…of). Jeśli zmienną „i” zadeklarujemy wcześniej to ponownie powrócimy do problemu, który występował przy deklaracji słowem var:

let i = 0; //deklaracja przed pętlą for
for (i = 1; i <= 5; i++) {
    setTimeout(function(){
        console.log(i);
    },i*500);
} 
//6
//6
//6
//6
//6

Należy o tym pamiętać, jeśli do tej pory mieliśmy w zwyczaju deklarowanie wszystkich zmiennych z użyciem var na początku zakresu funkcyjnego. Niektóre poradniki zalecały takie właśnie zachowanie zamiast deklaracji var w pętli for aby nie wymuszać hoistingu deklaracji zmiennych. Jeśli zechcemy teraz zacząć stosowanie deklaracji let lub const to musimy zmienić nawyki co do miejsca deklarowania zmiennych.

Czy zmienna deklarowana słowem const to na prawdę stała?

Do tej pory stosowaliśmy wszędzie deklarowanie zmiennych z użyciem var lub let. Wraz z instrukcją let ES6 wprowadza także instrukcję const, która tłumaczona jest we wszystkich poradnikach jako „stała”. Niestety w praktyce słowo „stała” nie do końca jest tutaj poprawne, a tłumaczenie wynika prawdopodobnie z usilnej chęci przyrównywania JS do innych języków jak PHP, C++, Java itp. Czym więc właściwie jest zmienna zadeklarowana z użyciem const? Spójrzmy na poniższy przykład „stałej” typu znakowego (string) i numerycznego (number).

const str = 'Hello';
const num = 5;

typeof str; //'string'
typeof num; //'number'

str = 'inny tekst'; //TypeError
num = 10;           //TypeError

str = 10;     //TypeError
num = 'tekst' //TypeError

s.length; //5
s.length = 0;
s.length; //5, powyższe przypisanie zostało zignorowane

Jak widać można w pewnym sensie przyjąć, że faktycznie udało nam się zadeklarować dwie stałe których wartości nie można zmienić gdyż powoduje to zgłoszenie błędu. Jest jednak małe „ale”… przeanalizujmy kolejny fragment kodu, w którym deklarujemy zmienną zawierającą tablicę:

const arr = [1,2,3];

arr = 'tekst'; //TypeError
arr = 5;       //TypeError
arr = [];      //TypeError
arr = [4,5,6]; //TypeError

//Niby ok, ale...
arr;         //[1,2,3]
arr.pop();
arr;         //[1,2] ups...
arr.push(5);
arr;         //[1,2,5] ups...
arr = [];    //TypeError
arr.length = 0;
arr;         //[] ups...

Otóż jeśli isntrukcją const deklarujemy zmienną o wartości typu prostego jak string czy number to faktycznie można je traktować jako stałe. Jeśli jednak const użyjemy do zadeklarowania tablicy czy obiektu to nie będzie możliwości ich bezpośredniego modyfikowania w operacji przypisania („=”), lecz będą działały metody, które operują na tablicy/obiekcie przez referencję.

Dlatego metoda pop() skutecznie usunęła ostatni element, gdyż pracuje ona na referencji do tablicy, a nie na jej kopii (jak np. metoda slice). Warto również zauważyć, że nie można usunąć elementów poprzez przypisanie stałej arr pustej tablicy []. Można jednak wyzerować jej właściwość length przez co uzyskamy właśnie pustą tablicę, choć osobiście nie zalecam tej metody do zerowania tablicy.

Należy pamiętać o tych zasadach jeśli będziemy chcieli w kodzie zadeklarować stałe. Z tych względów uważam, że const powinno się stosować wyłącznie do typów prostych. Słowem const można również deklarować funkcje:

const fn = (n) => n*2;
fn(5); //10

fn = (n) => n*3; //TypeError

Trzeba jednak uważać na pewien niuans, a mianowicie na brak przenoszenia deklaracji z użyciem let lub const. W przypadku zmiennych i „typowych” stałych znakowych lub numerycznych najczęściej nie będzie to problem i co więcej, pozwoli lepiej panować nad kodem i deklarować zmienne dopiero tam, gdzie faktycznie są potrzebne.

Uważajmy na to również wtedy, gdy przez const lub let chcemy deklarować wyrażenia funkcyjne. Nie zostaną one przeniesione na początek zakresu, co dotyczy zarówno zakresu leksykalnego funkcji jak i zakresu blokowego, w której znajduje się deklaracja const/let. Krótko mówiąc, wszystko co zadeklarujemy przez const/let możemy używać dopiero po tej deklaracji.

Zmienne let i const nie zawsze są zmiennymi o zasięgu blokowym…

Na koniec jeszcze parę słów na temat stosowania let/const w kodzie. Otóż są to elementy składni JavaScript w związku z czym nie da się ich zastąpić polyfill. Konieczne jest więc użycie transpilatora kodu. Osobiście stosuję i polecam transpilator Babel. Zobaczmy jednak jak wygląda transpialacja deklaracji let/const:

//Wersja przed transpilacją
var x = 1;
if (x===1) {
 let x = 's';
 const s = 2;
}
if (x===2) {
 let x = 'd';
 const s = 2;
 //s = 5;
}

//Wersja po transpilacji Babel ES2015
'use strict';
var x = 1;
if (x === 1) {
 var _x = 's';
 var s = 2;
}
if (x === 2) {
 var _x2 = 'd';
 var _s = 2;
}

Zmienne deklarowane z użyciem let i const są tak na prawdę zamieniane na „zwykłe” deklaracje z użyciem słowa var. Aby jednak uniknąć nadpisania innych zmiennych w danym zasięgu funkcyjnym przed nazwami nowych zmiennych dodawany jest znak „_”. W razie potrzeby do nazwy dodawane są również kolejne cyfry. I tak w naszym kodzie de facto otrzymujemy 5 zmiennych o zasięgu globalnym (gdyż nie mamy tutaj żadnej funkcji ograniczającej zasięg). Zmienne te używane są jednak wyłącznie w określonych zakresach blokowych, więc funkcjonalność zostaje zachowana.

Teoretycznie więc nie ma ryzyka kolizji nazw zmiennych i możemy spokojnie cieszyć się nową składnią ECMA Script. Tak… o ile nie wpadnie nam do głowy niezbyt mądry, choć poprawny składniowo pomysł, aby w dwóch skryptach ładowanych na stronie (dwa oddzielne pliki) stosować zmienne blokowe let/const o takich samych nazwach, które w obu plikach spowodują wygenerowanie zmiennych globalnych (var).

Transpilator Babel operuje na plikach, więc nie ma możliwości wykrycia tego typu konfliktów. Z tego względu zalecam, aby stosować praktykę wprowadzania dodatkowego zakresu funkcyjnego dla każdego z wczytywanych skryptów, np. z wykorzystaniem IIFE (natychmiastowe wyrażenia funkcyjne). Unikamy wtedy ryzyka jakichkolwiek kolizji nazw zmiennych.