Hlavní navigace

Reason: N-tice, záznamy a úvod do pattern matchingu

Radek Miček

Dnešní díl bude o stavových komponentách v Reactu. Abychom takové komponenty mohli programovat, naučíme se napřed používat n-tice a záznamy v Reasonu. Kromě toho si zlehka představíme jeden z trumfů Reasonu – pattern matching.

Doba čtení: 9 minut

N-tice

Minule jsme se naučili, jak do jedné proměnné uložit jednu hodnotu, například číslo nebo řetězec, dnes si ukážeme, jak do jedné proměnné uložit hodnot více.

Nejjednodušším způsobem, jak toho dosáhnout, jsou n-tice. Například souřadnice můžeme reprezentovat jako dvojice:

let coordinates = (23, 42);

Nebo vstup pro náš převodník měn můžeme rovněž reprezentovat jako dvojici  (kurz, počet korun):

let input = (25.4, 200);

Pattern matching

Dříve či později bude třeba z dvojice získat jednotlivé hodnoty. To lze udělat pomocí pattern matchingu (česky: srovnání se vzorem):

let (x, y) = coordinates;

Pattern (vzor) (x, y) je dvojice, kde místo konkrétních hodnot jsou názvy proměnných. Pattern matching (srovnávání se vzorem) je proces, který do proměnných ve vzoru (tj. x a y) přiřadí takové hodnoty, aby se vzor shodoval s hodnotou napravo od = (tj.  coordinates).

Hodnota napravo od = je (23, 42). Aby se vzor (x, y) shodoval s hodnotou napravo od =, je třeba do x přiřadit 23 a 42 do  y.

Jiný příklad:

let foo = (1, ("nested", "pair"), true);

Pokud je pro nás důležitá druhá hodnota z vnořeného dvojice, použijeme následující pattern matching:

let (_, (_, importantValue), _) = foo;

Pro hodnoty, jenž nás nezajímají, používáme podtržítka místo proměnných. Tímto jsme definovali proměnnou importantValue s hodnotou "pair". Pokud chceme celou vnořenou dvojici, použijeme následující vzor:

let (_, nestedPair, _) = foo;

Proměnná nestedPair bude obsahovat dvojici ("nested", "pair"). Pokud chceme vnořenou dvojici i její druhou hodnotu, použijeme následující vzor:

let (_, (_, importantValue) as nestedPair, _) = foo;

Vzor můžeme chápat jako hodnotu s dírami (proměnnými) a srovnávání se vzorem se snaží tyto díry vyplnit. Speciálním případem vzoru je vzor, který neobsahuje žádnou díru:

let (23, 42) = coordinates;

Definice proměnné z minula je rovněž speciální případ pattern matchingu:

let bar = (2, 3);

Proměnná bar je jediná díra. Proces srovnání se vzorem do proměnné bar přiřadí celou hodnotu napravo od  =.

Pokud hodnota napravo od = neodpovídá vzoru (do proměnných ve vzoru nejde přiřadit, aby se hodnota napravo od = shodovala se vzorem), bude vyhozena výjimka. Například

let (32, y) = coordinates;

Ať do proměnné y přiřadíme cokoliv, (32, y) se nebude shodovat s (23, 42). Pattern matching tedy vyhodí výjimku.

Dobrou zprávou je, že výjimkám při pattern matchingu lze snadno předejít, neb překladač Reasonu vypisuje varování this pattern matching is not exhaustive, když existují hodnoty, které neodpovídají vzoru. Důležité je uvědomit si, že překladač kouká pouze na vzor, nikoliv na hodnotu napravo od =. Takže nás bude varovat i v případech, kdy žádná výjimka vyhozena nebude:

let (23, 42) = coordinates;
let (23, y) = coordinates;
let 1 = 1;

V praxi se toto varování nikdy nebere na lehkou váhu a pattern matching, jenž jej způsobuje, se nepoužívá!

Pattern matching a funkce

Pattern matching můžeme použít i při definování funkcí. Například místo

let processCoordinates = (coordinates) => {
  let (x, y) = coordinates;
  Js.log3("Processing", x, y);
};

můžeme psát rovnou

let processCoordinates = ((x, y)) =>
  Js.log3("Processing", x, y);

Chceme-li funkci zavolat, nesmíme zapomenout na závorky kolem dvojice:

processCoordinates((3, 4));

Pouhé processCoordinates(3, 4) je chyba, neboť funkce processCoordinates nebere dva argumenty nýbrž jeden.

N-tice se také hodí v situacích, kdy z funkce vracíme více hodnot.

Záznamy

Nepříjemnost n-tic je, že hodnoty v nich nejsou pojmenované. Například u vstupu pro převodník měn

let input = (25.4, 200);

si programátor musí pamatovat, že kurz je první a počet korun druhý. V těchto případech bývá lepší definovat nový datový typ záznam:

type calcInput = {
  crownsPerUnit: float,
  crowns: int
};

Jméno typu je calcInput. Nyní jej můžeme použít místo dvojice:

let input = {crownsPerUnit: 25.4, crowns: 200};

