Kurs JavaScript i TypeScript · Moduł 12: Programowanie funkcyjne

Wzorce niemutowalności danych

6 min czytania
W tej lekcji7

Mapa parku i moduł raportów dostają ten sam obiekt stanu. Mapa przesuwa Rexa do strefy B, zmieniając pole bezpośrednio, i nagle raport z porannej zmiany też pokazuje strefę B, choć rano Rex był w A. Współdzielony obiekt zmieniony w jednym miejscu zmienia się wszędzie, gdzie ktoś trzyma do niego odniesienie.

W laboratoriach Parku Jurajskiego obowiązuje surowa zasada - nigdy nie modyfikujesz oryginalnej próbki DNA. Każda zmiana tworzy nową kopię. To samo podejście stosujemy w programowaniu funkcyjnym z danymi - oryginalne struktury pozostają nienaruszone, a każda modyfikacja tworzy nowy obiekt.

Spread operator - kopiowanie i rozszerzanie

Operator spread (...) to podstawowe narzędzie do tworzenia niemutowalnych kopii obiektów i tablic. Zacznijmy od przeniesienia dinozaura do innej strefy:

1// Kopiowanie obiektu z modyfikacja
2const dinosaur = { name: 'Rex', health: 100, zone: 'A' };
3const movedDino = { ...dinosaur, zone: 'B' };
4// dinosaur: { name: 'Rex', health: 100, zone: 'A' } - niezmieniony
5// movedDino: { name: 'Rex', health: 100, zone: 'B' } - nowy obiekt

Pola zapisane po spreadzie nadpisują skopiowane, więc zone: 'B' wygrywa z zone: 'A'. movedDino i dinosaur to dwa różne obiekty, a raport trzymający dinosaur wciąż widzi strefę A.

Z tablicami postępujemy tak samo: element dodajemy przez spread, a usuwamy przez filter:

1// Kopiowanie tablicy z dodaniem elementu
2const zones = ['A', 'B', 'C'];
3const extendedZones = [...zones, 'D'];
4// zones: ['A', 'B', 'C'] - niezmieniona
5// extendedZones: ['A', 'B', 'C', 'D']
6
7// Usuwanie elementu z tablicy (bez mutacji)
8const dinosaurs = ['Rex', 'Blue', 'Brachio', 'Stego'];
9const withoutBlue = dinosaurs.filter((d) => d !== 'Blue');
10// dinosaurs: ['Rex', 'Blue', 'Brachio', 'Stego'] - niezmieniona
11// withoutBlue: ['Rex', 'Brachio', 'Stego']

Żadna z tych operacji nie użyła push ani splice, które zmieniają tablicę w miejscu.

Zagnieżdżone obiekty - głęboka niemutowalność

Spread operator tworzy płytką kopię - zagnieżdżone obiekty nadal są współdzielone! Musisz spreadować na każdym poziomie. Oto stan parku ze strefami w środku i próba kopii jednym spreadem:

1const park = {
2  name: 'Jurassic Park',
3  zones: {
4    A: { dinosaurs: ['Rex'], voltage: 10000 },
5    B: { dinosaurs: ['Brachio'], voltage: 5000 },
6  },
7};
8
9// ŹLE - płytka kopia, zones jest współdzielone
10const badCopy = { ...park };
11badCopy.zones.A.voltage = 0; // ZMIENIA też oryginał!

badCopy to nowy obiekt, ale jego pole zones wskazuje na ten sam obiekt co park.zones. Zmiana napięcia przez kopię jest więc zmianą oryginału, dokładnie jak w historii z mapą i raportem.

Poprawna aktualizacja kopiuje każdy poziom na ścieżce do zmienianego pola. Zakładamy świeży obiekt park, bo linijka z badCopy zdążyła już wyzerować oryginał:

1// DOBRZE - głęboka kopia na każdym poziomie
2const updatedPark = {
3  ...park,
4  zones: {
5    ...park.zones,
6    A: {
7      ...park.zones.A,
8      voltage: 15000,
9    },
10  },
11};
12// park.zones.A.voltage: 10000 (niezmieniony)
13// updatedPark.zones.A.voltage: 15000 (nowy obiekt)

Nowe są tylko obiekty na ścieżce: updatedPark, jego zones i strefa A. Strefa B nie została skopiowana, więc updatedPark.zones.B === park.zones.B. To w porządku, bo jej nie zmienialiśmy, a współdzielenie niezmienionych części oszczędza pamięć.

Object.freeze() - zamrażanie obiektów

Object.freeze() zapobiega modyfikacji obiektu w runtime. Próba zmiany jest po cichu ignorowana, a w trybie ścisłym (strict mode) rzuca TypeError. W trybie ścisłym działa każdy moduł ES i każda klasa:

