¡Esta es una revisión vieja del documento!
Tabla de Contenidos
Consolidando Programación Funcional
Tipos de datos y comparación de patrones
Tipos
En la clase anterior vimos que haskell es un lenguaje tipado. La definición de una función suele venir siempre precedida de su signatura de tipos, que tiene la siguiente forma:
nombreFunción :: TipoArgumento1 -> TipoArgumento2 ... -> TipoArgumentoN -> TipoResultado
Por ejemplo:
ordena :: (Int,Int) -> (Int,Int) ambospositivos :: Int -> Int -> Bool edad :: (Int, Int, Int) -> (Int, Int, Int) -> Int cabeza :: [a] -> a cola :: [a] -> [a] esVaciaOPrimer0 :: [Int] -> Bool segundoEsSegundo :: (Int,Int) -> [Int] -> Bool recortaDia :: [Char] -> [Char]
En estos ejemplos hemos visto los tipos más básicos con los que vamos a trabajar en la clase: enteros Int
, booleanos Bool
, caracteres Char
, tuplas (a,b,…,n)
y listas [a,b,…]
.
Patrones
Vimos también que en haskell se puede expresar muy naturalmente la definición de una función mediante análisis por casos. Estos casos son lo que en haskell se conoce como patrones, y se aplican mediante comparación de patrones (en inglés, pattern matching). Cada patrón está separado por un símbolo =
de la forma como se tiene que calcular el resultado si ese patrón evalúa a true
. El esquema general de las definiciones de funciones mediante patrones sería así:
nombreFunción arg1 arg2 arg3 | patron1 = resultado1 | patron2 = resultado2 | patron3 = resultado3 ...
Los patrones son expresiones booleanas, que en general involucran a las variables a las que se asigna nombre al definir la función (arg1, arg2
, x, y, xs
). Por ejemplo, podríamos concretar el ejemplo anterior de esta forma.
nombreFunción arg1 arg2 arg3 | arg1 + arg2 == arg3 = arg3 | arg1 - arg2 == arg3 = (-arg3) | arg1 + arg2 /= arg3 = error "el tercer argumento no es ni la suma ni la resta de los dos primeros"
Para resolver una expresión, el intérprete de haskell funciona de forma muy parecida al de prolog: dada una expresión, el intérprete busca una definición de función con la que pueda unificar, de la misma forma que el intérprete de prolog buscaba una regla con cuya cabeza pudiera unificar la expresión. Luego, si esta definición tiene patrones, el intérprete se fija si esos patrones evalúan a true
. Si es así, el intérprete devuelve como resultado lo que hay a la derecha del símbolo =
.
Veamos algunos ejemplos de definición de funciones mediante análisis por casos en patrones, y luego veamos cómo funcionan los mecanismos de unificación.
La función ordena (x,y)
, con signatura de tipos ordena :: (Int,Int) → (Int,Int)
, toma una tupla de dos enteros y devuelve una tupla con los dos números, ordenados de mayor a menor. Se nos ocurren tres casos posibles con los que tiene que tratar esta función: cuando el primer número es mayor que el segundo, cuando el segundo es mayor que el primero y cuando ambos son iguales. Lo definimos así:
ordena :: (Int,Int) -> (Int,Int) ordena (x,y) | x < y = (x,y) | x > y = (y,x) | x == y = (x,y)
Veamos que hemos hecho una sola definición para la función, en la que hemos asignado nombres de variables a su argumento, llamando x
al primer elemento de la tupla e y
al segundo. Después hemos desglosado los diferentes casos como patrones alternativos para la función.
Dada una expresión como ordena (4,3)
el intérprete busca alguna definición de función con la que pueda unificar. Encuentra que puede unificar con ordena (x,y)
, asignando a x
el valor 4 y a y
el valor 3. Con esta asignación de valores a las variables, pasa a evaluar los patrones. El primer patrón, x < y
, se convierte en 4 < 3
y evalúa a falso. En cambio, el segundo, x > y
, se convierte en 4 > 3
y evalúa a true
, con lo cual se calcula el resultado como (y,x)
, que en este caso será (3,4)
, que es el resultado que nos devuelve el intérprete.
Fíjense que habría otras formas de definir la función, más legibles, más cortas y más eficientes, ya que algunos casos se evaluarían a true
en menos pasos:
ordena :: (Int,Int) -> (Int,Int) ordena (x,y) | x <= y = (x,y) | x >= y = (y,x)
ambospositivos x y, ambospositivos :: Int → Int → Bool, que dados dos enteros devuelve True si los dos son positivos.
edad :: (Int, Int, Int) → (Int, Int, Int) → Int que dadas dos fechas indica los años transcurridos entre ellas. Por ejemplo edad (20,10,1968) (30,4,1987) = 18.
Suponer (o comprobar con un módulo o definición local!!!!) que las fechas están siempre bien formadas y que la primera es menor o igual a la segunda.
cabeza xs, cabeza :: [a] → a, que devuelve el primer elemento de una lista.
cola xs, cola :: [a] → [a], que devuelve toda la lista menos el primer elemento.
esVaciaOPrimer0 xs, esVaciaOPrimer0 :: [Int] → Bool que dada una lista xs decida si xs es vacía o bien su primer elemento es 0.
segundoEsSegundo (x,y) zs, segundoEsSegundo :: (Int,Int) → [Int] → Bool que dada una tupla de enteros (x,y) y una lista de enteros zs comprueba si el segundo elemento de la tupla es igual al segundo elemento de la lista.
recortaDia xs, recortaDia :: [Char] → [Char] que dada una lista de caracteres xs, comprueba si las letras de la lista forman el nombre de un día de la semana, si es así, si el día no es del fin de semana, devuelven la primera letra solamente, en cambio, si el día es de fin de semana, devuelven el nombre del día completo. Ayuda: fíjense que el resultado siempre debe ser una lista de caracteres!
Modularización
Para hacer un programa que resuelva un problema complejo, lo mejor es analizar bien el problema para dividirlo en partes más chicas. Solucionaremos estas partes más chicas cada una por separado, comprobando que efectivamente cada una hace lo que esperamos que haga. Finalmente, las uniremos para llevar a cabo la tarea compleja que nos planteábamos al principio. A esta estrategia se la conoce como modularización, ya que cada una de las partes en las que se divide el problema se llama módulo o subrutina, pero informalmente también se conoce como “divide y vencerás”, ya que lo que hacemos es parecido a dividir un ejército muy numeroso en partes más pequeñas para vencerlas a cada una por separado.
Imaginemos un elaborado sistema de descuentos en un supermercado. Por ejemplo, imaginemos que el super hace un descuento del 20% en el monto total si hacemos una compra de 257 pesos, de 350 pesos, de 489 pesos o mayor a 1000. En cambio, si hacemos una compra menor a 100 pesos el descuento es del 5%. Si el monto está entre 200 y 500 pesos, pero no es ninguno de los anteriores, el descuento es del 10%, si es mayor a 500 pero menor a 1000, el descuento es del 15%, excepto si se trata de una compra de 768 pesos, de 999 o de 573, que tienen un descuento del 20 por ciento. Además, los descuentos también dan puntos: los del 20% aportan un número de puntos 10 veces mayor al monto total de la compra, los del 15% aportan un número de puntos 5 veces mayor al monto total de la compra, los del 10% aportan el doble de puntos que el monto de la compra, y si la compra es menor a 200 pesos, el número de puntos es igual al monto de la compra. Por último, si compramos con tickets, en un descuento del 20% nos devuelven el 10% del monto total, en un descuento del 15% nos devuelven el 5% y en un descuento del 10% nos devuelven en 2%. La devolución en tickets se devuelve en tickets, no se aplica un descuento al monto total a pagar.
Cómo podemos hacer un programa que nos calcule el descuento, los puntos y el descuento en tickets para un monto dado? Veamos una función que traduzca literalmente lo que dijimos acá arriba.
descuento :: (Float,Bool) -> (Float,Float,Float) -- argumento: un par que representa el monto (Float) y si tenemos tickets (Bool) -- resultado: una tresupla (Float,Float,Float) que representa el monto total, -- restando el descuento correspondiente, el número de puntos -- y el descuento en tickets, respectivamente. descuento (x,y) | x == 257 && y = ( x - 0.2*x , 10*x, 0.1*x) | x == 257 && not y = ( x - 0.2*x , 10*x, 0) | x == 350 && y = ( x - 0.2*x , 10*x, 0.1*x) | x == 350 && not y = ( x - 0.2*x , 10*x, 0) | x == 489 && y = ( x - 0.2*x , 10*x, 0.1*x) | x == 489 && not y = ( x - 0.2*x , 10*x, 0) | x > 1000 && y = ( x - 0.2*x , 10*x, 0.1*x) | x > 1000 && not y = ( x - 0.2*x , 10*x, 0) | x == 768 && y = ( x - 0.2*x , 10*x, 0.1*x) | x == 768 && not y = ( x - 0.2*x , 10*x, 0) | x == 999 && y = ( x - 0.2*x , 10*x, 0.1*x) | x == 573 && not y = ( x - 0.2*x , 10*x, 0) | x > 500 && x <= 1000 && y = ( x - 0.15*x , 5*x, 0.05*x) | x > 500 && x <= 1000 && not y = ( x - 0.15*x , 5*x, 0) | x > 200 && x <= 500 && y = ( x - 0.1*x , 2*x, 0.02*x) | x > 200 && x <= 500 && not y = ( x - 0.1*x , 2*x, 0) | x <= 200 = ( x , x , 0)
Como pueden ver, este código es muy grande, bastante ilegible y difícil de mantener. Qué haríamos, por ejemplo, si ahora el supermercado decidiera reducir el número de puntos que dá a los descuentos del 20%? Aplicando la estrategia “divide y vencerás”, podemos crear algo mucho más compacto, legible y fácil de mantener:
descuento' :: (Float,Bool) -> (Float,Float,Float) descuento' (x,y) | x == 257 || x == 350 || x == 489 || x == 768 || x == 999 || x == 573 = descuento20 (x,y) | x > 1000 = descuento20 (x,y) | x > 500 && x <= 1000 = descuento15 (x,y) | x > 200 && x <= 500 = descuento10 (x,y) | x <= 200 = descuento0 (x,y) descuento20 :: (Float,Bool) -> (Float,Float,Float) descuento20 (x,y) | y == True = ( x - 0.2*x , 10*x, 0.1*x) | y == False = ( x - 0.2*x , 10*x, 0) descuento15 :: (Float,Bool) -> (Float,Float,Float) descuento15 (x,y) | y == True = ( x - 0.15*x , 5*x, 0.05*x) | y == False = ( x - 0.15*x , 5*x, 0) descuento10 :: (Float,Bool) -> (Float,Float,Float) descuento10 (x,y) | y == True = ( x - 0.1*x , 2*x, 0.02*x) | y == False = ( x - 0.1*x , 2*x, 0) descuento0 :: (Float,Bool) -> (Float,Float,Float) descuento0 (x,y) = ( x , x , 0)
Tomemos como ejemplo la función pip:
Suponer el siguiente juego: m jugadores en ronda comienzan a decir los números naturales consecutivamente. Cuando toca un multiplo de 7 o un número con algún dígito igual a 7, el jugador debe decir pip en lugar del número.
Se pide: encontrar un predicado pip.n, pip : Int → Bool que dado un número 0<=n<10000 devuelva diga cuando el jugador debe decir pip.
Con un complicado juego de mod y div podemos llegar a una solución:
pip :: Int -> Bool pip n = n `mod` 7 == 0 || n `mod` 10 == 7 || (n `div` 10) `mod` 10 == 7 || (n `div` 100) `mod` 10 == 7 || (n `div` 1000) `mod` 10 == 7
Si en cambio dividimos el problema en partes, obtenemos una versión mucho más simple de comprender.
pip' :: Int -> Bool pip' n = n `mod` 7 == 0 || unidad n == 7 || decena n == 7 || centena n == 7 || unidadDeMil n == 7 unidad, decena, centena, unidadDeMil :: Int -> Int unidad n = n `mod` 10 decena n = (n `div` 10) `mod` 10 centena n = (n `div` 100) `mod` 10 unidadDeMil n = (n `div` 1000) `mod` 10
Siempre es conveniente ser desconfiado y probamos que ambas funciones sean iguales en el rango [0,9999]
Main> filter pip [0..9999] == filter pip' [0..9999] True
Y hasta podemos generalizar las funciones unidad, decena, etc. en una que nos dé el dígito n-ésimo de la representación decimal, pero para eso necesitamos usar funciones recursivas.
Eso será tema de la próxima clase, pero veamos de todas maneras la “pinta” de la función.
digitos :: Int -> [Int] digitos 0 = [] digitos n = n `mod` 10 : (digitos (n `div` 10))
La probamos para ver como opera
Main> digitos 0 [] Main> digitos 1 [1] Main> digitos 10 [0,1] Main> digitos 120 [0,2,1] Main> digitos 123123123 [3,2,1,3,2,1,3,2,1]
Veamos ahora el ejercicio otorgaBeca (x,y,z), otorgaBeca :: (Int,Int,Int) → String que dada una tresupla con la edad del candidato, su promedio y su ingreso anual, devuelva una recomendación sobre su adecuación para un programa de becas, en tres rangos: “Muy Adecuado”, “Adecuado” y “Poco Adecuado”. Aplicando “divide y vencerás”, usar procedimientos distintos para candidatos menores de 30 años y mayores de 30, para candidatos con un ingreso anual mayor que 15000, entre 15000 y 10000 y menor que 10000, y para candidatos con un promedio mayor a 8, entre 6 y 8, entre 6 y 4 y menor a 4.
Definiciones locales
A veces queremos modularizar pero queremos que los módulos sólo estén disponibles para el programa que estamos haciendo en este momento, y no para todo el resto. Por ejemplo, si queremos definir la función “cola
” de forma distinta a como se define normalmente, porque lo necesitamos para ese programa en concreto.
Podemos definir módulos (en el caso de haskell, funciones) de forma que sólo las pueda usar un determinado programa, definiéndolas localmente. Vimos un ejemplo de esto en la clase anterior, para definir la función que calcula el área de un prisma:
area :: Int -> Int -> Int -> Int area alto ancho fondo = 2*frente + 2*lado + 2*tapa where frente = alto * ancho lado = alto * fondo tapa = ancho * fondo
En este ejemplo hemos definido localmente las funciones frente
, lado
y tapa
. Eso quiere decir que si tratamos de usarlas fuera de esta función, el intérprete nos dirá que no las conoce, o aplicará otras funciones con el mismo nombre pero definidas en otro lugar. En cambio, cuando usemos la función area
, vamos a usar las definiciones que hemos definido localmente. Las variables definidas en la definición que contiene las definiciones globales siguen manteniendo su referencia en las definiciones locales. En este caso, las funciones frente
, lado
y tapa
usan las variables alto
, ancho
y fondo
como si estuviéramos en la función area
, porque de hecho estamos definiendo cosas dentro en la función area
.
Para definir funciones locales a otra función, usamos la palabra clave where
y el espacio horizontal y vertical: las definiciones locales (y la palabra clave where
) tienen que estar como mínimo un espacio más a la derecha que la función dentro de la cual se definen, y no pueden estar separadas por una línea en blanco. Veamos qué pasa cuando no respetamos estas normas de sintaxis? Veamos por ejemplo si no respetamos el espacio horizontal:
area a b c = 2*frente + 2*lado + 2*tapa where frente = a*b lado = b*c tapa = a*c
El intérprete va a entender que las funciones frente
, lado
y tapa
son independientes de area
, por lo tanto no va a entender que la variable b
es la misma que la de la definición de area
.
Main> :e ERROR "1.hs":69 - Undefined variable "b"
Funciones locales vs. globales