Document

Anuncio
Programación Funcional en Haskell
Paradigmas de Lenguajes de Programación
1◦ cuatrimestre 2006
1.
Expresiones, valores y tipos
Un programa en lenguaje funcional consiste en definir expresiones que
computan (o denotan) valores. Ası́ como los valores, en el mundo “real” o
“matemático”, pertenecen a un conjunto, las expresiones pertenecen a un
tipo.
Veamos qué tipos pueden tener las expresiones de Haskell:
Tipos básicos como Int, Char, Bool, etc.
Funciones , como a → Int, Bool → (Bool → Bool), etc.
Tuplas de cualquier longitud. Por ejemplo, (2 ∗ 5 +1, 4 >0) es de tipo
(Int, Bool).
Listas , secuencias ordenadas de elementos de un mismo tipo, con repeticiones. [Int] representa el tipo lista de enteros, [Bool] es una lista
de booleanos, etc. Las expresiones de tipo lista se construyen con []
(que representa la lista vacı́a) y : (a:as es la lista que empieza con el
elemento a y sigue con la lista as). También pueden escribirse entre
corchetes, con los elementos separados por comas:
[] :: [Bool]
[3] :: [Int]
’a’ : (’b’ : (’c’ : [])) :: [Char]
[2 > 0, False, ’a’ == ’b’] :: [Bool]
[[], [1], [1,2]] :: [[Int]]
El tipo String es sinónimo de [Char], y las listas de este tipo se pueden
escribir entre comillas: "plp" es lo mismo que [’p’, ’l’, ’p’].
Tipos definidos por el usuario , con la cláusula data. Los valores asociados a estos tipos consisten de un constructor (que se escribe con
mayúscula) acompañado de 0 o más argumentos.
data Dia = Lunes | Martes | Miercoles | Jueves | Viernes
1
Este tipo tiene cinco constructores, todos sin argumentos. A esta clase
de tipos se los llama enumerados.
data Either a b = Left a | Right b
data Maybe a = Nothing | Just a
Los tipos pueden tener argumentos, lo que los convierte en tipos paramétricos. Tipos como los de arriba suelen llamarse sumas o uniones,
porque pueden representar la unión de varios tipos. En particular,
Either representa la unión de dos tipos cualesquiera, y Maybe representa el mismo conjunto que su argumento, más un valor: Nothing.
Left True :: Either True a
Just 3 :: Maybe Int
data BinTree a = Nil | Branch a (BinTree a) (BinTree a)
Acá vemos que algunos de los constructores pueden tener como argumento el mismo tipo que determinan. Tipos ası́ se suelen llamar tipos
recursivos. En este caso, BinTree a representa el tipo de los árboles
binarios cuyos nodos tienen un elemento de a.
Nil :: BinTree a
Branch True Nil
(Branch (4 > 0) Nil
Nil) :: BinTree Int
Las funciones sobre tipos construidos con la cláusula data pueden definirse por pattern matching. Un patrón consiste de un constructor con
tantas variables como argumentos tenga; al evaluar la función en un
argumento, se intenta establecer una correspondencia entre él y cada
patrón, reduciendo en la primera ecuación donde se la encuentre.
proximo
proximo
proximo
proximo
etc.
:: Dia → Dia
Lunes = Martes
Martes = Miercoles
Miercoles = Jueves
aInt :: (Either Bool Int) → Int
aInt (Left x) = if x then 1 else 0
aInt (Right x) = x
esVacio :: BinTree a b → Bool
esVacio Nil = True
esVacio (Branch _ _ _) = False
Cuando las variables no se usan en el lado derecho de la ecuación, se
pueden reemplazar por un _.
2
Los tipos que permiten acceder a sus constructores y hacer pattern
matching se llaman tipos algebraicos. ¡Los booleanos, las tuplas y las
listas también son tipos algebraicos!
fst :: (a, b) → a
fst (x, y) = x
length :: [a] → Int
length [] = []
length (x:xs) = 1 + length xs
2.
Currificación y evaluación parcial
Currificación es una correspondencia entre:
funciones que reciben múltiples argumentos y devuelven un resultado
suma :: (Int, Int) → Int
suma (x, y) = x + y
funciones que reciben un argumento y devuelven una función intermedia que completa el trabajo
suma :: Int → Int → Int
suma x y = x + y
En este ejemplo, suma x es una función que dado y devuelve x+y.
Esta correspondencia siempre existe, y en el segundo caso decimos que
las funciones están currificadas. La ventaja de las funciones currificadas es
que permiten la aplicación parcial. ¡En una sola lı́nea estamos definiendo
varias funciones!
sucesor :: Int → Int
sucesor = suma 1
3.
Polimorfismo y overloading
El sistema de tipos de Haskell permite definir funciones para ser usadas
con más de un tipo. Ya vimos algunos ejemplos: esVacio, fst y length son
funciones polimórficas. Otras funciones polimórficas útiles son:
flip :: (a → b → c) → (b → a → c)
flip f x y = f y x
(.) :: (a → b) → (c → a) → (c → b)
(.) f g x = f (g x)
3
Las funciones polimórficas en general se definen según la estructura de
sus argumentos, sin fijarse en qué valores tienen internamente. Por ejemplo, la longitud de una lista puede calcularse sin saber nada acerca de sus
elementos. Veamos ahora este otro ejemplo.
ejemplo 1: Definamos una función que devuelva verdadero cuando
todos los elementos de una lista son iguales:
todosIguales [] = True
todosIguales [x] = True
todosIguales (x:y:xs) = (x == y) && todosIguales (y:xs)
¿Qué tipo tiene esta función? En principio, vemos que puede tomar listas de distintos tipos: todosIguales [1,2,3], todosIguales [True, True],
todosIguales "hola" parecen expresiones válidas. Sin embargo, por ejemplo,
todosIguales [sucesor, suma 1] no se podrı́a evaluar, porque las funciones
no pueden compararse por igualdad.
Lo que necesitamos es describir el conjunto de tipos que tienen la operación ==, o más en general, los tipos que tienen ciertas operaciones en
particular. Para ello, Haskell provee las clases de tipos. En este caso, los que
pueden compararse por igualdad corresponden a la clase Eq.
todosIguales :: Eq a ⇒ [a] → Bool
?
Otras clases útiles son:
Show: la clase de los tipos que pueden mostrarse por pantalla
Ord: la clase de los tipos que pueden compararse (por menor, igual,
etc.)
Num: la clase de los tipos con operaciones aritméticas.
El mecanismo de clases se denomina overloading. Notemos que == no es
una función polimórfica, por más que pueda tomar argumentos de distintos
tipos. Una función polimórfica tiene la misma definición para cualquier tipo,
y como dijimos, no podrá explotar “caracterı́sticas particulares” de cada
uno. En cambio, una función sobrecargada, entre los distintos tipos, sólo
comparte el nombre (y la aridad): su definición puede ser distinta para cada
uno de ellos.
4.
Alto orden
En Haskell, las funciones son valores como cualquier otro:
Pueden ser argumentos de una función
Pueden ser resultados de otras funciones
4
Pueden almacenarse en estructuras de datos
ejemplo 2: Definamos una función que toma el máximo de una lista:
maximo :: Ord a ⇒ [a] → a
maximo [x] = x
maximo (x:y:xs) = if x > y
then maximo (x:xs)
else maximo (y:xs)
?
Esta función es útil siempre y cuando no nos interese otro orden que el
del operador >.
maximo [1,4,3] = 4
maximo ["abc", "a", "b"] = "b"
maximo [False, True] = True
ejemplo 3: Ahora supongamos que quiero elegir, entre varias secuencias, la de mayor longitud.
maxLongitud :: [[a]] → [a]
maxLongitud [xs] = xs
maxLongitud (xs:ys:xss) = if length xs > length ys
then maxLongitud (xs:xss)
else maxLongitud (ys:xss)
?
Esta función se parece mucho a la primera, y sin embargo, tuvimos que
definirla aparte. ¿Podremos generalizar maximo para que nos sirva en ambos
casos? Sı́: en lugar de tener (>) embebido en la definición de la función,
¡tomemos una función de comparación como primer argumento!
ejemplo 4:
mejorSegun :: (a → a → Bool) → [a] → a
mejorSegun _ [x] = x
mejorSegun comp (x:y:xs) = if comp x y
then mejorSegun comp (x:xs)
else mejorSegun comp (y:xs)
maximo = mejorSegun (>)
maxLongitud = mejorSegun (λxs ys → length xs > length ys)
Y podemos definir más:
minimo :: Ord a ⇒ [a] → a
minimo = mejorSegun (<)
maxElemento :: Ord a ⇒ [[a]] → [a]
maxElemento = mejorSegun tieneMaxElemento
where tieneMaxElemento xs ys = maximo xs > maximo ys
5
?
En este ejemplo mostramos varias formas de escribir funciones como
argumentos de otras:
Por su nombre, cuando la función está definida aparte: length
Por sección de operadores: (>), (∗2), etc.
Como funciones anónimas: (λxs ys →length xs >length ys)
Con cláusulas where:
where tieneMaximoElemento xs ys =maximo xs >maximo ys
5.
Listas
Las listas son una construcción muy útil en Haskell. Cuando un programa
involucra una secuencia de valores, las listas suelen ayudar a expresarlo de
una forma simple y clara.
Hasta ahora vimos cómo escribir listas a partir de sus constructores, o
de darlas explı́citamente. Acá vamos a ver otras formas útiles de hacerlo.
5.1.
Algunas funciones útiles sobre listas
take n xs devuelve los n primeros elementos de xs
drop n xs devuelve el resultado de sacarle a xs los primeros n elementos
head xs devuelve el primer elemento de la lista
tail xs devuelve toda la lista menos el primer elemento
last xs devuelve el último elemento de la lista
init xs devuelve toda la lista menos el último elemento
xs ++ys concatena ambas listas
xs !! n devuelve el n-ésimo elemento de xs
elem x xs dice si x es un elemento de xs
5.2.
Secuencias aritméticas
Las siguientes expresiones representan listas de números en progresión
aritmética:
[1..4] = [1,2,3,4]
[5,7..13] = [5,7,9,11,13]
[1..]
[2,4..]
6
De estas, las dos últimas representan listas infinitas. Como tales, por
supuesto no tienen un valor asociado, pero pueden usarse para definir otras
expresiones1 :
take 10 [1..] = [1,2,3,4,5,6,7,8,9,10]
Claramente las secuencias aritméticas no son el único mecanismo para
definir listas infinitas:
infinitosUnos :: [Int]
infinitosUnos = 1 : infinitosUnos
ejemplo 5: ¿Cómo computar el factorial de un número?
factorial :: Int → Int
factorial 0 = 1
factorial n = n ∗ factorial (n-1)
factorial n = if n == 0
then 1
else n ∗ factorial (n-1)
factorial n = product [1..n]
Como vemos, el uso de listas nos da un código más sencillo y nos ahorra
la necesidad de escribir la recursión explı́citamente.
?
5.3.
Listas por comprensión
Las listas definidas por comprensión tienen la forma
[expresion |selectores, condiciones]
donde un selector es de la forma var ← lista y una condición es una expresión booleana. Tanto la expresión como las condiciones pueden depender de
las variables de los selectores.
[(x,y) | x ← [1,2], y ← [4,5]] = [(1,4),(1,5),(2,4),(2,5)]
[(x,y) | x ← [1,3], y ← [1..x]] = [(1,1), (2,1), (2,2),
(3,1), (3,2), (3,3)]
[(x,y) | x ← [1,2], y ← [1..3], y > x] = [(1,2), (1,3), (2,3)]
1
Esto funciona bien porque Haskell utiliza evaluación lazy, que está emparentada con
el orden normal de reducción: cuando una expresión puede, como la de arriba, reducirse
de más de una forma, se elige la expresión más externa. En el ejemplo presentado, se
podı́a reducir take 10 [1..] o solamente [1..], y esto último no hubiera terminado.
Intuitivamente, la estrategia lazy evalúa los argumentos de las funciones sólo en la medida
que es necesario. Entonces, en este caso, de la lista [1..] sólo hace falta computar los
primeros diez elementos.
La estrategia de evaluación eager, en cambio, está asociada al orden de reducción estricto: ante más de una opción, se reducen las expresiones más internas, con lo cual, los
argumentos de las funciones se evalúan completamente antes de computarlas.
7
ejemplo 6: Usando listas por comprensión, podemos ordenar una lista
con el algoritmo quicksort de una manera clara y concisa:
quicksort [] = []
quicksort (x:xs) = quicksort [y | y ← xs, y ≤ x]
++ [x] ++
quicksort [y | y ← xs, y > x]
?
ejemplo 7: Para decidir si un número es primo, en lugar de contar sus
divisores con recursión explı́cita, basta con tomar la longitud de una lista:
esPrimo n = length [x | x ← [1..n], n rem x == 0] == 2
?
6.
Esquemas de funciones
6.1.
Para listas
ejemplo 8: Definamos una función que duplique los elementos de una
lista de enteros.
duplicar :: [Int] → [Int]
duplicar [] = []
duplicar (x:xs) = 2∗x : duplicar xs
duplicar xs = [2 ∗ x | x ← xs]
Definamos también una función que, dada una lista de cadenas, devuelva
una lista con sus longitudes.
longitudes :: [[a]] → [Int]
longitudes [] = []
longitudes (xs:xss) = length xs : longitudes xss
longitudes xss = [length xs | xs ← xss]
Claramente estos esquemas son muy parecidos: lo único que cambia entre
uno y otro es la función aplicada en el paso recursivo. Entonces, como ya
hemos hecho, podemos generalizarlos en una función de alto orden:
map :: (a → b) → [a] → [b]
map f [] = []
map f (x:xs) = f x : map f xs
map f xs = [f x | x ← xs]
duplicar = map (∗2)
longitudes = map length
8
?
ejemplo 9: Definamos una función que, dada una lista de enteros,
devuelva los que son pares:
pares :: [Int] → [Int]
pares [] = []
pares (x:xs) = if (rem x 2 == 0)
then x : pares xs
else pares xs
pares xs = [x | x ← xs, rem x 2 == 0]
Y ahora otra que, dada una lista de cadenas y un número, devuelva una
con las de mayor longitud que ese número:
masLargasQue :: Int → [[a]] → [[a]]
masLargasQue _ []
= []
masLargasQue n (xs:xss) = if (length xs > n)
then xs : masLargasQue n xss
else masLargasQue n xss
masLargasQue n xs = [x | x ← xs, length x > n]
¡La única diferencia entre ellas es el primer argumento de if! ¿Cómo
podemos generalizarlas?
filter :: (a → Bool) → [a] → [a]
filter _ [] = []
filter p (x:xs) = if p x
then x : filter p xs
else filter p xs
filter p xs = [x | x ← xs, p x]
pares = filter (λx → rem x 2 == 0)
= filter ((== 0) . (‘rem‘ 2))
= filter ((== 0) . (flip rem 2))
masLargasQue n = filter ((> n) . length)
?
ejemplo 10: Definamos ahora funciones para sumar los elementos de
una lista, para multiplicarlos, para contarlos y para concatenarlos.
sum :: Num a ⇒ [a] → a
sum [] = 0
sum (x:xs) = x + sum xs
product :: Num a ⇒ [a] → a
product [] = 1
product (x:xs) = x ∗ product xs
9
length :: [a] → Int
length [] = 0
length (x:xs) = 1 + length xs
concat :: [[a]] → [a]
concat [] = []
concat (xs:xss) = xs ++ concat xss
Nuevamente tenemos un esquema que se repite en las tres funciones. En
este caso, las diferencias están en el valor devuelto en el caso base y en la
función aplicada en el caso recursivo. Ası́ que vamos a abstraerlas para crear
un esquema general.
foldr :: (a → b → b) → b → [a] → b
foldr f z []
=z
foldr f z (x:xs) = f x (foldr f z xs)
sum = foldr (+) 0
product = foldr (∗) 1
length = foldr (λx n → 1 + n) 0
concat = foldr (++) []
?
El esquema foldr sirve para recorrer una lista “de derecha a izquierda”:
foldr op b (a1 :
(a2 :
(a3 :
[]))) =
a1 ‘op‘ (a2 ‘op‘ (a3 ‘op‘ b ))
Notemos acá como : se “reemplaza” por op y [] por b.
ejemplo 11: ¿Qué computan las siguientes funciones?
f1 :: [Bool] → Bool
f1 = foldr (&&) True
f2 :: [a] → [a]
f2 = foldr (:) []
f3 :: [a] → [a] → [a]
f3 xs ys = foldr (:) ys xs
?
Ası́ como con foldr se asocia a derecha, podemos escribir un operador
genérico de recursión que asocie a izquierda.
foldl :: (b → a → b) → b → [a] → b
foldl f b [] = b
foldl f b (x:xs) = foldl f (f b x) xs
sum’ = foldl (+) 0
sum’
(a1 : (a2 : (a3 : []))) =
foldl (+) (0 + a1)
(a2 : (a3 : [])) =
10
foldl (+) ((0 + a1) + a2) (a3 : [])
foldl (+) (((0 + a1) + a2) + a3) []
=
= ((0 + a1) + a2)
+ a3
ejemplo 12: ¿Qué computa las siguente función?
f4 :: [a] → [a]
f4 = foldl (flip (:)) []
?
Cuando el caso base está en una lista unitaria en lugar de en una vacı́a,
se pueden usar foldr1 y foldl1.
foldr1 :: (a → a → a) → [a] → a
foldr1 f (x:xs) = foldr f x xs
foldl1 :: (a → a → a) → [a] → a
foldl1 f (x:xs) = foldl f x xs
maximo = foldr1 max
Estos esquemas de recursión asocian a las listas un recorrido “estándar”,
a partir del cual se puede definir un conjunto importante de operaciones. Todas ellas se pueden definir entonces sin pattern matching, concentrándonos
únicamente en el aspecto de cada una que las diferencia de las demás.
ejemplo 13: Definamos map y filter usando foldr
map :: (a → b) → [a] → [b]
map f = foldr fun []
where fun x xs = f x : xs
map f = foldr (λx xs → f x : xs)
filter :: (a → Bool) → [a] → [a]
filter p = foldr selec []
where selec x xs = if p x then x : xs else xs
?
6.2.
Para otros tipos algebraicos
Los esquemas generales de recursión pueden escribirse para cualquier
tipo, y son muy útiles para evitar la repetición de código por pattern matching. En general, necesitamos:
Para cada constructor base A a1 ... an del tipo, una función base
z :: a1 →... → an → b.
Para cada constructor recursivo, una función que tome, además de los
argumentos no recursivos, los resultados acumulados, y devuelva un
nuevo resultado acumulado.
11
Recordemos la definición de BinTree al principio:
data BinTree a = Nil | Branch a (BinTree a) (BinTree a)
ejemplo 14: Empecemos por definir una función sobre BinTree Int,
que multiplique los nodos del árbol, y otra que cuente los elementos:
prodTree :: BinTree Int → Int
prodTree Nil
=1
prodTree (Branch x t1 t2) = x ∗ prodTree t1 ∗ prodTree t2
countTree :: BinTree a → Int
countTree Nil
=0
countTree (Branch x t1 t2) = 1 + countTree t1 + countTree t2
?
Acá, al igual que en las listas, hay un único caso base sin argumentos.
Pero a diferencia de ellas, el caso recursivo tiene tres, dos de los cuales
se corresponden con llamados recursivos propiamente dichos. Para definir
foldTree, necesitaremos entonces una función f de tres argumentos:
foldTree :: (a → b → b → b) → b → BinTree a → b
foldTree f z Nil
=z
foldTree f z (Branch x t1 t2) = f x (foldTree f z t1)
(foldTree f z t2)
prodTree = foldTree (λx y z → x ∗ y ∗ z) 1
countTree = foldTree (λx y z → 1 + y + z) 0
ejemplo 15: ¿Cómo podemos definir la función que dado un árbol,
devuelva su simétrico?
simetrico :: BinTree a → BinTree a
simetrico = foldTree rev Nil
where rev x t1 t2 = Branch x t2 t1
?
Referencias
[1] Página de Haskell
www.haskell.org
[2] A tour of the Haskell Prelude, describe y da ejemplos de las funciones
de uso más común
http://www.cs.uu.nl/%7Eafie/haskell/tourofprelude.html
[3] Haskell report es la especificación completa y oficial del lenguaje.
http://www.haskell.org/onlinereport
12
[4] A tour of the Haskell Syntax, una descripción más amigable de la sintaxis
de Haskell.
http://www.cs.uu.nl/%7Eafie/haskell/tourofsyntax.html
[5] A gentel introduction to Haskell, uno de los tutoriales más famosos y
bien completo. Incluye más temas que los que vamos a ver en la materia.
http://www.haskell.org/tutorial
[6] John Hughes, Why functional programming matters, Institutionen för
Datavetenskap, Chalmers Tekniska Högskola. Disponible en:
http://www.cs.chalmers.se/∼rjmh/Papers/whyfp.html
[7] Graham Hutton, A tutorial on the universality and expressiveness of
fold, University of Nottingham, UK. Disponible en:
http://www.cs.nott.ac.uk/∼gmh/fold.ps
13
Descargar