This is an old revision of the document!


Tipuri în Haksell

Scopul laboratorului:

  • introducerea conceptului de Tipuri de Date Algebrice
  • introducerea conceptului de Tipuri de Date Abstracte
  • clarificarea distincției dintre acestea
  • introducerea conceptului de tipuri de date monomorfice/polimorfice
  • definirea și lucrul cu propriile tipuri de date în Haskell

Tipurile de date algebrice sunt tipurile pentru care putem specifica forma elementelor. Acestea pot avea unul sau mai mulți constructori, iar constructorii pot avea zero sau mai multe câmpuri.

În Haskell, definirea unui tip se face folosind cuvântul cheie data urmat de numele tipului, semnul egal, apoi constructorii:

-- Tipul de date "Color" are doi constructori posibili; bara "|" indică
-- faptul că o culoare poate fi ori "White", ori "Black" (similar cu tipul "Bool").
data Color = White | Black
 
-- Constructorul "Point" este practic doar un tuplu cu nume; un (Float, Float) mai specific.
data Point = Point Float Float
Observați că, în cel de-al doilea caz, tipul de date și constructorul au același nume, “Point”. Acest lucru e perfect ok, deoarece reprezintă concepte diferite.

Desigur, putem combina aceste două concepte (multiplii constructori, multiple câmpuri ale unui singur constructor):

-- O formă este fie un cerc definit de centru și rază, fie un
-- dreptunghi definit de două colțuri opuse.
data Shape = Circle Point Float | Rectangle Point Point
Numele tipurilor de date și numele constructorilor trebuie scrise cu literă mare.

Pattern matching

Aduceți-vă aminte de mecanismul de “pattern matching” din Haskell (pe care până acum l-ați folosit predominant pentru liste). Acestea functionează pe constructori, deci îl putem folosi pentru tipurile definite de noi:

  • putem avea definiții diferite pentru constructori diferiți
  • putem asocia valori constituente cu nume, pe care să le folosim în expresia funcției
colorToString :: Color -> String
colorToString White = "White"
colorToString Black = "Black"
 
pointsDistance :: Point -> Point -> Float
pointsDistance (Point x1 y1) (Point x2 y2) = sqrt ((x1 - x2) ** 2 + (y1 - y2) ** 2)
 
area :: Shape -> Float
area (Circle _ r) = pi * (r ** 2)
area (Rectangle (Point x1 y1) (Point x2 y2)) = (y2 - y1) * (x2 - x1)

Tipurile de date abstracte sunt tipurile care au asociată o interfață, dar nu și o implementare. De exemplu, o listă poate fi văzută ca o structură care suportă anumite operații: isEmpty, length, head etc. Utilizatorul listei poate fi preocupat doar de comportamentul acestei interfețe (i.e. colecția de funcții), nu și de detalii de implementare. De exemplu, o listă poate fi reprezentată ca o zonă contiguă de memorie (Array List), sau ca o colecție de celule care conțin o valoare și adresa următoarei celule (Linked List). Unul din avantajele acestei separări - între comportament și implementare - este că dezvoltatorul unei biblioteci poate modifica implementarea unei structuri, lăsând interfața neschimbată; ceea ce nu necesită modificări în codul client.

Conceptele de “tip de date abstract” și “tip de date algebric” sunt ortogonale, ele vizând trăsături diferite.

Vrem să definim propria noastră listă care poate conține numere întregi:

data List = Empty | Cons Int List

Acesta este un tip de date monomorfic, deoarece operează cu un singur tip, anume Int. Foarte multe operații pe listă nu sunt preocupate de tipul sau de valorile elementelor: verificarea dacă lista este goală, calculul lungimii listei, inversarea listei etc. Redefinirea listei, precum și a tuturor acestor operații pentru fiecare tip pentru care am avea nevoie de o listă, ar necesita multă muncă.

Soluția este să definim un tip de date polimorfic, parametrizat cu tipul elementelor pe care le conține:

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

