Iterowanie po tablicach – pętle for czy metody Array.prototype?

Iterowanie po tablicach w JavaScript to proces realizowany bardzo często, praktycznie w większości nawet prostych projektów. Wielu początkujących programistów ogranicza się wyłącznie do standardowej pętli for lub metod z różnych bibliotek, np. jQuery. W artykule omówimy dokładnie zalety i wady różnych rozwiązań.

W dzisiejszym wpisie zajmiemy się dwoma podstawowymi zagadnieniami związanymi z iterowaniem po tablicach: wykorzystaniem pętli będących elementami składni języka JavaScript oraz wykorzystaniem metod Array.prototype.

Na początek dobrze znana pętla for

Najczęściej spotykaną metodą iterowania po tablicach (nie tylko w poradnikach o JavaScript) jest zastosowanie pętli for w postaci pokazanej na poniższym kodzie:

let arr = [1,2,3];
for (let i = 0; i < arr.length; i++) {
    console.log(arr[i]);
}
// 1
// 2
// 3

Rozwiązanie to działa jak najbardziej prawidłowo, lecz nie jest to forma optymalna pod względem wydajności i wygody. Pętla for składa się z trzech elementów: zmiennej iterowanej (u nas „i”), warunku dla zakończenia iteracji (u nas po dojściu do końca tablicy) oraz instrukcji inkrementującej / dekremetującej zmienną iterowaną. W definicji pętli nie muszą wystąpić wszystkie trzy elementy (może nawet nie być żadnego) lecz muszą wystąpić dwa średniki (można więc stworzyć pętlę w postacji for(;;){...}).

Osobiście nie lubię jednak stosować formy z pomijaniem któregoś z elementów, gdyż uważam, że w pętli for podświadomie spodziewamy się ww. trzech elementów dlatego stosuję zawsze pełną deklarację. Uważam, że w tym momencie czytelność kodu powinna być najważniejsza.

Ponad to zaleca się unikania tworzenia warunku zakończenia iteracji z odwoływaniem się do wartości obliczanej dynamicznie lub takiej, która może ulegać zmianie (za wyjątkiem oczywiście świadomych decyzji co do takiej właśnie pętli, ale wtedy warto to odpowiednio skomentować). W powyższym kodzie dotyczy to zapisu i<arr.length). Lepszym rozwiązaniem jest zapis pętli w postaci:

var arr = [1,2,3];
for (var i = 0, max = arr.length; i < max; i++) {
    console.log(arr[i]);
}
// 1
// 2
// 3

Pętla taka będzie bardziej wydajna gdyż nie ma konieczności obliczania wartości arr.length przy każdej iteracji. Przy zwykłych tablicach o niedużej liczbie elementów nie ma to może większego znaczenia, ale warto pamiętać o tej metodzie w przypadku iterowania po elementach DOM, jeśli zdecydujemy się użyć do tego właśnie pętlę for.

Deklarowanie zmiennej „i” w pętli for

W powyższym przykładzie zmienną „i” zadeklarowaliśmy wewnątrz definicji pętli for. Jeśli jednak użyjemy w tym miejscu instrukcji var, co jest bardzo często spotykaną praktyką, to należy mieć świadomość, że de facto zmienna „i” deklarowana jest znacznie wcześniej. Związane jest to z tzw. hoistingiem, czyli przenoszeniem deklaracji na początek zakresu leksykalnego. Powyższy kod jest więc w rzeczywistości traktowany jako:

var arr = [1,2,3],
    i;
for (i = 0, max = arr.length; i < max; i++) {
    console.log(arr[i]);
}

Zmienna „i” zostaje zadeklarowana na początku wraz z naszą tablicą arr, przy czym otrzymuje początkową wartość undefined. Dopiero w pętli for następuje przypisanie wartości równej zero, aż do pierwszej inkrementacji (i++).

Lepszym rozwiązaniem jest deklarowanie zmiennych w pętli for słowem let, co tworzy zmienną o zasięgu blokowym, ograniczonym do bloku pętli for. W tym wypadku deklaracja let ma jednak jeszcze jedną, szczególną cechę. Otóż przy każdej iteracji tworzona jest nowa zmienna „i” o wartości równej wartości poprzedniej zmiennej (czyli po przejściu etapu inkrementacji). Czy ma to jakieś praktyczne znaczenie? Tak, i to duże, szczególnie gdy wewnątrz pętli for chcemy korzystać z funkcji pobierających wartość zmiennej inkrementowanej „i”:

