Kurs JavaScript i TypeScript · Moduł 12: Programowanie funkcyjne

Domknięcia w programowaniu funkcyjnym

6 min czytania
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ępu

Wywoł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 bloku

Trzy 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 dane

zoneA 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 }); // false

Każ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 HIT

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Czym jest domknięcie (closure) w JavaScript?

  2. 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.

Przydatne artykuły