Table of Contents

Clase de tipuri (Typeclasses)

Scopul laboratorului:

Polimorfism

Polimorfismul constă în ideea de a oferi o interfață comună pentru entități de tipuri diferite. Interfața poate fi o funcție sau un simbol (amintiți-vă de constantele polimorfice). Comportamentul din spatele acestei interfețe poate să fie același, sau poate varia în funcție de tipul pe care operează. Ne interesează două feluri importante de polimorfism: polimorfism parametric și polimorfism ad hoc.

Polimorfism parametric

Pentru polimorfism parametric, există un singur comportament pentru toate tipurile. Polimorfismul parametric este util atunci când dorim abstractizarea unui tip, neavând nevoie de operații specifice pe acesta, sau de a-i cunoaște structura. Multe dintre funcțiile întâlnite până acum în Haskell prezintă polimorfism parametric. De exemplu, o implementare posibilă pentru lista length este următoarea:

length :: [a] -> Int
length [] = 0
length (_:xs) = 1 + length xs

Observați că tipul elementelor din listă este irelevant și nu aplicăm vreo funcție pe aceste elemente (nici nu le legăm la un nume). Tipurile concrete ce pot fi conținute într-o listă sunt abstractizate în semnătura de tip a lui length, prin variabila de tip a. Astfel putem apela funcția length pe orice tip concret ca: [Int], [Char], [Float], [[Int]] etc., implementarea fiind aceeași.

În Java, polimorfismul parametric este realizat prin genericitate.

Polimorfism ad hoc

Pentru polimorfism ad hoc, există multiple comportamente. Un comportament concret este selectat pe baza tipurilor pe care se operează. În Java, acest lucru se poate obține prin “overloading”: definirea multiplor funcții cu același nume, dar tipuri diferite pentru parametrii:

// File: Main.java
public class Main {
    static void function(int a) {
        System.out.println("int function!");
    }
 
    static void function(double a) {
        System.out.println("double function!");
    }
 
    public static void main(String[] args) {
        function(3.0);
        function(3);
    }
}
$ javac Main.java && java Main
double function!
int function!

Compilatorul poate distinge între cele două funcții pe baza tipurilor prezente în semnătura lor și poate alege în mod corect funcția apelată pe baza tipului argumentului din apelul funcției.

Această formă de overloading nu este posibilă în Haskell, în mod direct; i.e. astfel de definiție nu va compila, pentru că nu putem avea două funcții distincte cu același nume:

function :: Int -> String
function _ = "int function!"
 
function :: Double -> String
function _ = "double function!"
Prelude> :l Main.hs
[1 of 1] Compiling Main             ( Main.hs, interpreted )

test.hs:4:1: error:
    Duplicate type signatures for ‘function’
[...]

Polimorfismul ad hoc se realizează în Haskell cu ajutorul claselor de tipuri.

Typeclasses

O clasă de tipuri (typeclass) este o colecție de funcții care specifică un comportament. Un tip de date poate fi înrolat într-o clasă, furnizând implementări ale funcțiilor, particularizate pentru acest tip.

În ciuda numelui, clasele Haskell nu corespund claselor din limbaje de programare orientate pe obiecte (e.g. Java, C++). Clasele Haskell nu conțin date și nu pot fi instanțiate. O comparație mai pertinentă este cu interfețele din Java.

Una dintre cele mai comune clase Haskell este Eq: clasa tipurilor ale căror valori pot fi comparate pentru egalitate. Aceasta conține doar două funcții: == și /=. O posibilă definiție a acesteia (observați sintaxa Haskell):

class Eq a where  
    (==) :: a -> a -> Bool  
    (/=) :: a -> a -> Bool  
    x == y = not (x /= y)  
    x /= y = not (x == y)  

a este o variabilă de tip cu rol de placeholder pentru tipuri concrete care pot fi înrolate in Eq și are acceași semnificație în tot scope-ul clasei; i.e. a-ul din semnătura funcțiilor se referă la același tip. De exemplu, pentru înrolarea tipului Int la clasa Eq, funcția == va avea tipul Int -> Int -> Bool.

