Kurs JavaScript i TypeScript · Moduł 12: Programowanie funkcyjne
Kompozycja funkcji: compose i pipe
W tej lekcji5
Nazwa gatunku przychodzi z sensora jako ' rex ' i zanim trafi do rejestru, musi przejść przez trzy obróbki. Zapis addPrefix(capitalize(normalize(name))) czyta się od środka na zewnątrz, a przy pięciu etapach nawiasy zaczynają przypominać splątaną nić DNA. Chcemy pisać etapy w tej kolejności, w jakiej naprawdę się wykonują.
W laboratorium genetycznym Parku Jurajskiego DNA dinozaura przechodzi przez serię etapów przetwarzania - ekstrakcja, oczyszczanie, sekwencjonowanie, walidacja. Każdy etap to oddzielna operacja, a ich połączenie w łańcuch daje kompletny proces. Dokładnie tak działa kompozycja funkcji - łączenie prostych funkcji w bardziej złożone operacje.
Czym jest kompozycja funkcji?
Kompozycja funkcji polega na łączeniu dwóch lub więcej funkcji, gdzie wynik jednej staje się wejściem następnej. Matematycznie: f(g(x)) - najpierw stosujemy g, potem f.
Oto trzy stanowiska do obróbki nazwy: trim usuwa spacje z brzegów, charAt(0).toUpperCase() powiększa pierwszą literę, a slice(1) dokleja resztę tekstu:
1// Pojedyncze operacje na danych dinozaura
2const normalize = (name) => name.trim().toLowerCase();
3const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
4const addPrefix = (name) => 'DINO-' + name;Każda funkcja jest czysta i robi jedną rzecz. Teraz połączmy je na dwa sposoby, najpierw zagnieżdżając wywołania, potem zamykając je w nazwanej funkcji:
1// Bez kompozycji - zagnieżdżone wywołania (trudne do czytania)
2const result = addPrefix(capitalize(normalize(' rex ')));
3// 'DINO-Rex'
4
5// Z kompozycją - czytelny pipeline
6const formatDinoName = (name) => addPrefix(capitalize(normalize(name)));
7formatDinoName(' rex '); // 'DINO-Rex'Oba zapisy dają 'DINO-Rex'. Druga wersja nadaje procesowi nazwę i można jej używać wielokrotnie, ale kolejność etapów nadal czytasz od prawej do lewej. Funkcje compose i pipe automatyzują takie łączenie dla dowolnej liczby etapów.
Funkcja compose
compose łączy funkcje od prawej do lewej - ostatnia funkcja w liście jest wykonywana pierwsza. To odwzorowanie matematycznej notacji f(g(x)).
Parametr resztowy ...fns zbiera wszystkie przekazane funkcje w tablicę. reduceRight działa jak reduce, ale przechodzi tablicę od końca, a wartością początkową akumulatora są dane wejściowe:
1function compose(...fns) {
2 return (value) => fns.reduceRight(
3 (acc, fn) => fn(acc),
4 value
5 );
6}Sama funkcja compose niczego jeszcze nie przetwarza, tylko zwraca nową funkcję czekającą na dane. Sprawdźmy ją na trzech etapach obróbki próbki:
1// Funkcje przetwarzające próbę DNA
2const extractDNA = (sample) => ({ ...sample, dna: sample.tissue + '-DNA' });
3const sequenceDNA = (sample) => ({ ...sample, sequence: sample.dna + '-SEQ' });
4const validateDNA = (sample) => ({ ...sample, valid: sample.sequence.length > 5 });
5
6// Compose: od prawej do lewej (extractDNA -> sequenceDNA -> validateDNA)
7const processSample = compose(validateDNA, sequenceDNA, extractDNA);
8
9const result = processSample({ tissue: 'TREX-001' });
10// { tissue: 'TREX-001', dna: 'TREX-001-DNA', sequence: 'TREX-001-DNA-SEQ', valid: true }extractDNA wykonuje się pierwsza, bo stoi najbardziej na prawo. Każdy etap zwraca nowy obiekt przez spread, więc próbka wejściowa { tissue: 'TREX-001' } zostaje nietknięta, a wynik zbiera pola dopisane po drodze.
Funkcja pipe
pipe to odwrotność compose - łączy funkcje od lewej do prawej. Czyta się jak naturalny przepływ danych, co wielu programistów uważa za bardziej intuicyjne.
Implementacja różni się jednym słowem, reduce zamiast reduceRight, a pierwsza funkcja na liście wykonuje się pierwsza:
1function pipe(...fns) {
2 return (value) => fns.reduce(
3 (acc, fn) => fn(acc),
4 value
5 );
6}
7
8// Pipe: od lewej do prawej (naturalny kierunek czytania)
9const processSample = pipe(extractDNA, sequenceDNA, validateDNA);
10// Identyczny wynik, ale bardziej czytelna kolejnośćTe same trzy etapy dają ten sam wynik co w compose, ale kolejność argumentów odpowiada teraz kolejności wykonania. Etapami mogą być też funkcje zapisane w miejscu, jak w tym raporcie aktywnych dinozaurów:
1// Praktyczny przykład - przetwarzanie danych parku
2const processReport = pipe(
3 (data) => data.filter((d) => d.status === 'active'),
4 (data) => data.map((d) => ({ name: d.name, zone: d.zone })),
5 (data) => data.sort((a, b) => a.name.localeCompare(b.name)),
6);
7
8const parkData = [
9 { name: 'Rex', zone: 'A', status: 'active' },
10 { name: 'Stego', zone: 'B', status: 'inactive' },
11 { name: 'Blue', zone: 'A', status: 'active' },
12];
13processReport(parkData);
14// [{ name: 'Blue', zone: 'A' }, { name: 'Rex', zone: 'A' }]Stego odpada na filtrze, map zostawia nazwę i strefę, a sort z localeCompare układa wynik alfabetycznie. Uwaga na pułapkę: sort zmienia tablicę, na której go wywołasz. Tu jest to bezpieczne, bo sortujemy świeżą tablicę z map, a parkData pozostaje w oryginalnej kolejności. Gdyby sortowanie było pierwszym etapem, użyj toSorted (ES2023), które zwraca posortowaną kopię.
Compose vs Pipe - kiedy którego używać?
Obie funkcje robią to samo, ale w odwrotnej kolejności. Wybór zależy od preferencji, co najlepiej widać na schemacie z trzema etapami:
1// compose: od prawej do lewej (jak w matematyce)
2const process1 = compose(validate, transform, parse);
3// Czytaj: validate(transform(parse(x)))
4
5// pipe: od lewej do prawej (jak naturalny przepływ)
6const process2 = pipe(parse, transform, validate);
7// Czytaj: x -> parse -> transform -> validateW praktyce pipe jest częściej używany, bo odwzorowuje naturalny kierunek przetwarzania danych - od wejścia do wyjścia, od góry do dołu.
Jak budować pipeline
Sprawdzony porządek pracy wygląda tak: najpierw zdefiniuj czystą funkcję dla każdego kroku, potem przetestuj każdą osobno, następnie połącz je przez pipe lub compose, a na końcu przekaż pipeline'owi dane wejściowe. Pilnuj jednej zasady: każdy etap przyjmuje jeden argument i zwraca to, czego oczekuje następny. Jeśli któryś zapomni o return, kolejny dostanie undefined.
Gdy chcesz podejrzeć dane między etapami, wstaw pomocniczą funkcję tap. Wykonuje ona efekt uboczny, ale oddaje dalej dokładnie tę samą wartość:
1// Podgląd danych między etapami, bez zmiany wyniku
2const tap = (fn) => (value) => {
3 fn(value);
4 return value;
5};
6
7const debugReport = pipe(
8 (data) => data.filter((d) => d.status === 'active'),
9 tap((data) => console.log('Po filtrze:', data.length)),
10 (data) => data.map((d) => d.name),
11);
12
13debugReport(parkData); // ['Rex', 'Blue'], w konsoli: Po filtrze: 2Konsola pokazuje liczbę aktywnych dinozaurów, a wynik końcowy to wciąż zwykła lista ich nazw - tap niczego w nim nie zmienił. Polecam Ci trzymać tap pod ręką przy debugowaniu i usuwać go przed wdrożeniem. W następnej lekcji zobaczysz, jak currying zamienia funkcje wieloargumentowe w jednoargumentowe, które idealnie pasują do pipe.
Pamiętaj: pipe to taśma w laboratorium - próbka wjeżdża z lewej, przechodzi przez kolejne stanowiska i wyjeżdża z prawej jako gotowy wynik.
Kod do tej lekcji: index.js
1// Kompozycja funkcji: compose i pipe
2console.log("=== Park Jurajski - Rekombinacja DNA ===\n");
3
4// Implementacja compose (prawo -> lewo)
5function compose(...fns) {
6 return (value) => fns.reduceRight((acc, fn) => fn(acc), value);
7}
8
9// Implementacja pipe (lewo -> prawo)
10function pipe(...fns) {
11 return (value) => fns.reduce((acc, fn) => fn(acc), value);
12}
13
14// --- PRZYKLAD 1: Formatowanie nazw ---
15console.log("--- Compose: Formatowanie nazw ---");
16const normalize = (str) => str.trim().toLowerCase();
17const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
18const addPrefix = (str) => "DINO-" + str;
19
20const formatName = compose(addPrefix, capitalize, normalize);
21console.log(formatName(" rex ")); // DINO-Rex
22console.log(formatName(" VELOCIRAPTOR ")); // DINO-Velociraptor
23
24// --- PRZYKLAD 2: Pipeline przetwarzania DNA ---
25console.log("\n--- Pipe: Przetwarzanie proby DNA ---");
26
27const extractDNA = (sample) => ({ ...sample, dna: sample.tissue + "-DNA" });
28const sequenceDNA = (sample) => ({ ...sample, sequence: sample.dna + "-SEQ" });
29const assessQuality = (sample) => ({
30 ...sample,
31 quality: sample.sequence.length > 15 ? "HIGH" : "LOW",
32});
33const createReport = (sample) => ({
34 ...sample,
35 report: sample.quality + " quality sample from " + sample.tissue,
36});
37
38const processSample = pipe(extractDNA, sequenceDNA, assessQuality, createReport);
39
40const result = processSample({ tissue: "TREX-001" });
41console.log("Wynik:", JSON.stringify(result, null, 2));
42
43// --- PRZYKLAD 3: Przetwarzanie danych parku ---
44console.log("\n--- Pipe: Raport bezpieczenstwa ---");
45
46const parkData = [
47 { name: "Rex", zone: "A", status: "active", danger: 9 },
48 { name: "Brachio", zone: "B", status: "active", danger: 2 },
49 { name: "Raptor", zone: "A", status: "escaped", danger: 8 },
50 { name: "Stego", zone: "B", status: "active", danger: 3 },
51 { name: "Blue", zone: "A", status: "active", danger: 7 },
52];
53
54const generateReport = pipe(
55 (data) => data.filter((d) => d.status === "active"),
56 (data) => data.filter((d) => d.danger >= 5),
57 (data) => data.map((d) => ({ name: d.name, zone: d.zone, level: d.danger > 7 ? "CRITICAL" : "HIGH" })),
58 (data) => [...data].sort((a, b) => a.name.localeCompare(b.name)),
59);
60
61const report = generateReport(parkData);
62console.log("Aktywne zagrozenia:");
63report.forEach((r) => console.log(` [${r.level}] ${r.name} - Zone ${r.zone}`));
64
65// --- PRZYKLAD 4: Compose vs Pipe ---
66console.log("\n--- Porownanie compose vs pipe ---");
67const double = (x) => x * 2;
68const addTen = (x) => x + 10;
69const square = (x) => x * x;
70
71const withCompose = compose(square, addTen, double);
72const withPipe = pipe(double, addTen, square);
73
74console.log("compose(square, addTen, double)(3):", withCompose(3));
75console.log("pipe(double, addTen, square)(3):", withPipe(3));
76console.log("Oba: 3 -> double(6) -> addTen(16) -> square(256)");Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. W jakiej kolejności pipe wykonuje funkcje?
2. Jaka jest różnica między compose a pipe?
To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Napisz funkcje normalizujące i klasyfikujące dane, a następnie połącz je za pomocą pipe.
- Układanie w poziomie
Ułóż elementy implementacji funkcji pipe:
- Edytor kodu
Grupuj dane po strefach, oblicz statystyki per typ sensora i zlicz alerty.