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.