Spre deosebire de interfețele din Java ce oferă doar declarații de funcții, clasele de tipuri pot specifica și definiții. Observăm că, în mod bizar, definițiile funcțiilor == și /= depind una de cealaltă. Acesta este un mecanism care ne ușurează munca atunci când vrem să înrolăm un tip acestei clase: este suficient să suprascriem definiția unei singure funcții, cealaltă păstrându-și implementarea default.

Vom defini un tip simplu și îl vom înrola în Eq:

data Color = Red | Green | Blue
 
instance Eq Color where
    Red == Red = True
    Green == Green = True
    Blue == Blue = True
    -- observați cum "_" ne salvează de la a adăuga explicit încă 6 cazuri
    _ == _ = False

Am redefinit ==, iar definiția lui /= rămâne neschimbată (x /= y = not (x == y)); acum putem folosi ambii operatori cu valori de tip Color:

*Main> Red == Red
True
*Main> Red /= Red
False
*Main> Red == Blue
False
*Main> Red /= Blue
True

O altă clasă des întâlnită este Show, care ne oferă o funcție show pentru a obține o reprezentare sub formă de șir de caractere a unei valori (similar cu toString din Java):

class Show a where
    show :: a -> String
 
instance Show Color where
    show Red = "Red"
    show Green = "Green"
    show Blue = "Blue"

Cuvântul cheie "deriving"

Observați că, înrolând tipul Color în clasele Eq și Show, definițiile oferit pentru == și show sunt simple și evidente (e.g. pentru ==, orice constructor e egal cu el însuși și diferit de ceilalți). Astfel de implementări pot fi derivate automat, fără a le explicita; pentru asta, Haskell ne oferă cuvântul cheie deriving. Adăugat la sfârșitul unei definiții de tip, acesta poate fi urmat de una sau mai multe clase (într-un tuplu) în care tipul de date este înrolat cu o implementare simplistă:

Prelude> data Color = Red | Green | Blue deriving (Eq, Show)
Prelude> Red == Red
True
Prelude> Red == Green
False
Prelude> Red
Red
Mai multe informații despre ce clase pot fi derivate automat și cum se realizează această derivare găsiți aici:

Haskell Report - 4.3.3 Derived Instances
Haksell Report - 10 Specification of Derived Instances

Constrângeri de tip

Amintiți-vă, din laboratorele trecute, de funcția elem: aceasta primea un element și o listă și întorcea o valoare booleană care ne spunea dacă elementul se găsește în listă; deducem tipul ei ca fiind a -> [a] -> Bool.

O definiție posibilă a funcției elem este:

elem _ [] = False
elem e (x:xs) = e == x || elem e xs

Observăm că, pe cel de-al doilea caz, aplicăm funcția == între elementul căutat e și capul listei x. Dar funcția == este parte a clasei Eq, deci valorile primite ca argumente trebuie să aparțină unui tip înrolat în Eq; ceea ce înseamnă că tipul a din semnătura funcției elem, trebuie să fie înrolat în Eq. Aceasta este o constrângere de tip (type-constraint) și este exprimată în Haskell prin următoarea construcție sintactică:

Prelude> :t elem
elem :: (Eq a) => a -> [a] -> Bool

(Eq a) => precizează că, în semnătura de tip care urmează, variabila a nu se poate referi chiar la orice tip, ci doar la cele înrolate în Eq.

Toate constrângerile de tip sunt trecute într-un tuplu, urmat de =>, apoi de tipul funcției:
*Main> :t (\x y -> x == 0 && y < 0)
(\x y -> x == 0 && y == 0) :: (Eq a, Ord b, Num a, Num b) => a -> b -> Bool

Odată înrolat un tip într-o clasă, putem folosi toate funcțiile care au acea clasă printre constrângeri:

*Main> :t elem
elem :: (Eq a) => a -> [a] -> Bool
*Main> elem Red [Blue, Green, Green, Red, Blue]
True
*Main> :m +Data.List
*Main Data.List> :t delete
delete :: (Eq a) => a -> [a] -> [a]
*Main Data.List> delete Green [Blue, Green, Green, Red, Blue]
[Blue, Red, Blue]

Înrolarea tipurilor polimorfice