const arr = [5,10,15];

//Wariant z VAR
for (var i = 0, max = arr.length; i < max; i++) {
    setTimeout(() => console.log(arr[i]), i*500);
}
//undefined
//undefined
//undefined

//Wariant z LET
for (let i = 0, max = arr.length; i < max; i++) {
    setTimeout(()=> console.log(arr[i]), i*500);
}
//5
//10
//15

Deklaracja let czy var?

Czy potrafisz wyjaśnić co się stało w pierwszym przypadku, gdy użyliśmy deklaracji VAR? Otóż metoda setTimeout pobiera w tym wypadku ostatnią wartość zmiennej „i”, którą używa w każdym wywołaniu. Skąd jednak wartość undefined?

Aby to zrozumieć, należy dokładnie prześledzić działanie pętli for. Po trzeciej iteracji zmienna „i” ma wartość „2”, więc jest to wartość mniejsza od arr.length (które wynosi „3”). Pętla wchodzi więc w kolejną iterację, zwiększając zmienną „i” o jeden (i++). Po jej wykonaniu ponownie sprawdzany jest warunek i<arr.length, lecz w tym wypadku jest on nieprawdziwy (3 === 3) więc pętla kończy działanie. Zmienna „i” stanowi jednak zmienną o zasięgu leksykalnym (a nie blokowym), więc nadal pozostaje w swojej ostatecznej wartości, czyli 3. W tym momencie wywoływane są dopiero kolejno metody setTimeout, które pobierają zmienną i=3. Próbujemy więc odnieść się do elementu arr[3], który nie istnieje, gdyż ostatnim elementem tablicy jest arr[2]===15. Otrzymujemy więc prawidłowo wartość undefined.

Drugi przypadek z użyciem słowa LET przy każdej iteracji tworzy nową zmienną „i” o zasięgu blokowym, dlatego każde wywołanie setTimeout pobiera wartość w pewnym sensie innej zmiennej (z jej ostatnią wartością), więc prawidłowo uzyskujemy wyniki 5,10,15. Ale tutaj mała uwaga. Sposób ten działa tylko wtedy, gdy zmienną „i” zadeklarujemy z użyciem LET wewnątrz definiowania pętli for (w innym wypadku nie można bowiem kilka razy definiować tej samej zmiennej z użyciem słowa let). Jeśli deklaracja znajdzie się poza pętlą to otrzymamy wynik taki sam jak dla deklaracji var.

Alternatywa w postaci pętli while

Niektóre poradniki zalecają stosowanie alternatywnej wersji z użyciem pętli while co pozwala skrócić nieco zapis. Należy jednak uważać, gdyż kryją się w tym wypadku dwie pułapki:

let arr = [5,10,15], i = arr.length;
while (i--) {
    console.log(arr[i]);
}
//15
//10
//5

W wyniku takiego zapisu pętli while tablica jest iterowana od końca. Jest to dobre pod kątem wydajności, gdyż odliczanie do zera jest szybsze niż odliczanie „w przód”. Nie zawsze jednak jest to oczekiwane zachowanie, dlatego musimy być świadomi takiego wyniku. Zmienna „i” wchodzi do pierwszego wywołania pętli z wartością pomniejszoną o jeden w stosunku do wartości z momentu deklaracji, dlatego możliwe jest odczytanie wartości arr[i].

Pętlę while można również użyć do iterowania w przód, ale należy przy tym bardzo uważać i dobrze przemyśleć początkowe wartości zmiennej „i”:

let arr = [5,10,15],
    i = -1,
    max = arr.length;
while (i++ < max) {
    console.log(arr[i]);
}
//5
//10
//15

Gdybyśmy początkową wartość zmiennej i ustalili jako „zero” to otrzymalibyśmy w efekcie „5,10,undefined”. Wynika to z faktu, że na początku następuje sprawdzenie czy „-1 < 3”, co daje wynik true, więc następuje inkrementacja (i++) i zmienna wchodzi w pętlę z wartością -1+1=0.  Z tego względu zalecam ostrożność w czasie korzystania z takich skróconych form pętli while do iterowania po tablicach i dokładne analizowanie wartości zmiennych, jakie wchodzą do pętli. Oczywiście pętlę while można zapisać również z innej postaci, bezpieczniejszej pod względem wartości zmiennej inkrementowanej:

let arr = [5,10,15],
    i = 0, //teraz ustawiamy na ZERO
    max = arr.length;
