wSołtysiak BlogPraktycznie o tworzeniu oprogramowania i nie tylko
02 lutego 2020

Pattern matching w JavaScript

Jedną z fundamentalnych umiejętności programisty jest rozdzielanie logiki programu na podstawie warunków. Instrukcje takie jak if/else oraz switch zna każdy, kto chociaż na moment zetknął się z programowaniem. Ponadto wiele osób używa w swoim kodzie operatora trójargumentowego ?:. Nie są to jednak wszystkie konstrukcje warunkowe, jakie mamy do dyspozycji.

Istnieje także inny, mniej popularny sposób zwany dopasowaniem do wzorca. Jest on szczególnie często używany w podejściu funkcyjnym i systematycznie pojawia się w kolejnych językach programowania. Z zalet pattern matchingu możemy skorzystać także w języku JavaScript, mimo iż nie jest jeszcze częścią specyfikacji ECMAScript.

Pattern matching w JavaScript

Krótko o pattern matchingu

Skoro znamy już trzy inne instrukcje warunkowe to kiedy przyda się nam dopasowanie do wzorca? Pattern matching znajdzie zastosowanie w sytuacji, w której chcemy rozdzielić logikę naszego programu na podstawie struktury obiektu lub tablicy. Jest to często spotykany przypadek podczas operacji na skomplikowanych danych, choć zdarza się on także w przypadku prostych algorytmów.

Tradycyjnym rozwiązaniem tego problemu jest przeplatanie przypisań niektórych wartości z przekazanej wcześniej struktury do nowych nazw oraz logiki biznesowej lub innych ważnych operacji między instrukcjami if lub switch. Podejdźmy do tego trochę inaczej. Rozwiązanie opisanej sytuacji można przedstawić w trzech krokach:

  1. Dopasowanie podanych danych do wzorca.
  2. Przypisanie potrzebnych wartości do ustalonych nazw.
  3. Wykonanie instrukcji odpowiadających konkretnemu wzorcowi.

Tak właśnie działa pattern matching. Oznacza to, że możemy zastąpić drabinkę if/else albo switcha, przypisania i destrukturyzacje jednym mechanizmem. Kod staje się zwięźlejszy, bardziej zrozumiały i łatwiejszy do przyszłego rozwijania.

W wielu językach funkcyjnych na etapie kompilacji sprawdzane są dodatkowe zasady:

  • pokrycie kodem wszystkich możliwych dopasowań — każda możliwa wartość musi dopasować się do jednego ze zdefiniowanych wzorców,
  • redundancje zdefiniowanych wzorców — zabronione jest definiowanie wzorców, które się pokrywają. Taka sytuacja może się zdarzyć, gdy wcześniej zdefiniowany wzorzec jest ogólniejszy i “przechwytuje” przypadki przeznaczone dla późniejszych wzorców,
  • możliwość spełnienia wzorców przez przekazaną wartość — nie można tworzyć wzorców, które nigdy nie zostaną spełnione

W JavaScripcie niestety nie ma możliwości przeprowadzenia takiej analizy kodu, więc cała odpowiedzialność za przestrzeganie tych zasad spada na programistę.

Pattern matching w praktyce

Pomimo braku dopasowania do wzorca w specyfikacji ECMAScript, nic nie stoi na przeszkodzie, żeby zaimplementować część przydatnych mechanizmów. Zostało już to zrobione w bibliotece z, na której opierać będą się poniższe przykłady.

Funkcja matches

Biblioteka z eksportuje tylko jedną funkcję o nazwie matches. Jako parametr przyjmuje ona dowolną wartość, która później będzie dopasowywana do wzorców. Wynikiem matches jest funkcja, do której podajemy “funkcje-reguły” składające się z wzorca i instrukcji, które zostaną wykonane w przypadku dopasowania. Chociaż opis może wydawać się dość zawiły, to całość prezentuje się nieskomplikowanie:

import { matches } from 'z';

const result = matches(value)(
  (pattern1) => ...,
  (pattern2) => ...,
  (pattern3) => ...,
);

Powyższy kod może przypominać wałkowany wielokrotnie na lekcjach matematyki zapis funkcji:

Wzór na ciąg Fibonacciego

Jest to bardzo trafne skojrzenie. Dlatego przekształcenie matematycznego zapisu na kod z użyciem dopasowania do wzorca jest banalnie proste:

import { matches } from 'z';

const fib = n => matches(n)(
  (n = 0) => 0,
  (n = 1) => 1,
  (n) => fib(n-1) + fib(n-2)
);

Uproszczenie pracy z tablicami

Operacje na tablicy, które wykraczają poza zwykłą iterację, potrzebują informacji o konkretnych elementach (np. pierwszym) albo wymagają różnych operacji dla określonych podzbiorów. Dodając do tego specjalne przypadki (pusta tablica, tablica zawierająca jeden element itd.), które musimy dodatkowo obsłużyć, kończymy z kodem zawierającym wiele warunków, przypisań i rozgałęzień logiki.

Spójrzmy jak wygląda kod z zastosowaniem biblioteki z na przykładzie implementacji quicksort:

import { matches } from 'z';