Při vytváření záznamu typu calcInput není nutné psát název typu, Reason typ odovdí podle názvů polí crownsPerUnitcrowns.

Abychom dostali hodnotu ze záznamu, můžeme opět použít pattern matching:

let {crownsPerUnit: cpu, crowns: crowns} = input;

Princip je stejný. Vzor je záznam, kde místo hodnot používáme názvy proměnných. Výše uvedený řádek tedy zadefinuje 2 nové proměnné cpu a crowns. Pokud je název definované proměnné stejný jako název pole záznamu, nemusíme jej psát. Stačí tedy

let {crownsPerUnit: cpu, crowns} = input;

Pokud nás navíc nějaké pole záznamu nezajímá, můžeme jej vynechat:

let {crownsPerUnit: cpu} = input;

Kromě pattern matchingu lze k polím záznamu přistupovat pomocí tečky. Kurz tedy můžeme získat  input.crownsPerUnit.

Jak změnit záznam

Hodnoty polí v záznamu měnit nelze, musíme vytvořit záznam nový. Například:

let input' = {crownsPerUnit: input.crownsPerUnit, crowns: 300};

Abychom ručně nemuseli kopírovat původní nezměněné hodnoty, existuje speciální syntax používající tři tečky:

let input' = {...input, crowns: 300};

Za třemi tečkami je záznam, ze kterého se vezmou hodnoty polí, jež explicitně nenastavíme – v našem případě explicitně nastavujeme pouze hodnotu pole  crowns.

Třítečková syntax se používá i v souboru src/page.re ve funkci  make.

let make = (~message, _children) => {
  ...component,
  render: (self) =>
    <div onClick=(self.handle(handleClick))>
      (ReasonReact.stringToElement(message))
      (ReasonReact.stringToElement({j|BTW $crowns CZK is equal to $dollars5 USD!|j}))
    </div>
};

Záznam component reprezentuje komponentu se standardním chováním. Tato komponenta nic nedělá. My měníme funkci render, aby komponenta něco vykreslila do stránky.

Reactí komponenty se stavem

Zatím náš převodník měn neumožňuje uživateli zadat kurz a ani částku v korunách, kterou chce převést na cizí měnu. Oboje je pevně určeno přímo v kódu. To nyní změníme. Do webové stránky přidáme vstupní pole pro kurz a pro částku. Kdykoliv se obsah jednoho z polí změní, spočteme částku v cizí měně a výsledek zobrazíme ve stránce.

Začneme tím, že z nestavové komponenty Page uděláme komponentu stavovou. Stav nám umožní uložit obsah vstupních polí.

Změna nestavové komponenty na stavovou se provede úpravou řádku

let component = ReasonReact.statelessComponent("Page");

na

let component = ReasonReact.reducerComponent("Page");

Tato úprava však způsobí, že se náš program nepřeloží. Překladač hlásí chybu:

