Jeżeli programujesz, lub dopiero uczysz się programować w JavaScript, to istnieje bardzo duża szansa, że zetknąłeś się już z tym terminem. Domknięcie (ang. closure) to nic innego, jak zapamiętanie przez funkcję jej zakresu leksykalnego (ang. lexical scope).
Wielu początkujących programistów omija ten temat, a jeżeli już zdecydują się go w ogóle dotknąć, to traktują go bardzo pobieżnie. Wynika to często z tego, że temat ten nie jest prosty do zrozumienia, a spora część kursów i różnego rodzaju poradników nie wyjaśnia go wystarczająco, lub robi to w nieprzystępny sposób, czym tylko zniechęca do głębszego zrozumienia zagadnienia.
Właśnie dlatego zdecydowałem się zastosować nieco inne podejście - zamiast zasypywać Cię definicjami i technicznymi opisami, których sam nie rozumiałem, gdy dopiero poznawałem JavaScript, postaram się użyć porównań do otaczającego nas świata. Oczywiście pojawi się też trochę technicznej wiedzy i terminologii, ale postaram się ograniczyć do niezbędnego minimum i dać Ci czas na przyswojenie nowej wiedzy.
Teoria i przykład
Na początku artykułu wspomniałem, że domknięcie to mechanizm zapamiętywania przez funkcję jej środowiska leksykalnego. Zatrzymajmy się w tym momencie - obiecałem, że będą porównania, dlatego zamiast próbować zrozumieć poprzednie zdanie (na to przyjdzie czas później), spójrz na obrazek poniżej.
Wyobraź sobie, że jesteś na imprezie, a oprócz Ciebie, w pomieszczeniu jest jeszcze kilka innych osób.
Jeżeli w tym momencie zadajesz sobie w głowie pytanie, jak to wszystko ma się do kodu, to spieszę z wyjaśnieniem 🙂.
Ty - w naszym przykładzie reprezentujesz funkcję w JavaScript.
Inni uczestnicy imprezy - to po prostu zmienne, które są utworzone w tym samym bloku (np. w pętli, lub w funkcji), co Ty.
Pokój - w naszym przykładzie reprezentuje zakres funkcji. Widzimy tylko ludzi (zmienne), którzy są w tym samym zakresie (pokoju), co my (w celu uproszczenia tego przykładu zignorujmy fakt, że funkcja widzi również zewnętrzne zakresy).
Odtwórzmy teraz tę sytuację za pomocą kodu:
Zewnętrzna funkcja room
to pokój, w którym znajdują się wszyscy uczestnicy imprezy. Zmienne person1
, person2
, oraz person3
są odzwierciedleniem ludzi obecnych w pokoju. Ostatni element to funkcja (utworzona wewnątrz funkcji room
) o nazwie you
- jak zapewne się domyślasz, jest ona reprezentacją Twojej osoby 😄.
Rozbudujmy nieco ten przykład - przypuśćmy, że postanowiłeś opuścić imprezę i udać się do domu. Zanim jednak to zrobisz, żegnasz się ze wszystkimi. Zwróć uwagę, że na obrazku powyżej każdy z uczestników posiada telefon i przekazuje Ci jego numer. Możesz z niego skorzystać w każdej chwili, nawet po wyjściu z pokoju i w ten sposób uzyskać kontakt z daną osobą.
Jeżeli przełożymy to na JavaScript, to można to interpretować w ten sposób: funkcja wywołana poza swoim zakresem leksykalnym (pokojem) nadal posiada możliwość odwołania się do wszystkich zmiennych (uczestników imprezy) zadeklarowanych w miejscu utworzenia funkcji. Numery telefonów, to po prostu referencje do tych zmiennych.
Pozostaje więc pytanie, w jaki sposób funkcja może zostać wywołana poza swoim zakresem leksykalnym? Odpowiedź na nie jest prosta - wystarczy, że funkcja zewnętrzna (w naszym przypadku jest to room
), posiada wewnątrz deklarację kolejnej funkcji (you
), a następnie zwraca ją, udostępniając ją tym samym "na zewnątrz".
Gdyby porównać to do przykładu z imprezą, to wyjście z niej (użycie słowa return
) nie oznacza, że tracimy możliwość zadzwonienia do innego uczestnika - przecież zostawił nam swój numer telefonu 😎
Ostatni krok to wywołanie zwróconej funkcji. Przekonajmy się, jaki będzie tego wynik:
Mimo, że w momencie wywołania funkcji callToFriends
(kryje się pod nią funkcja you
, zwrócona z room
) zmienne person1
, person2
oraz person3
nie znajdują się w obecnym zakresie, ani też nie zostały zadeklarowane bezpośrednio w you
, to funkcja ta nadal ma do nich dostęp!
Podsumujmy czego dowiedziałeś/aś się do tej pory i przy okazji doprecyzujmy kilka pojęć:
zakres leksykalny funkcji to inaczej miejsce, w którym została utworzona. Czasami nazywany jest zakresem statycznym (ang. static scope), ponieważ nie może ulec zmianie w trakcie działania programu (poza dwoma wyjątkami, jednak ten temat wykracza znacznie poza zakres artykułu).
każda funkcja zapamiętuje swój zakres leksykalny, a co za tym idzie - ma dostęp do wszystkich zmiennych utworzonym w tym zakresie, nawet gdy zostanie wywołana poza nim
domknięcie tworzone jest zawsze, gdy tworzymy funkcję. To, że z niego nie korzystamy, nie oznacza, że go nie ma
jeżeli chcemy skorzystać z domknięcia, to należy wywołać funkcję poza zakresem w którym została utworzona
Domknięcie w praktyce
Czas wykorzystać zdobytą wiedzę w praktyce. Stworzymy dwa zupełnie różne przykłady, dzięki czemu będziesz mieć okazję by szerzej poznać możliwości wynikające z tego mechanizmu.
W pierwszym z nich wykorzystamy natychmiast wywoływane wyrażenie funkcyjne (ang. immediately invoked function expression lub IIFE) do utworzenia prywatnej zmiennej i zwrócenia kilku metod do jej obsługi.
Wyrażenie funkcyjne przypisane do zmiennej jest natychmiast wywoływane, a jego wynik (obiekt z trzema metodami) od razu ulega destrukturyzacji. Jeżeli wcześniej nie spotkałeś się z tym zapisem, to napisałem na ten temat osobny artykuł.
Nie daj się zwieść tym, że zwracamy obiekt, a nie funkcję. Metody w nim zawarte również utworzą domknięcie. W efekcie uzyskaliśmy prywatną zmienną counter
, do której nie ma bezpośredniego dostępu, a jedynym sposobem na modyfikację lub odczytanie jest wykorzystanie jeden z trzech metod.
W ten sposób możemy ograniczyć operacje, które można wykonać na naszej zmiennej i udostępnić na zewnątrz tylko to, co powinno być publiczne.
W drugim przykładzie skorzystamy z łańcucha zakresów (ang. scope chain) w połączeniu z domknięciem. Zanim jednak pokażę Ci kod, wyjaśnijmy czym jest wspomniany łańcuch zakresów.
W momencie tworzenia funkcji, zapisane zostają nie tylko wszystkie zmienne znajdujące się w jej zakresie, ale także referencja do zakresu leksykalnego rodzica. Jeżeli rodzicem jest funkcja, to ona również tworzy zakres leksykalny, a więc posiada referencję do zakresu swojego rodzica. W efekcie powstaje łańcuch połączonych za sobą zakresów.
A więc gdy tylko mamy do czynienia z zagnieżdżonymi funkcjami, to każda z nich posiada referencję do zakresu leksykalnego rodzica, więc ma dostęp do zmiennych utworzonych wewnątrz jego zakresu. Ten łańcuch ciągnie się, aż dojdziemy do zakresu globalnego.
Zobaczmy jak ten przykład będzie się zachowywać po przeniesieniu do kodu JavaScript:
Zauważ, że nadal każda z funkcji zwraca kolejną funkcję (i nadal mamy do czynienia z wywołaniem poza zakresem leksykalnym), dlatego możemy zastosować zapis global(1)(2)(3)
. Mimo, że na pierwszy rzut oka wygląda dziwnie, to jest całkowicie poprawny.
Z takim kodem najczęściej zetkniesz się w przypadku programowania funkcyjnego, jednak już teraz dobrze jest umieć rozpoznać w nim domknięcie. Zdarza się, że niektóre specyficzne dla programowanie funkcyjnego elementy przedostają się również do innych paradygmatów, a zetknięcie z nimi, gdy nie rozumiemy domknięć, będzie bardzo bolesne.
Podsumowanie
Domknięcie to kluczowy mechanizm w JavaScript z którym zetkniesz się niemal codziennie, a wykorzystany nieświadomie, może spowodować wiele niezrozumiałych i trudnych do znalezienia błędów.
Jeżeli na tym etapie masz jeszcze wątpliwości, lub coś wydaje Ci się nie do końca jasne, to zachęcam do ponownego przeczytania artykułu, lub zadania pytania w sekcji komentarzy. Koniecznie wypróbuj również nową wiedzę w praktyce, bo tylko w ten sposób możesz ją w pełni przyswoić.