Algoritmos y Estructuras de Datos 1
Haskell

Haskell

En esta sección se presentan distintas propiedades del lenguaje Haskell, basado en el paradigma de programación funcional.

Creando Listas

Existen algunas formas distintas de crear listas en Haskell:

1) Por extensión:

lista = [1, 2, 3, 4, 5]

2) Por rango:

lista = [1..n]
 
-- ghci> lista
-- [1, 2, 3, 4, n]

En este caso se usa la notación .. para definir un rango de valores.

3) Por repetición:

lista = replicate 5 1
 
-- ghci> lista
-- [1, 1, 1, 1, 1]

replicate toma un número n y un valor x, y devuelve una lista con n elementos iguales a x.

4) Por comprensión:

lista = [x | x <- [1..5]]
 
listaConTuplas = [(x, y) | x <- [1..3], y <- [1..3]]
 
-- ghci> lista
-- [1, 2, 3, 4, 5]
 
-- ghci> listaConTuplas
-- [(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (3, 3)]

En este caso, x toma los valores de la lista [1..3] e y toma los valores de la lista [1..3].
La notación | se lee como “tal que”, y se usa para determinar los valores de x e y que cumplen con la condición (similar a definir un conjunto por comprensión en matemática).

5) Por concatenación:

lista = [1] ++ [2] ++ [3] ++ [4] ++ [5]
 
-- ghci> lista
-- [1, 2, 3, 4, 5]

Tipos

Haskell es un lenguaje de programación de tipado fuerte y estático. Esto significa que:
1 ) Toda expresión tiene un tipo definido y sin cambiar.
2 ) Los tipos son verificados antes de ejecutar (al compilar) el programa.

Por ejemplo, veamos la siguiente función:

add :: Int -> Int -> Int

Si intentamos ejecutar la función add con: add 1 2.0, Haskell nos arrojará un error de tipo, ya que el segundo argumento es de tipo Float y no de tipo Int.

Haskell puede inferir el tipo de una función sin necesidad de declararlo explícitamente. Por ejemplo, la función add puede ser definida de la siguiente manera:

add x y = x + y

Haskell inferirá que x e y son de tipo Num a => a, donde Num es una clase de tipo que incluye a todos los números, y a es una variable de tipo. Esto se puede consultar con el siguiente comando:

:t add
 
add :: Num a => a -> a -> a

Algunos de los tipos son básicos, como Int, Float, Double, Bool, Char, entre otros. También existen tipos compuestos, como las listas y tuplas vistas en IAA.

Polimorfismo

Haskell soporta polimorfismo, lo que significa que las expreisones pueden tener más de un tipo. Existen dos tipos de polimorfismo: paramétrico y ad-hoc.

Polimorfismo paramétrico

El polimorfismo paramétrico es aquel en el que una función puede ser aplicada a cualquier tipo de datos. Por ejemplo, la función id que devuelve el mismo valor que recibe:

id :: a -> a
 
id x = x
 
-- ghci> id 1
-- 1

Donde a es una variable de tipo reemplazable por cualquier tipo de dato (ej. Int, Float, String, etc.).

Si tuviesemos la función definida:

f x = x + 1

Haskell inferirá que x es de tipo Num a => a, ya que el operador + requiere que x sea un número.

Polimorfismo ad-hoc

El polimorfismo ad-hoc se refiere a la capacidad de una función de tener distintos comportamientos según el tipo de datos a usar. Por ejemplo, la función elem:

elem :: Eq a => a -> [a] -> Bool

Donde Eq es una typeclass que incluye a todos los tipos que pueden ser comparados por igualdad. Las typeclases son como interfaces en otros lenguajes de programación.

Si hubiesemos definido la función elem de la siguiente manera:

elem :: a -> [a] -> Bool

Haskell nos arrojaría un error de tipo, ya que el operador == requiere que a sea de tipo Eq.

Existen otras typeclasses en Haskell, como Ord (para tipos ordenables), Show (para tipos que pueden ser convertidos a String), Num (para tipos numéricos), entre otros.

