Kurs JavaScript i TypeScript · Moduł 12: Programowanie funkcyjne
Domknięcia w programowaniu funkcyjnym
W tej lekcji4
Licznik incydentów trzymany w zmiennej globalnej może zmienić każdy fragment kodu: skrypt raportu, stary moduł ochrony, literówka stażysty. Kiedy licznik pokaże bzdurę, nie wiadomo, kogo pytać. Potrzebujemy zmiennej, do której dostęp mają tylko wybrane funkcje.
W Parku Jurajskim każdy wybieg ma swój własny system zabezpieczeń - napięcie ogrodzenia, poziom alarmowy, licznik incydentów. Te dane są prywatne i niedostępne z zewnątrz, ale funkcje zarządzające wybiegiem mają do nich pełny dostęp. Dokładnie tak działają domknięcia (closures) - tworzą prywatne środowisko dla danych, dostępne tylko przez określone funkcje.
Czym jest domknięcie?
Domknięcie (closure) to funkcja, która "pamięta" zmienne z zakresu (scope), w którym została stworzona, nawet po tym, jak ten zakres przestał istnieć.
W JavaScript każda funkcja w chwili utworzenia zapamiętuje swoje otoczenie leksykalne, czyli zmienne widoczne w miejscu, w którym zapisano ją w kodzie. Najprostszy przykład to licznik:
1function createCounter() {
2 let count = 0; // Zmienna prywatna - niedostępna z zewnątrz
3
4 return {
5 increment() { count++; return count; },
6 decrement() { count--; return count; },
7 getCount() { return count; },
8 };
9}
10
11const counter = createCounter();
12counter.increment(); // 1
13counter.increment(); // 2
14counter.getCount(); // 2
15// count jest całkowicie prywatna - nie ma do niej bezpośredniego dostępuWywołanie createCounter dawno się zakończyło, a count wciąż żyje, bo trzy metody trzymają do niej odniesienie. counter.count zwróci undefined, ponieważ zmienna nie jest właściwością obiektu, tylko częścią domknięcia. Domknięcie pamięta samą zmienną, a nie kopię jej wartości, dlatego drugie increment widzi wynik pierwszego. Metody licznika nie są czyste, bo zmieniają count, ale ta zmiana jest zamknięta w jednym miejscu.
Prywatność danych przez domknięcia
Domknięcia pozwalają tworzyć prawdziwie prywatne zmienne w JavaScript - to kluczowy wzorzec w programowaniu funkcyjnym. Zbudujmy system bezpieczeństwa wybiegu w dwóch krokach. Najpierw wnętrze sejfu, czyli prywatny stan i pomocnicza funkcja:
1function createSecuritySystem(zoneName) {
2 let fenceVoltage = 10000;
3 let alertLevel = 0;
4 let incidentLog = [];
5
6 // Prywatna funkcja pomocnicza
7 function logEvent(event) {
8 incidentLog.push({ event, time: Date.now(), zone: zoneName });
9 }
10
11 // ...ciąg dalszy w następnym blokuTrzy zmienne let i funkcja logEvent istnieją tylko wewnątrz jednego wywołania fabryki. Nie zwracamy ich, więc z zewnątrz nie da się ich ani odczytać, ani zmienić.
Na zewnątrz wychodzi wyłącznie obiekt z trzema metodami. To jedyne drzwi do sejfu:
1 return {
2 raiseFence() {
3 fenceVoltage += 2000;
4 logEvent('Fence voltage raised to ' + fenceVoltage);
5 return fenceVoltage;
6 },
7 triggerAlert() {
8 alertLevel++;
9 logEvent('Alert level: ' + alertLevel);
10 return alertLevel;
11 },
12 getStatus() {
13 return {
14 zone: zoneName,
15 voltage: fenceVoltage,
16 alert: alertLevel,
17 incidents: incidentLog.length,
18 };
19 },
20 };
21}Każda metoda widzi prywatne zmienne i logEvent, ale sama decyduje, co udostępnić: getStatus podaje liczbę incydentów, a nie samą tablicę dziennika. Sprawdźmy, czy dwa wybiegi naprawdę mają osobne sejfy:
1const zoneA = createSecuritySystem('Zone A');
2const zoneB = createSecuritySystem('Zone B');
3
4zoneA.raiseFence(); // 12000
5zoneA.triggerAlert(); // 1
6zoneB.getStatus(); // { zone: 'Zone B', voltage: 10000, alert: 0, incidents: 0 }
7// Każda strefa ma własne, izolowane danezoneA podniosła napięcie i ogłosiła alarm, a zoneB nic o tym nie wie, bo każde wywołanie fabryki tworzy nowe, niezależne domknięcie. Uważaj na jedną furtkę: gdyby metoda zwracała samą tablicę incidentLog, kod z zewnątrz dostałby odniesienie do oryginału i mógłby dopisywać do niej przez push. Zwracaj kopię, na przykład [...incidentLog]. Klasy mają pola prywatne ze znakiem #, znane Ci z modułu o klasach, a domknięcia dają ten sam efekt bez klas.
Fabryki funkcji (Function Factories)
Domknięcia są idealne do tworzenia fabryk - funkcji, które produkują wyspecjalizowane funkcje. Najmniejsza fabryka przyjmuje próg i zwraca predykat, który go pamięta:
1function createFilter(minValue) {
2 return (item) => item.value >= minValue;
3}
4
5const isStrongSignal = createFilter(70);
6isStrongSignal({ value: 85 }); // true
7isStrongSignal({ value: 12 }); // falseKażde wywołanie createFilter daje nowy filtr z własnym progiem, gotowy do przekazania metodzie filter. Fabryka może też zamknąć w sobie cały zestaw reguł. Object.entries zamienia obiekt reguł na tablicę par [pole, reguła], po której przechodzi pętla for...of:
1// Fabryka walidatorów
2function createValidator(rules) {
3 return function validate(data) {
4 const errors = [];
5 for (const [field, rule] of Object.entries(rules)) {
6 const value = data[field];
7 if (rule.required && (value === undefined || value === '')) {
8 errors.push(field + ' is required');
9 }
10 if (rule.min !== undefined && value < rule.min) {
11 errors.push(field + ' must be >= ' + rule.min);
12 }
13 if (rule.max !== undefined && value > rule.max) {
14 errors.push(field + ' must be <= ' + rule.max);
15 }
16 }
17 return { valid: errors.length === 0, errors };
18 };
19}
20
21const validateDinosaur = createValidator({
22 name: { required: true },
23 weight: { required: true, min: 1, max: 100000 },
24 health: { required: true, min: 0, max: 100 },
25});
26
27validateDinosaur({ name: 'Rex', weight: 8000, health: 95 });
28// { valid: true, errors: [] }
29
30validateDinosaur({ name: '', weight: -5, health: 150 });
31// { valid: false, errors: ['name is required', 'weight must be >= 1', 'health must be <= 100'] }Zwróć uwagę na porównania z undefined zamiast skrótu w stylu rule.min && .... Reguła min: 0 to wartość fałszywa (falsy), więc skrót po cichu by ją pominął, a warunek !value uznałby zdrowie równe 0 za brak danych. Wewnątrz validate działa push, ale tylko na lokalnej tablicy errors, więc z zewnątrz funkcja pozostaje czysta.
Memoizacja z domknięciami
Memoizacja to technika cachowania wyników kosztownych obliczeń. Domknięcia zapewniają prywatny cache. Kluczem w Map jest tekst z JSON.stringify(args), bo dwie różne tablice argumentów nigdy nie są sobie równe jako obiekty:
1function memoize(fn) {
2 const cache = new Map();
3
4 return function(...args) {
5 const key = JSON.stringify(args);
6 if (cache.has(key)) {
7 console.log('Cache hit for:', key);
8 return cache.get(key);
9 }
10 const result = fn(...args);
11 cache.set(key, result);
12 return result;
13 };
14}Sprawdźmy teraz memoizację na analizie próbki DNA, która symuluje kosztowne obliczenie pętlą ze stu tysiącami kroków:
1// Kosztowna analiza DNA
2function analyzeDNA(sampleId, depth) {
3 // Symulacja długiego obliczenia
4 let result = 0;
5 for (let i = 0; i < depth * 1000; i++) {
6 result += Math.sqrt(i);
7 }
8 return { sampleId, score: result / (depth * 1000) };
9}
10
11const memoizedAnalyze = memoize(analyzeDNA);
12memoizedAnalyze('TREX-001', 100); // oblicza
13memoizedAnalyze('TREX-001', 100); // z cache - natychmiastowo!Drugie wywołanie wypisuje Cache hit for: ["TREX-001",100] i oddaje zapamiętany wynik bez liczenia. Memoizuj wyłącznie czyste funkcje: gdyby analiza losowała wynik przez Math.random(), cache zamroziłby pierwsze losowanie i kolejne wywołania by kłamały. Dlatego symulacja korzysta tu z deterministycznego Math.sqrt.
W kodzie spotkasz też krótszą wersję na zwykłym obiekcie i operatorze ||= (logiczne przypisanie OR z ES2021):
1const memoize = (fn) => {
2 const cache = {};
3 return (...args) => cache[args] ||= fn(...args);
4};cache[args] zamienia tablicę argumentów na tekst, np. 'TREX-001,100', a ||= przypisuje wynik tylko wtedy, gdy pod kluczem jest wartość fałszywa. Wada: wynik równy 0, '' lub false będzie liczony za każdym razem od nowa. Dlatego polecam Ci wersję z Map - jest dłuższa, ale wolna od tej pułapki. Domknięcia napędzają też currying z poprzedniej lekcji, a w następnej poznasz rekurencję.
Pamiętaj: domknięcie to sejf wybiegu - funkcja nosi ze sobą klucz do zmiennych z miejsca, w którym powstała, a reszta parku widzi tylko drzwi, które sam zamontujesz.
Kod do tej lekcji: index.js
1// Domkniecia w programowaniu funkcyjnym
2console.log("=== Park Jurajski - Prywatne Systemy Bezpieczenstwa ===\n");
3
4// --- PRZYKLAD 1: Prywatnosc danych ---
5console.log("--- Prywatnosc danych ---");
6
7function createSecuritySystem(zoneName) {
8 let fenceVoltage = 10000;
9 let alertLevel = 0;
10 const incidentLog = [];
11
12 function logEvent(event) {
13 incidentLog.push({ event, zone: zoneName, time: Date.now() });
14 }
15
16 return {
17 raiseFence() {
18 fenceVoltage += 2000;
19 logEvent("Fence raised to " + fenceVoltage);
20 return fenceVoltage;
21 },
22 triggerAlert() {
23 alertLevel++;
24 logEvent("Alert: " + alertLevel);
25 return alertLevel;
26 },
27 getStatus() {
28 return {
29 zone: zoneName,
30 voltage: fenceVoltage,
31 alert: alertLevel,
32 incidents: incidentLog.length,
33 };
34 },
35 };
36}
37
38const zoneA = createSecuritySystem("Zone A");
39const zoneB = createSecuritySystem("Zone B");
40
41zoneA.raiseFence();
42zoneA.raiseFence();
43zoneA.triggerAlert();
44
45console.log("Zone A:", zoneA.getStatus());
46console.log("Zone B:", zoneB.getStatus());
47console.log("Kazda strefa ma izolowane dane!\n");
48
49// --- PRZYKLAD 2: Fabryka walidatorow ---
50console.log("--- Fabryka walidatorow ---");
51
52function createValidator(rules) {
53 return function validate(data) {
54 const errors = [];
55 for (const [field, rule] of Object.entries(rules)) {
56 if (rule.required && !data[field]) {
57 errors.push(field + " is required");
58 }
59 if (rule.min !== undefined && data[field] < rule.min) {
60 errors.push(field + " must be >= " + rule.min);
61 }
62 if (rule.max !== undefined && data[field] > rule.max) {
63 errors.push(field + " must be <= " + rule.max);
64 }
65 }
66 return { valid: errors.length === 0, errors };
67 };
68}
69
70const validateDino = createValidator({
71 name: { required: true },
72 weight: { required: true, min: 1, max: 100000 },
73 health: { required: true, min: 0, max: 100 },
74});
75
76console.log("Valid:", validateDino({ name: "Rex", weight: 8000, health: 95 }));
77console.log("Invalid:", validateDino({ name: "", weight: -5, health: 150 }));
78
79// --- PRZYKLAD 3: Memoizacja ---
80console.log("\n--- Memoizacja ---");
81
82function memoize(fn) {
83 const cache = new Map();
84 return function(...args) {
85 const key = JSON.stringify(args);
86 if (cache.has(key)) {
87 console.log(" Cache HIT:", key);
88 return cache.get(key);
89 }
90 console.log(" Computing:", key);
91 const result = fn(...args);
92 cache.set(key, result);
93 return result;
94 };
95}
96
97function expensiveAnalysis(species, depth) {
98 let sum = 0;
99 for (let i = 0; i < depth * 100; i++) sum += i;
100 return { species, score: sum };
101}
102
103const memoAnalysis = memoize(expensiveAnalysis);
104memoAnalysis("T-Rex", 10); // Computing
105memoAnalysis("T-Rex", 10); // Cache HIT
106memoAnalysis("Raptor", 5); // Computing
107memoAnalysis("Raptor", 5); // Cache HIT
108memoAnalysis("T-Rex", 10); // Cache HITWidzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Czym jest domknięcie (closure) w JavaScript?
2. Czym jest memoizacja w kontekście programowania funkcyjnego?
To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Zbuduj fabrykę trackerów incydentów z prywatnymi danymi i funkcję memoize.
- Układanie w poziomie
Ułóż elementy fabryki funkcji z domknięciem:
- Edytor kodu
Przepisz imperatywne pętle for na filter, map i reduce z użyciem pipe.