Jak naprawdę działa closure?

Jak naprawdę działa closure?

Closure to jeden z najważniejszych konceptów w języku Javascript. Jest to połączenie funkcji i środowiska zmiennych, do których się ona odwołuje powstające w momencie stworzenia funkcji. Każdy piszący w JSie ich używa, ale nie każdy wie jak naprawdę działają.

Czym jest i jak działa closure? – według Erica Eliott’a jest to pytanie rekrutacyjne pokazujące czy dany kandydat jest juniorem czy mid/senior deweloperem.

If you answer this question wrong, there’s a good chance you won’t get hired. If you do get hired, there’s a good chance you’ll be hired as a junior developer, regardless of how long you’ve been working as a software developer. On average, junior developers get paid $40k/year less USD than more experienced software engineers.

Eric Eliott, Master the JavaScript Interview: What is a Closure?

A propos pytań rekrutacyjnych, napisałem darmowy ebook zawierający 25 pytań, które możesz usłyszeć na rozmowie kwalifikacyjnej.

Higher Order Functions

W języku Javascript funkcje są first-class objects. Z tego powodu możemy je przechowywać w zmiennych, przekazywać jako argument do funkcji i zwracać z funkcji.

Higher order functions nazywamy funkcje przyjmujące lub zwracające funkcję. Obecność tego mechanizmu nadaje Javascriptowi funkcyjny charakter.

// HOF Example
function createAddFunction() {
  return function add(a, b) {
    return a + b;
  }
}

const add = createAddFunction();
add(1, 2) // 3

Użycie closure

Wykorzystanie closure jest bardzo proste. Potrzebujemy funkcji zwracającej funkcję (bezpośrednio lub jako atrybut zwracanego obiektu). Dodatkowo wewnętrzna funkcja musi odnieść się do zmiennej znajdującej się w zasięgu (scope) funkcji zewnętrznej.

Poniżej możesz zobaczyć implementację licznika wykorzystującą closure. Zmienna counter jest dostępna tylko wewnątrz funkcji createCounter. Można w ten sposób zapewnić enkapsulację – prywatność danych i dostęp do nich tylko poprzez publiczny interfejs counterInterface.

function createCounter() {
  let counter = 0;

  const counterInterface = {
    getCount() {
      return counter;
    },
    increment() {
      counter++;
    }
  }

  return counterInterface;
}

const coffeeCounter = createCounter();
coffeeCounter.getCount(); // 0
coffeeCounter.increment();
coffeeCounter.getCount(); // 1

Closure stoi u podstawy wielu ważnych mechanizmów Javascriptu. Iteratory, generatory, async/await – wszystkie z nich istnieją dzięki domknięciom. Gdyby nie closure, niemożliwe byłoby obsługiwanie zdarzeń w przeglądarce, currying, a nawet przekazywanie callbacków.

Kiedy powstaje closure?

Aby zobrazować działanie closure, pokażę co robi powyższy kod linijka po linijce. W tym celu potrzebne będą 3 elementy silnika Javascript:

  • Thread – Javascript to język synchroniczny i jednowątkowy. Wykonuje instrukcje jedna po drugiej i nie przejdzie do kolejnej dopóki nie skończy wykonywać poprzedniej. Podczas startu skryptu tworzony jest global execution context i to tam wątek zaczyna.
  • Call stack (stos wywołań) – element na szczycie stosu wskazuje w którym kontekście znajduje się aktualnie wątek. Na początku jest to global execution context.
  • Global memory (variable environment) – miejsce w którym Javascript zapisuje zmienne stworzone w global execution context