Currificación

En Haskell, todas las funciones son currificadas. Esto significa que una función que recibe múltiples argumentos, en realidad recibe un solo argumento y devuelve otra función que recibe el siguiente argumento.

Tomemos por ejemplo la función max:

max :: Ord a => a -> a -> a

En primera instancia podemos pensar que la función max recibe dos argumentos, pero en realidad recibe un solo argumento y devuelve otra función que recibe el siguiente argumento y lo compara.
Por ejemplo, si ejecutamos max 1 2, Haskell lo interpretará como (max 1) 2.

Sería como definir:

max :: Ord a => a -> (a -> a)

Aplicación parcial

La aplicación parcial es una técnica que consiste en aplicar una función a menos argumentos de los que espera. Por ejemplo, si tenemos la función add:

add :: Int -> Int -> Int
 
add x y = x + y
 
-- ghci> add 1 2
-- 3

Podemos aplicar la función add a un solo argumento, y obtener otra función que recibe el siguiente argumento:

(add 1) :: Int -> Int
 
-- ghci> (add 1) 2
-- 3 

Funciones de alto orden

Las funciones de alto orden son aquellas que toman funciones como argumentos y/o devuelven funciones como resultado. Por ejemplo, la función:

applyTwice :: (a -> a) -> a -> a
 
applyTwice f x = f (f x)
 
-- ghci> applyTwice (+2) 1
-- 5

Donde applyTwice toma una función como argumento (también llamado callback) y un valor, y aplica la función dos veces al valor.

Haskell posee funciones de alto orden como map, filter, entre otras.

Funciones Lambda o Anónimas

Las funciones anónimas o lambda son funciones que no tienen un nombre asociado. Por ejemplo, la función applyTwice puede ser definida de la siguiente manera:

applyTwice :: (a -> a) -> a -> a
 
applyTwice f x = f (f x)
 
-- ghci> applyTwice (\x -> x + 2) 1
-- 5

Por ejemplo podríamos definir una función que sume 3 a los elementos de una lista:

sumarTres :: [Int] -> [Int]
 
sumarTres xs = map (\x -> x + 3) xs
 
-- ghci> sumarTres [1, 2, 3, 4, 5]
-- [4, 5, 6, 7, 8]

Usamos \ para definir una función lambda, y -> para separar los argumentos de la función del cuerpo de la función. Esta sintáxis corta es útil para funciones simples que no necesitan un nombre.

Tipos algebraicos sin parámetros

Si bien Haskell ofrece tipos de datos básicos y compuestos, también es posible definir nuevos tipos de datos.

Supongamos que definimos una función que enuncia nuestra tarea según el día de la semana:

tareaDiaria :: Int -> String 
 
tareaDiaria 0 = "Trabajar"
tareaDiaria 1 = "Trabajar"
tareaDiaria 2 = "Trabajar"
... 

(En este ejemplo el 0 representa el lunes). Esto resulta difícil de leer y mantener. La codificación ad hoc puede funcionar, pero requiere tener mucho más cuidado.
En su lugar, podemos definir un nuevo tipo de datos Dia:

data Dia = Lunes | Martes | Miercoles | Jueves | Viernes | Sabado | Domingo
 
tareaDiaria :: Dia -> String
 
tareaDiaria Lunes = "Trabajar"
tareaDiaria Martes = "Trabajar"
tareaDiaria Miercoles = "Trabajar"
tareaDiaria Jueves = "Trabajar poco"
tareaDiaria Viernes = "Trabajar poco"
tareaDiaria Sabado = "Descansar"
tareaDiaria Domingo = "Descansar"
tareaDiaria _ = "Trabajar"

Donde _ representa un comodín que captura cualquier otro valor que no esté definido en la función.

Incluso podríamos mejorar el anterior ejemplo definiendo un nuevo tipo de datos Tarea:

