Kurs JavaScript i TypeScript · Moduł 12: Programowanie funkcyjne

Kompozycja funkcji: compose i pipe

6 min czytania
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 -> validate

W 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: 2

Konsola 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. 1. W jakiej kolejności pipe wykonuje funkcje?

  2. 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.

Przydatne artykuły