Jak stworzyć kopię tablicy w JavaScript?

Z pozoru pytanie postawione w tytule mogłoby się wydawać banalne, ale jak się za chwilę okaże wcale nie jest to takie proste, szczególnie, jeśli w tablicy zaczniemy przechowywać obiekty…

W internecie można znaleźć wiele sposobów na wykonanie kopii tablicy w JavaScript, którym przyjrzymy się nieco bliżej. Na początek musimy jednak przypomnieć sobie, że w JS tak na prawdę nie ma oddzielnego typu dla tablic i są to po prostu obiekty z tzw. prototypem Array.prototype. Drugą ważną sprawą jest fakt, że obiekty w JS są przekazywane przez referencję, a nie przez wartość. Za chwilę wszystko wyjaśnimy na przykładach.

Przypisanie przez referencję

Na początek weźmiemy najprostsze przykłady, gdzie w tablicach zapiszemy wartości typu prostego. Co prawda w JavaScript możliwe jest mieszanie różnych typów w tablicy to osobiście uważam to za złą praktykę i unikam takich rozwiązań. W tym artykule również zakładam, że każda tworzona przez nas tablica przechowuje wartości jednego typu.

Osoby nie mające doświadczenia w JavaScript mogłyby próbować napisać kod podobny do poniższego:

const arr = [1,2,3];
const cloneArr = arr;

arr;      //[1, 2, 3]
cloneArr; //[1, 2, 3]

arr.push(4); //4
arr;         //[1, 2, 3, 4]
cloneArr;    //[1, 2, 3, 4]

Widzimy jednak, że zmiana dokonana na tablicy bazowej arr (dodanie nowego elementu o wartości 4) skutkuje również modyfikacją tablicy cloneArr. Otóż w tym miejscu tak na prawdę nie zmieniła się tablica cloneArr, ale zmieniła się tablica, na którą wskazuje cloneArr. To właśnie jest tzw. przypisanie przez referencję, czyli nie kopiujemy „zawartości” arr do cloneArr, a jedynie wskazujemy, że cloneArr ma odnosić się do arr.

Co ciekawe, działa to „w dwie strony”:

cloneArr.pop(); //4
cloneArr;       //[1, 2, 3]
arr;            //[1, 2, 3]

czyli wywołanie metody pop() na cloneArr skutkuje modyfikacją tablicy bazowej arr. Z tego względu najlepiej unikać tego typu przypisań, gdyż prawdopodobnie nie o takie zachowanie nam faktycznie chodzi…

Klonowanie tablic z wartościami prostymi

No dobrze, to w takim razie jak zrobić faktyczną kopię tablicy arr? Pozostajemy cały czas przy tablicy, która zawiera wartości typu prostego (tutaj typu number), więc możemy ją sklonować na kilka sposobów, na przykład:

const arr = [1,2,3];

const cloneA = arr.slice();
const cloneB = [...arr];
const cloneC = Array.from(arr);

cloneA; //[1, 2, 3]
cloneB; //[1, 2, 3]
cloneC; //[1, 2, 3]

//Modyfikujemy tablicę bazową arr:
arr.push(10); //4

arr; //[1, 2, 3, 10]
cloneA; //[1, 2, 3]
cloneB; //[1, 2, 3]
cloneC; //[1, 2, 3]

W tym wypadku oba rozwiązania przyniosły pożądany efekt, czyli wykonanie faktycznej kopii tablicy arr. Zauważ, że modyfikacja tablicy bazowej nie ma wpływu na jej dwie kopie, czyli nie mamy do czynienia już z przypisaniem referencji do tablicy arr ale z jej faktycznym sklonowaniem.

Pierwszy sposób wykorzystuje metodę Array.prototype.slice, która może zwracać nową tablicę będącą wskazanym fragmentem tablicy bazowej. Jeśli jednak nie podamy jej żadnych argumentów (czyli miejsc gdzie należy dokonać „wycięcia”) to przekopiuje ona całą tablicę.

Drugie rozwiązanie działa podobnie, ale wykorzystaliśmy tu po prostu operator spread, co wg mnie jest wygodniejszym rozwiązaniem, albo użycie metody Array.from, co bardzo wyraźnie wskazuje cel jej zastosowania. Metoda Array.from jest wg mnie czytelniejszą formą niż użycie metody slice, ale to już kwestia indywidualnych upodobań.

A co z tablicami zawierającymi obiekty?

I tutaj dochodzimy do największego problemu… W przypadku tablic zawierających wartości typu prostego (np. number, string) można tworzyć ich kopie przy użyciu jednej z trzech ww. metod.

Co innego, jeśli w tablicy zaczniemy przechowywać obiekty, na przykład:

const arr = [
    {x: 5, y: 10},
    {x: 20, y: 50},
];

const cloneArr = [...arr];

//Modyfikujemy pierwszy element tablicy bazowej:
arr[0].x = 1;
arr[0].x; //1

//A co siedzi w tablicy cloneArr?
cloneArr[0].x; //1