while (i < max) {
    console.log(arr[i++]);
    /*Alternatywnie można zpisać:
      console.log(arr[i]);
      i++;
    */
}
//5
//10
//15

W tym wypadku inkrementacji dokonujemy dopiero wewnątrz pętli odczytaniu wartości arr[i] i przekazaniu jej do metody console.log().

A co z pętlą for-in?

Alternatywą dla tradycyjnej pętli for może wydawać się pętla for-in, która często jest omawiana w poradnikach o JavaScript. Niestety najczęściej nie ma jednoznacznej informacji, że jest to pętla służąca do iterowania po obiektach, a nie tablicach, a ponad to bardzo rzadko można spotkać wyjaśnienie dlaczego for-in nie należy używać z tablicami.

Na wstępie należy zaznaczyć, że pętla for-in iteruje nie po wartościach lecz po nazwach właściwości obiektu! Tablice w języku JavaScript nie są oddzielnym typem, lecz są obiektami z Array.prototype. Jako obiekty mają więc właściwości i metody. W pewnym uproszczeniu można przyjąć, że tablice są reprezentowane jako obiekty, które posiadają właściwości nazwane jako „0”, „1”, „2” itp., a ponad to mają właściwość length i różnego rodzaju metody dziedziczone po Array.prototype.

Takie spojrzenie na tablice w JavaScript pozwoli nam zrozumieć, dlaczego w pętli for-in iterujemy właśnie po indeksach, czyli dokładniej mówiąc po nazwach właściwości (które w przypadku tablic są określane jako indeksy). Z metodą for-in związanych jest jednak kilka drobnych problemów. Przeanalizujmy poniższy kod:

let arr = [5,10,15];
for (let i in arr) {
    console.log(i, arr[i], typeof i);
}
//0 5 string
//1 10 string
//2 15 string

Teoretycznie uzyskaliśmy oczekiwany rezultat, czyli kolejne elementy tablicy zrzucone do metody console.log(). Zmienna „i” wewnątrz pętli for jest jednak nie zmienną typu number lecz ciągiem znakowym string. Czy ma to znaczenie? I tak i nie… zależy co chcemy robić ze zmienną „i” wewnątrz pętli for.

Ryzykowne stosowanie for-in dla tablic

let arr = [5,10,15];
for (let i in arr) {
    console.log(i + 100);
}
//0100
//1100
//2100

Próba dodania do każdego indeksu wartości 100 kończy się pewnym niepowodzeniem. W rezultacie otrzymujemy ciąg znakowy stanowiący konkatenację zmiennej „i” (występującej tutaj jako string) z wartością 100, która jest konwertowana do ciągu znakowego. Wynika to z ogólnej zasady konkatenacji i przeciążania operatora „+” w JavaScript – jeśli jednym z elementów jest typ string, to następuje niejawna konwersja wszystkich innych elementów do string i połączenie ich jako ciągów znakowych. Dlatego w efekcie dostajemy „0” + „100” czyli „0100” itd.

Jest to jeden z powodów, dla którego odradzam stosowanie pętli for-in do iterowania po tablicach. Ponad to specyfikacja ECMAScript nie określa dokładnie w jakiej kolejności mają być iterowane właściwości obiektu wewnątrz pętli for-in oraz umożliwia uwzględnienie w wartościach zmiennej „i” również innych właściwości niż „indeksy” tablicy. Co prawda większość obecnie stosowanych przeglądarek zachowa się w oczekiwany sposób, czyli przeiteruje kolejno po elementach tablicy to jednak opieramy się tutaj nie na specyfikacji ES lecz na implementacji przeglądarek i nigdy nie możemy mieć pewności co do takiego zachowania pętli for-in.

Pozostawmy więc pętlę for-in do iterowania po właściwościach obiektów nie będących tablicami, gdzie powyższe problemy mają mniejsze znaczenie. Używając for-in pamiętajmy jednak o zabezpieczeniu się np. dodatkową metodą object.haOwnProperty(i) aby wyfiltrować tylko oczekiwane właściwości obiektu.

Czas na nowość ECMAScript 6 – pętla for-of

W internecie funkcjonuje wiele skryptów bazujących na pętli for-in wykorzystywanej do tablic więc nie było możliwe zmienienie zachowania tej pętli. Dlatego właśnie wprowadzono nowy element składniowy czyli pętlę for-of, która przeznaczona jest właśnie do iterowania po tablicach.