data Dia = Lunes | Martes | Miercoles | Jueves | Viernes | Sabado | Domingo
 
data Tarea = Trabajar | Descansar | TrabajarPoco
 
tareaDiaria :: Dia -> Tarea
 
tareaDiaria Lunes = Trabajar
tareaDiaria Martes = Trabajar
tareaDiaria Miercoles = Trabajar
tareaDiaria Jueves = TrabajarPoco
tareaDiaria Viernes = TrabajarPoco
tareaDiaria Sabado = Descansar
tareaDiaria Domingo = Descansar
tareaDiaria _ = Trabajar
 
-- ghci> tareaDiaria Lunes 
-- Trabajar

Sintaxis case

Siguiendo el ejemplo anterior, si queremos definir una función horasTrabajo que devuelva la cantidad de horas de trabajo según nuestra tarea diaria, podríamos hacerlo:

horasTrabajo :: Dia -> Int
 
horasTrabajo d = case tareaDiaria d of
    Trabajar -> 8
    TrabajarPoco -> 4
    _ -> 0

Donde case es una expresión que permite hacer pattern matching sobre una expresión. En este caso usamos los valores del tipo Tarea para asignar una cantidad de horas según el día (valor).
(Esto es similar a un switch en otros lenguajes de programación).

Instancias derivadas y definiciones locales

Siguiendo lo anterior, no podríamos haber definido horasTrabajo de la siguiente manera:

horasTrabajo :: Dia -> Int
 
horasTrabajo d | tareaDiaria d == Trabajar = 8
               | tareaDiaria d == TrabajarPoco = 4
               | otherwise = 0
 
-- ghci> horasTrabajo Lunes
-- Error: ... 

(Podríamos hacerlo con where):

horasTrabajo :: Dia -> Int
 
horasTrabajo d | tarea d == Trabajar = 8
               | tarea d == TrabajarPoco = 4
               | otherwise = 0
                where tarea = tareaDiaria d

Sin embargo, esto no permite comparar directamente dos valores de tipo Tarea con ==, ya que Haskell no sabe cómo compararlos.
Para ello, el tipo Tarea debe ser una instancia de la typeclass Eq:

data Tarea = Trabajar | Descansar | TrabajarPoco deriving Eq
 
horasTrabajo :: Dia -> Int
 
horasTrabajo d | tareaDiaria d == Trabajar = 8
               | tareaDiaria d == TrabajarPoco = 4
               | otherwise = 0
 
-- ghci> horasTrabajo Lunes
-- 8

De esta forma, deriving nos permite derivar instancias de typeclasses para nuestros tipos de datos.
Tarea ahora es una instancia de Eq, por lo que podemos comparar valores de tipo Tarea con ==.

Tipos algebraicos con parámetros

Los tipos algebraicos pueden tener parámetros, lo que permite definir tipos de datos más genéricos.

Supongamos que tenemos el siguiente tipo de dato Figura:

data Figura = Circulo | Rectangulo

Lo que nos dice este tipo de dato es que una Figura puede ser un Circulo o un Rectangulo. Ambos son constructores de datos, pero no tienen parámetros.

Si quisieramos diferenciar los tamaños de superficie en Circulo y Rectangulo, una forma podría ser la siguiente:

data Figura = CirculoChico 
            | CirculoMediano 
            | CirculoGrande
            | RectanguloChico 
            | RectanguloMediano 
            | RectanguloGrande

Sin embargo, esto implica que debemos definir cada tipo de figura con su tamaño, lo que resulta tedioso (e impreciso).
Existe una forma mucho mejor de hacerlo, y de poder adoptar cualquier dimensión para cada figura:

data Figura = Circulo (Float, Float) Float | Rectangulo (Float, Float) (Float, Float)
 
:t Circulo 
 
- Circulo :: (Float, Float) -> Float -> Figura
 
:t Rectangulo
 
- Rectangulo :: (Float, Float) -> (Float, Float) -> Figura

Ahora Circulo y Rectangulo son constructores de datos que reciben parámetros.