Simbolul a reprezintă o variabilă de tip. O variabilă de tip trebuie reprezentată de un nume care începe cu literă mică. Prezența variabilelor de tip reprezintă o formă de abstracție; ele pot fi înlocuite de un tip propriu-zis pentru a obține un tip concret, e.g. List Int, List Char, List (List Char) etc.

Putem astfel să scriem funcții polimorfice care operează pe lista noastră și care nu sunt preocupate de tipul conținutului:

myLength :: List a -> Int
myLength Empty = 0
myLength (Cons _ t) = 1 + myLength t
 
myHead :: List a -> a
myHead (Cons a _) = a

Observați că, înlocuind List a cu [a], semnăturile acestor funcții se potrivesc cu cele ale length și head din Prelude.

Ce este List?

În definirea unei liste polimorfice, am scris un nou tip generic List a. Dar ce anume reprezintă pentru Haskell List? List este un constructor de tip, nu un tip de date. Constructorii de tip primesc ca parametrii tipuri și întorc un tip (sau un alt constructor de tip dacă nu primesc suficienți parametrii).

Asta înseamnă că List nu este un tip, dar List Int, List Char etc. sunt tipuri.

Constante polimorfice

Care este tipul listei vide?

Prelude> :t []
[] :: [a]

Observăm că tipul listei vide este general. Asta înseamnă că lista vidă poate fi tratată ca o listă de orice tip.

Prelude> [1,2,3] ++ "123" -- eroare, tipuri diferite
Prelude> [1,2,3] ++ []
[1,2,3]
Prelude> "123" ++ []
"123"

Spunem că [] este o constantă polimorfică.

O altă constantă polimorfică este, de exemplu 1. De aceea putem scrie:
Prelude> 1 + 2
3
Prelude> 1 + 2.71
3.71

Dacă forțăm 1 să fie întreg, vom primi o eroare la a doua adunare:

Prelude> (1 :: Int) + 2.71

<interactive>:60:14:
    No instance for (Fractional Int) arising from the literal '2.71'
    In the second argument of '(+)', namely '2.71'
    In the expression: (1 :: Int) + 2.71
    In an equation for 'it': it = (1 :: Int) + 2.71

1 are însă o constrângere despre care vom discuta în laboratorul următor.

Maybe

Maybe este un tip polimorfic care modelează prezența unei anumite valori, sau absența acesteia:

data Maybe a = Nothing | Just a

Probabil ați scris și folosit funcții care ar trebui să se descurce cu anumite cazuri în care nicio valoare din codomeniu nu se potrivește ca rezultat. Să considerăm, de exemplu, o funcție care calculează suma primului și al ultimului element dintr-o listă de numere întregi. Dacă tipul acesteia ar fi:

sumExtremities :: [Int] -> Int

ce am putea întoarce pentru lista vidă? O idee ar fi să întoarcem o valoare default (e.g. 0); sau o valoare ca -1 și să impunem ca lista primită să conțină doar numere pozitive. O soluție mai elegantă este să îmbogățim domeniul valorilor returnate cu o valoare specială, care semnalează că nu putem calcula această sumă:

sumExtremities :: [Int] -> Maybe Int
sumExtremities [] = Nothing
sumExtremities [_] = Nothing
sumExtremities l = Just (head l + last l)
Pentru o listă cu un singur element, ar putea avea sens să-l considerăm și primul și ultimul, deci să returnăm dublul lui. Abordarea noastră e doar o convenție.

Definim o funcție care întoarce un șir descriptiv al sumei calculate:

describeSum :: [Int] -> String
describeSum l = case sumExtremities l of
                    Nothing -> "Can't calculate sum"
                    Just x -> "Sum is " ++ show x

Putem vedea cum funcționează, din ghci:

*Main> describeSum []
"Can't calculate sum"
*Main> describeSum [8]
"Can't calculate sum"
*Main> describeSum [8, 12, 35]
"Sum is 43"
*Main>

Concepte similare se regăsesc și în alte limbaje de programare moderne:

Either

Un TDA similar este Either, care modelează valori cu două tipuri posibile:

data Either a b = Left a | Right b

