Closure w JavaScript, czyli jak funkcja może coś pamiętać

Funkcje w JS zwykle znikają ze swoją lokalną pamięcią. Closure łamie tę regułę - i to na niej opiera się module pattern, memoizacja, asynchroniczny JS i prywatny stan bez klas.
Intuicja, którą większość ludzi ma na początku nauki, jest taka: funkcja jest krótkotrwała. Wchodzi, tworzy swoją lokalną pamięć, wykonuje kod, zwraca wynik i znika razem ze wszystkim, co miała w środku. Przy następnym wywołaniu zaczyna od zera.
Ta intuicja wystarcza do większości codziennego kodu. Przestaje wystarczać, kiedy zobaczysz coś takiego:
function gradient() {
let step = 0;
return () => `hsl(${step++ * 30}, 72%, 56%)`;
}
const next = gradient();Kliknij - każde wywołanie zwraca kolejny kolor w sekwencji. gradient() zakończył się zaraz po utworzeniu next, a mimo to step gdzieś żyje i z każdym wywołaniem rośnie:
jeszcze nie wywołanejedna liczba żyjąca w zapamiętanym otoczeniu.
Zwróć uwagę na podział w komponencie. Na górze widzisz dwie informacje związane z jednym wywołaniem: kolor, który funkcja właśnie zwróciła, oraz step = N - liczbę, którą next trzyma między wywołaniami. To jest stan po stronie funkcji. Na dole masz pasek z poprzednimi kolorami - to log prowadzony przez interfejs, nie przez funkcję. Gdybyś zapytał next: „jakie kolory zwróciłeś wcześniej?” - nie potrafiłby odpowiedzieć, bo tego nie wie. Wie tylko, ile razy już był wywołany.
Pierwsze założenie ze wstępu - „lokalna pamięć znika razem z funkcją” - okazuje się więc niepełne: step żyje właśnie tam, w miejscu, które powinno było zostać sprzątnięte.
#Funkcja nie zwraca samego kodu
Kiedy gradient() zwraca wewnętrzną funkcję, zwraca też referencję do otoczenia (scope’u), w którym ta funkcja została zdefiniowana. step nie jest kopiowany - zwracana funkcja zachowuje do niego żywą referencję. Przy jej wywołaniu JS szuka zmiennych najpierw w lokalnej pamięci, potem w tym doczepionym otoczeniu, a dopiero na końcu w globalu.
Formalnie każda funkcja ma ukrytą właściwość, która wskazuje na scope, w którym została stworzona - w starszej specyfikacji nazywaną [[Scope]], w aktualnej [[Environment]]. Ta referencja jest ustawiana raz, w momencie tworzenia funkcji, i zostaje w niej do końca jej życia - trzymając przy życiu również te zmienne z otoczenia, do których funkcja się odwołuje.
Na co dokładnie wskazuje? Na strukturę, która w specyfikacji nazywa się Environment Record. To jest miejsce w pamięci silnika, które trzyma listę zmiennych i ich wartości. Każdy Environment Record ma pole [[OuterEnv]] wskazujące na rodzica - i to właśnie jest ten łańcuch, po którym wędruje wyszukiwanie zmiennej, aż znajdzie ją lub dojdzie do globalnego środowiska. Ta sama struktura pojawia się też w poście o execution context - tam pokazuję, jak ona buduje się dla każdego wywołania funkcji w call stacku.
Wyobraź to sobie tak: plecak doczepiony do funkcji. Funkcja wychodzi ze swojego rodzica z plecakiem, w którym ma wszystko, do czego odwołuje się jej kod. Plecak zostaje z nią tak długo, jak długo sama funkcja żyje.
#Gdzie zdefiniowano, nie gdzie wywołano
Nieoczywisty wniosek z tego obrazu: plecak pakowany jest już w momencie, w którym piszesz kod - nie w runtime. Gdzie zdefiniujesz funkcję, takie dane dostaje. Miejsce, w którym ją wywołasz, nie ma tu nic do gadania.
To jest leksykalny (czyli statyczny) scoping. Patrzysz w kod, widzisz gdzie siedzi function, i już wiesz, do czego ma dostęp. Gdyby JS działał odwrotnie - z dynamic scope - closure w tej formie by nie istniało, bo funkcja nie miałaby z czego wiedzieć, co zapakować.
#Każde wywołanie ma własny plecak
Kolejna konsekwencja tego samego mechanizmu - każde wywołanie gradient() tworzy nową, osobną instancję step i nową funkcję z własnym zapamiętanym otoczeniem.
Poniżej widać to z dwóch stron. W zakładce Two calls to gradient() mamy dwa niezależne wywołania - każde z własnym otoczeniem, które zostało zwrócone, ale dalej żyje (stąd przerywana ramka). Klikając przycisk, zobacz jak działa wyszukiwanie zmiennej: funkcja szuka step u siebie w lokalnej pamięci, nie znajduje, i idzie w górę po łańcuchu otoczeń - aż trafi na to zapamiętane w plecaku. W zakładce Aliasing ta sama funkcja przypisana do innej zmiennej - oba imiona odwołują się do jednego otoczenia.
Każde wywołanie gradient() tworzy własne zapamiętane otoczenie - z własną wartością step.
step++; return step;step++; return step;Kluczowy wniosek: miejsce, w którym step żyje, jest zaszyte w definicji funkcji przez ukrytą właściwość [[Environment]]. Nie można stworzyć nowego plecaka, nadając funkcji nowe imię - plecak powstaje tylko razem z nowym wywołaniem gradient().
W specyfikacji ten krok nazywa się NewFunctionEnvironment. Gdy wywołujesz funkcję, silnik tworzy nowy Environment Record i łączy jego [[OuterEnv]] z tym, co siedzi w [[Environment]] tej funkcji. Dopiero w tym nowym środowisku wykonuje się jej ciało. Każde wywołanie gradient() przechodzi przez ten sam krok od nowa - i dlatego dostajesz osobny Environment Record, osobny step, osobną „instancję” closure’a.
#W plecaku siedzi referencja, nie kopia
Warto jeszcze wyraźnie powiedzieć, co dokładnie siedzi w plecaku. Nie jest to kopia wartości zrobiona w momencie tworzenia funkcji - to żywa referencja do zmiennej. Jeśli wartość tej zmiennej się zmieni, funkcja przy wywołaniu zobaczy nową.
Najsłynniejsza konsekwencja tego zachowania to klasyczny for-loop z var. Trzy funkcje, trzy plecaki - ale w każdym z nich ta sama zmienna i, bo var ma scope funkcyjny i wszystkie iteracje dzielą jedno miejsce w pamięci. let ma scope blokowy, więc każda iteracja dostaje swoje własne i, a każdy callback wiąże się z osobnym. (Dlaczego var i let mają różne scope - wyjaśniam w poście o hoistingu, tam też pokazuję, jak silnik rejestruje deklaracje na etapie parsowania, na długo przed tym, zanim closure w ogóle dostanie swój plecak.)
Przeklikaj poniższy komponent. Pętla powstaje krok po kroku, każda iteracja zostawia po sobie callback. Na końcu wszystkie trzy odpalają się jednocześnie - i każdy odczytuje wartość i w tym momencie, a nie tę, którą i miał, gdy callback powstał. Z var to ta sama, jedna zmienna i współdzielona przez wszystkie callbacki - czytają więc to samo. Z let każda iteracja dostaje własne i, więc każdy callback widzi swoje.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}W specyfikacji ta reguła ma swoją nazwę: CreatePerIterationEnvironment. Co się tam dzieje, w skrócie: dla pętli for/for-in/for-of z let albo const silnik przed każdą iteracją tworzy zupełnie nowe Environment Record (nową lokalną pamięć tej iteracji) i przepisuje do niego aktualną wartość zmiennej iteracyjnej - tej, którą zadeklarowałeś w for (let i = 0; ...). Wszystkie callbacki utworzone wewnątrz tej iteracji „zamykają się” nad tym konkretnym Environment Recordem, więc każdy ma własne, niezależne i. Dla var ten krok się nie wykonuje: cała pętla ma jedno wspólne Environment Record i wszystkie callbacki w nim się przepychają o tę samą zmienną.
Przed let jedynym sposobem na ten efekt było ręczne opakowanie ciała pętli w IIFE, żeby wymusić nowy scope na iterację.
#Jak to się jeszcze nazywa
Mając już model plecaka pod ręką, łatwiej połapać się w nazwach, które krążą wokół tego samego zjawiska. Warto rozróżnić, które są z formalnej dokumentacji, a które z materiałów dydaktycznych - bo mają różną wagę, a trafisz na oba rodzaje.
W specyfikacji i dokumentacji:
- Environment Record - formalna nazwa z ECMAScript. To jest ten „plecak” w języku specyfikacji.
- Closure - najogólniejsze, pojęciowe określenie zjawiska. Występuje w wielu językach (Lua, Lisp, Smalltalk, Haskell) w różnych wariantach. „Plecak doczepiony do funkcji” to po prostu obrazowy sposób, w jaki o closure mówi się w JS - i konkretny efekt tego, że JS ma leksykalny scope.
W materiałach dydaktycznych (głównie kursy Willa Sentance’a):
- C.O.V.E. - Closed Over Variable Environment. Akronim spopularyzowany przez „JavaScript: The Hard Parts” na Frontend Masters - w ECMAScript ani MDN go nie znajdziesz. To ta sama rzecz co Environment Record, z inną etykietą.
- P.L.S.R.D. - Persistent Lexical Static-scoped Referenced Data: Persistent (dane trwają), L/S (scoping leksykalny i statyczny), Reference (podpięte przez
[[Environment]]), Data (sama zawartość). Też od Sentance’a, poza jego ekosystemem niespotykany. - Variable environment - potoczne określenie lokalnej pamięci wywołania.
Wszystko to ten sam mechanizm pod różnymi etykietami - warto znać obie strony. W oficjalnej dokumentacji TC39, w issue-ach na GitHubie i w postach o profilowaniu pamięci w V8 zobaczysz Environment Record. W postach na blogach, transkryptach z konferencji i w materiałach z bootcampów - „plecak” albo COVE. Czytasz to samo, tylko w innym języku - i jeśli rozpoznasz, że to ta sama rzecz, oszczędzisz sobie wrażenia, że co autor, to nowa koncepcja.
#Co to w praktyce daje
Closure nie jest ciekawostką języka - to mechanizm, z którego wypada zaskakująco duża część kodu pisanego na co dzień:
- Prywatny stan bez klas. Licznik, cache, feature flag - cokolwiek chcesz ukryć przed światem. Bez closure musiałbyś trzymać tę zmienną na zewnątrz funkcji (gdzie byle kto może ją nadpisać) albo opakować ją w klasę z
this- closure pozwala trzymać dane „w środku” bez tego całego ceremoniału. - Memoizacja. Pierwsze wywołanie liczy, wynik ląduje w plecaku, kolejne wywołania tylko go odczytują. Cache należy bezpośrednio do funkcji - nie potrzebujesz globalnego obiektu z mapą wyników, którym musiałby się ktoś opiekować.
- Module pattern. Stan aplikacji w jednym miejscu, bez zasypywania globala. Wystawiasz na zewnątrz tylko publiczne API (
export), reszta zmiennych zostaje schowana w closure modułu - nie ma jak ich nadpisać ani odczytać z innego pliku. - Async JS. Każdy callback i każda kontynuacja po
awaitto w praktyce funkcja, która pamięta, gdzie była w momencie uśpienia. Bez closure każdysetTimeout,fetch().then()czyawaitmusiałby ręcznie przekazywać sobie kontekst - z closure dzieje się to automatycznie.
Kiedy zobaczysz te cztery rzeczy jako różne zastosowania tego samego mechanizmu, większość asynchronicznego i „magicznego” JS przestaje wyglądać magicznie.
#Cena, którą się płaci
Closure (czyli ten metaforyczny plecak doczepiony do funkcji) trzyma referencję, więc trzyma też dane przy życiu. Dopóki żyje funkcja, żyje jej zapamiętane otoczenie razem ze wszystkim, co się w nim znalazło.
Nowsze silniki (V8) są sprytne i zachowują w closure tylko te zmienne, do których funkcja się faktycznie odwołuje - to już nie jest „zamknij wszystko na wszelki wypadek”. Historycznie było gorzej: starsze silniki (w tym V8 mniej więcej do 2013 roku) zamykały całe outer scope, niezależnie od tego, czy funkcja korzystała ze wszystkich zmiennych. Klasyczny przykład to memory leak odkryty w Meteorze, gdzie dwie zagnieżdżone funkcje dzieliły jedno środowisko leksykalne i jedna z nich trzymała przy życiu ogromny obiekt, którego sama nie używała.
Dzisiejsze silniki ograniczają to do niezbędnego minimum, ale fundament zostaje ten sam: jeśli zamkniesz w closure dużą strukturę danych, do której funkcja się odwołuje, i gdzieś trzymasz tę funkcję na stałe - te dane zostaną w pamięci niezależnie od tego, czy ich używasz. Closure to nie darmowy lunch.
Mechanizm reachability i to, dlaczego closure-y są jednym z czterech klasycznych źródeł memory leaków w JS, opisałem osobno w poście o garbage collection - tam też jest interaktywny wzorzec „listener trzymający DOM” w komponencie mark-and-sweep.
#Puenta
Wszystko sprowadza się do jednego zdania: funkcja ma dostęp do tego, co widziała w momencie, gdy powstała - i nosi to ze sobą, dopóki sama żyje.
Reszta - prywatny stan, memoizacja, moduły, async - to różne zastosowania tej samej właściwości. Gdy ten mechanizm raz zaskoczy w głowie, kod, który na nim bazuje, przestaje wyglądać jak zlepek oddzielnych trików, a zaczyna wyglądać jak jeden spójny wzorzec w różnych przebraniach.
Komentarze
Wczytywanie komentarzy…