Para que Circulo y Rectangulo sean instancias válidas de Figura, es necesario que cumplan con los parámetros definidos.

En el ejemplo Circulo recibe un par de coordenadas y un radio, mientras que Rectangulo recibe dos pares de coordenadas.

Ahora podemos definir una función que nos diga el área de cada figura:

area :: Figura -> Float
 
area (Circulo (x, y) r) = 3.1416 * r * r
area (Rectangulo (x1, y1) (x2, y2)) = base * altura
                                      where base = abs (x2 - x1)
                                            altura = abs (y2 - y1)
 
-- ghci> area (Circulo (0, 0) 2)
-- 12.5664
 
-- ghci> area (Rectangulo (0, 0) (2, 2))
-- 4.0

Los identificadores x, y, r, x1, y1, x2 e y2 son cualquier valor de tipo Float.

Sinónimos de tipos

Los sinónimos de tipos son una forma de darle un nombre alternativo a un tipo existente. Por ejemplo, si quisieramos definir un tipo Punto o Radio:

type Punto = (Float, Float)
type Radio = Float
 
data Figura = Circulo Punto Radio | Rectangulo Punto Punto
 
:t Circulo
 
- Circulo :: Punto -> Radio -> Figura
 
:t Rectangulo
 
- Rectangulo :: Punto -> Punto -> Figura
 
-- ghci> area (Circulo (0, 0) 2)
-- 12.5664
 
-- ghci> area (Rectangulo (0, 0) (2, 2))
-- 4.0

La función que definimos anteriormente sigue funcionando, y ahora Punto y Radio son sinónimos de Float, Float y Float, respectivamente.

Definición de Igualdad

Es posible definir la igualdad entre dos valores de un tipo de dato específico mediante la instancia de la typeclass Eq.

instance Eq Figura 
  where
    f1 == f2 = area f1 == area f2

Donde f1 y f2 son dos figuras, y area es la función que definimos anteriormente.

Tipos recursivos

Un tipo recursivo es aquel que se define en términos de sí mismo.

Por ejemplo, si quisieramos representar una palabra:

data Palabra = PVacia | Agregar Char Palabra

Donde PVacia representa una palabra vacía, y Agregar agrega un caracter a la palabra. Por ejemplo, la palabra “Hola” se representaría como:

let p = Agregar 'H' (Agregar 'o' (Agregar 'l' (Agregar 'a' PVacia)))

Para poder imprimir la palabra, necesitamos definir una función que recorra la palabra y la imprima:

imprimir :: Palabra -> String
 
imprimir PVacia = ""
imprimir (Agregar c p) = c : imprimir p
 
-- ghci> imprimir p
-- "Hola"

Tipo Maybe

El tipo Maybe es un tipo de dato que puede tener un valor o no tenerlo.

data Maybe a = Nothing | Just a

El tipo Maybe agrega un elemento Nothing que representa la ausencia de un valor, y un elemento Just a que representa un valor de tipo a.

Por ejemplo, si quisieramos definir una función que divida dos números:

dividir :: Float -> Float -> Maybe Float
 
dividir _ 0 = Nothing
dividir x y = Just (x / y)
 
-- ghci> dividir 1 0
-- Nothing
 
-- ghci> dividir 1 2
-- Just 0.5

Si el divisor es 0, la función devuelve Nothing, ya que su resultado no existe en el tipo de la imagen de la función.

Podriamos saber si la división entre dos números es posible mediante el uso de Maybe y case:

dividir :: Float -> Float -> Maybe Float
 
dividir _ 0 = Nothing
dividir x y = Just (x / y)
 
puedeDividirse :: Float -> Float -> String
 
puedeDividirse x y = case dividir x y of
    Nothing -> "No se puede dividir"
    Just z -> "Se puede dividir" 
 
-- ghci> puedeDividirse 1 0
-- "No se puede dividir"
 
-- ghci> puedeDividirse 1 2
-- "Se puede dividir"