1const CONFIG = Object.freeze({
2  maxDinosaurs: 100,
3  maxVoltage: 50000,
4  alertThreshold: 3,
5});
6
7CONFIG.maxDinosaurs = 200; // Ignorowane! (lub TypeError w strict mode)
8console.log(CONFIG.maxDinosaurs); // nadal 100

Object.freeze zwraca ten sam obiekt, tylko zamrożony: nie da się zmienić, dodać ani usunąć jego pól. Jest jednak haczyk, widoczny przy obiekcie z zagnieżdżoną strukturą:

1// UWAGA: freeze jest płytki!
2const deepConfig = Object.freeze({
3  zones: { A: { limit: 10 } },
4});
5deepConfig.zones.A.limit = 999; // TO ZADZIAŁA - freeze jest płytki

Zamrożony jest tylko pierwszy poziom. Obiekt zones pozostał zwykłym obiektem, więc żeby zamrozić całość, trzeba rekurencyjnie wywołać Object.freeze na każdym zagnieżdżonym obiekcie.

const to nie freeze

Częste nieporozumienie: skoro CONFIG jest zadeklarowane przez const, po co jeszcze freeze? Bo te dwa mechanizmy chronią zupełnie co innego:

1const zone = { id: 'A', voltage: 10000 };
2zone.voltage = 0;           // działa - const nie chroni zawartości
3// zone = { id: 'B' };      // TypeError - const chroni przypisanie
4
5const lockedZone = Object.freeze({ id: 'A', voltage: 10000 });
6lockedZone.voltage = 0;     // ignorowane (TypeError w strict mode)

const blokuje ponowne przypisanie zmiennej, czyli referencję. Object.freeze blokuje zmianę wartości wewnątrz obiektu. Stałą konfigurację chroń obydwoma naraz, tak jak CONFIG.

structuredClone() - głębokie kopiowanie

structuredClone() tworzy głęboką kopię obiektu - wszystkie zagnieżdżone struktury są niezależne. To wygodne, gdy zmieniasz wiele rzeczy głęboko w strukturze:

1const original = {
2  name: 'Jurassic Park',
3  zones: [
4    { id: 'A', dinosaurs: [{ name: 'Rex', health: 100 }] },
5    { id: 'B', dinosaurs: [{ name: 'Blue', health: 95 }] },
6  ],
7};
8
9const deepCopy = structuredClone(original);
10deepCopy.zones[0].dinosaurs[0].health = 50;
11
12console.log(original.zones[0].dinosaurs[0].health); // 100 (niezmieniony!)
13console.log(deepCopy.zones[0].dinosaurs[0].health);  // 50 (niezależna kopia)

Zmiana w kopii nie dotarła do oryginału. structuredClone działa w nowoczesnych przeglądarkach i w Node.js od wersji 17, ale nie skopiuje funkcji (rzuci DataCloneError), a instancje klas zamieni w zwykłe obiekty bez metod. Moja rada: na co dzień kopiuj spreadem tylko ścieżkę, którą zmieniasz, a structuredClone zostaw na chwilę, gdy potrzebujesz w pełni niezależnej kopii danych.

Niemutowalne aktualizacje tablic

Pełny zestaw operacji na tablicach bez mutacji, zebrany na jednej liście dinozaurów:

1const dinosaurs = [
2  { id: 1, name: 'Rex', health: 100 },
3  { id: 2, name: 'Blue', health: 95 },
4  { id: 3, name: 'Brachio', health: 88 },
5];
6
7// Dodawanie
8const added = [...dinosaurs, { id: 4, name: 'Stego', health: 100 }];
9
10// Usuwanie
11const removed = dinosaurs.filter((d) => d.id !== 2);
12
13// Aktualizacja
14const updated = dinosaurs.map((d) =>
15  d.id === 1 ? { ...d, health: 80 } : d
16);
17
18// Sortowanie (bez mutacji - tworzymy kopie przed sort)
19const sorted = [...dinosaurs].sort((a, b) => a.health - b.health);

Aktualizacja przez map z operatorem warunkowym to wzorzec, który zobaczysz wszędzie: zmieniony element dostaje nowy obiekt, pozostałe przechodzą bez zmian. Tablicę kopiujemy przed sort, bo sort zmienia ją w miejscu. Od ES2023 masz też gotowe odpowiedniki bez mutacji: toSorted, toReversed, toSpliced i with.

Od prostych technik do bibliotek

Uporządkujmy narzędzia od najprostszego: Object.freeze pilnuje jednego poziomu, spread robi płytką kopię ze zmianą, a structuredClone głęboką kopię. Przy dużych strukturach sięga się po biblioteki. Immutable.js dostarcza trwałe struktury danych (persistent data structures): każda "zmiana" zwraca nową wersję, która współdzieli niezmienione fragmenty z poprzednią. Immer pozwala pisać zwykłe przypisania na szkicu, a potem sam tworzy nową, niemutowalną wersję. Ten sam wzorzec czeka na Ciebie w świecie React, gdzie stan aktualizuje się nową kopią, a nie mutacją.