Să ne reamintin tipul de listă polimorfică din laboratorul trecut:

data List a = Empty | Cons a (List a)

Deși am putea folosi mecanismul de deriving, în scop pedagogic vom înrola manual lista noastră în clasa Eq. Țineți minte că List nu este un tip, ci un constructor de tip. Astfel, va trebui să înrolăm List a în clasa Eq. Când sunt două liste egale?

eqLists Empty Empty = True
eqLists (Cons a as) (Cons b bs) = (a == b) && (eqLists as bs)

În cel de-al doilea caz, utilizăm funcția == pentru a compara capetele listelor, deci tipul eqLists trebuie să conțină o constrângere de tip: eqLists :: (Eq a) -> List a -> List a -> Bool. Scopul nostru final este de a înrola List a în Eq și a folosi chiar == în loc de eqLists; pentru a face asta, avem nevoie de o constrângere de tip la nivel de clasă:

instance (Eq a) => Eq (List a) where
    Empty == Empty = True
    (Cons a as) == (Cons b bs) = (a == b) && (as == bs)
    _ == _ = False

Primul rând exprimă ideea că putem egala două liste de același tip (List a), doar dacă elementele lor sunt egalabile ((Eq a) =>).

Observați că, în expresia celui de-al doilea caz, funcția == apare de două ori: o dată pentru a compara elemente, o dată pentru a compara liste. Acesta este un exemplu de polimorfism ad hoc. Cele două apeluri de == pot avea implementări diferite (al doilea este mereu un apel recursiv; primul poate fi, de exemplu, comparație între doi întregi, în cazul List Int).

Cuvântul cheie deriving se descurcă și cu înrolarea de tipuri polimorfice, fiind capabil să genereze această definiție cu tot cu constrângerea ca și elementele să fie parte din Eq.

Informații despre clase în ghci

Din cadrul ghci, puteți obține informații despre o clasă anume folosind comanda :info <typeclass> (:i <typeclass>):

