Hlavní navigace

Úvod do jazyka Reason: polymorfní varianty a objekty

Radek Miček

Dnes si ukážeme alternativy ke standardním variantám a záznamům, které podporují řádkový polymorfismus. Bude se jednat o typy, které není třeba explicitně definovat, a přesto lze vytvářet jejich hodnoty.

Doba čtení: 7 minut

Motivace pro polymorfní varianty

V jednom z minulých dílů jsme si ukázali datový typ pro JSON. S jeho pomocí můžeme napsat knihovnu (zde pouze jeden modul Json) pro zpracování JSONu:

module Json = {
  type t =
    | Assoc(list((string, t)))
    | Bool(bool)
    | Float(float)
    | Floatlit(string)
    | Int(int)
    | Intlit(string)
    | List(list(t))
    | Null
    | String(string);

  let parse = (_: string) =>
    Assoc([("bug", Int(122)), ("title", String("Implement JSON parser"))]);
};

Použití knihovny je snadné:

let isInteger = (json: Json.t) =>
  switch json {
  | Int(_) | Intlit(_) => true
  | _ => false
  };

isInteger(Json.parse("12"));

Potud je vše v pořádku. Představme si však, že vznikne jiná knihovna pro zpracování JSONu, samozřejmě lepší. Autoři nové skvělé knihovny by mohli použít typ Json.t z naší knihovny, ale to oni neudělají, neboť chtějí, aby jejich skvělá knihovna měla co nejméně závislostí. Implementují tedy úplně stejný typ jako je Json.t ve své skvělé knihovně:

module AwesomeJson = {
  type t =
    | Assoc(list((string, t)))
    | Bool(bool)
    | Float(float)
    | Floatlit(string)
    | Int(int)
    | Intlit(string)
    | List(list(t))
    | Null
    | String(string);

  let parse = (_: string) =>
    Assoc([("bug", Int(1)), ("title", String("Implement awesome JSON parser"))]);
};

Ač jsou typy Json.t a AwesomeJson.t nachlup stejné, naše funkce isInteger funguje pouze s typem Json.t. Pokus použít ji s hodnotou typu  AwesomeJson.t

isInteger(AwesomeJson.parse("12"));

vyústí v chybu

This has type:
  AwesomeJson.t
But somewhere wanted:
  Json.t 

V některých jazycích se tento problém řeší tak, že vznikne třetí knihovna, která obsahuje pouze typ pro reprezentaci JSONu a neobsahuje žádné funkce. Knihovny implementující funkce pro práci s JSONem pak musí záviset na této třetí knihovně a používat typy z této třetí knihovny.

Pokud se spokojíme s tím, že všechny knihovny pro práci s JSONem používají stejný typ pro reprezentaci JSONu, je problém vyřešen. Máme-li však ambicióznější cíl, toto řešení selhává. Co když se knihovna AwesomeJson rozhodne přidat konstruktor Comment(string, t) pro ukládání komentářů? Nebo, co když se knihovna MinimalJson rozhodne použít typ, který nemá konstruktory Floatlit a Intlit? Dokážeme zařídit, aby naše funkce isInteger fungovala se všemi typy Json.t, MinimalJson.t a AwesomeJson.t naráz? Nechceme přeci psát tři různé funkce!

Polymorfní varianty

Kromě standardních variant existují v Reasonu i tzv. polymorfní varianty, jejichž konstruktory jsou na rozdíl od normálních variant uvozeny zpětnou uvozovkou. Tyto varianty se nemusí před použitím definovat, například můžeme rovnou psát