const quicksort = array => matches(array)(
  (arr = []) => [],
  (pivot, tail = []) => [pivot],
  (pivot, tail) => [
    ...quicksort(tail.filter(x => x < pivot)),
    pivot,
    ...quicksort(tail.filter(x => x >= pivot)),
  ],
);

Specjalne przypadki zamknęliśmy w dwóch pierwszych regułach. Najważniejsza część implementacji algorytmu została przypisana do trzeciego wzorca. Zastosowanie pattern matchingu pozwala już na pierwszy rzut oka zrozumieć, jak powinna zachować się funkcja dla konkretnego wejścia. Potrzebne dane zostały rozbite i przypisane do odpowiednich nazw. Nie musimy więc martwić się o odpowiednie przygotowanie danych, co skutkuje brakiem dodatkowego kodu.

Sprawdzanie wartości i typu pól w obiekcie

Pattern matching przychodzi nam z pomocą również w przypadku pracy z obiektami. Możemy budować wzorce na podstawie wartości i typów pól. Weźmy na warsztat codzienną sytuację, którą spotykamy w komponentach wyświetlających dane z API:

function Dogs({ onDogSelected }) {
  const { loading, error, data } = useQuery(GET_DOGS);

  if (loading) return 'Loading...';
  if (error) return `Error! ${error.message}`;

  return (
    <select name="dog" onChange={onDogSelected}>
      {data.dogs.map(dog => (
        <option key={dog.id} value={dog.breed}>
          {dog.breed}
        </option>
      ))}
    </select>
  );
}

Hook useQuery zwraca obiekt zawierający m.in. trzy pola: loading typu boolean, error przyjmujący wartość typu ApolloError oraz data wypełnione danymi z API lub przyjmujące undefined. Musimy więc obsłużyć 3 stany — ładowanie, wystąpienie błędu i poprawną odpowiedź serwera. Każdy z tych stanów możemy rozpoznać po konkretnym wzorcu:

export function Dogs({ onDogSelected }) {
  return matches(useQuery(GET_DOGS))(
    (result = { loading: true }) => 'Loading...',
    (result = { error: typeof ApolloError }) => 
      `Error! ${result.error.message}`,
    (result = { data: Object }) => (
      <select name="dog" onChange={onDogSelected}>
        {result.data.dogs.map(dog => (
          <option key={dog.id} value={dog.breed}>
            {dog.breed}
          </option>
        ))}
      </select>
    ),
  );
}

Pierwszy wzorzec, odpowiadający za stan ładowania danych, sprawdza wartość pola loading. Kolejne dwa przypadki mogą przyjmować szeroki zakres wartości. Z tego powodu ich sprawdzanie odbywa się po typie. Jeśli sytuacja by tego wymagała, można w czytelny sposób rozbudowywać kod o kolejne przypadki.

Czy warto używać pattern matchingu w JS?

Pomimo wielu zalet rozwiązanie oferowane przez bibliotekę z nie jest jeszcze zbyt dojrzałe. Podczas eksperymentowania z różnymi zastosowaniami natknąłem się na kilka problemów:

  • brak wsparcia dla async/await,
  • brak możliwości sprawdzenia, czy obiekt posiada konkretne pola,
  • brak możliwości wykorzystania rozbitych pól obiektu ze wzorca w instrukcjach (z tego powodu w przykładzie z useQuery ciągle powtarzane jest odwołanie do result),
  • nieprzystosowanie do partial application

Autor pracuje nad nową wersją biblioteki, w której znajdą się powyższe funkcjonalności. Do tego czasu wstrzymałbym się z używaniem tej biblioteki w komercyjnych projektach. Zupełnie inne zdanie mam w przypadku projektów hobbistycznych. Pattern matching w wielu miejscach poprawi czytelność twojego kodu oraz wprowadzi do projektu trochę świeżości.

ECMAScript proposal

Od roku 2017 trwają pracę nad wprowadzeniem nowej instrukcji warunkowej do standardu ECMAScript. W maju 2018 projekt uzyskał status Stage 1: proposal. Wiele założeń pokrywa się się z wcześniej przedstawioną biblioteką, jednak pojawiają się różnice o których warto poczytać. Niestety potrzeba jeszcze wiele pracy i czasu, zanim dopasowanie do wzorca stanie się częścią standardu. Więcej informacji można znaleźć na GitHubie.

const getLength = vector => case (vector) {
  when { x, y, z } -> Math.sqrt(x ** 2 + y ** 2 + z ** 2)
  when { x, y } -> Math.sqrt(x ** 2 + y ** 2)
  when [...etc] -> vector.length
}

getLength({ x: 1, y: 2, z: 3 }) // 3.74165

Podsumowanie

Pattern matching to kolejna konstrukcja warunkowa, która potrafi znacząco uprościć kod w sytuacjach, które wymagają podjęcia decyzji na podstawie struktury, typu oraz wartości danych. W wielu językach (Haskell, Scala, Elm, OCaml itd.) jest to jedna ze standardowych konstrukcji, z którą warto się zapoznać. Nic nie stoi na przeszkodzie, żeby wykorzystać dopasowanie do wzorca w swoim “pobocznym projekcie” napisanym w JavaScripcie, do czego gorąco zachęcam.