Prin convenție, constructorul Left reprezintă o eroare, iar constructorul Right o valoare corectă. Astfel putem extinde mecanisme de error-handling pentru a conține mai multe informații despre ce a cauzat eroarea (spre deosebire de Maybe, care doar indică apariția unei erori):

Adaptăm cele două funcții de mai sus:

sumExtremities :: [Int] -> Either String Int
sumExtremities [] = Left "Can't sum on empty list"
sumExtremities [_] = Left "Can't sum on singleton list"
sumExtremities l = Right (head l + last l)
 
describeSum :: [Int] -> String
describeSum l = case sumExtremities l of
                    Left e -> e
                    Right x -> "Sum is " ++ show x
*Main> describeSum []
"Can't sum on empty list"
*Main> describeSum [8]
"Can't sum on singleton list"
*Main> describeSum [8, 12, 35]
"Sum is 43"
*Main>

Concepte similare se regăsesc și în alte limbaje de programare moderne:

Vrem să definim un tip de date pentru a descrie un student, folosind următoarele câmpuri: nume, prenume, an de studiu, medie.

-- tipul de date și constructorul pot avea același nume,
-- pentru că reprezintă concepte diferite.
data Student = Student String String Int Float

Observăm că semnficația câmpurilor nu este evidentă din definiție.

Dacă am vrea să putem extrage câmpurile unei valori de tip Student? Definim funcțiile:

nume :: Student -> String
nume (Student n _ _ _) = n
 
prenume :: Student -> String
prenume (Student _ p _ _) = p
 
an :: Student -> Int
an (Student _ _ a _) = a
 
medie :: Student -> Float
medie (Student _ _ _ m) = m

Astfel de cod (necesar, neinteresant, laborios) poartă denumirea de "boilerplate". Adesea, limbajele de nivel înalt oferă construcții sintactice pentru a evita scrierea “de mână”. În Haskell, putem scrie:

data Student = Student { nume :: String
                       , prenume :: String
                       , an :: Int
                       , medie :: Float
                       }

Avem astfel aceeași funcționalitate în mult mai puține linii. Există și alte avantaje ale acestei sintaxe, precum și capcane. Citiți mai multe aici.

1. Arbori binari

Fie următorul tip care modelează arbori binari polimorfici:

data BTree a = Nil | Node a (BTree a) (Btree a)

a. definiți o funcție foldrT, echivalentul lui foldr pentru arbori
b. definiți o funcție mapT echivalentul lui map pentru arbori
c. definiți o funcție zipWithT, echivalentul lui zipWith pentru arbori

Orientați-vă după tipurile funcțiilor prezente în schelet.

2. Tabele asociative

O tabelă asociativă este o colecție de perechi (cheie, valoare). O cheie prezentă în tabelă are asociată o anumită valoare și putem folosi tabela pentru a accesa valori pe baza unei chei. Vom reprezenta o tabelă asociativă ca o listă de perechi, folosind cuvântul cheie type pentru a defini un alias:

type Assoc k v = [(k, v)]

a. definiți o funcție care primește o cheie, o valoare, o tabelă asociativă și întoarce o nouă tabelă care conține noua asociere (fie adăugând-o, fie modificând-o)
b. definiți o funcție care primește o cheie și o tabelă asociativă și întoarce valoarea asociată cheii, împachetată într-un Maybe
c. definiți o funcție care primește o cheie și o tabelă asociativă și întoarce tabela fără cheia dată
d. definiți o funcție care primește o tabelă asociativă și întoarce o listă de chei

3. Numere naturale extinse

Fie mulțimea de numere naturale extinse cu un punct la infinit ($ \hat{\mathbb{N}} = \mathbb{N} \cup \{ \infty \}$).

data Extended = Infinity | Value Integer

a. definiți o funcție extSum care să evalueze suma a două numere naturale extinse
b. definiți o funcție extDiv care să evalueze raportul a două numere naturale extinse (împărțirea la 0 și la infinit are rezultat definit)
c. definiți o funcție extLess care spune dacă un număr natural extins e mai mic decât un altul

Laborator 4 - Schelet