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.