W przeciwieństwie do pętli for-in, nowa pętla for-of iteruje nie po indeksach lecz po wartościach tablicy. Zobaczmy jak działa nowa pętla:

let arr = [5,10,15];

for (let val of arr) {
    console.log(val);
}

//5
//10
//15

Jest to krótka i czytelna forma iterowania po tablicy, dużo wygodniejsza niż stosowanie pętli for-in lub tradycyjnej pętli for.  W dalszej części artykułu omówimy również metody Array.prototype lecz jedną z większych zalet pętli for-of jest dobra współpraca z elementami sterującymi jak instrukcje continue, break, return.

Co więcej, pętlę for-of z powodzeniem można stosować również na tzw. pseudotablicach, czyli np. obiektach [object NodeList] zwracanych przez metody pobierające referencje do elementów DOM. Dodatkowo pętla dobrze działa przy iterowaniu po mapach i kolekcjach – które również stanowią nowe elementy języka ES6 (nie będziemy jednak ich tutaj dziś omawiać).

Pętla for-of nie działa jednak z „tradycyjnymi” obiektami. W tym wypadku pozostaje nam pętla for-in, która od początku była przeznaczona właśnie do iterowania po obiektach. Istnieje co prawda pewna sztuczka pozwalająca użyć for-of dla obiektów, lecz osobiście nie zalecam tego typu praktyk chyba, że dokładnie udokumentujemy w komentarzach nasze zamiary. Aby było to możliwe musimy w obiekcie stworzyć metodę [Symbol.iterator] oraz metodę next.

A może metoda map() lub forEach()?

Po za trzema typami pętli for w języku JavaScript dysponujemy również metodami Array.prototype, jak map() oraz forEach(). Są one często traktowane jako identyczne, lecz w praktyce ich przeznaczenie i sposób pracy jest różny.

Obie metody przyjmują jeden lub dwa argumenty. Pierwszym argumentem jest funkcja zwrotna (callback), wywoływana dla każdego elementu tablicy. Drugi, opcjonalny argument to referencja do obiektu, który będzie używany w funkcji callback jako odniesienie do this. Ponad to funkcja zwrotna pobiera trzy argumenty (choć nie trzeba wykorzystywać wszystkich): wartość elementu, indeks, tablica. Zobaczmy jak zachowują się obie metody w praktyce:

let arr = [5,10,15];

let a = arr.forEach(v => v*10),
    b = arr.map(v => v * 10);

a; //undefined
b; //[50,100,150]

arr; //[5,10,15]

Zamysłem było stworzenie zmiennych a i b, które miały przechowywać tablicę arr w postaci zmodyfikowanej, w której każdy element będzie przemnożony przez 10.

W praktyce jednak efekt udało się uzyskać wyłącznie w zmiennej „b”. Stało się tak dlatego, że metoda forEach nie zwraca żadnej tablicy w przeciwieństwie do metody map, która zwraca przetworzoną tablicę wejściową. Należy również zauważyć, że żadna z metod nie modyfikuje tablicy arr.

Argumenty metod map i forEach

Jak wspomniałem wcześniej metody te wywołują dla każdego elementu tablicy funkcję zwrotną, która może pobierać aż trzy argumenty. Wartość elementu i jego indeks są wykorzystywane stosunkowo często i nie budzą żadnych wątpliwości.

Pytanie jednak po co funkcja przyjmuje jako argument również całą tablicę? Otóż ma to dwie zalety. Po pierwsze zabezpiecza nas przed ewentualną zmianą nazwy zmiennej przechowującej tablicę wejściową. W razie takiej zmiany operacje wewnątrz funkcji zwrotnej callback nadal pozostają bez zmian, gdyż odnoszą się do zmiennej w zakresie leksykalnym funkcji zwrotnej.

Drugi plus takiego rozwiązania to brak konieczności przeszukiwania łańcucha prototypów w celu znalezienia odniesienia do tablicy. Nie jest to może istotne przy małych tablicach, lecz przy dużych zbiorach wydajność nabiera coraz większego znaczenia.

Kiedy używać map(), a kiedy forEach()?

Pytanie to pojawia się na różnych forach i grupach dyskusyjnych lecz odpowiedź wg mnie jest bardzo prosta. Metodę map wykorzystujemy wtedy, gdy chcemy zwrotnie otrzymać nową, przetworzoną tablicę natomiast metodę forEach gdy tablica jest nam jedynie potrzebna do wywołania jakiegoś działania na każdym z elementów lecz nie chcemy zwrotnie żadnej nowej tablicy.