Pierwszym na co natrafia wątek jest deklaracja funkcji createCounter(). Javascript zapisuję funkcję w pamięci i wątek przechodzi do kolejnej instrukcji.

  1. Interpreter trafia na linię wywołującą metodę createCounter():
    1. Tworzy zmienną coffeeCounter w pamięci globalnej. Zostanie tutaj przypisany rezultat wywołania funkcji createCounter().
    2. Powstaje nowy execution context mający swoją własną lokalną pamięć.
    3. Dodaje kontekst createCounter na szczyt stosu wywołań.
  2. Wątek wchodzi do createCounter.
  3. Tworzona jest zmienna counter w lokalnej pamięci funkcji i przypisana zostaje do niej wartość 0.
  4. Zostaje stworzona zmienna counterInterface, której wartością jest obiekt . To właśnie tutaj powstaje closure. Dzieje się tak, ponieważ metody getCount() i increment() odwołują się do zmiennej counter, która znajduje się w zewnętrznej funkcji createCounter.
  5. Obiekt przechowywany pod zmienną counterInterface zostaje zwrócony i przypisany do zmiennej coffeeCounter znajdującej się w globalnej pamięci.
  6. Javascript niszczy execution context powstały w punkcie 1. W rezultacie jego lokalna pamięć jest czyszczona.

Analizując wykonanie kodu wskazałem miejsce powstania closure. Po wyświetleniu funkcji za pomocą console.dir (pokazuje więcej szczegółów) widzimy, że closure zostało dodane do ukrytego atrybutu funkcji [[Scopes]]. To jest właśnie to połączenie funkcji i środowiska zmiennych o którym wspomniałem na samym początku.

Warto zaznaczyć, że Script to nasza globalna pamięć, w której znajduje się obiekt coffeeCounter. Global to obiekt window w przeglądarce.

Kiedy Javascript odwołuje się do closure?

Wiemy już, kiedy powstaje i znamy miejsce przechowywania closure. Ostatnim co zostało do wyjaśnienia, jest to jak Javascript je wykorzystuje.

  1. Interpreter trafia na linię coffeeCounter.getCount()
    1. Wyszukuje w globalnej pamięci obiekt coffeeCounter, pobiera to co znajduję się pod atrybutem getCount i próbuje to wywołać.
    2. Powstaje nowy execution context mający swoją lokalną pamięć.
    3. Dodaje kontekst getCount() na szczyt stosu.
  2. Wątek wchodzi do getCount.
  3. Interpreter trafia na instrukcję zwrócenia wartości znajdującej się pod zmienną counter. Zanim to zrobi, musi ją znaleźć.
    1. Pamięć lokalna funkcji jest miejscem w którym Javascript zaczyna poszukiwania. W tym przypadku nie ma tam zmiennej counter.
    2. Następnie Javascript kontynuuje poszukiwania w [[Scopes]] danej funkcji i sprawdza czy znajduje się tam poszukiwana zmienna (przechodzi po kolei przez tablicę [[Scopes]]). W tym przypadku counter zostaje znalezione od razu w Closure (createCounter) i zwrócona zostaje wartość 0.

Lexical scope

Na koniec chciałbym wspomnieć, że w języku Javascript mamy do czynienia z zasięgiem leksykalnym (lexical scope). Oznacza to, że interpreter określa zasięg funkcji w momencie tworzenia funkcji, a nie w momencie wywołania.

Jeśli tworzymy funkcję wewnątrz innej funkcji i dodatkowo odwołuje się ona do do zmiennej znajdującej się w zewnętrznej funkcji i dodatkowo zwracamy tę wewnętrzną funkcję to powstaje closure.

Powstanie closure jest niezbędne, ponieważ po zakończeniu działania zewnętrznej funkcji, jej execution context zostanie zniszczony, a lokalna pamięć w której znajdowała się zmienna wyczyszczona.

Gdyby to połączenie między zwróconą funkcją a zmienną nie zostało wcześniej zapisane (w closure dodanym do [[Scopes]]) to ta funkcja utraciłaby niezbędną dla niej informację. W rezultacie taka funkcja byłaby bezużyteczna.

Podsumowanie

Closure to dosyć trudny, ale niesamowicie ważny aspekt języka Javascript. Jeśli zrozumiesz czym jest i jak działa ten mechanizm, to zaczniesz zauważać, że wykorzystujesz go bez przerwy. Dodatkowo będziesz wiedział jak odpowiedzieć na pytanie, które może mieć duży wpływ na Twoją karierę.

Jeśli spodobał Ci się ten wpis, to być może spodoba Ci się również wpis w którym tłumaczę różnicę między __proto__ a prototype.

Pobierz darmowy ebook zawierający 20 pytań, które możesz usłyszeć na rozmowie kwalifikacyjnej