let colorToString = (color) => switch color {
  | `Red => "red"
  | `Green => "green"
  | `Blue => "blue"
};

a kompilátor automaticky odvodí správný typ funkce – parametr color dostane typ [< `Red | `Green | `Blue ], což znamená, že povolené hodnoty jsou buď `Red nebo `Green nebo  `Blue.

Dále můžeme napsat funkci randomColor:

let randomColor = () => switch (Random.int(2)) {
  | 0 => `Red
  | 1 => `Green
  | _ => failwith("absurd")
};

která vrací náhodně vybranou barvu, výstup je typu [> `Green | `Red ]. Výstup z randomColor  můžeme použít jako vstup  colorToString

randomColor() |> colorToString;

i když typy nejsou úplně shodné. To je důsledkem < resp. >, jež jsou součástí typu parametru resp. typu výsledku. Znaménko < říká, že je přípustný každý typ, který obsahuje podmnožinu uvedených konstruktorů. Tj. funkce colorToString se podle potřeby může tvářit, že má mj. jeden z následujících typů:

([ `Blue | `Green | `Red ]) => string
([ `Blue | `Red ]) => string
([ `Green ]) => string 

Na druhé straně > říká, že je přípustný každý typ, který obsahuje nadmnožinu uvedených konstruktorů. Funkce randomColor může mj. mít jeden z následujích typů:

(unit) => [ `Green | `Red ]
(unit) => [ `Black | `Green | `Red ]
(unit) => [> `Black | `Green | `Red | `UtterNonsense ] 

Nevýhodou > je, že typ můžeme rozšířit o úplně nesmyslné konstruktory, polymorfní varianty tedy oslabují typovou kontrolu. Například upravme funkci colorToString, aby fungovala s libovolnou barvou:

let colorToString = (color) => switch color {
  | `Red => "red"
  | `Green => "green"
  | `Blue => "blue"
  | _ => "unknown color"
}; 

Změna se projeví v typu parametru, znaménko < se změní na >, nový typ bude [> `Blue | `Green | `Red ]. Nyní lze volat colorToString(`Black), ale i colorToString(`Block), což je překlep, jenž bohužel nevyústí v chybu při kompilaci. Flexibilita je vykoupena slabší typovou kontrolou.

Polymorfní varianty a JSON

Nyní vyřešíme naši motivační úlohu. Máme tři knihovny pro práci JSONem:

module Json = {
  type t = [
    | `Assoc(list((string, t)))
    | `Bool(bool)
    | `Float(float)
    | `Floatlit(string)
    | `Int(int)
    | `Intlit(string)
    | `List(list(t))
    | `Null
    | `String(string)
  ];
  let parse: string => t =
    (_) => `Assoc([("bug", `Int(122)), ("title", `String("Implement JSON parser"))]);
};

module AwesomeJson = {
  type t = [
    | `Assoc(list((string, t)))
    | `Bool(bool)
    | `Float(float)
    | `Floatlit(string)
    | `Int(int)
    | `Intlit(string)
    | `List(list(t))
    | `Null
    | `String(string)
    | `Comment(string, t)
  ];
  let parse: string => t =
    (_) => `Assoc([("bug", `Int(1)), ("title", `String("Implement awesome JSON parser"))]);
};

module MinimalJson = {
  type t = [
    | `Assoc(list((string, t)))
    | `Bool(bool)
    | `Float(float)
    | `Int(int)
    | `List(list(t))
    | `Null
    | `String(string)
  ];
  let parse: string => t =
    (_) => `Assoc([("bug", `Int(1)), ("title", `String("Implement simple JSON parser"))]);
};

Definujeme funkci isInteger:

let isInteger = (json) =>
  switch json {
  | `Int(_)
  | `Intlit(_) => true
  | _ => false
  };

Typová inference pro ni odvodí typ ([> `Int 'a | `Intlit 'b ]) => bool, což znamená, že funkce přijímá typy, které mají alespoň konstruktory `Int a `Intlit. To nám snadno umožní použít ji s knihovnami Json a AwesomeJson:

isInteger(Json.parse("12"));

isInteger(AwesomeJson.parse("12"));

Pokus o použití s knihovnou MinimalJson

isInteger(MinimalJson.parse("12"));

však selže chybou

This has type:
  MinimalJson.t