Przykładem może być chociażby operacja na elementach DOM jak dodawanie styli CSS czy obsługi zdarzenia (choć to ostatnie lepiej robić stosując event delegation). W takim wypadku zastosujemy metodę forEach gdyż naszym celem jest zmodyfikowanie elementów, na które wskazują kolejne wartości tablicy (referencje DOM) bez modyfikowania samej tablicy.

Teoretycznie metoda map() nie modyfikuje przetwarzanej tablicy, ale jeśli potrzebujemy wykonać taką modyfikację to możemy zastosować sztuczkę z nadpisaniem wartości zmiennej przechowującej tablicę:

let arr = [1,2,3];

arr = arr.map(v => v*10);
arr; //[10,20,30]

Pułapki związane z metodami map() i forEach()

Czytając ten artykuł początkujący programista JS mogłby pomyśleć, że metody map i forEach są czytelniejsze i lepsze w użyciu niż tradycyjne iterowanie pętlami for lub for-of.

Należy jednak uważać z takim myśleniem i zawsze dokładnie określić cel w jakim iterujemy po tablicy. Metody map i forEach są dobre jeśli z góry wiemy, że chcemy przeiterować po całej tablicy. Co jednak jeśli w pewnym momencie będziemy chcieli zakończyć iterację?

let arr = [5,10,"text",15,20];

arr.forEach(val => {
    if (typeof val !== 'number') {
        return 'Błędna wartość!';
    }
});

Tablica arr powinna zawierać wartości numeryczne, lecz podczas iterowania chcemy sprawdzać typ wartości i gdy nie jest to 'number' to próbujemy zakończyć iterację metodą return ze zwróceniem komunikatu "Błędna wartość!". W praktyce jednak metoda forEach iteruje po całej tablicy, a instrukcja return dotyczy nie metody forEach lecz jej funkcji wewnętrznej callback. Aby lepiej to zrozumieć przeanalizujmy drugi przykład z metodą map:

let arr = [5,10,"text",15,20];

let a = arr.map(val => {
             if (typeof val !== 'number') {
                 return 'Błąd!';
             }
         });

a; //[undefined, undefined, "Błąd!", undefined, undefined]

Jak widać zmienna „a” przechowuje tablicę przetworzoną przez metodę map(). Skąd takie „dziwne” wyniki? Otóż funkcja callback jest wywoływana dla każdego elementu tablicy po kolei. Instrukcja return umieszczona w funkcji callback powoduje zwrócenie jakieś wartości (wartością może być jednak również obiekt lub nawet inna funkcja) i przypisanie jej w miejscu występowania wartości na której funkcja została wywołana („val”).

Jawnie wskazujemy jednak tylko jeden przypadek instrukcji return – sytuację, gdy wartość „val” nie jest typu numerycznego. W innych wypadkach następuje niejawne wywołanie domyślnej instrukcji return undefined. Ostatecznie takie właśnie wartości (zwracane przez return jawne i niejawne) są zapisywane w tablicy wynikowej.

Istnieje jeszcze jedna pułapka, związana z jawnym wskazywaniem obiektu this w metodach map i forEach, która została opisana w innym moim wpisie: Funkcje strzałkowe. Omówiono tam problem stosowania funkcji arrow function jako funkcji zwrotnych w metodach Array.prototype.

Podsumowanie – czyli jak iterować po tablicach?

Na tak postawione pytanie nie ma jednoznacznej odpowiedzi. Wszystko zależy od tego w jakim celu dokonujemy iteracji. Zaletą metod for oraz for-of jest możliwość dokładnego kontrolowania każdej iteracji i nawet omijanie niektórych indeksów – w pętli for zmienna iterowalna, np. „i” nie musi przecież inkrementować o 1 (i++, i–, ++i, –i) lecz o dowolną wartość, np. i+=5.

W funkcjach callback metod map i forEach możemy co prawda weryfikować indeks przekazanej wartości ale i tak metody przeiterują po całej tablicy. Wyjątek stanowią metody typu every() czy some() lecz nie będziemy ich dzisiaj omawiać.

Korzystając z metod Array.prototype należy uważać na kilka możliwych pułapek związanych m.in. z wychodzeniem z iteracji (a raczej brakiem takiej możliwości). Istnieje co prawda jeden sposób na przerwanie metody map czy forEach – jest to wywołanie instrukcji throw zgłaszającej wyjątek. Wyjątki należy jednak obsługiwać bardzo ostrożnie i z rozwagą.