Currying w JavaScripcie - sposób na tworzenie wyspecjalizowanych funkcji

Poznawanie dobrych praktyk jest bardzo ważne podczas nauki programowania. Jedną z takich reguł jest DRY (don't repeat yourself), która zaleca unikanie powtórzeń w kodzie. Chociaż skrajnie podążanie za tą praktyką może powodować tworzenie niepotrzebnych abstrakcji to jej znajomość i stosowanie w odpowiednich przypadkach może przynieść pozytywne efekty. Currying pomoże w wykorzystaniu w zastosowaniu funkcji o ogólnym przeznaczeniu do bardziej wyspecjalizowanych celów.

Currying (zwany też rozwijaniem funkcji) polega budowaniu funkcji w ten sposób, aby można było jej podawać argumenty pojedynczo, zamiast wszystkie na raz. Pozwala to na tworzenie na ich podstawie nowych funkcji posiadających już zapamiętane argumenty.

Zastosowanie curryingu będzie pomagało w ograniczeniu powtarzającego się kodu. Pozwoli na zamianę funkcji o bardzo ogólnym przeznaczeniu, np. get, find, join na bardziej wyspecjalizowane - getName, findId, joinWithSpaces. Jednak na początek pokazę jak wygląda proste zastosowanie curryingu na przykładzie funkcji sum.

function sum(a) {
  return function(b) {
    return a + b;
  }
}

Po wykonaniu sum(5), nie zwróci ona liczby, lecz kolejną funkcję do której musimy podać następny argument. Dopiero po ponownym jej wykonaniu uzyskamy wynik - czyli sumę dwóch liczb.

Oto przykład użycia:

function sum(a) {
  return function(b) {
    return a + b;
  }
}

const addTo5 = sum(5) // function(b) { return 5 + b }

addTo5(3) // 8
sum(5)(3) // 8

Stworzyliśmy wyspecjalizowaną funkcję addTo5 - jej jedyne zadanie to dodanie 5 do dowolnej liczby podanej jako argument. Jak widać jest dużo mniej elastyczna niż sum, która może dodawać dowolne liczby, lecz w niektórych przypadkach będziemy potrzebować właśnie takich funkcji mających już zapamiętane pewne argumenty.

Zobaczmy kolejny przykład z funkcją get, która zwraca wartości z obiektu.

function get(key) {
  return function(obj) {
    return obj[key]
  }
}

const getName = get('name')

const users = [{ name: 'Adam' }, { name: 'Maciej' }]

const namesList = users.map(user => user.name) // bez użycia curryingu
const namesList = users.map(getName) // użycie wyspecjalizowanej funkcji
const namesList = users.map(get('name')) // bezpośrednie użycie funkcji get

Widzimy tutaj dużo praktyczniejsze użycie curryingu. Obiekt, z którego chcemy pobrać dane jest ostatnim argumentem - dzięki temu taką niedokończoną funkcję możemy podać jako argument do map. Funkcje podane jako argument do map dla każdego użytkownika wybiorą wartość przypisaną do name.

Zwróc uwagę na czytelność oraz możliwość ponownego użycia takiego kodu. Za każdym razem kiedy chcemy pobrać dane z obiektu, czy to bezpośrednio czy w map, możemy użyc funkcji get lub stworzyć osobną funkcję z zapamiętanym argumentem.

Podczas tworzenia rozwijanych funkcji operujących na pewnym źródle danych pamiętaj, aby argumenty odpowiedające za konfigurację (w tym przypadku key) były podawane jako pierwsze, a źródło danych (obj) jako ostatnie. W odwrotnym przypadku użycie takich funkcji do mapowania czy filtrowaniu byłoby uciążliwe i nie poprawiałoby zbytnio czytelności kodu.

// odwrócone argumenty
function get(obj) {
  return function(key) {
    return obj[key]
  }
}
const users = [{ name: 'Adam' }, { name: 'Maciej' }]

const namesList = users.map(user => get(user)('name'))

W większości przypadków będziemy najpierw znali potrzebną konfigurację do stworzenia funkcji (pobieranie nazw), a dane mogą nadejśc później, np. z API.

Automatyczne tworzenie rozwijanej funkcji

Tworzenie funkcji w wyżej zaprezentowany sposób nie jest zbyt intuicyjne. Odbiera też możliwość używania ich w normalny sposób, z dostarczeniem wszystkich argumentów na raz. Inny programista, który zobaczy funkcje sum nie powinien się zastanawiać nad sposobem przekazania argumentów (add(5, 3) lub add(5)(3)).

Zobaczmy najpierw w jaki sposób można pozwolić na większą dowolność przy przekazywaniu argumentów w przypadku funkcji add.

function add(...args) {
  if (args.length === 1) {
    const [a] = args
    return function(b) {
      return a + b
    }
  }
  const [a, b] = args
  return a + b
}

add(5, 3) // 8
add(5)(3) // 8

Korzystam tutaj z możliwości języka, aby zebrać wszystkie argumenty funkcji do tablicy (rest parameters) oraz wybrać pierwszy lub dwa pierwsze elementy z tablicy (destructuring). Funkcja będzie sprawdzała ile argumentów otrzymała i zachowa właściwości curryingu lub podawania wszystkich argumentów na raz.

Chcielibyśmy jednak zautomatyzować takie transformowanie zwykłej funkcji do wersji curried. Z tego powodu przy korzystaniu z curryingu często używa się funkcji pomocniczej curry.

function curry(argsLength, originalFunction) {
  function next(prevArgs) {
    function curriedFunction(nextArgs) {
      const allArgs = [...prevArgs, ...nextArgs]
      if (allArgs.length >= argsLength) {
        // wszystkie argumenty zostały podane
        return originalFunction(...args);
      }
      else {
        return next(allArgs)
      }
    }
  }
  return next([])
}

Jej działanie na pierwszy rzut oka jest dość skomplikowane, na tym etapie nie musisz dokładnie rozumieć w jaki sposób jest zaimplementowana. Mimo tego warto się z nią zapoznać. Funkcja curry jest dostępna jako część biblioteki ramda.

Implementacja funkcji add z użyciem curry będzie wyglądało w taki sposób:

const add = curry(2, (a, b) => a + b)

add(5, 3) // 8
add(5)(3) // 8

Jak widać nie musimy już martwić się o ilość argumentów, z którą funkcja została użyta. curry zajmie się tym za nas i pozwoli na pisanie funkcji w sposób taki sam jak dotychczas dostarczając jednocześnie dodatkowe możliwości użycia.

Dołącz do Newslettera

A będziesz na bieżąco ze wszystkimi postami na moim blogu.