- Niemutowalność
- Programowanie imperatywne a deklaratywne
- Lambda expression
- Czyste funkcje
- Funkcje wyższego rzędu
- Currying
- Kompozycja
Niemutowalność
Niemutowale (immutable) oznacza, że nie może być zmodyfikowane po tym, jak zostanie stworzone. Jest to jeden z corowych konceptów w programowaniu funkcyjnym, który zapewnia;
- przewidywalny stan
- łatwość debugowania
Warto wrócić na chwilę, do const
i do tego, że nie gwarantuje on nam niemutowalności, o czym pisałem tutaj.
W Js wbudowane metody są metody, zapewniające niemutowalność.
Zobaczmy to na przykładzie tabeli obiektów, w jaki sposób mutowalne operacje wykonać w niemutowalny sposób.
Dodawanie do tablicy – spread, concat
const cars = [
{ id: 1, brand: 'Volvo' },
{ id: 2, brand: 'Audi' }
];
const newCar = { id: 3, brand: 'Tesla' };
// MUTOWALNY
cars.push(newCar);
console.log(cars); // [{id: 1, brand: "Volvo"}, {id: 2, brand: "Audi"}, {id: 3, brand: "Tesla"}];
// Jak widać powyższa tabela została zmieniona
// NIEMUTOWALNY za pomocą operatora sprad
const newCars = [...cars, newCar];
console.log(cars); // [{id: 1, brand: "Volvo"}, {id: 2, brand: "Audi"}]; <-- oryginału nie zmieniono
console.log(newCars); // [{id: 1, brand: "Volvo"}, {id: 2, brand: "Audi"}, {id: 3, brand: "Tesla"}];
// Zwrócono nową tablicę bez zmianiania oryginału
// NIEMUTOWALNY za pomocą concat
const newCars2 = cars.concat([newCar]);
console.log(cars); // [{id: 1, brand: "Volvo"}, {id: 2, brand: "Audi"}]; <-- oryginału nie zmieniono
console.log(newCars2); // [{id: 1, brand: "Volvo"}, {id: 2, brand: "Audi"}, {id: 3, brand: "Tesla"}];
// Zwrócono nową tablicę bez zmianiania oryginału
Usuwanie z tablicy – filter
const cars = [
{ id: 1, brand: 'Volvo' },
{ id: 2, brand: 'Audi' }
];
// MUTOWALNY za pomocą splice
cars.splice(0,1);
console.log(cars); // [{id: 2, brand: 'Audi'}];
// Jak widać powyższa tabela została zmieniona
// NIEMUTOWALNY za pomocą operatora filter
const newCars = cars.filter(car => car.id !== 1);
console.log(cars); // [{id: 1, brand: "Volvo"}, {id: 2, brand: "Audi"}]; <-- oryginału nie zmieniono
console.log(newCars); // [{id: 2, brand: "Audi"}]; <-- Zwrócono nową tablicę bez zmianiania oryginału
Dodawanie do obiektu – spread
const car = { id: 1, brand: 'Volvo' };
// MUTOWALNIE
car.price = 100;
console.log(car); // {id: 1, brand: "Volvo", price: 100} <-- zmodyfikowano oryginalny obiekt
// NIEMUTOWALNIE
const newCar = {...car, price: 100 };
console.log(car); // {id: 1, brand: "Volvo", price: 100} <-- oryginał bez zmian
console.log(newCar); // {id: 1, brand: "Volvo", price: 100} <-- nowy obiekt zawiera price
Usuwanie z obiektu – destructuring
const car = {id: 1, brand: 'Volvo', price: 100};
// MUTOWALNIE
delete car.price;
console.log(car); // {id: 1, brand: "Volvo"} <-- zmodyfikowano oryginalny obiekt, usunięty price
// NIEMUTOWALNIE - destructuring
const { price, ...newCar } = car;
console.log(car); // {id: 1, brand: "Volvo", price: 100} <-- oryginał bez zmian
console.log(newCar); // {id: 1, brand: "Volvo"} <-- nowy obiekt bez price
console.log(price); // 100 <-- dodatkowo możemy dobrać się do wydzielonej propertki
Nowoczesne frameworki, wykorzystuą porównanie obiketów (tzw. indentity) do uruchomienia detekcji zmian i tym samym odświeżenia widoku. Mutując dane to porównanie nie działa, co może skutkować problemami z detekcją zmian.
Programowanie imperatywne a deklaratywne
Programowanie imprtatywne, to programowanie takie, jakim znamy ze starego dobrego Js, gdzie wszystko po kolei wskazujemy, co ma być wykonane. Za przykład może posłużyć pętla for, gdzie kod to lista instrukcji, która musi zostać wykonana, aby uzyskać listę marek:
const brands = [];
const cars = [
{ id: 1, brand: 'Volvo' },
{ id: 2, brand: 'Audi' }
];
for (let i = 0; i < cars.length; i++) { // lista instrukcji, która opisuje dokładnie CO ma być
const brand = cars[i].brand;
brands.push(brand);
}
console.log(brands); // ['Volvo', 'Audi'];
W deklaratywnym programowaniu opisujemy w jaki sposób coś ma być osiągnięte.
Aby powyższy kod był deklaratywny, wykorzystamy map
, któy opisuje to, co się dzieje, nie zmienia stanu i jest dużo czytelniejszy.
Można myśleć o programowaniu deklaratywnym jako o programowaniu sentencjami, a nie instrukcjami dla maszyny:
const cars = [
{ id: 1, brand: 'Volvo' },
{ id: 2, brand: 'Audi' }
];
const brands = cars.map(car => car.brand); // tutaj jedynie opisujemy JAK
console.log(brands); // ['Volvo', 'Audi'];
Jak widać, kod jest dużo czytelniejszy. Łącząc ze sobą wiele czystych funkcji, możemy w łatwy sposób stworzyć czytelną i bezpieczną kompozycję.
Lambda expression
Żeby lepiej zrozumieć, czym są lamdy, zacznijmy od zrozumienia następujących rzeczy:
deklaracja funkcji (function declaration)
Jest definicją funkcji
function getBrand(car) {
return car.brand;
};
function expression
wyrażenie jest wtedy, gdy funkcja jest przypisana do zmiennej
const getBrandExpression = function getBrand(car) {
return car.brand;
}
funkcje anonimowe
Funkcje które nie zawierają nazw, najczęściej wykorzystywana jako callback. Ze względu na brak nazwy, nie można uzyskać dostępu do tej funkcji po tym, jak zostanie ona utworzona.
const cars = [
{ id: 1, brand: 'Volvo' },
{ id: 2, brand: 'Audi' }
];
console.log(cars.map(function(car) { // Funkcja anonimowa wykorzystana jako callback
return car.brand;
}));
W świecie programowania lambdami nazywamy stricte funkcje anonimowe. Co wyróżnia lambdę to to, że jest jej wartość jest przekazywana jako argument do innej funkcji. Nie wywołujemy jej bezpośrednio a jedynie przekazujemy.
const cars = [
{ id: 1, brand: 'Volvo' },
{ id: 2, brand: 'Audi' }
];
const getBrand = car => car.brand; // To jest lambda expression;
console.log(cars.map(getBrand)); // Przekazana jako argument;
Czyste funkcje
Aby mówić o tym, że funkcja jest czysta, musi ona spełniać 2 wymagania:
Referential transparency – oznacza, że funkcja polega jedynie na własnych argumentach. Przekazywanie tych samych argumentów zwróci ten sam wynik. Oznacza to też, że nie może polegać na żadnym stanie, leżącym poza tą funkcją.
Side effect free – oznacza, że funkcja nie może mieć wpływu na otoczenie, nie może mutować innych obiektów, wykonywać zapytań API, odwoływać się do consoli, czy nawet modyfikować DOM
const items = [
{ id: 1, value: 100 },
{ id: 2, value: 200 }
];
// Przykład funkcji nieczystej
const getTotalValuesImpure = () => {
const sum = items.reduce((prev, next) => prev + next.value, 0); // Odwołanie się do stanu zewnętrznego
console.log(sum); // Side effect w postaci console.log
};
getTotalValuesImpure(); // 300
// Przykład powyższej funkcji jako czystej
// przekazanie items jako argument funkcji oraz usunięcie side effektu
const getTotalValuesPure = (passedItems) => passedItems.reduce((prev, next) => prev + next.value, 0);
console.log(getTotalValuesPure(items)); // 300
Czyste funckje, w porównaniu do funkcji nieczystych, są dużo łatwiejsze w reużyciu oraz testowaniu.
Domknięcia w programowaniu funkcyjnym
Pisałem o domknięciach już wcześniej.
Dla przypomnienia, domknięcie tworzone jest z każdą nową funkcją i daje nam dostęp do zasięgu leksykalnego funkcji.
Domknięcia, z racji tego, że są to funkcje zwracane przez inne funkcje, musimy wywoływać dwa razy.
const cars = [
{ id: 1, brand: 'Volvo' },
{ id: 2, brand: 'Audi' }
];
const mobiles = [
{ id: 1, brand: 'Iphone' },
{ id: 2, brand: 'Samsung' }
];
// Całość to lambda expression, nie ma nazwy, a rzeczy są tworzone anonimowo.
// A więc mamy lambda expression, które zwraca domknięcie
const getBrandForId = id => {
return items => items.find(item => item.id === id).brand; // Domknięcie
};
const getBrandForId1 = getBrandForId(1); // Przy okazji stworzyliśmy Higher Order Function
console.log(getBrandForId1(cars)); // Volvo
console.log(getBrandForId1(mobiles)); // Iphone
console.log(getBrandForId(2)(mobiles)); // Samsung <-- podwójne wywołanie domknięcia
Funkcje wyższego rzędu
Funkcje wyższego rzędu (HOC – Higher Order Functions) posiadają dwie cechy:
- Zwracają nowo funkcję
- Funkcja ta może przyjąć inną funkcję, jako argument
W programowaniu funkcyjnym, funkcje te, pozwalają pisać nam prostszy i czytelniejszy kod i jest to ładana abstrakcja, którą możemy użyć do komponowania.
W Js wbudowano szereg funkcji wyższego rzędu: forEach(); map(); filter(); reduce().
Currying
Jest to możliwość do transformacji funkcji wieloargumentowej w pojedyńcze funkcje, wywoływane po kolei.
A wiec f(a,b,c)
po curryingu będzie wyglądała tak f(a)(b)(c)
.
Co ciekawe, używanie funkcji rozbitej na pojedyńcze, wcale nie pozbawia nas możliwości wywołania funkcji pierwotnej (z wieloma argumentami);
// Prosta implementacja funckcji curry, która zazwyczaj jest dostarczana z biblioteką np. Ramda
const curry = fn => (...args) => {
if(args.length >= fn.length) {
return fn.apply(null, args); // Wywołanie funkcji w trybie 'normalnym'
}
return fn.bind(null, ...args); // Przekazanie funkcji jako argument
}
const cars = [
{ id: 1, brand: 'Volvo' },
{ id: 2, brand: 'Audi' }
];
const getBrandForId = curry(
(id, items) => items.find(item => item.id === id).brand
);
const getId1 = getBrandForId(1,cars);
const getId2 = getBrandForId(2); // zaaplikowano częściowo (partially applied)
console.log(getId1); // Volvo
console.log(getId2(cars)); // Audi
console.log(getBrandForId(1)(cars)) // Volvo
Jak widać, dzięki użyciu curryingu, mamy możliwość dowolnego wywołania naszej funkcji. Do tego, możemy wykorzystać curriying do tworzenia tzw. kompozycji.
Szerzej o tym opowiada Todd w swoim kursie Javascript Masterclass.
Kompozycja
Kompozycja funkcji polega na łączeniu ze sobą funkcji w tzw. łańcuch wywołań. Wejście jednej z funkcji jest wejściem do kolejnej.
Rozważmy poniższy przykład, w którym będziemy chcieli napisać funkcję slugify, zmianiającą 'Sample text’ na 'sample-text’.
const myText = "Sample text";
// Podejście klasyczne do rozwiązania problemu
const classicSlug = myText
.split(' ')
.map(x => x.toLowerCase())
.join('-');
console.log(classicSlug); // sample-text
Jak widać, strumień przetwarzania jest całkiem czytelny. Sprónujmy jednak to zrobić w sposób funkcyjny, tworząc odpowiednie carried functions.
// Prosta implementacja funckcji curry, która zazwyczaj jest dostarczana z biblioteką np. Ramda
const curry = fn => (...args) => {
if(args.length >= fn.length) {
return fn.apply(null, args); // Wywołanie funkcji w trybie 'normalnym'
}
return fn.bind(null, ...args); // Przekazanie funkcji jako argument
};
// Prosta implementacja funkcji pipe, która zazwyczaj jest dostarczana z biblioteką np. RxJs
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x); // aby zrobić compose użyj reduceRight
// Helperki, będące curried functions
const split = curry((separator, string) => string.split(separator));
const map = curry((fn, array) => array.map(fn));
const join = curry((separator, string) => string.join(separator));
const toLowerCase = text => text.toLowerCase(); // Lambda expression
const functionalSlug = pipe(
split(' '), // częściowo wykonane (partially applied) i przekazane niżej
map(toLowerCase), // ...
join('-') // ...
);
// Łańcuch przetwarzania jest uśpiony do momentu wywołania tej funkcji
console.log(functionalSlug('Sample text')); // sample-text