Widać zatem, że w cloneArr nie mamy faktycznej kopii tablicy arr i znowu zmiany dokonywane są na zasadzie referencji. Trzeba na to uważać jeśli faktycznie zależy nam na operowaniu na niezależnej kopii bez ingerencji w oryginał.

W powyższej sytuacji jedną z chyba najprostszych metod poradzenia sobie z tym problemem jest użycie obiektu JSON:

const arr = [
    {x: 5, y: 10},
    {x: 20, y: 50},
];

const cloneA = [...arr];
const cloneB = JSON.parse(JSON.stringify(arr));

//Modyfikujemy pierwszy element tablicy bazowej:
arr[0].x = 1;
arr[0].x; //1

//A co siedzi w tablicach będących kopiami?
cloneA[0].x; //1 czyli wartość po zmianie
cloneB[0].x; //5 czyli faktyczna wartość pierwotna

Widać więc, że użycie obiektu JSON pozwoliło stworzyć faktyczną kopię elementów będących obiektami. Stało się tak dlatego, że metoda JSON.stringify dokonuje serializacji tablicy do postaci ciągu znakowego, a następnie metoda JSON.parse tworzy ze wskazanego ciągu nowy obiekt, w tym wypadku nową tablicę. Nie jest więc ona w żaden sposób połączona z bazową tablicą zapisaną w arr.

A co z obiektami zagnieżdżonymi?

Każdy element tablicy może być dowolnego typu, w tym również może to być obiekt jak poprzednio. Obiekt może również posiadać swoje właściwości, a wśród nich mogą być… kolejne obiekty. Zobaczmy to na przykładzie:

const arr = [
    {
        name: 'Tomek', 
        skills: {
            JS: 4, 
            PHP: 2
        }
    },
    {
        name: 'Adam', 
        skills: {
            JS: 3, 
            PHP: 5
        }
    },
]

Mamy więc sytuację, w której każdy element tablicy (mamy ich aż dwa) zawiera w sobie kolejny obiekt zapisany we właściwości skills. Spróbujmy ponownie użyć obiektu JSON do wykonania kopii tablicy arr:

const arr = [
    { name: 'Tomek',skills: { JS: 4,PHP: 2 } },
    { name: 'Adam',skills: { JS: 3,PHP: 5 } },
];
const cloneA = JSON.parse(JSON.stringify(arr));

arr[0].skills.JS;    //4
cloneA[0].skills.JS; //4

//Modyfikujemy tablicę arr:
arr[0].skills.JS = 1;

arr[0].skills.JS;    //1 czyli wartość po zmianie
cloneA[0].skills.JS; //4 czyli wartość pierwotna

Ponownie metoda wykorzystująca obiekt JSON zdała egzamin, prawidłowo kopiując zagnieżdżone obiekty. Wyjaśnienie tego jest nadal takie samo, po prostu tworzymy całkowicie nową tablicę (nowy obiekt) na podstawie nie tyle tablicy arr co ciągu znakowego zgodnego z formatem JSON, który został wygenerowany metodą JSON.stringify.

W zasadzie na tym można by poprzestać, gdyż metoda ta sprawdzi się chyba w 99% przypadków. Pytanie jednak, co znajduje się w tym jednym procencie, gdzie takie rozwiązanie nie zda egzaminu?

Obiekty z deskryptorami właściwości

Otóż w JavaScript mamy możliwość jawnego tworzenia właściwości obiektów z konkretnie wskazanymi wartościami dla poszczególnych deskryptorów (dla przypomnienia mówimy tu o deskryptorach writable, enumerable i configurable). Nie będę teraz omawiał czym są same deskryptory – jeśli nie miałeś z nimi do czynienia to odsyłam Cię np. do dokumentacji JS na MDN.

W praktyce raczej chyba rzadko stosuje się jawne określanie deskryptorów, aczkolwiek po pierwsze JS na to pozwala i są sytuacje, gdzie jest to przydatne, a po drugie to lubię w moich wpisach nieco „pokombinować” dlatego parę słów o tego typu obiektach 🙂

Weźmy sobie za przykład tablicę:

const arr = [
    Object.defineProperty({}, 'name', {value: 'Tomek', enumerable: false}),
    Object.defineProperty({}, 'name', {value: 'Adam', enumerable: true}),
];

w której tworzymy dwa elementy, każdy będący obiektem zawierającym właściwość name, ale z różnymi wartościami deskryptora enumerable (odnosi się od do widoczności np. w pętli for...in).

Sprawdźmy teraz co siedzi w tablicy arr:

Object.getOwnPropertyDescriptors(arr[0]);
name: {value: "Tomek", writable: false, enumerable: false, configurable: false}

Object.getOwnPropertyDescriptors(arr[1]);
name: {value: "Adam", writable: false, enumerable: true, configurable: false}

Spróbujmy więc stworzyć kopię przy użyciu znanego już obiektu JSON:

const cloneA = JSON.parse(JSON.stringify(arr));