This expression's type contains type variables that can't be generalized:
ReasonReact.componentSpec
('_a,  ReasonReact.stateless,  ReasonReact.noRetainedProps,
  ReasonReact.noRetainedProps,  '_b) 

Problém spočívá v tom, že se překladači nepodařilo zjistit konkrétní typ pro stav a konkrétní typ pro akce, jenž slouží ke změně stavu.

Akce, jenž slouží ke změně stavu? Ano, naše komponenta začne s nějakým počátečním stavem, který vrátila funkce initialState. Změny stavu se budou povádět tak, že se funkci reducer předá akce a funkce reducer na základě předané akce zajistí změnu stavu. Funkce initialState a reducer jsou součástí záznamu, jenž vrací make, a musíme je naimplementovat. Až je naimplementujeme, zmizí i chybové hlášení, protože překladač bude schopen zjistit konkrétní typy pro stav a akce, které slouží ke změně stavu.

Implementace initialState

Funkce initialState vrací počáteční stav naší komponenty. Abychom ji mohli naimplementovat, musíme zadefinovat datový typ pro stav:

type state = {
  crownsPerUnit: string,
  crowns: string
};

Datový typ je třeba zadefinovat před proměnnou component, neboť bude použit jako součást jejího typu. Nyní předefinujeme funkci initialState v záznamu, jenž vrací make. Zde je celý kód funkce  make:

let make = (~message, _children) => {
  ...component,
  initialState: () => {crownsPerUnit: "0.0", crowns: "0"},
  render: (self) =>
    <div onClick=(self.handle(handleClick))>
      (ReasonReact.stringToElement(message))
      (ReasonReact.stringToElement({j|BTW $crowns CZK is equal to $dollars5 USD!|j}))
    </div>
};

Implementace reducer

Abychom mohli naprogramovat funkci reducer, potřebujeme typ akcí, jež slouží ke změně stavu. Tento typ bude dvojice, jejíž první složka určuje, jaké vstupní pole se mění, a druhá složka je nová hodnota, nový řetězec. Musíme tedy definovat typ, kterým vyjádříme, jaké vstupní pole se mění:

type inputField =
  | CrownsPerUnit
  | Crowns;

Výše uvedený kód zadefinuje výčtový typ, enum, který má dvě možné hodnoty – buď CrownsPerUnit, nebo Crowns. Definici typu inputField opět musíme umístit před proměnnou component, důvod je stejný jako v případě typu state. Předefinujme  reducer:

let make = (~message, _children) => {
  ...component,
  initialState: () => {crownsPerUnit: "0.0", crowns: "0"},
  reducer: ((inputField, value), state) =>
    switch inputField {
    | Crowns => ReasonReact.Update({...state, crowns: value})
    | CrownsPerUnit => ReasonReact.Update({...state, crownsPerUnit: value})
    },
  render: (self) =>
    <div onClick=(self.handle(handleClick))>
      (ReasonReact.stringToElement(message))
      (ReasonReact.stringToElement({j|BTW $crowns CZK is equal to $dollars5 USD!|j}))
    </div>
};

Všimněme si, že předefinováním funkce reducer zmizela chyba kompilátoru. Pojďme si vysvětlit, co reducer dělá. První parametr funkce je akce, jenž slouží ke změně stavu, druhý parametr je původní stav. reducer napřed pomocí pattern matchingu rozloží akci, jenž slouží ke změně stavu, na inputField a value. inputField značí, které vstupní pole se změnilo, a value je jeho nová hodnota.

reducer musí vrátit instrukci, jak změnit stav komponenty. V našem případě se vždy bude jednat o instrukci tvaru ReasonReact.Update(novýStav), kde novýStav je záznam typu state obsahující nový stav. Abychom mohli spočítat nový stav, musíme vědět, které ze vstupních polí se změnilo, což zjistíme pomocí konstrukce  switch.

Zobrazení výsledku

Funkce pro práci se stavem máme napsané a program se přeloží. Ze stavu můžeme ve funkci render spočítat částku v cizí měně:

let make = (~message, _children) => {
  ...component,
  initialState: () => {crownsPerUnit: "0.0", crowns: "0"},
  reducer: ((inputField, value), state) =>
    switch inputField {
    | Crowns => ReasonReact.Update({...state, crowns: value})
    | CrownsPerUnit => ReasonReact.Update({...state, crownsPerUnit: value})
    },
  render: (self) => {
    let crownsPerUnit = float_of_string(self.state.crownsPerUnit);
    let crowns = int_of_string(self.state.crowns);
    let dollars = crownsToForeignCurrency2(~crownsPerUnit, crowns);
    <div onClick=(self.handle(handleClick))>
      (ReasonReact.stringToElement(message))
      (ReasonReact.stringToElement({j|BTW $crowns CZK is equal to $dollars USD!|j}))
    </div>
  }
};

K aktuálnímu stavu komponenty lze z funkce render přistupovat přes self.state. self je obyčejný parametr funkce render, nejedná se o analogii this z objektově orientovaných jazyků (záznamy v Reasonu jsou něco úplně jiného než objekty v Reasonu, bohužel).

Vstupní pole

Zbývá už jen přidat vstupní pole pro uživatele:

let make = (~message, _children) => {
  ...component,
  initialState: () => {crownsPerUnit: "0.0", crowns: "0"},
  reducer: ((inputField, value), state) =>
    switch inputField {
    | Crowns => ReasonReact.Update({...state, crowns: value})
    | CrownsPerUnit => ReasonReact.Update({...state, crownsPerUnit: value})
    },
  render: (self) => {
    let crownsPerUnit = float_of_string(self.state.crownsPerUnit);
    let crowns = int_of_string(self.state.crowns);
    let dollars = crownsToForeignCurrency2(~crownsPerUnit, crowns);
    let inputValue = (event) => ReactDOMRe.domElementToObj(ReactEventRe.Form.target(event))##value;
    <div onClick=(self.handle(handleClick))>
      <input
        value=self.state.crownsPerUnit
        onChange=((event) => self.send((CrownsPerUnit, inputValue(event))))
      />
      <input
        value=self.state.crowns
        onChange=((event) => self.send((Crowns, inputValue(event))))
      />
      (ReasonReact.stringToElement(message))
      (ReasonReact.stringToElement({j|BTW $crowns CZK is equal to $dollars USD!|j}))
    </div>
  }
};

Funkce inputValue použitá v handlerech událostí onChange získá novou hodnotu ze vstupního pole. V definici inputValue používáme ##, jenž slouží pro přítup k polím a metodám objektů. ## je pro objekty totéž co . pro záznamy. self.send dostane akci ke změně stavu a zavolá funkci  reducer.

Hotovo. Vše zdánlivě funguje, dokud uživatel zadává platná čísla do vstupních polí.

MIF18 tip v článku ROSA

Příště

Příště se budeme zabývat ošetřováním chyb, variantami a pattern matchingem. Základy pattern matchingu jsme si představili dnes, příště se podíváme na to, jak pattern matching funguje s variantami. Nově nabyté znalosti nám umožní vylepšit převodník měn, aby se choval slušně, i když uživatel zadává řetězce, jenž není možné převést na čísla.

Repozitář s kódy k tomuto dílu najdete na GitHubu.

Našli jste v článku chybu?