Prelude> :info Ord
class Eq a => Ord a where
  compare :: a -> a -> Ordering
  (<) :: a -> a -> Bool
  (<=) :: a -> a -> Bool
  (>) :: a -> a -> Bool
  (>=) :: a -> a -> Bool
  max :: a -> a -> a
  min :: a -> a -> a
  {-# MINIMAL compare | (<=) #-}
        -- Defined in ‘GHC.Classes’
instance Ord a => Ord [a] -- Defined in ‘GHC.Classes’
instance Ord Word -- Defined in ‘GHC.Classes’
instance Ord Ordering -- Defined in ‘GHC.Classes’
instance Ord Int -- Defined in ‘GHC.Classes’
...

Putem observa mai multe informații utile:

Legi pentru clase

Pe lângă nume, constrângeri, tipurile și implementările default ale funcțiilor, mai există o caracteristică a claselor de tip: legile respectate de funcții. Având cunoștiințe de matematică, s-ar putea să vă așteptați ca funcția == să aibă anumite proprietăți (e.g. reflexivitate: pentru orice valoare x, x == x să fie True). Într-adevăr, documentația claselor vine de obicei și cu un set de proprietăți care ar trebui respectate; aici puteți observa cele cinci legi ale clasei Eq.

Din păcate, aceste legi nu pot fi specificate la nivel de limbaj. Amintiți-vă de înrolarea tipului Color în clasa Eq. Având control total asupra implementării, am putea scrie:

instance Eq Color where
    _ == _ = True
    _ /= _ = True

Astfel, specificăm că oricare două culori sunt și egale între ele și diferite. Deși o definiție contraintuitivă și inutilă, din perspectiva Haskell acest lucru este perfect ok atât din punct de vedere sintactic cât si semantic; nu există, la nivel de limbaj, un mecanism de a specifica ce reguli trebuiesc respectate și de a asigura respectarea acestora. Este îndatorirea programtorului de a cunoaște aceste reguli și de a se asigura că sunt respectate de tipul înrolat; similar, când definiți propriile clase, ar trebui să vă gândiți ce legi ar trebui respectate și să le documentați. Scopul legilor este de a asigura comportamente intuitive, ferite de surprize.

Respectarea strictă a tuturor legilor unei clase are excepții. De exemplu, pentru operațiile în virgulă mobilă, o valoare specială NaN (Not a Number) modelează rezultatul anumitor expresii cu rezultat nedefinit. Două valori NaN nu sunt considerate egale; prin urmare, pentru tipuri ca Float și Double, operația == nu respectă proprietatea de reflexivitate pe întreg domeniul:
Prelude> a = 0/0 :: Float
Prelude> a == a
False

Exerciții

1. Înrolați tipul List a în clasa Show, astfel încât șirul rezultat să fie identic cu cel pentru listele Haskell:

*Main> Cons 1 (Cons 2 (Cons 3 Empty))
[1,2,3]

2. În laboratorul anterior, ați definit operații (foldrT, mapT etc.) pentru lucrul cu arbori binari polimorfici. Am vrea să înrolăm acest tip la clasele corespunzătoare.

a. înrolați BTree a în Eq

b. înrolați BTree în Foldable și definiți foldr cu aceeași definiție ca foldrT din laboratorul trecut; veți avea apoi acces la funcții ca foldl, sum, minimum (:info Foldable în ghci pentru a le vedea pe toate)

c. înrolați BTree în Functor și definiți fmap cu aceeași definiție ca mapT din laboratorul trecut (mai multe informații aici)

Atenție la tipurile foldr și fmap:
Prelude> :t foldr
foldr :: Foldable t => (a -> b -> b) -> b -> t a -> b
Prelude> :t fmap
fmap :: Functor f => (a -> b) -> f a -> f b

Observați că constrângerile sunt puse pe constructorul de tip, deci va trebui înrolat BTree, nu BTree a.

3. Tot în laboratorul anterior, ați definit un tip pentru a modela numerele naturale extinse cu un punct la infinit, precum și niște operații pe acestea. Dorim să facem implementarea elegantă, pentru a putea folosi operatori deja existenți (e.g. == pentru comparare) și pentru a putea folosi alte funcții existente care impun constrângeri de tip (e.g. Data.List.sort :: Ord a => [a] -> [a]). Înrolați tipul Extended în clasele:

a. Show (a.î. să afișăm numerele fără vreun nume de constructor, iar infinitul ca “Inf”)

b. Eq

c. Ord

d. Num

4. Definiți un tip de date polimorfic care să modeleze următoarea gramatică (în care o valoare poate avea orice tip):

   <expr> ::= <value> | <variable> | <expr> + <expr> | <expr> * <expr> 

5. Amintiți-vă de tabelele asociative din laboratorul trecut. Vom defini un “dicționar” ca fiind o tabelă asociativă cu șiruri de caractere drept chei:

type Dictionary a = [(String, a)]

Ne mai interesează și funcția care primește o cheie și o tabelă asociativă și întoarce valoarea asociată cheii, împachetată într-un Maybe:

getValue :: String -> Dictionary a -> Maybe a

Fie următoarea clasă:

class Eval t a where
    eval :: Dictionary a -> t a -> Maybe a

Spre deosebire de clasele prezentate în exemplele anterioare, care desemnează o proprietate a unui tip sau constructor de tip, Eval stabilește o relație între un constructor de tip t și un tip a. Relația spune că orice container de tip t a poate fi evaluat in prezența unui dicționar cu valori de tip a, la o valoare de tip Maybe a.

Acest tip de clasă reprezintă o extensie a limbajului, Multi-parameter type-class. Cel mai probabil este nevoie de următoarele directive la începutul fișierului pentru a o defini și pentru a înrola tipuri la ea (directivele se găsesc deja în scheletul de laborator):
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
 
class Eval t a where
    eval :: Dictionary a -> t a -> Maybe a

Mai multe despre acestea, precum și despre Functional Dependencies, un alt feature care este strâns legat de clasele de tipuri cu mai mulți parametrii puteți găsi aici.

a. Înrolați Expr și Integer în clasa Eval. Care este semnificația evaluării?
b. înrolați Expr și Extended în clasa Eval.
c. Ce observați? Cum ați putea simplifica?

Lab 5 - Schelet