But somewhere wanted:
  [> `Int 'a | `Intlit 'b ]
The first variant type does not allow tag(s) `Intlit 

Příčinou problému je, že typ MinimalJson.t neobsahuje konstruktor `Intlit(string), což typ funkce isInteger požaduje (typ funkce isInteger říká, že MinimalJson.t by měl obsahovat alespoň konstruktory `Int a `Intlit). Nápravu zjednáme tak, že vstupní hodnotu typu MinimalJson.t explicitně přetypujeme na  Json.t:

isInteger((MinimalJson.parse("12") :> Json.t));

Kdybychom neměli k dispozici Json.t, stačí přetypovat na nový typ, který vznikne z MinimalJson.t  přidáním chybějícího konstruktoru:

isInteger((MinimalJson.parse("12") :> [ MinimalJson.t | `Intlit(string)]));

Kompilátor samozřejmě kontroluje, že přetypování dávají smysl, například z parametrů funkce můžeme při přetypování ubírat konstruktory a naopak do typu návratové hodnoty můžeme konstruktory přidávat.

Objekty

U záznamů nastává podobný problém jako u standardních variant, totiž nejde napsat funkci, která zpracuje libovolný záznam, jenž obsahuje určitá pole. Například funkci, která zpracuje libovolný záznam s polem error typu string. S pomocí objektů to lze:

let printError = (data) => print_endline(data#error);

Přístup k polím objektu se provádí pomocí mřížky #, nikoliv pomocí tečky . jako je tomu u záznamů. Typ parametru data je {.. error : string }, dvě tečky za otevírací závorkou značí, že se jedná o objekt, který musí obsahovat nadmnožinu uvedených polí:

printError({
  pub errorCode = 4;
  pub error = "File not found"
});

Instanci objektu lze vytvořit přímo v místě volání (není třeba ani definovat třídu). Mezi složenými závorkami mohou být veřejné metody uvozené pub, privátní metody uvozené pri a proměnné uvozené  val.

Pokud v typu printError použijeme jednu tečku místo dvou teček

let printError = (data: {. error: string}) => print_endline(data#error);

bude na vstupu muset být objekt, který má pouze uvedená pole a žádná jiná. Tedy původní volání

printError({
  pub errorCode = 4;
  pub error = "File not found"
});

se kompilátoru nebude líbit:

This has type:
  {. error : string, errorCode : int }
But somewhere wanted:
  {. error : string }
The second object type has no method errorCode 

Explicitní přetypování nás zbaví problémů:

printError({
  pub errorCode = 4;
  pub error = "File not found"
} :> {. error: string});

Řádkový polymorfismus

Polymorfní varianty i objekty mají určitá omezení. Ukážeme je na příkladu. Zobecněme naši funkci printError, tak aby přijímala seznam objektů:

let printErrors = (objs) => List.iter((o) => print_endline(o#error), objs);

Typ této funkce je (list({.. error : string })) => unit, jak se dalo očekávat. Překvapením však zřejmě bude, že následující kód se nepřeloží:

let o1 = {
  pub errorCode = 4;
  pub error = "File not found"
};

let o2 = {
  pub error = "Fatal error"
};

printErrors([o1, o2]);

Kompilátoru se nelíbí, že o2 uvnitř seznamu má typ {. error : string }, místo toho požaduje typ {. error : string, errorCode : int }, stejný typ jako má o1. Proč zde nastává chyba, když oba objekty obsahují pole error typu  string?

Důvod je, že se jedná o řádkový polymorfismus, nikoliv podtypový. Kdykoliv aplikujeme funkci, kompilátor místo každého .. zkouší doplnit konkrétní pole a jejich typy. Když tedy kompilátor vidí objekt o1, usoudí, že místo .. musí doplnit pole errorCode typu int. Jenže toto pole mu pak přebývá, když narazí na o2. Řešením je explicitně přetypovat o1 na typ  {. error : string }:

printErrors([o1 :> {. error: string}, o2]);

Podobně funguje > u polymorfních variant. A jsou s ním i podobné problémy:

let colorsToString = (colors) =>
  List.map(
    (color) =>
      switch color {
      | `Red => "red"
      | `Green => "green"
      | `Blue => "blue"
      | _ => "unknown color"
      },
    colors
  );

let c1: [ `Red | `Blue | `Green] = `Red;

let c2: [ `Red | `Blue | `Green | `Black] = `Black;

colorsToString([c1, c2]);

Kód se přeloží, když změníme typ c1, aby se shodoval s typem  c2.

Příště seriál dokončíme

V dnešním dílu jsme nakousli poměrně obtížná témata, kterým se navíc dokumentace Reasonu příliš nevěnuje a je třeba hledat v manuálu OCamlu. Příští díl bude dílem posledním. Shrneme si, co jsme se naučili, ale také co jsme se nenaučili a řekneme si, kde se to dá doučit.

Našli jste v článku chybu?