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