//I sprawdzamy:
Object.getOwnPropertyDescriptors(cloneArr[0]);
//{}
Object.getOwnPropertyDescriptors(cloneArr[1]);
//{}

Widzimy więc, że niestety deksryptory nie zostały skopiowane. Dzieje się tak dlatego, że nie są one serializowane w metodzie JSON.stringify, ale co ciekawe, są brane pod uwagę:

cloneArr[0]; //{}
cloneArr[1]; //{name: "Adam"}

Dla przypomnienia pierwszy element tablicy był obiektem z jedną właściwością name, ale miała ona ustawiony deskryptor enumerable na false, przez co nie została wzięta pod uwagę podczas serializacji obiektu, ale w jej miejsce został przypisany pusty obiekt.

Nie jest to jednak porządny przez nas efekt…

Spróbujmy faktycznie sklonować obiekty z deskryptorami

Przypomnijmy jeszcze raz jak wygląda nasza tablica bazowa:

const arr = [
    Object.defineProperty({}, 'name', {value: 'Tomek', enumerable: false}),
    Object.defineProperty({}, 'name', {value: 'Adam', enumerable: true}),
];

Tym razem spróbujemy problem rozwiązać z nieco inny sposób, przy użyciu metody Object.create w połączeniu z metodą Array.prototype.map:

const cloneArr = arr.map(obj => {
    return Object.create(obj, Object.getOwnPropertyDescriptors(obj))
});

Ponownie sprawdźmy co kryje się w obu tablicach:

//Na początek tablica bazowa:
Object.getOwnPropertyDescriptors(arr[0]);
name: {value: "Tomek", writable: false, enumerable: false, configurable: false}
Object.getOwnPropertyDescriptors(arr[1]);
name: {value: "Adam", writable: false, enumerable: true, configurable: false}

//Kopia tablicy bazowej:
Object.getOwnPropertyDescriptors(cloneArr[0]);
name: {value: "Tomek", writable: false, enumerable: false, configurable: false}
Object.getOwnPropertyDescriptors(cloneArr[1]);
name: {value: "Adam", writable: false, enumerable: true, configurable: false}

Tym razem otrzymujemy oczekiwany efekt, czyli faktyczną kopię deskryptorów. Ale czy jest to rzeczywiście kopia, czy znowu zatoczyliśmy koło i stworzyliśmy „jakąś referencję”?

Zobaczmy co się stanie, gdy do pierwszego elementu (obiektu) tablicy arr dodamy nową właściwość:

Object.defineProperty(arr[0], 'age', {value: 32});

//Sprawdzamy tablicę bazową:
Object.getOwnPropertyDescriptors(arr[0]);
age: {value: 32, writable: false, enumerable: false, configurable: false}
name: {value: "Tomek", writable: false, enumerable: false, configurable: false}

//A teraz sprawdzamy naszą kopię:
Object.getOwnPropertyDescriptors(cloneArr[0]);
name: {value: "Tomek", writable: false, enumerable: false, configurable: false}

Widać więc, że wszystko się zgadza i otrzymaliśmy kopię tablicy, gdyż modyfikacja tablicy arr nie wpływa na zawartość tablicy cloneArr ale jednocześnie udało nam się przekopiować wszystkie deskryptory.

Podsumowanie

W praktyce pewnie bardzo rzadko, jeśli w ogóle będziesz stosował deskryptory jawnie ustawiane metodę defineProperty czy defineProperties, ale warto zdawać sobie sprawę jakie może to rodzić ewentualne problemy podczas tworzenia kopii takich obiektów.

W większości sytuacji dobrym, prostym i skutecznym rozwiązaniem będzie użycie obiektu JSON, co powinno rozwiązać wiele problemów. Trzeba jednak pamiętać jak zachowują się metody Array.prototype.slice, Array.prototype.from czy operator spread aby przypadkowo nie stworzyć nowej referencji do tablicy co mogłoby skutkować jej nieświadomą modyfikacją. Są to jednak skuteczne metody do kopiowania tablic zawierających wartości typu prostego (które w JS są przekazywane przez wartość, a nie przez referencję).

I na koniec moja osobista sugestia, aby pilnować jednego typu dla wszystkich elementów tablicy. Można co prawda w JS tworzyć tablice o wielu typach elementów, na co pozwala również np. TypeScript (przy wskazaniu odpowiednich alternatyw dla typów elementów) ale w praktyce kod taki może być ciężki w utrzymaniu i może rodzić problemy, np. gdy pomieszamy w tablicy typ number i string, a za chwilę zechcemy wykonywać na elementach tablicy operacje dodawania… co czasami może skończyć się operacją konkatenacji…

  • Po tytule spodziewałem się jakiegoś banału, ale temat został fajnie rozpisany 🙂

    • Dzięki 🙂
      Zapewne dla większości osób i większości przypadków przydatna będzie tylko pierwsza część, bez ruszania w ogóle tematu destruktorów, ale jak pisałem lubię czasem pokombinować i porobić coś „pod górkę”, w kodzie nie można sobie na to pozwalać, ale blog to co innego 🙂