Laboratorio Análisis Lógico Práctica 1 Pedro Arturo Góngora Luna 1 1 Introducción 1.1 Haskell y la programación funcional En la década de los 30 s, Alonzo Church desarrolló un lenguaje formal llamado cálculo λ con la finalidad de analizar las propiedades del concepto de función en matemáticas. Con la continua investigación de Church, Curry 2, Kleene y otros, se sabe ahora que el cálculo λ es un modelo completo de computación, equivalente a otros modelos más conocidos como las máquinas de Turing. El paradigma de programación funcional tiene su base en el concepto de función, con sólidos fundamentos teóricos en el cálculo λ, la lógica combinatoria y las ecuaciones recursivas. A diferencia de los lenguajes imperativos, que no se deslindan del todo de las características del hardware, los lenguajes funcionales brindan un mayor nivel de abstracción. Por ejemplo, una oración como x = 5, significa que definimos a x como 5, mas no que asignamos el valor 5 a alguna celda de memoria de la computadora. Haskell es un lenguaje de programación perteneciente a este paradigma. La primera versión apareció al rededor de los 90 s. Desde entonces, el desarrollo ha sido continuo y ahora tenemos un estándar, Haskell 98. Ya el ejemplo de un párrafo anterior nos muestra una de las mayores diferencias (y de mayor confusión para los novatos) con los lenguajes imperativos, en Haskell no existe el concepto de asignación. La ausencia de la asignación en un lenguaje, implica que no tenemos a la mano estructuras como for ó while que aparecen en la mayoría de los lenguajes populares. Lo anterior puede parecer difícil, sin embargo, en Haskell podemos hacer uso de conceptos de más alto nivel como las definiciones recursivas, tipos de datos inductivos, etc. Aunque existen otros lenguajes funcionales más conocidos, como Lisp y Scheme, Haskell es un lenguaje funcional moderno. Es es un lenguaje funcional puro, es decir, no posee ninguna característica imperativa. Es un lenguaje no estricto y de orden superior. Además, tiene características únicas como las mónadas y tipos de datos algebraicos. En definitiva, Haskell provee de características interesantes para los matemáticos, computólogos y entusiastas de la programación funcional en general. 1.2 Implementaciones Existen varias implementaciones de Haskell, sin embargo, las más populares son Hugs y GHC (Glasgow Haskell Compiler). Hugs es un itérprete escrito en C. Es altamente portable y existen versiones para los sistemas operativos más comunes. GHC es un compilador, está escrito en Haskell y permite generar programas ejecutables binarios bastante eficientes. GHC también puede ser usado en modo interactivo (GHCi) de forma similar a Hugs. Ambos ambientes, y otros más, pueden descargarse del sitio oficial de Haskell http://www.haskell.org. 2 Desarrollo 2.1 Manejando el entorno Para iniciar el entorno de Haskell, ingresamos la instrucción $ hugs 1 pedro.gongora@gmail.com 2 Haskell Curry (1900-1982) es uno de los fundadores de la lógica combinatoria, sobre la cual se basa el cálculo λ 1
en la línea de comandos, o la instrucción $ ghci para la versión interactiva de GHC. Como resultado, despues de unas líneas de presentación del programa, obtenemos un prompt como el siguiente Prelude> _ El significado del prompt es que se ha cargado la librería Prelude, la cual contiene definiciones básicas para nuestros programas. Intentemos ahora algunas pruebás sencillas Prelude> 2+1 3 Prelude> 10+5*2 20 Prelude> max 25 30 30 Prelude> max 25 (30 + 1) 31 Para cargar nuestros programas desde un archivo de texto, podemos usar los siguientes comandos del intérprete: :load <MiPrograma.hs> Compila y carga en el intérprete el código Haskell contenido en el archivo de texto MiPrograma.hs (podemos usar la abreviatura :l). :load Sin argumentos, el intérprete retirará de la memoria los módulos cargados anteriormente, dejando sólo Prelude. :reload Repite la última operación :load (esta instrucción puede abreviarse como :r). Por ejemplo, ejecuta un editor de textos (notepad, emacs, vi, etc.) y captura lo siguiente: -- Módulo: Prueba -- En Haskell los módulos empiezan con una letra mayúscula y los -- comentarios de una linea con -- module Prueba where {- Definimos la función cuad Las funciones y variables comienzan con una letra minúscula También tenemos comentarios de varias lineas -} cuad x = x * x -- esta función calcula el cuadrado identidad x = x -- esta es la función identidad y = 0 -- definimos la variable y como 0 Después de guardar el archivo bajo el nombre Prueba.hs, lo cargamos en el intérprete: Prelude> :l Prueba.hs Compiling Prueba ( Prueba.hs, interpreted ) Ok, modules loaded: Prueba. *Prueba> _ 2
Vemos que el prompt cambia para indicarnos que el módulo prueba ha sido cargado exitosamente. Podemos hacer las pruebas correspondientes: *Prueba> cuad 2 4 *Prueba> cuad (cuad 2) 16 *Prueba> identidad 5 5 *Prueba> identidad "hola" "hola" *Prueba> y 0 *Prueba> y + 1 1 Vamos resaltar algunos puntos del programa anterior: Utilizamos los identificadores cuad, identidad e y. En Haskell un identificador comienza con una letra minúscula, y puede seguirse de cualquier letra, mayúscula o minúscula, dígitos, y los caracteres _ y. Por ejemplo, todos los siguientes son identificadores válidos en Haskell: a, x1, x_1 y x. Utilizamos un identificador para nombrar a las variables y a las funciones. Usamos el símbolo = para definir el valor de una variable o el código de una función. Es importante entender la diferencia entre definición y asignación destructiva. En Haskell no existen las asignaciones destructivas (como en Java o C), pues éstas destruyen el valor anterior y lo cambian por uno nuevo. Por el contrario, una definición es inmutable. Esto quiere decir que cuando nos preguntemos por el valor de un identificador, siempre vamos a obtener el mismo resultado. Que en Haskell sólo existan definiciones, nos garantiza que podemos razonar ecuacionalmente sobre nuestros programas. Cuando escribimos en el intérprete: *Prueba> cuad 2 4 lo que estamos preguntando es: cuál es el resultado de aplicar la función cuad a 2? Para resolver esta pregunta, el intérprete simplemente sustituye cuad por el lado derecho de la definición: x * x, y sustituye cada ocurrencia de x por 2. Esto es, resolvemos la siguiente ecuación: 2.2 Funciones y recursión cuad 2 = (x x) {x:=2} = 2 2 = 4 Podríamos empezar con el ya gastado hola mundo, pero como estamos estudiando un lenguaje funcional, mejor pasemos a ejemplos más interesantes. Consideremos la siguiente definición de la función de factorial { 1 si n = 0 fac(n) = n fac(n 1) si n > 0 Podríamos ingresar entonces lo siguiente en nuestro archivo Prueba.hs 3
fac n = if n==0 then 1 else n * fac (n-1) lo cual funciona perfectamente, pero veamos otras definiciones alternativas: fac n n==0 = 1 n>0 = n * fac (n-1) También ésta otra opción fac 0 = 1 fac (n+1) = (n+1) * fac n Esta última versión muestra la característica de pattern matching de Haskell. Para ejecutar las funciones anteriores, las agregamos a nuestro archivo Prueba.hs y lo volvemos a cargar en el intérprete con la instrucción :r. Algunas pruebas: *Prueba> :r [1 of 1] Compiling Prueba ( Prueba.hs, interpreted ) Ok, modules loaded: Prueba. *Prueba> fac 4 24 *Prueba> fac 4 24 *Prueba> fac 4 24 2.3 Listas Uno de los tipos de datos más usados son las listas. Haskell incorpora a las listas dentro de sus definiciones por defecto. Creamos una lista con la siguiente sintaxis *Prueba> [1,2,3,4,5] [1,2,3,4,5] Otra manera de definir las listas es con el operador : *Prueba> 1:2:3:4:5:[] [1,2,3,4,5] donde [] es la lista vacía (o fin de lista). En realidad, la notación con corchetes ([ ]) es una abreviatura para la notación con :. Por ejemplo (el operador == compara dos expresiones y devuelve True si dan el mismo resultado): *Prueba> [1,2,3] == 1:2:3:[] True El operador : puede razonarse como una función que recibe un elemento x, y una lista xs, y nos construye una nueva lista con x al inicio de la lista xs. Por ejemplo: *Prueba> 1:[2,3,4] [1,2,3,4] 4
Lo único que tenemos que cuidar es el hecho de que las listas son homogeneas, es decir, todos los elementos de una lista deben ser del mismo tipo. Lo siguiente es un error: *Prueba> [1, a ] <interactive>:1:1: No instance for (Num Char) arising from the literal 1 at <interactive>:1:1 Probable fix: add an instance declaration for (Num Char) In the list element: 1 In the definition of it : it = [1, a ] El operador : no será útil para definir funciones sobre listas, sin embargo, Haskell provee de otras alternativas para crear listas. Con el operador.. podemos crear listas por rangos. Por ejemplo: *Prueba> [1..5] [1,2,3,4,5] Otro ejemplo: *Prueba> [ a.. z ] "abcdefghijklmnopqrstuvwxyz" nos muestra el rango de caracteres de la a a la z. El ejemplo anterior también ilustra el hecho de que, en Haskell, las cadenas (o strings) son listas de caracteres. Podemos concatenar dos listas (y por lo tanto cadenas) con el operador ++ *Prueba> [1..5] ++ [6..10] [1,2,3,4,5,6,7,8,9,10] 3 Funciones sobre listas Podemos usar la notación : y la característica de pattern matching de Haskell para definir funciones recursivas sobre listas. Por ejemplo, la función: tamano [] = 0 tamano (x:xs) = 1 + tamano xs nos calcula el tamaño de una lista: *Prueba> tamano [-1..100] 102 Otro ejemplo, la función: sumlist [] = 0 sumlist (x:xs) = x + sumlist xs 5
calcula la suma de los elementos de una lista. Aquí ya podemos observar el patrón. Recordemos que el conjunto de (todas) las listas de elementos de tipo A es un conjunto generado inductiva y libremente. Esto es, el conjunto de las listas de elementos en A es la cerradura inductiva de la lista vacía con la función :. Listas de a A = ({[]}) + Entonces, para definir funciones recursivas sobre las listas tenemos que hacerlo por partes: primero la definimos para el caso base (la lista vacía) y después para las siguientes listas, es decir las que se forman con la función constructora :. Una función interesante es map que aplica una función dada a todos los elementos de una lista. Por ejemplo: *Prueba> map succ [1..10] [2,3,4,5,6,7,8,9,10,11] aplica la función succ (sucesor) a todos los elementos de la lista. Aunque la función map ya viene predefinida en Haskell, no es difícil definir nuestra propia versión map usando el patrón que ya vimos: map f [] = [] map f (x:xs) = f x : (map f xs) Y obtenemos el mismo resultado: *Prueba> map succ [1..10] [2,3,4,5,6,7,8,9,10,11] Una lista se compone de dos partes: una cabeza y una cola. Para cualquier lista de tipo: x 1 : x 2 : : [] x 1 es la cabeza, y la lista x 2 : : [] es la cola. Las funciones head y tail nos regresan la cabeza y la cola de una lista, respectivamente: *Prueba> head [1..10] 1 *Prueba> tail [1..10] [2,3,4,5,6,7,8,9,10] 4 Ejercicios 1. Escribe tu propia versión de las funciones head y tail (puedes nombrarlas head y tail ). 2. Traduce la función concat vista en clase a la sintaxis de Haskell. 3. Verifica (haz un esbozo) que la demostración vista en clase, también aplica para su versión en Haskell: (sumlist l1) + (sumlist l2) = sumlist (concat l1 l2) 6