Pamiętaj: oryginalnej próbki DNA nie ruszasz nigdy - każda zmiana to nowa probówka, a spread, structuredClone i Object.freeze pilnują tej zasady na różnych poziomach.

Kod do tej lekcji: index.js
1// Wzorce niemutowalnosci danych
2console.log("=== Park Jurajski - Ochrona Oryginalnych Probek ===\n");
3
4// --- PRZYKLAD 1: Spread operator ---
5console.log("--- Spread: obiekty ---");
6const dino = { name: "Rex", health: 100, zone: "A" };
7const moved = { ...dino, zone: "B" };
8console.log("Oryginal:", dino);
9console.log("Przeniesiony:", moved);
10
11console.log("\n--- Spread: tablice ---");
12const zones = ["A", "B", "C"];
13const extended = [...zones, "D"];
14console.log("Oryginalne:", zones);
15console.log("Rozszerzone:", extended);
16
17// --- PRZYKLAD 2: Zagniezdzone obiekty ---
18console.log("\n--- Zagniezdzone obiekty ---");
19const park = {
20  name: "Jurassic Park",
21  zones: {
22    A: { dinosaurs: ["Rex"], voltage: 10000 },
23    B: { dinosaurs: ["Brachio"], voltage: 5000 },
24  },
25};
26
27const updatedPark = {
28  ...park,
29  zones: {
30    ...park.zones,
31    A: { ...park.zones.A, voltage: 15000 },
32  },
33};
34
35console.log("Oryginal A voltage:", park.zones.A.voltage);
36console.log("Updated A voltage:", updatedPark.zones.A.voltage);
37console.log("Oryginal B voltage:", park.zones.B.voltage, "(niezmieniony)");
38
39// --- PRZYKLAD 3: Object.freeze ---
40console.log("\n--- Object.freeze ---");
41const CONFIG = Object.freeze({
42  maxDinosaurs: 100,
43  maxVoltage: 50000,
44});
45
46CONFIG.maxDinosaurs = 200; // Ignorowane!
47console.log("maxDinosaurs:", CONFIG.maxDinosaurs, "(nadal 100)");
48
49// --- PRZYKLAD 4: structuredClone ---
50console.log("\n--- structuredClone ---");
51const original = {
52  zones: [{ id: "A", dinos: [{ name: "Rex", hp: 100 }] }],
53};
54const deepCopy = structuredClone(original);
55deepCopy.zones[0].dinos[0].hp = 50;
56
57console.log("Oryginal hp:", original.zones[0].dinos[0].hp, "(niezmieniony!)");
58console.log("Kopia hp:", deepCopy.zones[0].dinos[0].hp);
59
60// --- PRZYKLAD 5: Niemutowalne operacje na tablicach ---
61console.log("\n--- Niemutowalne operacje ---");
62const dinosaurs = [
63  { id: 1, name: "Rex", health: 100 },
64  { id: 2, name: "Blue", health: 95 },
65  { id: 3, name: "Brachio", health: 88 },
66];
67
68// Dodawanie
69const added = [...dinosaurs, { id: 4, name: "Stego", health: 100 }];
70console.log("Dodano (nowa tablica):", added.length, "items");
71
72// Usuwanie
73const removed = dinosaurs.filter((d) => d.id !== 2);
74console.log("Usunieto Blue:", removed.map((d) => d.name));
75
76// Aktualizacja
77const updated = dinosaurs.map((d) =>
78  d.id === 1 ? { ...d, health: 80 } : d
79);
80console.log("Updated Rex:", updated[0].health);
81console.log("Oryginal Rex:", dinosaurs[0].health, "(niezmieniony!)");
82
83// Sortowanie (niemutowalne)
84const sorted = [...dinosaurs].sort((a, b) => a.health - b.health);
85console.log("Posortowane:", sorted.map((d) => d.name + ":" + d.health));
86console.log("Oryginal:", dinosaurs.map((d) => d.name + ":" + d.health));

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. Co robi operator spread (...) z zagnieżdżonymi obiektami?

  2. 2. Która metoda kopiowania obiektów w JavaScript tworzy głęboką kopię (deep clone)?

Zadania praktyczne w grze

  • Edytor kodu

    Dodaj, usuń, zaktualizuj dane w zagnieżdżonych obiektach używając spread operator i filter/map.

  • Układanie w pionie

    Ułóż techniki zapewniania niemutowalności danych od najprostszej do najbardziej zaawansowanej:

  • Edytor kodu

    Zbuduj pipeline z walidacją, klasyfikacją alertów, grupowaniem i statystykami.

  • Klikanie w kolejności

    Ułóż elementy zamrażania obiektu konfiguracyjnego:

  • Układanie w poziomie

    Ułóż elementy niemutowalnej aktualizacji elementu w tablicy:

  • Klikanie w kolejności

    Ułóż elementy niemutowalnego dodawania elementu do tablicy:

Przydatne artykuły