Map i Reduce w JS

Napisałem artykuł o obserwablach, ale czegoś mi w nim zabrakło: Objaśnienia tak podstawowych pojęć i funkcji jak map i reduce. Observable na blogu pojawią się wkrótce, a ten krótki wpis ma na celu tylko lekkie wprowadzenie. Bardzo krótko i pobieżnie.

Jeśli oczekujesz zgłębiania programowania funkcyjnego w JS to natychmiast zamknij ten wpis. Nie jest dla Ciebie :)

MapReduce to także nazwa konceptu w programowaniu, który polega na dzieleniu danych i zrównolegleniu oraz rozproszeniu wykonywanych na nich operacji map/reduce. Jest to podejście zaimplementowane m.in. w Hadoop czy nawet MongoDB. Ten wpis jednak o nich nie mówi.

map i reduce w JS

Jeśli nie używasz jeszcze funkcji map i reduce w JavaScripcie, to czas najwyższy zacząć :) Wykorzystanie tych dwóch metod może sprawić, że Twój kod będzie znacznie czystszy i bardziej czytelny, a zrozumienie go będzie łatwiejsze. No i będziesz bardziej cool.

Map

Funkcja map jest jedną z bardziej rozpoznawalnych metod programowania funkcyjnego1. Służy do operowania i transformowania wszystkich elementów w tablicy.

Map przyjmuje tablicę, transformuje i zwraca tablicę o tej samej długości.

W JS ta funkcja to Array.prototype.map i wywołujemy ją np. w ten sposób:

const arr = [2, 6, 10];

arr.map(x => x * x) // [4, 36, 100]  

Tutaj tablicę z liczbami zamieniam (mapuję) na tablicę z kwadratami tych liczb.

Reduce

Reduce to fundament programowania funkcyjnego1. Podobnie do map, operuje na elementach tablicy, jednak zamiast kolejnej tablicy zwraca tylko pojedynczą wartość.

Reduce przyjmuje tablicę, transformuje i zwraca jedną wartość.

W JS ta funkcja to Array.prototype.reduce i wywołujemy ją np. w ten sposób:

const arr = [2, 6, 10];

arr.reduce((x, y) => x + y) // 18  

Tutaj obliczam sumę liczb w tablicy.

Map i reduce

Oczywiście Map i Reduce możemy łączyć (chain). Weźmy poprzedni przykład, chcemy policzyć sumę kwadratów liczb w tablicy:

const arr = [2, 6, 10];

arr  
    .map(x => x * x) // [4, 36, 100]
    .reduce((x, y) => x + y) // 140

Warto pamiętać o tym, że wewnątrz map i reduce nie powinny się dziać żadne efekty uboczne. Funkcje te służą tylko do transformacji jednych danych w drugie! Dzięki temu możliwa jest bardzo mocna optymalizacja tych metod i np. współbieżne przetwarzanie danych.

Niemutowalność

Bardzo istotnym atrybutem map i reduce jest to, że funkcje te zwracają nowe wartości i nie modyfikują przekazanej tablicy. Często jest to bardzo pożądana cecha! Dzięki temu zachowujemy oryginalną tablicę w niezmienionej formie, a inne komponenty, które mogą na niej polegać (hmm, nie powinny, ale to się często zdarza…), nie będą miały problemów z wykonywanymi przez nas operacjami. No, ale wróćmy do map/reduce, niemutowalność to trochę osobny temat, a tu miało być tylko krótkie wprowadzenie :)

Implementacja map w reduce

Napisałem, że reduce jest tutaj fundamentem, gdyż funkcja map jest tak naprawdę redundantna. Map można zaimplementować korzystając z reduce!

function map(array, fn) {  
    return array.reduce((newArray, el) => [...newArray, fn(el)], [])
}

map([1,2,3], x => x*x); // [1, 4, 9]  

Nie jest to pewnie najwydajniejsza implementacja, ani też wierne odwzorowanie Array.prototype.map, ale ważne, że działa. Teza udowodniona. Skoro wiemy już jak można implementować metody pomocniczne przy pomocy reduce, napiszmy bardzo przydatną funkcję flatMap, która zamienia tablicę tablic wartości w tablicę wartości (przyjmuje Array<Array<T>> i zwraca Array<U>):

// samo `reduce`
function flatMap(array, fn) {  
    return array.reduce((newArray, el) => [...newArray, ...fn(el)], [])
}

// alternatywnie `map` i `reduce`
function flatMap(array, fn) {  
    return array
      .map(x => fn(x))
      .reduce((a1, a2) => [...a1, ...a2]);
}

// alternatywnie przez `concat` i `map`
function flatMap(array, fn) {  
    return [].concat(...array.map(fn));
}

flatMap([[1,2], [3, 4]], x => x) // [1, 2, 3, 4]  
flatMap([1,2,3], x => [x,x]) // [1, 1, 2, 2, 3, 3]  

Podałem aż trzy alternatywne implementacje, żebyście mogli się bardziej oswoić z map/reduce. Która najbardziej przypada Wam do gustu?

Podobnie możemy też zaimplementować forEach, filter, czy też sum). Implementację ich pozostawiam jako ćwiczenie :)

Przykład z życia wzięty

Wyobraźmy sobie, że z API otrzymujemy dane w poniższym formacie:

const users = [  
    {id: 'a1', email: [email protected]', name: 'Abc'},
    {id: 'b2', email: [email protected]', name: 'Def'},
    {id: 'c3', email: [email protected]', name: 'Ghi'},
    {id: 'd4', email: [email protected]', name: 'Jkl'},
]

Czyli tablica obiektów reprezentujących użytkownika. Każdy obiekt zawiera losowe id, email oraz imię. Nasza aplikacja jednak oczekuje innego formatu danych. Interesuje nas tylko ID oraz email i chcielibyśmy mieć je w jednym obiekcie:

const result = {  
    a1: [email protected]',
    b2: [email protected]',
    c3: [email protected]',
    d4: [email protected]',
}

Czy możemy tutaj skorzystać z map i reduce? Tak! Najpierw każdego „użytkownika” zmapujemy na obiekt w postaci {[id]: email}, a następnie te obiekty zredukujemy do jednego:

users  
    .map(user => ({[user.id]: user.email}))
    .reduce((obj1, obj2) => Object.assign(obj1, obj2), {})

Zobacz Pen map reduce by Michał Miszczyszyn (@mmiszy) on CodePen.

Podsumowanie

Jaka jest zaleta tego rozwiązania? Na pewno zwięzłość. Dodatkowo, jak już wspomniałem, możliwe byłoby wykonanie tutaj wszystkich operacji całkowicie równolegle! I o ile prawdopodobnie żaden silnik JavaScript tego teraz nie robi, to jednak warto o tym pamiętać w kontekście innych technologii i języków programowania. Map reduce to koncept uniwersalny i powszechnie wykorzystywany.

Jeśli rozumiesz powyższe przykłady i czujesz się swobodnie z map i reduce to prawdopodobnie Observable będą dla Ciebie łatwe do zrozumienia. O tym mój kolejny wpis.

Dla dociekliwych

Pomimo, że obiecałem, że będzie prosto i pobieżnie, to jednak warto zastanowić się nad tym jak bardzo uniwersalne koncepty zostały tutaj omówione… Przykładowo, jeśli w opisie funkcji map zamiast słowa „tablica” wstawimy „funktor” to prawdopodobnie nadal wszystko co napisałem będzie prawdą.

Czym jest funktor? Funktor to koncept z teorii kategorii, bardzo abstrakcyjnej gałęzi matematyki, na której bazuje całe programowanie funkcyjne. Teraz w zasadzie nie jest do końca istotne czym funktor jest, ważne co jeszcze jest funktorem… a funktorami są np. tablica, Promise albo Observable. Wszystko co tutaj opisałem, mimo że proste, jest bardzo uniwersalne i opisuje tak naprawdę szerokie pojęcia.

Czym na przykład jest funkcja Promise.resolve? To przecież flatMap gdy wywołamy ją na innym obiekcie Promise oraz map gdy na wartości niebędącej Promise. Warto się zastanowić dlaczego i jakie są tego implikacje :)

  1. Opinia własna ;)

Michał Miszczyszyn

Programista z doświadczeniem w JavaScripcie po stronie klienta i serwera. Wielki fan TypeScripta.

Subscribe to Type of Web

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!