Apuntes sobre Paradigmas de Programación Por Dr. Ignacio Ponzoni Correcciones 2010: Dra. Jessica A. Carballido Departamento de Ciencias de la Computación Universidad Nacional del Sur Prólogo Existen varias maneras de clasificar los lenguajes. Una de las formas más empleadas consiste en agruparlos según el paradigma de programación que soportan. Para comprender esta clasificación debemos comenzar por establecer el significado de la palabra “paradigma”. Este vocablo, surgido en la Grecia Clásica, tiene 2500 años de antigüedad y proviene de la conjunción de los términos: para, ‘lado por lado’, y deigma, ‘tal como se muestra’. Originariamente, esta expresión se utilizaba durante la exhibición de productos en el mercado. Posteriormente adquirió el significado de ‘ejemplo’. En esa época, era común enseñar a través de casos particulares, luego un paradigma era un ejemplo “bien establecido” para el aprendizaje de un concepto específico. Hoy en día, se sigue hablando de casos paradigmáticos para expresar un ejemplo que reúne las características sobresalientes de una familia de entidades. Esta última constituye la definición humanística de la palabra paradigma. En el campo de las ciencias, tiene una acepción algo diferente. Aquí, el vocablo refiere a un ejemplo tomado como punto de partida para el desarrollo de una teoría o método. A modo de ilustración, se puede afirmar que el movimiento de los planetas fue el paradigma usado para establecer las Leyes de la Mecánica. Luego, un paradigma bien elegido usualmente conduce a una teoría o metodología exitosa. En Ciencias de la Computación, y en otras ramas del conocimiento, un paradigma es un conjunto coherente de métodos, más o menos efectivo, para el manejo de un tipo de problemas, también llamado dominio de aplicación. A manera de ejemplo, la democracia, oligarquía y dictadura pueden constituir paradigmas de gobierno. Usualmente, un paradigma puede ser caracterizado mediante un principio básico, fácil de formular. Para el caso anterior, en una dictadura la autoridad se concentra usualmente en una única persona, en una oligarquía el poder es ejercido por una pequeña elite y en democracia recae sobre todos los ciudadanos. Por otra parte, para llevar a la práctica este principio, es necesario establecer un conjunto de métodos y técnicas. Prosiguiendo con nuestro ejemplo, una democracia requiere de un sistema electoral que permita establecer los gobernantes. Asimismo, estas técnicas deben ser soportadas por una serie de conceptos, a fin de asegurar un funcionamiento apropiado de las mismas. Luego, el sistema electoral requerirá de conceptos tales como elección, representación, ballotage, etc. Es importante notar, que al igual que los métodos, estos conceptos tienen su raíz en los principios fundamentales que guían al paradigma. Volviendo al tema que nos ocupa, varios paradigmas de programación han sido establecidos desde los inicios de la computación. De los cuales, rescatamos como más prominentes a cuatro de ellos: el imperativo, el funcional, el lógico y el orientado a objetos. Durante los primeros años de la carrera, los alumnos han adquirido sólidamente los conceptos inherentes a cada enfoque. Por tal motivo, en la práctica de Lenguajes de Programación trataremos de concentrar esfuerzos para alcanzar una visión más global de los distintos paradigmas, trazando paralelos y estableciendo distintos puntos de comparación entre los diferentes tipos de lenguajes. Por otra parte, y con el fin de mejorar la comprensión de los tópicos expuestos, se presentará con cada paradigma un lenguaje que lo soporta. En tal sentido, diremos que un lenguaje soporta un paradigma si provee mecanismos que facilitan su aplicación de manera eficiente y natural. El espíritu de estas notas es introducir a los estudiantes en los aspectos esenciales de cada paradigma. Lógicamente, un análisis exhaustivo de los temas escapa al alcance del curso. No obstante, se recomienda la lectura de los libros y artículos citados en este trabajo. El apunte está organizado en tres partes. La primera, describe el paradigma funcional empleando Standard ML como lenguaje de referencia. El segundo, presenta el paradigma lógico, utilizándose Prolog como caso de estudio. Finalmente, el paradigma orientado a objetos es ilustrado a través de Smalltalk. En todos los casos, el énfasis esta puesto en las características que soportan el paradigma. Sin embargo, con frecuencia los lenguajes pierden parte de su pureza al incorporar prácticas propias de otros paradigmas, las cuales son introducidas para satisfacer requerimientos de eficiencia y algunas cuestiones de índole comercial. Esto no debe confundirse con la denominada “integración de paradigmas”. Esta nueva tendencia persigue la integración de los distintos conceptos y métodos de manera apropiada, a partir de una propuesta conceptualmente clara. La idea es integrar, no mezclar. Desde un punto de vista pedagógico, se espera que este apunte resulte de fácil lectura, liberando a los alumnos de la necesidad de tomar notas. De este modo, se busca agilizar el dictado de la práctica. Asimismo, se descuenta la colaboración de los estudiantes, quienes con sus comentarios y sugerencias pueden ayudar a mejorar este texto. Dr. Ignacio Ponzoni Bahía Blanca, 6 de abril de 2001 TABLA DE CONTENIDOS 1. INTRODUCCIÓN 1 Breve reseña histórica 1 2. CONCEPTOS BÁSICOS 2 2.1 OBJETOS 2.2 FUNCIONES PRIMITIVAS 2.3 FORMAS FUNCIONALES 2.4 OPERACIÓN DE APLICACIÓN 2 2 3 3 3. PROPIEDADES DE LOS LENGUAJES FUNCIONALES 3 3.1 SEMÁNTICA DE VALORES 3.2 TRANSPARENCIA REFERENCIAL 3.3 FUNCIONES COMO VALORES DE PRIMERA CLASE 3.4 CURRYING 3.5 EVALUACIÓN PEREZOSA (LAZY EVALUATION) 3 3 4 4 5 4. ML: UN CASO DE ESTUDIO. 7 4.1 PRINCIPALES CARACTERÍSTICAS DE ML Unit Booleanos Enteros Cadenas Números Reales Tipos de Datos Estructurados Tuplas (Producto Cartesiano) Listas (Secuencia) Registros (Producto Cartesiano) 7 8 8 9 9 9 9 9 9 9 9 10 10 10 11 4.2.2 IDENTIFICADORES, LIGADURAS Y DECLARACIONES Inferencia de Tipos 11 12 Sobre el software empleado por la Cátedra Interacción con ML 4.2. ELEMENTOS BÁSICOS DE ML 4.2.1 EXPRESIONES BÁSICAS, VALORES Y TIPOS Tipos de Datos Simples Declaraciones Locales 4.2.3 PATTERN MATCHING 4.2.4 DEFINICIÓN DE FUNCIONES Tipo Función Funciones Definidas por el Usuario Restricciones de Tipo Funciones Definidas mediante Cláusulas Declaraciones Locales Referencias a Variables No Locales Funciones Curried Formas Funcionales Composición Aplicar a Todos Operador de Inserción 4.2.5 POLIMORFISMO Y SOBRECARGA 4.2.6 DEFINICIÓN DE TIPOS Abreviación de Tipo Definición de Tipo Definición de un Tipo de Dato Abstracto 4.2.7 DEFINICIÓN DE MÓDULOS Structures Signatures Functors 4.2.8 MANEJO DE EXCEPCIONES Declaración de Excepciones Activación de Excepciones Excepciones predefinidas Manejadores de excepciones REFERENCIAS 12 13 15 15 16 17 17 18 18 19 19 20 20 20 20 21 21 21 23 23 24 24 Error! Bookmark not defined. 26 26 27 27 28 28 PARADIGMA FUNCIONAL 1. INTRODUCCIÓN Los lenguajes imperativos permiten a los programadores expresar algoritmos, los cuales son descripciones precisas de “cómo” resolver determinados problemas. El modelo de computación subyacente usa un estado, conformado por las variables manipuladas por el programa, el cual es modificado repetidamente a través de sentencias de asignación. La computación termina al alcanzarse un estado final. A grandes rasgos, este modelo puede ser implementado almacenando el estado en la memoria principal y traduciendo las operaciones sobre el estado en instrucciones de la máquina. Como apreciará el lector, este modelo está fuertemente influenciado por la arquitectura de von Neumann. Justamente, esta última característica constituye la principal ventaja, como también la mayor desventaja, de los lenguajes imperativos. Por un lado, estos lenguajes pueden ser implementados de manera muy eficiente debido a que el mapeo con el hardware resulta bastante directo. Como contrapartida, el paradigma imperativo restringe el modelo mental del programador a la estructura de la máquina. La persona debe pensar en función de nombres de locaciones de memoria (variables) cuyos contenidos deberán ser modificados hasta alcanzar la solución (estado final del computo). De este modo, se produce un alejamiento entre el planteo natural de la solución de un problema y su implementación a través del lenguaje. Esto último motivó el desarrollo de modelos alternativos que no reflejasen la arquitectura de von Neumann. La idea central de estos nuevos enfoques consistió en especificar “qué” se desea computar, en lugar de “cómo” la computación debe ser llevada a cabo. Los modelos que persiguen este objetivo se denominan declarativos. Básicamente, podemos distinguir dos paradigmas que responden a esta filosofía: el paradigma funcional y el paradigma lógico. En este apunte nos concentraremos en el primero de ellos, el cual ilustraremos mediante el uso del lenguaje funcional ML. Breve reseña histórica Sin duda alguna, Lisp fue el primero de los lenguajes funcionales. La versión original de Lisp, desarrollada por John McCarthy hacia fines de los años 50, estaba orientada a la computación simbólica. Este lenguaje introdujo varios conceptos novedosos para la época. En particular, la implementación a través de intérpretes, la cual es común a la mayoría de estos lenguajes. APL y SNOBOL4 surgieron en la década del 60. El primero, diseñado por Kenneth Iverson, fue ampliamente utilizado en ambientes científicos dada su versatilidad para el manejo de problemas numéricos. El segundo, concebido por Ralph Griswold para la manipulación de textos, provee facilidades para el trabajo con cadenas de caracteres. A principios de los años 70, John Backus presenta una fuerte crítica a los lenguajes imperativos. En dicho trabajo, introduce un lenguaje funcional puro al que denomina FP. En realidad, FP constituye una familia de lenguajes, donde cada uno provee un conjunto de funciones y formas funcionales. En estos años también surge un dialecto de Lisp conocido como Scheme. Este, a diferencia de su antecesor, adopta reglas de alcance estático. 2 Apuntes sobre Paradigmas de Programación Paradigma Funcional Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido En los años 80 surgen los primeros lenguajes funcionales modernos. El primero de ellos, denominado ML, fue propuesto por Robin Milner. ML es fuertemente tipado y brinda soporte para conceptos tales como encapsulamiento y modularidad. Este lenguaje será tomado como caso de estudio durante el presente cuatrimestre. Miranda, presentado por A. Turner en 1986, y Haskell, diseñado A. Davie en 1992, cierran el panorama actual dentro de los lenguajes funcionales. Ambos resumen el estado del arte en lo que refiere al paradigma funcional. 2. CONCEPTOS BÁSICOS El paradigma funcional (ó aplicativo) se fundamenta en la evaluación de expresiones construidas a partir de la combinación de funciones. Una función, al igual que en matemática, mapea valores tomados de un conjunto, denominado dominio, en valores de otro conjunto, conocido como rango o codominio. Básicamente, los lenguajes funcionales se caracterizan por cuatro componentes: • Un conjunto de Objetos1: son los objetos miembros de los dominios y rangos de las funciones. • Un conjunto de Funciones Primitivas: son aquellas predefinidas por el lenguaje. • Un conjunto de Formas Funcionales: son mecanismos que permiten combinar funciones y/o objetos con el fin de construir nuevas funciones. • Una operación de Aplicación: es un mecanismo predefinido por el lenguaje para aplicar la función a sus argumentos. 2.1 Objetos El conjunto de objetos provistos por un lenguaje funcional depende de las características del mismo. Por ejemplo, FP es un lenguaje no tipado donde un objeto puede ser un átomo, una secuencia de objetos ó el elemento ⊥ (indefinido). En cambio, Standard ML es fuertemente tipado y sus objetos deben ser de algún tipo predefinido (boolean, string, integer, real) o de un tipo definido por el programador. 2.2 Funciones Primitivas Las funciones predefinidas por los lenguajes dependen del dominio de aplicación del mismo. Por ejemplo, la primera y más pura versión de Lisp contaba sólo con cinco funciones primitivas (ATOM, EQ, CAR, CDR y CONS) destinadas al manejo de listas. En contraste, APL, ideado para el rápido desarrollo de prototipos en aplicaciones numéricas, posee una gran cantidad de funciones vinculadas a la operación de matrices. Por otra parte, SNOBOL4, otro lenguaje surgido en la década del 60, fue diseñado para el procesamiento de texto y provee principalmente funciones para la manipulación de cadenas de caracteres. 1 Resulta importante aclarar que el término objeto posee diferentes significados según el contexto. Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido 3 Paradigma Funcional 2.3 Formas Funcionales Una función se dice de primer orden si ninguno de sus argumentos y/o resultado es una función. Las formas funcionales, también llamadas funciones de alto orden, son aquellas cuyos argumentos y/o resultado son funciones. Un ejemplo, familiar al lector, es la composición. Las funciones de alto orden constituyen junto con las de primer orden la base para la construcción de funciones más poderosas. En tal sentido, el conjunto de formas funcionales provistas por un lenguaje puede tener un fuerte impacto en el poder expresivo del mismo. En contrapartida, el exceso en el número de funciones primitivas puede ir en detrimento de la sencillez del lenguaje sin aumentar su expresividad. Nuevamente, existen notorias diferencias entre un lenguaje y otro. En FP existen ocho formas funcionales, mientras que APL sólo provee tres. En tal sentido, Backus [1978] opina que las funciones de alto orden provistas por APL resultan insuficientes y difíciles de usar. 2.4 Operación de Aplicación La operación de aplicación de funciones constituye el principal mecanismo de control de los lenguajes funcionales. Una función puede llamar a otra. Más aún, una función puede ser recursiva, ya sea de manera directa o indirecta. La aplicación puede ser definida de distintas formas. Por ejemplo, en algunos lenguajes, tales como Miranda, Gofer y Standard ML, se soporta la aplicación parcial (currying). Además, existen otros aspectos (como por ejemplo la evaluación perezosa) que determinan la semántica de la operación de aplicación. En las siguientes secciones se profundizará en estos temas. 3. PROPIEDADES DE LOS LENGUAJES FUNCIONALES Los lenguajes funcionales puros poseen varias propiedades que los distinguen. En esta sección se presentan algunas de sus características más relevantes. 3.1 Semántica de Valores Desaparece la noción de estado, subyacente al Modelo de von Neumann, dado que no existen los conceptos de locación de memoria (cuya abstracción son las variables) y de modificación de las mismas (sentencia de asignación). Luego, el ambiente de ejecución asocia variables a valores únicamente (no a locaciones de memoria). Por otra parte, una variable dentro de un ambiente no puede cambiar su valor. Esto favorece fuertemente la demostración formal de programas. 3.2 Transparencia Referencial Un sistema se dice referencialmente transparente si, en un determinado contexto, la semántica del sistema puede ser determinada a partir de la semántica de sus partes, sin importar las computaciones previas ni el orden de evaluación. Los lenguajes funcionales puros poseen transparencia referencial. En ellos, una función computa valores basándose únicamente en sus parámetros de entrada, también llamados argumentos. Por otra parte, la ejecución de una función no produce efectos colaterales. 4 Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido Paradigma Funcional 3.3 Funciones como Valores de Primera Clase Los lenguajes funcionales tratan a las funciones como valores de primera clase. Es decir, ellas pueden ser pasadas como argumentos, retornadas como resultados de otras funciones, y compuestas a partir de otros valores (funciones). Esto genera un fuerte impacto en el estilo de programación, contribuyendo entre otras cosas a la regularidad y flexibilidad de estos lenguajes. 3.4 Currying La aplicación funcional constituye el mecanismo de control fundamental de los lenguajes funcionales. En general, una aplicación tiene la forma: e e’ donde e y e’ son expresiones. Esto establece que la expresión e será aplicada a la expresión e’. Esta simple frase posee fuertes implicaciones. Por ejemplo, asuma la existencia de una función doble, con dominio en los enteros, que retorna el valor de su argumento multiplicado por 2. Luego, doble 4*5 es una aplicación funcional tal que e= doble y e’=4*5. El resultado de esta aplicación es 40. En términos generales, la secuencia es: • • • evaluar e, obteniendo una función f (en nuestro caso resulta trivial, f es doble) evaluar e’, obteniendo un argumento x (en nuestro caso x es 20) aplicar la función f a x. Sin embargo, hay un caso especial en lo que concierne a la aplicación funcional. Este surge al definir una función de manera curried, como por ejemplo: - fun add x y = x + y; (dos argumentos, x e y, y no (x,y), que sería una tupla) val add = fn : int -> int -> int Esta función puede ser aplicada: - add 3 5; val it = 8: int Notar que la diferencia está en el tipo de add que es: int -> (int -> int) (recordar que el operador -> asocia a derecha). Lo que sucede al hacer add 3 5 es: - (add 3) 5; val it = 8 : int ya que add es una función que toma un entero y produce una función que a su vez, toma un entero y produce un entero. Esto es, add 3 produce una función que luego es aplicada con el argumento 5. En general, una aplicación del tipo f x y z se resuelve como (((f x) y) z). De esta manera, también podemos hacer: 5 Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido Paradigma Funcional - val suma3 = add 3; obteniendo a partir de esta aplicación parcial una nueva función llamada suma3 que recibe un argumento de tipo entero y retorna el mismo incrementado en 3 unidades. La idea principal del currying consiste en que permite reducir funciones con múltiples argumentos a funciones de menos argumentos. En particular, la más importante utilidad de las funciones curried es que si se las aplica parcialmente, obtenemos nuevas funciones. El currying permite además evaluar un número variable de argumentos. Cada argumento es manejado en secuencia a través de una aplicación funcional. Cada aplicación reemplaza una de las variables ligadas, resultando en una función “parcialmente evaluada” que puede ser aplicada al siguiente argumento. Esto es lo que se conoce como aplicación parcial o parametrización parcial según el autor. Como dijimos previamente, en general, una función f (x,y,x) puede ser escrita en forma curried como g x y z, lo que corresponde a (((g x) y) z). En este caso, g x retorna una función que será aplicada a y. La función resultante de esta última aplicación será luego aplicada a z. Veamos un ejemplo: sea f x y z = 3*x+y*z fxyz ↓ gyz ↓ g’ z ↓ valor f 2 1 4 = 3*x+y*z ↓ g 1 4 = 3 * 2 + y*z ↓ g’ 4 = 3 * 2 + 1 * z ↓ 10 Note que luego de cada aplicación parcial se obtuvo una nueva función con “un argumento menos” que la anterior hasta llegar a una función de “un argumento”, que en realidad es la función cuyo resultado no constituye una nueva función. La evaluación de esta última función (g’ en el ejemplo) da como resultado un valor entero. 3.5 Evaluación Perezosa (Lazy Evaluation) Una manera simple de aplicar una función es evaluar primero sus argumentos y luego evaluar la función. Por ejemplo, en: mult (fact 3) (fact 4) las aplicaciones (fact 3) y (fact 4) deben ser evaluadas en primer término, en un orden arbitrario, reduciendo la expresión a: mult 6 24 Posteriormente, la función mult es evaluada con argumentos 6 y 24, dando como resultado final 144. Esta manera de reducir la expresión original se conoce como reducción de orden aplicativo. 6 Apuntes sobre Paradigmas de Programación Paradigma Funcional Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido Una alternativa es reducir las expresiones yendo de afuera hacia adentro. De este modo, las subexpresiones recién son evaluadas cuando sus valores son necesarios. Este enfoque, se denomina reducción de orden normal. Volviendo al ejemplo anterior: mult (fact 3) (fact 4) es reducida en primer lugar a: (fact 3) * (fact 4) y luego a: 6*24 Para obtener finalmente el valor 144. El lector acostumbrado a la programación imperativa puede pensar que la reducción de orden aplicativo resulta más natural. Sin embargo, esta presenta algunas desventajas. Suponga que se necesita implementar una función cond con tres argumentos, donde el primer argumento, b, es un valor booleano. Si b es true, cond devuelve el segundo argumento, en caso contrario retorna el tercero: cond b x y = if b then x else y Si todos los argumentos de cond son evaluados antes de aplicar la función, existen dos problemas: • • Uno de los argumentos de cond es evaluado innecesariamente. Si la evaluación innecesaria de un argumento no termina, entonces la expresión completa no finalizará. Como un ejemplo de esto último, considere la siguiente definición de la función factorial: fac n = cond (n=0) 1 (n*fac(n-1)) Si el tercer argumento de cond es siempre evaluado, la función fac nunca termina. Estos problemas conducen al concepto de evaluación perezosa (lazy), la cual usa reducción de orden normal. La idea es evaluar los argumentos de una función sólo cuando es indispensable, y evaluarlos una única vez para aquellos que estén repetidos. En contraste, la reducción de orden aplicativo siempre evalúa sus argumentos, esto se conoce como evaluación ansiosa (eager). Para el ejemplo anterior, si n es igual 0 la expresión (n*fac(n-1)) jamás será reducida por la evaluación perezosa, pero sí por la evaluación ansiosa. Los lenguajes basados en esta última se denominan de semántica estricta. En contrapartida, los lenguajes que usan evaluación perezosa poseen semántica no estricta, tal es el caso de Miranda. Standard ML emplea evaluación ansiosa dado que tiene asociada la semántica call-by-value para los argumentos. Sin embargo, presenta otros aspectos de diseño del lenguaje que son “perezosos” (tal es el caso de las expresiones “ifthen-else” y “handle” entre otras). Por otro lado, existe una versión conocida como Lazy ML, que soporta evaluación perezosa. Costo Computacional Lamentablemente, la evaluación perezosa tiene asociado un alto costo computacional. Un problema es que la evaluación de orden normal puede evaluar la misma expresión más 7 Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido Paradigma Funcional de una vez. Esto no sucede con la evaluación de orden aplicativo. A manera de ilustración, considere la siguiente función: doble x = x + x La evaluación de orden normal aplica doble 23*45 como sigue: doble 23*45 ⇒ 23*45 + 23*45 ⇒ 1035 + 23*45 ⇒ 1035 + 1035 ⇒ 2070 mientras que la evaluación de orden aplicativo sólo efectúa las siguientes reducciones: doble 23*45 ⇒ doble 1035 ⇒ 1035 + 1035 ⇒ 2070 Lo que resulta poco eficiente. Este problema puede resolverse con la técnica de reducción de grafos de Peyton Jones [1987], la cual evalúa las expresiones a lo sumo una vez. Otro problema es que la evaluación de los argumentos es más sencilla cuando se realiza antes de la llamada, y por lo tanto, la postergación de estas evaluaciones ocasiona un significativo overhead en la evaluación perezosa. No obstante, algunas optimizaciones pueden bajar este costo. Como ya se mencionó, no todos los lenguajes funcionales soportan la evaluación perezosa. No obstante, muchos proveen algunas funciones predefinidas con semántica perezosa. Por ejemplo, como se dijo en la sección anterior, en Standard ML existen primitivas evalúan su segundo argumento sólo si es necesario (ejemplos adicionales son “ándalos” y “orelse”). Características similares son provistas por lenguajes imperativos, tal es el caso de C y Ada. 4. ML: UN CASO DE ESTUDIO. En esta sección se describe un lenguaje, denominado Standard ML, que soporta el paradigma funcional. El objetivo es introducir a los alumnos en la programación funcional mediante la implementación de pequeños programas destinados a profundizar y afianzar los conceptos vertidos en clase. Cabe destacar que nuestra meta no es formar expertos en ML sino lograr que los estudiantes capturen la esencia del paradigma. Varias razones motivaron la elección de este lenguaje. En primer lugar, ML permite ejemplificar las principales características de la programación funcional. Por otro lado, es uno de los lenguajes funcionales más difundidos. De hecho, buena parte de la bibliografía actual (entre ellos Ghezzi[1998], Sethi[1992] y Watt[1990]) emplean ML para presentar este paradigma. Finalmente, ML provee un fuerte soporte para aspectos tales como: modularidad, encapsulamiento, tipos de datos abstractos, manejo de excepciones, etc. 4.1 Principales Características de ML ML es un lenguaje funcional dentro de la tradición de ISWIM de Landin [1966], que es un acrónimo de la frase en inglés “If you See What I Mean” (si entiendes lo que digo). ISWIN está basado en Lisp y en el cálculo lambda. ML (Meta Language) fue creado por Robin Milner en los años setenta, y con el tiempo surgieron varios dialectos. En 1983 se inició un esfuerzo de normalización que dio como resultado Standard ML. La más reciente 8 Apuntes sobre Paradigmas de Programación Paradigma Funcional Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido contribución al lenguaje fue el sistema de módulos desarrollado por Dave MacQueen [1986]. De aquí en adelante diremos simplemente ML para referirnos a su versión estándar. A continuación se enumeran las principales características del lenguaje: • Soporta el paradigma funcional. Las funciones son valores de primera clase: ellas pueden ser pasadas como argumentos y retornadas como resultados. El principal mecanismo de control en ML es la aplicación funcional. • Fuertemente tipado. Esta característica garantiza que un programa no incurra en un error de tipos en tiempo de ejecución. • Sistema de tipos polimorfo. Esto contribuye a la flexibilidad del lenguaje. • Soporta tipos de datos abstractos. Nuevas declaraciones de tipos pueden ser definidas por el programador, junto con una familia de funciones para la manipulación de variables de dichos tipos. Los detalles de la implementación quedan ocultos al usuario del tipo. • Alcance estático. Las referencias a identificadores se resuelven en tiempo de compilación. • Mecanismos para el manejo de excepciones. Esto aumenta la confiabilidad de los programas escritos en ML. • Soporta programación modular. En ML un programa es implementado como una colección de “structures” interdependientes vinculados mediante “functors”. La compilación separada de módulos es soportada a través de la importación y exportación de “functors”. En las siguientes subsecciones sólo se introducirán los aspectos más relevantes del lenguaje. El lector interesado en profundizar sus conocimientos sobre ML puede consultar el libro de Lawrence Paulson [1996] y los reportes de Robert Harper [1993] y Mads Tofte [1997], en este último se brinda una minuciosa descripción del sistema de módulos de ML. Sobre el software empleado por la Cátedra En Lenguajes de Programación se adoptará como versión oficial el ML Works, versión 2.0. La versión utilizada por la cátedra es de distribución gratuita. Interacción con ML La implementación de ML es interactiva, con una forma de diálogo “leer-evaluarmostrar” común a las implementaciones de otros lenguajes funcionales, por ejemplo Lisp. En otras palabras, cada vez que una expresión es entrada, ML la analiza, compila y ejecuta. Por ejemplo: MLWorks> 3+2; val it : int = 5 ML emplea el prompt “MLWorks>” para indicar que espera el ingreso de una expresión. El usuario entra una expresión, por ejemplo “3+2”. Luego, se muestra el resultado de la evaluación, “5”, precedido por su tipo, “int”. En este caso, como el resultado no es ligado a una variable, el compilador lo liga automáticamente al identificador it. Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido 9 Paradigma Funcional 4.2. Elementos Básicos de ML Se brindará a continuación una descripción detallada de los principales aspectos referidos al lenguaje. Las distintas funciones primitivas y formas funcionales provistas por ML serán introducidas gradualmente. 4.2.1 Expresiones básicas, valores y tipos Las expresiones en ML denotan valores de la misma manera que un numeral denota un número. Cada expresión tiene asociado un tipo. El lenguaje provee un conjunto básico de tipos predefinidos, más una colección de constructores para la definición de tipos de datos estructurados. Tipos de Datos Simples Unit El tipo unit consiste de un único valor, denotado como (). Este tipo es empleado cuando no interesa el valor de una expresión, o cuando una función no tiene argumentos. En tal sentido, unit se asemeja al tipo void de C. Booleanos El tipo bool consiste de los valores true y false. Las funciones not, andalso y orelse son provistas como primitivas. Las funciones andalso y orelse realizan evaluaciones en corto circuito. Enteros El tipo int está formado por el conjunto de enteros, positivos y negativos. Los enteros se escriben de la manera usual excepto que los negativos van precedidos del caracter “~”. Los operadores aritméticos +, -, *, div y mod, están disponibles, donde div y mod son la división entera y resto respectivamente. También se proveen las operaciones relacionales <, <=, >, >=, = y <>, las cuales toman como “argumentos” dos expresiones de tipo int y retornan un valor de tipo bool. Cadenas El tipo string está constituido por un conjunto de secuencias finitas de caracteres. Las strings se denotan de la manera tradicional (entre comillas). Para indicar una string formada por una comilla se escribe “\””. ML provee dos primitivas para el manejo de valores de este tipo. La primera, denominada size, retorna la longitud de una string (medida en cantidad de caracteres). La segunda, permite concatenar dos strings y se denota con el caracter ^. Por ejemplo: MLWorks> "Lenguajes"^"Funcionales"; val it : string = "LenguajesFuncionales" Números Reales 10 Apuntes sobre Paradigmas de Programación Paradigma Funcional Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido El tipo correspondiente a los números en punto flotante es conocido en ML como real. Estos números se escriben de la forma usual: un entero seguido de un punto y uno o más decimales. También puede indicarse el exponente. Por ejemplo: MLWorks> 3.14159; val it : real = 3.14159 MLWorks> 3E2; val it : real = 300.0 MLWorks> 3.14159E2; val it : real = 314.159 Las funciones aritméticas predefinidas para los reales son: ~, +, -, *. Es importante aclarar que no se pueden mezclar tipos en estas operaciones. Por ejemplo, un real sólo puede ser sumado a otro real, y no a un entero. Esto constituye una diferencia importante con respecto a los lenguajes que efectúan coerciones, por ejemplo Pascal. Los operadores relacionales provistos son los mismos que para enteros. La función ‘/’ denota la división de números reales. Por último, las funciones real y floor permiten la conversión explícita de tipos entre enteros y reales. La primera toma por argumento un número entero y devuelve el real correspondiente, mientras que floor trunca un real al mayor entero menor que él. Esto completa la presentación de los tipos de datos simples predefinidos por ML. En la siguiente subsección se describen los tipos de datos estructurados. Tipos de Datos Estructurados Tuplas (Producto Cartesiano) El tipo σ*τ es el tipo de los pares ordenados cuyas primeras y segundas componentes tienen tipo σ y τ respectivamente. Un par ordenado se escribe como (e1, e2), donde e1 y e2 son expresiones. Generalizando, podemos definir n-tuplas, con n ≥ 2, escribiendo las expresiones separadas por comas y entre paréntesis. Por ejemplo: MLWorks> (1, false, 17, "lenguajes"); val it : (int * bool * int * string) = (1, false, 17, "lenguajes") Dos tuplas son iguales si cada una de sus componentes coincide en valor y posición. Si se comparan dos tuplas con diferente tipo se produce un error de tipos. Listas (Secuencia) El tipo τ list consiste de una secuencia finita de valores de tipo τ. Por ejemplo, el tipo int list refiere a listas de enteros, mientras que el tipo bool list list corresponde a listas de listas de valores booleanos. Existen dos notaciones para las listas. La primera esta basada en la siguiente caracterización recursiva: una τ list está vacía, o contiene un valor de tipo τ seguido de una τ list. De acuerdo con esta visión, una lista vacía se representa mediante la palabra reservada nil , mientras que una lista no vacía se denota como e :: l, siendo e una expresión de tipo τ y l una τ list. El operador :: se pronuncia “cons”. En la otra notación, denominada “forma comprimida”, se listan los elementos separados por comas y cerrados entre corchetes. Por ejemplo: MLWorks> (3 :: nil) :: (4 :: 5 :: nil) :: nil; 11 Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido Paradigma Funcional val it : int list list = [[3], [4, 5]] Note que ML siempre muestra las listas en el formato comprimido. La función length permite obtener la longitud de una lista. Por otra parte, dos listas son iguales si tienen la misma longitud, y son iguales componente a componente. Una cuestión interesante es: ¿cuál es el tipo de nil? Dada una lista vacía, no hay manera de saber si esta es una lista de enteros, de booleanos o de cualquier otra cosa. ML resuelve este problema asignando el tipo ‘a list a nil, donde ‘a es una variable cuyos posibles valores pertenecen a una colección de tipos. Un tipo que involucra variables de tipos (como ‘a) se denomina politipo. Por otra parte, una instancia de un politipo se obtiene sustituyendo sus variables de tipos por tipos, incluyendo politipos. Por ejemplo: int list y (int * ‘b) list son instancias del politipo ‘a list. Luego, nil es tratado como un objeto polimorfo que puede ser considerado de diferentes tipos según el contexto. Finalmente, un tipo que no contiene variables de tipos se denomina monotipo. Este tema será profundizado en la sección de definición de funciones. Registros (Producto Cartesiano) El último tipo compuesto, considerado en esta sección, es el tipo record. Este es similar a los registros de Pascal. Un registro consiste de un conjunto finito de campos etiquetados, cada de los cuales contiene un valor de algún tipo. Los valores de los registros se escriben como un conjunto de ecuaciones de la forma l = e, donde l es una etiqueta y e una expresión. Las componentes de un registro se seleccionan por sus campos y no por sus ubicaciones dentro del mismo. Nuevamente, la igualdad se define componente a componente. Son ejemplos de registros: MLWorks> {nombre="Jorge", edad=18}; val it : {edad: int, nombre: string} = {edad=18, nombre="Jorge"} MLWorks> {nombre="Jorge", edad=18} = {edad = 3*6, nombre="Jor"^"ge"}; val it : bool = true 4.2.2 Identificadores, Ligaduras y Declaraciones En ML todos los identificadores deben ser declarados antes de ser utilizados (los nombres tales como + y size están pre-declarados por el compilador). El sistema mantiene un ambiente con todas las ligaduras que el programa crea. Los identificadores pueden ser empleados en distintas maneras en ML. En esta sección, se presentará solamente lo concerniente a las ligaduras entre valores y variables. Una variable es ligada a un valor como sigue: MLWorks> val x = 4*5; val x : int = 20 Para establecer una ligadura, ML evalúa el lado derecho de la ecuación y asocia dicho valor al identificador de la izquierda. En el ejemplo anterior, x es ligado a 20. Múltiples identificadores pueden ser ligados simultáneamente, usando la palabra “and” como separador: 12 Paradigma Funcional Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido MLWorks> val x = 17; val x : int = 17 MLWorks> val x = true and y = x; val x : bool = true val y : int = 17 Nótese que y recibe el valor 17, no true. Cuando se realizan ligaduras múltiples estas son evaluadas en paralelo. Primero todas sus partes derechas son evaluadas y luego son ligadas a sus partes izquierdas. Otra manera de combinar ligaduras es empleando el punto y coma como sigue: MLWorks> val x = 17; val x = true and y = x; val x : int = 17 val x : bool = true val y : int = 17 En este caso, ML evalúa primero la declaración más a la izquierda, produciendo un ambiente E, y luego evalúa la declaración de la derecha (con respecto al entorno E), generando un nuevo ambiente, E’. Inferencia de Tipos Como ya se ha mencionado, ML es un lenguaje fuertemente tipado donde todos los identificadores deben ser declarados antes de ser utilizados. Sin embargo, y a diferencia con otros lenguajes como Pascal, el tipo de una variable no es especificado explícitamente en su declaración. Luego, la pregunta es: ¿cómo determina el lenguaje el tipo de una variable? La respuesta es sencilla, ML realiza inferencia de tipos. En pocas palabras, cada vez que se declara una variable, se establece una ligadura entre ella y un valor. Luego, el tipo de la variable será el tipo al cual pertenece el valor. Si el valor es simple (entero o booleano) está tarea es directa, si el valor es compuesto el tipo se determina a partir de los constructores de tipos. Más adelante, en la sección de declaración de funciones, se retomará este tema. Declaraciones Locales ML también permite efectuar declaraciones locales. Estas asisten en la declaración de otras construcciones. Por ejemplo: MLWorks> local val x = 10 in val u = x*x + x*x val v = 2*x + (x div 5) end; val u : int = 200 val v : int = 22 La ligadura de x es local a las ligaduras de u y v, en este sentido x está disponible sólo durante la evaluación de las ligaduras de u y v, pero no después. Como consecuencia, únicamente u y v quedan declaradas. También es posible localizar una declaración a una expresión usando la palabra let: 13 Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido Paradigma Funcional MLWorks> let val x = 10 in x*x + 2*x + 1 end; val it : int = 121 La declaración de x es local a la expresión que sigue al in, y por lo tanto no es visible fuera de ella. 4.2.3 Pattern Matching En secciones anteriores se han presentado varios tipos de datos estructurados, entre ellos las tuplas. Sin embargo, no se indicó hasta ahora cómo recuperar una componente de una tupla. Dada la siguiente declaración: MLWorks> val tup1 = ("jorge", 12, true, 24); Puede resultar de interés obtener la segunda componente del tup1. Para ello, existe un mecanismo, denominado pattern matching, que permite descomponer valores compuestos. El pattern matching es un concepto soportado por muchos lenguajes funcionales actuales, entre ellos ML. Esta técnica consiste en aparear componentes con identificadores no ligados. Por ejemplo: MLWorks> val tup1 = ("Jorge", 12, true, 24); val tup1 : (string * int * bool * int) = ("Jorge", 12, true, 24) MLWorks> val (primero, segundo, true, cuarto) = tup1; val cuarto : int = 24 val primero : string = "Jorge" val segundo : int = 12 (1) La parte izquierda de la segunda ligadura (1) es un patrón (pattern), que es construido usando variables, constantes y constructores de tipos. En definitiva, un pattern es una expresión especial que puede contener variables no ligadas previamente. Nótese, que un caso particular de pattern es una variable, por ejemplo: MLWorks> val x = 7; val x : int = 7 MLWorks> val y = x; val y : int = 7 La sentencia en negrita es un caso simple de pattern matching entre variables. El pattern matching también puede aplicarse sobre listas. Abajo se muestran varios patrones de esta clase: MLWorks> val lista1 = ["Jorge", "Marcos", "Laura"]; val lista1 : string list = ["Jorge", "Marcos", "Laura"] MLWorks> val [x1, x2, x3] = lista1; val x1 : string = "Jorge" 14 Paradigma Funcional Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido val x2 : string = "Marcos" val x3 : string = "Laura" Este tipo de matching resulta útil cuando conocemos a priori la longitud de la lista. Sin embargo, ¿qué sucedería si lista1 fuera nil? Una solución consiste en efectuar la siguiente descomposición inductiva: MLWorks> val lista1 = ["Jorge", "Marcos", "Laura"]; val lista1 : string list = ["Jorge", "Marcos", "Laura"] MLWorks> val cabeza :: cola = lista1; val cabeza : string = "Jorge" val cola : string list = ["Marcos", "Laura"] Por otro lado, puede ocurrir que sólo sea de interés recuperar la cabeza de la lista, y no la cola. En tal caso, ML brinda un wildcard pattern que permite realizar el matching sin crear una ligadura entre la cola y una variable. Por ejemplo: MLWorks> val lista1 = ["Jorge", "Marcos", "Laura"]; val lista1 : string list = ["Jorge", "Marcos", "Laura"] MLWorks> val cabeza :: _ = lista1; val cabeza : string = "Jorge" El pattern matching también puede efectuarse sobre registros a través de los nombres de sus campos. El siguiente ejemplo ilustra ese tipo de matching: MLWorks> val registro1 = {edad = 25, nombre = "Jorge"}; val registro1 : {edad: int, nombre: string} = {edad=25, nombre="Jorge"} MLWorks> val {nombre = u, edad = v} = registro1; val u : string = "Jorge" val v : int = 25 Algunas veces puede resultar conveniente utilizar un record wildcard, el mismo se utiliza como sigue: MLWorks> val registro1 = {edad = 25, nombre = "Jorge"}; val registro1 : {edad: int, nombre: string} = {edad=25, nombre="Jorge"} MLWorks> val {nombre = u, ... } = registro1; val u : string = "Jorge" Existe una importante restricción para el uso de record wildcard: debe ser posible determinar en tiempo de compilación el tipo del pattern record completo. En otras palabras, debe ser factible la inferencia de todos los campos y sus tipos, a partir del contexto. Un análisis más profundo de este tema escapa al alcance del curso. No obstante, el lector interesado en record matching puede consultar el trabajo de R. Harper [1993]. Una última forma de pattern matching soportada por ML es el denominado layered pattern, el cual permite anidar patrones. Por ejemplo: MLWorks> val x = ( ("casa", true), 17 ); val x : ((string * bool) * int) = (("casa", true), 17) MLWorks> val (tup as (tizq,tder), der) = x; val der : int = 17 Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido 15 Paradigma Funcional val tder : bool = true val tizq : string = "casa" val tup : (string * bool) = ("casa", true) Por último, cabe destacar que los pattern poseen una significativa limitación: deben ser lineales, es decir, una variable no puede aparecer más de una vez en el pattern. Esto excluye la posibilidad de escribir patrones de la forma (x, x) para la identificación de partes simétricas. 4.2.4 Definición de Funciones Hasta ahora se han presentado algunas de las funciones predefinidas por el lenguaje, tales como las funciones aritméticas y las operaciones relacionales. En esta sección se introducirá el concepto de ligadura funcional que es la herramienta mediante la cual ML permite definir nuevas funciones. Para comenzar, se analizarán los aspectos generales relativos a las funciones en ML. Las funciones son “aplicadas” sobre un argumento. Este último puede ser un valor simple o compuesto, incluso otra función (funciones de alto orden). Sintácticamente, esto se indica en notación prefija, primero el nombre de la función y luego el argumento. Por ejemplo, size “estudio”. Las funciones sólo tienen un argumento. Múltiples argumentos se pasan a través de tuplas. De este modo, la función append recibe una tupla formada por las dos listas a concatenar. Sin embargo, para algunas funciones (usualmente las predefinidas) el lenguaje adopta la notación infija. Por ejemplo, la expresión e1+ e2 en realidad significa “aplicar la función +” a la dupla (e1, e2). Él usuario también tiene la posibilidad de escribir funciones infijas. Sin embargo, por razones de tiempo esta alternativa no será tratada en el curso. Nótese que el uso de dos notaciones distintas perjudica la uniformidad del lenguaje. La aplicación de una función puede ser sintácticamente más compleja en ML que en los lenguajes de programación procedurales. En estos últimos, las funciones sólo pueden ser designadas a un identificador y por lo tanto la aplicación de una función es siempre de la forma f(e1, e2, ..., en), donde f es un identificador. En cambio, ML no tiene esa restricción. Las funciones son valores y pueden ser empleados arbitrariamente en expresiones. La aplicación de una función tiene la forma e e’. Primero se evalúa e, obteniéndose una función f. Luego se evalúa e’ obteniéndose un valor v. Finalmente se aplica f a v. En el caso más simple e es un identificador, por ejemplo size, y de esta manera la evaluación resulta sencilla. Pero en general, e puede ser muy complejo y requerir varias computaciones hasta obtener la función f como un valor. Tipo Función Una pregunta que el lector puede hacerse es: ¿cómo se puede garantizar en una aplicación e e’ que la evaluación de e retornará una función y no otro valor, como por ejemplo un booleano? La respuesta es simple. Las funciones son valores, y todos los valores en ML tienen un tipo asociado. Luego, basta con asegurar que el tipo de e corresponda a un valor del tipo función. El tipo función está integrado por funciones. Un valor de este tipo tiene la forma σ→τ, pronunciado como “de σ a τ”, donde σ y τ son tipos. Una expresión de este tipo devuelve una función que puede ser aplicada a un valor de tipo σ, la cual retorna valores de tipo τ. Los tipos de σ y τ son llamados los tipos del dominio y rango de la función respectivamente. De este modo, una aplicación e e’ es legal si e tiene tipo σ→τ y e’ tiene 16 Paradigma Funcional Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido tipo σ. Luego, el tipo de la expresión completa e e’ es τ. Lo anterior, permite comprender como infiere ML el tipo de una expresión. Sin embargo, como se verá más adelante, existen situaciones en que el mismo no puede ser inferido. Dado que las funciones son valores en ML. Estas pueden ser ligadas a variables. Para ilustrar lo anterior, considere las siguientes declaraciones: MLWorks> val longitud = size; val longitud : string -> int = fn MLWorks> longitud "hola"; val it : int = 4 El identificador size es ligado a la variable longitud. La aplicación longitud “hola” es procesada evaluando longitud para obtener una función, luego dicha función es aplicada a “hola” obteniéndose el valor 4. Una función es un objeto complejo. Su estructura es invisible al programador. De hecho, a las funciones no se les puede aplicar el pattern matching como a los demás objetos. Más aún, no es posible determinar si dos funciones son iguales. Luego, se dice que el tipo función no admite igualdad. De todos los tipos introducidos hasta el momento, el tipo función es el único que no define la igualdad entre dos de sus valores. Funciones Definidas por el Usuario Para declarar nuevas funciones el programador establece ligaduras mediante la palabra fun, seguida por el nombre de la función y sus argumentos. Si existe más de un parámetro, estos se pasan a través de una tupla ó, como veremos más adelante, se define una función curried. Son ejemplos de definiciones: MLWorks> fun doble x = 2*x; val doble : int -> int = fn MLWorks> doble 4; val it : int = 8 MLWorks> fun factorial n = if n = 0 then 1 else n * factorial (n-1); val factorial : int -> int = fn MLWorks> factorial 5; val it : int = 120 MLWorks> fun f1(x,y) = x*3 - y*2; val f1 : (int * int) -> int = fn MLWorks> f1 (5, 2); val it : int = 11 Cuando una función definida por el usuario es aplicada a un valor, este es ligado al argumento generándose un nuevo ambiente en el cual se evaluará el cuerpo de la función. Por ejemplo, para la función doble, x será ligado a 4. Una vez finalizada la evaluación de doble, se restablece el ambiente anterior a la aplicación. En la función f1, definida arriba, el argumento es una dupla. Otras funciones podrían tener argumentos más complejos aún, Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido 17 Paradigma Funcional construidos a partir de registros y listas. En todos estos casos ML utiliza el pattern matching para ligar los argumentos con sus valores. Restricciones de Tipo En la definición de la función factorial se empleó la expresión condicional, la cual tiene la forma if e then e1 else e2. El primer argumento, e, debe ser booleano. Es importante destacar que la cláusula else no es opcional. La razón es que este “if” es una expresión condicional y no una sentencia como en Pascal. Si el else fuera omitido, y la evaluación de e fuera false, la expresión condicional no tendría valor. Además, las expresiones e1 y e2 deben ser del mismo tipo. Para comprender esta aseveración basta con analizar el siguiente caso: if x = y then true else 16 Si x e y están ligados al mismo valor, la expresión vale true, pero si x e y tienen ligados valores distintos dicha expresión vale 16. ¿Cuál es el tipo de la expresión?, ¿ bool o int?. Luego, es claro que e1 y e2 deben ser del mismo tipo. Cuestiones como la anterior, pueden ser fácilmente manejadas en tiempo de compilación mediante un chequeo de los tipos de e1 y e2. Sin embargo, existen situaciones donde resulta imposible determinar el tipo de una expresión durante la compilación. Suponga la siguiente declaración de función: MLWorks> fun suma (x, y) = x + y; Dado que el operador + está sobrecargado. El compilador no puede distinguir si se trata de una función de tipo (real*real) → real o (int*int) → int. Existen dos alternativas para la implementación del compilador. Algunos muestran un error de tipos derivada de la presencia de un operador sobrecargado. Otros, tal es el caso del empleado en este curso, asumen el tipo int por defecto. En el primer caso, resulta necesario indicar el tipo de al menos uno de los argumentos. Para el ejemplo: MLWorks> fun suma (x, y:int) = x + y; De este modo, el tipo de la función queda bien establecido. Esto se conoce como restricción de tipos. Note que “:int” sólo declara el tipo de y, los tipos de x y suma son inferidos del contexto. Para la otra alternativa, únicamente es necesario especificar el tipo cuando este no coincide con el definido por defecto. Por ejemplo, si se desea definir la suma de reales: MLWorks> fun suma (x, y: real) = x + y; Funciones Definidas mediante Cláusulas Algunas funciones requieren una definición por partes, de acuerdo al valor de su argumento. Por ejemplo, la función esvacia puede definirse como: MLWorks> fun esvacia(nil) = true | esvacia(_::_) = false; val esvacia : 'a list -> bool = fn 18 Paradigma Funcional Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido Este tipo de expresiones se conocen como funciones definidas por cláusulas. Estas funciones deben estar definidas para todos los valores del dominio. Las cláusulas son muy útiles para la definición de funciones recursivas. Por ejemplo, podemos definir la función concatenar como: MLWorks> fun concatenar (nil, L2) = L2 | concatenar (cabeza :: cola, L2) = cabeza :: concatenar(cola,L2); val concatenar : ('a list * 'a list) -> 'a list = fn (1) (2) El tipo de la función concatenar es un politipo, esto es un tipo que involucra una variable de tipo ’a. Dado que nil es de tipo ’a list, de (2) ML puede inferir que cabeza es de tipo ‘a y cola es de tipo ’a list. Además, dada la definición del constructor ::, por (2) resulta que concatenar es de tipo ’a list. Finalmente por (1), L2 también debe ser de tipo ’a list. Nótese, que la función concatenar es en sí una función polimorfa. Es decir, presenta un comportamiento uniforme para listas con distintos tipos de componentes. Declaraciones Locales Una característica a tener en cuenta es que las funciones pueden declarar variables y funciones locales empleando el constructor let visto anteriormente. Por ejemplo: MLWorks> fun multxpi (x) = let val c = 3.14 in x*c end; val multxpi : real -> real = fn Referencias a Variables No Locales Las funciones no están restringidas al uso de parámetros y variables locales, ellas pueden referir a cualquier variable visible dentro de su ambiente de referenciamiento. Por ejemplo, dada la siguiente secuencia de definiciones: MLWorks> val c = 2.5; val c : real = 2.5 MLWorks> fun multx (x) = x*c; val multx : real -> real = fn MLWorks> multx (2.0); val it : real = 5.0 MLWorks> val c = 8.17; val c : real = 8.17 MLWorks> multx (2.0); val it : real = 5.0 Se aprecia que una vez declarada multx, las nuevas ligaduras del identificador c no alteran la definición de la función. 19 Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido Paradigma Funcional Funciones Curried ML permite que el usuario defina funciones curried. Por ejemplo, se puede definir una función que multiplique dos enteros en forma curried: MLWorks> fun producto (x:int) (y:int) = x*y; val producto : int -> int -> int = fn Esta función tiene tipo int -> int -> int. Luego, se puede definir la función multx5 como: MLWorks> val multx5 = producto (5); val multx5 : int -> int = fn Note que producto difiere de la siguiente función: MLWorks> fun producto2 (x, y:int) = x*y; val producto2 : (int * int) -> int = fn El tipo de producto2 es (int * int) -> int. A modo de ilustración considere el siguiente ejemplo: Sea fun f cond x y z = if cond then (x – y)*z else x*(z - x)*y; luego, analicemos la siguiente evaluación f false 2 4 3 = if cond then (x – y)*z else x*(z - x)*y; fn: boolean -> (int -> (int -> (int -> int))) ↓ g 2 4 3 = x*(z - x)*y; fn: int -> (int -> (int -> int)) ↓ g’ 4 3 = 2*(z - 2)*y; fn: int -> (int -> int) ↓ g’’ 3 = 8*(z - 2); fn: int -> int ↓ 8 int A la derecha de cada evaluación se indica el tipo de la función*. Es fácil ver que la aplicación parcial trata a las funciones como de un único argumento, mientras que el resultado de cada aplicación es una función. Formas Funcionales Las funciones en ML son valores; ellas pueden ser pasadas como argumentos y devueltas como resultado de otras funciones. Como se definió anteriormente, una función que emplea otras funciones como argumentos y/o resultado se denomina forma funcional o función de alto orden. En ML existen varias formas funcionales: la composición, el operador “aplicar a todos” (denominada map) y los operadores de “inserción” a izquierda y derecha (conocidos como foldl y foldr respectivamente). * Note que la notación de tipos de funciones curried en ML no emplea los paréntesis. Aquí son incluidos para facilitar la comprensión del lector. 20 Paradigma Funcional Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido Composición La composición permite construir una nueva función a partir de la composición de otras dos. Por ejemplo: MLWorks> fun comp (f, g) (x) = f(g(x)); val comp : (('a -> 'b) * ('c -> 'a)) -> 'c -> 'b = fn Para que la composición sea válida los tipos de f y g deben ser compatibles. Es decir, si g tiene tipo σ→ϖ, entonces f debe tener dominio ϖ, dado que el valor devuelto por g es el argumento de f. En particular, si f tiene tipo ϖ→τ, comp será de tipo σ→τ. Aplicar a Todos El operador “aplicar a todos” toma como argumento una función f y una lista L. El valor retornado es una lista cuya componente yi es el resultado de aplicar f al i-ésimo elemento de L. Por ejemplo: MLWorks> map length [[], [1,2,3],[3]]; val it : int list = [0, 3, 1] Note, que map es una función curried. Operador de Inserción El operador de inserción a derecha permite generalizar una función binaria a n-aria. Esta función puede definirse como: MLWorks> fun foldr (f, s, nil) = s | foldr (f, s, (cabeza :: cola)) = f(cabeza, foldr (f, s, cola)); val foldr : ((('a * 'b) -> 'b) * 'b * 'a list) -> 'b = fn Por ejemplo, se puede sumar una lista de números como sigue: MLWorks> foldr (op +, 0, [1, 2, 3, 4]); val it : int = 10 De forma similar puede definirse el operador de inserción a izquierda foldl. Nótese que en Standard ML foldr y foldl están predefinidas de manera curryed, lo cual constituye una distinta implementación de la anteriormente mostrada. Piense como realizaría tal implementación. 4.2.5 Polimorfismo y Sobrecarga En las secciones anteriores se establecieron los conceptos de politipo, como aquel tipo que contiene variables de tipo, y monotipo. También se definieron como funciones polimorfas, a aquellas que trabajan sobre una clase de tipos de manera uniforme. Por ejemplo, para la función concatenar no importa el tipo de las componentes de las listas. Sólo interesa que el argumento esté formado por dos listas cuyos componentes sean del mismo tipo. En ML el tipo de una función polimorfa es siempre un politipo. Esta clase de polimorfismo se denomina polimorfismo paramétrico dado que el tipo esta parametrizado Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido 21 Paradigma Funcional por las variables de tipos. Nótese que el polimorfismo no está limitado a las funciones. Por ejemplo, nil es un objeto polimorfo. Este concepto puede ser contrastado con otro, conocido como sobrecarga. Esta es una noción mucho más ad hoc de polimorfismo dado que mira más el nombre de la función que su estructura de definición. A modo de ilustración considérese la función de suma, +. La expresión 3 + 2 hace referencia a la suma entre enteros, 3 y 2, mientras que 3.0 + 2.0 implica la suma de dos reales, 3.0 y 2.0. En principio, esta situación puede parecer similar a la concatenación de listas, pero esta semejanza es sólo aparente: la función concatenar empleada sobre listas de distintos tipos es siempre la misma, en cambio el algoritmo para la suma de enteros difiere del empleado para reales. Por lo tanto, en el caso de la suma, se está en presencia de dos funciones distintas que comparten un mismo nombre. Luego, se dice que el operador de suma está sobrecargado. 4.2.6 Definición de Tipos El sistema de tipos en ML es extensible. El programador puede definir nuevos tipos usando alguna de las siguientes formas: abreviación de tipo, definición de tipo de dato y definición de tipo de dato abstracto. Abreviación de Tipo La forma más elemental de definir un tipo es la abreviación, la cual liga una expresión de tipo a un identificador de tipo. Por ejemplo: MLWorks> type parenteros = int*int; eqtype parenteros = (int * int) MLWorks> type 'a par = 'a * 'a; eqtype 'a par = ('a * 'a) MLWorks> type parbooleanos = bool par; eqtype parbooleanos = (bool * bool) La segunda definición corresponde a un politipo. Luego, es posible definir variables y funciones polimorfas de tipo ‘a par, como también definir subtipos del mismo, por ejemplo parbooleanos. Definición de Tipo La definición de tipos consiste de un nombre (y quizás algunos parámetros de tipo) y un conjunto de constructores de valores del tipo. Por ejemplo: MLWorks> datatype color = Red | Blue | Yellow; datatype color = Blue | Red | Yellow val Blue : color val Red : color val Yellow : color MLWorks> Red; val it : color = Red 22 Paradigma Funcional Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido No todos los tipos definidos por el programador necesitan tener constantes como constructores. Suponga la siguiente definición: MLWorks> datatype dinero = sindinero | centavos of int | pesos of int | cheque of string*int; datatype dinero = centavos of int | cheque of (string * int) | pesos of int | sindinero val centavos : int -> dinero val cheque : (string * int) -> dinero val pesos : int -> dinero val sindinero : dinero MLWorks> fun contarcentavos (sindinero) = 0 | contarcentavos (centavos(cant)) = cant | contarcentavos (pesos(cant)) = 100*cant | contarcentavos (cheque(banco,cant)) = 100*cant; val contarcentavos : dinero -> int = fn Analizando este último ejemplo, surge una duda: ¿cuándo dos valores de tipo dinero son iguales? ML define como iguales a aquellos valores del mismo tipo, construidos de la misma forma y cuyas componentes son iguales. Las evaluaciones de las siguientes expresiones ilustran lo anterior: MLWorks> sindinero = sindinero; val it : bool = true MLWorks> sindinero = centavos(5); val it : bool = false MLWorks> centavos(5) = centavos(2+3); val it : bool = true MLWorks> cheque("MoneyBank", 500) = cheque("GoldBank", 500); val it : bool = false Los tipos de datos también pueden ser recursivos. De esta manera, se puede definir un tipo arbolbinario como sigue: MLWorks> datatype arbolbinario = vacio | hoja | nodo of arbolbinario*arbolbinario; datatype arbolbinario = hoja | nodo of (arbolbinario * arbolbinario) | vacio val hoja : arbolbinario val nodo : (arbolbinario * arbolbinario) -> arbolbinario val vacio : arbolbinario También se pueden especificar tipos de datos paramétricos. Un ejemplo es el tipo predefinido τ list, el cual corresponde a la siguiente definición: datatype ‘a list = nil | :: of ‘a * ‘a list; Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido 23 Paradigma Funcional Sin embargo, si el programador ingresa está definición, ML Works retornará un error de compilación debido a que el operador “::” está reservado. Definición de un Tipo de Dato Abstracto Finalmente, ML soporta la definición de tipos de datos abstractos (TDA). Un tipo de dato abstracto está formado por un tipo de dato y un conjunto de funciones que permiten manipular valores de dicho tipo. La principal característica de los TDA es que permiten definir nuevos tipos, ocultando la representación interna de los mismos. De este modo, el usuario puede operar las variables únicamente a través de las operaciones implementadas dentro del TDA. A continuación se muestra la declaración de un TDA: MLWorks> abstype color = blend of int * int * int with val blanco = blend (0,0,0) and rojo = blend (15,0,0) and azul = blend (0,15,0) and amarillo = blend (0,0,15) fun mezclar (parte1: int, blend(ro1, az1, am1), parte2: int, blend(ro2, az2, am2)) = if parte1 < 0 orelse parte2 < 0 then blanco else let val tp = parte1+parte2; val rop = (parte1*ro1 + parte2*ro2) div tp and azp = (parte1*az1 + parte2*az2) div tp and amp = (parte1*am1 + parte2*am2) div tp in blend(rop, azp, amp) end end; type color = color val amarillo : color = _ val azul : color = _ val blanco : color = _ val mezclar : (int * color * int * color) -> color = fn val rojo : color = _ La representación interna de un valor de tipo color no es visible fuera del TDA. Únicamente se puede acceder a la interface del TDA (bloque comprendido entre el with y el end). A diferencia con los tipos de datos (datatype), el constructor de datos blend queda oculto. Un programa usuario del TDA sólo puede definir una variable de tipo color y ligarle algunos de los valores definidos en la interface (blanco, rojo, azul o amarillo), pero no puede construir un nuevo valor empleando blend. La única manera de generar nuevos valores es a través de la función mezclar. 4.2.7 Definición de Módulos En la mayoría de los lenguajes modernos los programas son concebidos a partir de la integración de distintos módulos, cada uno de los cuales define sus propias estructuras de datos y operaciones relacionadas. ML se vale de dos constructores para la definición de módulos: las “structures” que permiten combinar declaraciones de tipos, valores y otras structures relacionadas, y las “signatures” que especifican una clase de structures mediante el listado de los nombres y tipos de cada componente. Las signatures y structures de ML guardan similitud con las partes de definición e implementación de módulos de Modula-2. Finalmente, ML también provee “functors”, los cuales son structures parametrizados con otras structures. 24 Paradigma Funcional Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido Structures Para introducir este constructor nos valdremos de un ejemplo. Suponga que se desea definir un módulo para el manejo de números complejos. Dicha structure puede definirse como sigue: structure Complejo = struct type t = real*real; val cero = (0.0, 0.0); fun suma ((x, y), (x’, y’)) = (x+x’, y+y’) : t; fun resta ((x, y), (x’, y’)) = (x-x’, y-y’) : t; fun producto ((x, y), (x’, y’)) = (x*x’ – y*y’, x*y’ + y*x’) : t; fun reciproco (x, y) = let val p = x*x + y*y in (x/p, ∼y/p) : t end; fun division (z, z’) = producto (z, reciproco z’); end; Las distintas declaraciones efectuadas dentro del módulo son encerradas entre las palabras reservadas struct y end. En toda parte del programa en donde la structure Complejo sea visible, sus componentes pueden ser accedidos mediante una notación que recuerda a los registros de Pascal. Por ejemplo, el número cero definido dentro del módulo puede ser referenciado externamente como Complejo.cero, y la función suma como Complejo.suma. Dentro del cuerpo de la structure las componentes son conocidas por sus identificadores, en los casos anteriores cero y suma. El tipo de un número complejo se denomina Complejo.t, de este modo es posible declarar variables de este tipo. En este punto, es posible utilizar las funciones definidas dentro del módulo sobre cualquier dupla de reales. Por ejemplo: - val a = (0.0, 1.0); > val a : (real * real) = (0.0, 1.0) - val b = (2.3, 1.0); > val b : (real * real) = (2.3, 1.0) - val c = Complejo.suma(a, b); > val c : (real * real) = (2.3, 2.0) - val d = Complejo.suma(c, (1.5, 2.1)); > val d : (real * real) = (3.8, 4.1) Es importante notar que la representación interna del tipo Complejo.t no está oculta dentro del módulo. Signatures Una signature especifica la información que ML necesita para integrar las distintas unidades que conforman un programa de manera segura. En sí, una signature es una descripción de las componentes de una structure. Una vez que efectuamos la declaración de la structure Complejo, ML responde mostrando su correspondiente signature: 25 Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido Paradigma Funcional structure Complejo = struct type t = (real * real) val cero : (real * real) = (0.0, 0.0) val division : ((real * real) * (real * real)) -> (real * real) = fn val producto : ((real * real) * (real * real)) -> (real * real) = fn val reciproco : (real * real) -> (real * real) = fn val resta : ((real * real) * (real * real)) -> (real * real) = fn val suma : ((real * real) * (real * real)) -> (real * real) = fn end Los identificadores struct y end encierran el cuerpo de la signature. La misma sólo muestra los nombres y tipos de las componentes definidas en la structure, junto con los identificadores correspondientes a los tipos declarados. La signature inferida por ML no siempre es la mejor a los propósitos del programador. De hecho, muchas veces un módulo contiene la declaración de tipos y valores privados, los cuales deseamos que permanezcan ocultos dentro de la structure. Para nuestro ejemplo, reciproco es una función auxiliar que podría ser privada. Luego, el usuario puede restringir la visión definiendo una signature específica para el módulo. Volviendo a nuestro ejemplo, la definición de una nueva signature se realizaría así: - signature ARITMETICA = sig type t; val cero : t; val resta : t * t -> t; val division : t * t -> t; val producto : t * t -> t; val suma : t * t -> t; end ; Luego, esta signature es asociada a Complejo en el encabezamiento de la structure: structure Complejo : ARITMETICA = struct type t = real*real; val cero = (0.0, 0.0); fun suma ((x, y), (x’, y’)) = (x+x’, y+y’) : t; fun resta ((x, y), (x’, y’)) = (x-x’, y-y’) : t; fun producto ((x, y), (x’, y’)) = (x*x’ – y*y’, x*y’ + y*x’) : t; fun reciproco (x, y) = let val p = x*x + y*y in (x/p, ∼y/p) : t end; fun division (z, z’) = producto (z, reciproco z’) end; Note que la signature inferida por ML muestra el tipo de los argumentos como real * real mientras que ARITMETICA sólo emplea el tipo t. 26 Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido Paradigma Funcional Alternativamente, se puede asociar una structure con una signatura diferente a la sugerida por ML de la siguiente manera: structure NewComplejo: ARITMETICA = Complejo; Es importante aclarar que una misma signature puede ser asociada a más de una structure, por ejemplo, es posible definir un módulo para el manejo de fracciones como sigue: structure Fraccion : ARITMETICA = struct type t = int*int; val cero = (0, 1); M end; 4.2.8 Manejo de Excepciones Muchas veces, una función aparentemente bien definida, puede tener un comportamiento inesperado bajo circunstancias poco frecuentes. Por ejemplo, considere una base de datos relativa a la Universidad y una función alumno que dado un número de libreta devuelve el nombre del mismo. ¿Qué ocurriría si se pasa como parámetro un número de libreta que no corresponde a ningún alumno? Una alternativa para estas situaciones es agregar chequeos antes y/o después de efectuar ciertas operaciones. En el caso anterior, se puede verificar que el número este dentro de un rango preestablecido. Sin embargo, la incorporación de estos tests “ensucia” el código, tornándose más difícil su seguimiento. Por otra parte, no siempre es fácil determinar que eventos extraordinarios pueden suceder y en que lugares ocurrirán. Declaración de Excepciones Con el fin de tratar estos casos, el lenguaje provee mecanismos para el manejo de excepciones. En ML existe un tipo predefinido, denominado exn, cuyos valores se conocen como valores de excepción. Este tipo se asemeja a los tipos de datos (datatype), pero a diferencia de estos, nuevos constructores (denominados constructores de excepciones) pueden ser sumados al mismo mediante la declaración: exception nomexcep o exception nomexcep of tipo donde nomexcep es el nombre de la excepción. Por ejemplo, se puede declarar: exception Divisionx0 exception PalProhibida of string Luego, son ejemplos de valores de excepciones: Divisionx0, PalProhibida(“combate”). Note que si bien Divisionx0 es una constante, también es posible definir constructores que sean funciones, por ejemplo, PalProhibida es de tipo string → exn. Esta capacidad de definir excepciones como funciones brinda la posibilidad de que el manejador reciba, a través de los argumentos de la excepción, información que le permita decidir la secuencia de acciones a realizar para recuperarse de la falla. 27 Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido Paradigma Funcional Las excepciones pueden ser declaradas a nivel local usando let, incluso dentro de una función recursiva. De este modo, puede suceder que diferentes excepciones tengan el mismo nombre. También se pueden declarar excepciones al nivel del programa principal, pero en este caso es necesario asegurarse de que las excepciones sean monomórficas. Esta restricción responde a ciertos aspectos imperativos subyacentes en la definición de ML que no serán tratados en esta materia. Activación de Excepciones El alcance de una excepción (raise) crea un paquete de excepción conteniendo un valor de tipo exn. Si Ex es una expresión de tipo exn, y la evaluación de Ex arroja un valor e, luego raise Ex evalúa a un paquete de excepción que contiene a e. Los paquetes no son valores de ML y las únicas operaciones que reconocen son raise y handle. El tipo exn actúa como mediador entre los paquetes y los valores de ML. Durante la evaluación, los paquetes de excepción se propagan bajo reglas de llamadas por valor. Si una expresión Ex retorna un paquete de excepción que es el resultado de la aplicación f(Ex) para cualquier función f. Luego, f(raise Ex) es equivalente a raise Ex. Una excepción puede ser activada mediante la expresión: raised exp La siguiente función interes calcula el cociente entre dos valores reales (c y d ) y activa la excepción Divisionx0 si d es igual a 0 ó ValorNegativo si es d es negativo: fun interes (c,d) = if d = 0 then raise Divisionx0 else if d < 0 then raise ValorNegativo else c/d; El comportamiento normal de interes es devolver el cociente c/d, pero si d es igual a 0 ó d es menor que 0, las excepciones Divisionx0 ó ValorNegativo se activarán respectivamente. Excepciones predefinidas Fallas en el “pattern-matching” puede ser alcanzadas mediante las excepciones predefinidas Match y Bind. Una función alcanza la excepción Match cuando es aplicada a un argumento que no hace “matching” con su pattern. La excepción Bind es alcanzada cuando en una declaración del tipo val P = E, el valor de la expresión E no hace “patternmatching” con P. Otras excepciones presentes en la librería de estándar son: Overflow Es alcanzada por operaciones aritméticas cuyos resultados están fuera de rango. Es alcanzada cuando se produce una división por cero. Div Domain Es alcanzada por algunas funciones matemáticas con restricciones de dominio, por ejemplo, si se aplica la función logaritmo a un argumento negativo. Es alcanzada si k no corresponde a un caracter. Chr(k) Subscript Es alcanzada cuando un índice está fuera de rango. Las operaciones sobre listas, strings y arreglos pueden alcanzar esta excepción. 28 Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido Paradigma Funcional Size Es alcanzada si se intenta crear un lista, string ó arreglo de tamaño negativo ó muy grande. Manejadores de excepciones La ligadura entre una excepción y su manejador es dinámica. Si f invoca a g, g invoca a h y h activa una excepción excep, luego se buscan manejadores para excep siguiendo la cadena h, g y f. El primer manejador encontrado captura la excepción. Básicamente, el manejador de excepción chequea si el resultado de una expresión es un paquete de excepción. Si es así, el paquete contiene un valor de tipo exn, el cual puede ser examinado por casos. En general, una expresión que tiene un manejador de excepción utiliza el constructor case de la siguiente forma: E handle P1 => E1 | ... Pn => En Si E retorna un valor del tipo esperado, el manejador devuelve ese valor. En cambio, si E retorna un paquete de excepción, el contenido c de dicho paquete es apareado con los Pi del case, i= 1...n. De este modo, si Pj es el primer patrón que es apareable con c, el manejador devolverá el valor obtenido de evaluar Ej. En caso de que ningún Pi sea apareable con c, la excepción es propagada hasta que sea capturada por otro manejador o alcanzar la excepción Match. Por ejemplo, dada la siguiente función: fun calculainteres (c,d) = interes (c,d) handle Divisionx0 => 0.0 | ValorNegativo => 0.0; Suponga que la evaluación de calculainteres(2.0,4.0) retornará el valor 0.5, en cambio la expresión calculainteres(3.0,0.0) tomará el valor 0.0 devuelto por el manejador. REFERENCIAS Backus J., “Can Programming be Liberated from the von Neumann Style? A Functional Style and its Algebra of Programs”, Comm. ACM, 21(8), 613-641, 1978. Ghezzi C. y Jazayeri M., “Programming Language Concepts”, 3rd Ed., John Wiley & Sons, 1998. Harper R., “Introduction to Standard ML”, School of Computer Science, Carnegie Mellon University, Estados Unidos, 1993. Hudak P., “Conception, Evolution and Application of Functional Programming Languages”, ACM Computing Surveys, 21 (3), 359-411, 1989. Landi P.J, “The Next 700 Programming Languages”, Comm. ACM. 9 (3), 157-166, 1966. Mac Queen D., “Modules for Standard ML”, in Standard ML, Edimburgh University Internal Report ECS-LFCS-86-2, 1986. Paulson, L.C., “ML for the Working Programmer”, 2nd edition, Cambridge University Press, 1996. Peyton Jones S.L., “The Implementation of Functional Programming”, Englewood Cliffs NJ, Prentice Hall, 1987. Sethi R., “Lenguajes de Programación: Conceptos y Constructores”, Addison-Wesley Iberoamericana, 1992. 29 Apuntes sobre Paradigmas de Programación Dr. Ignacio Ponzoni – Dra. Jessica A. Carballido Paradigma Funcional Tofte M., “Essentials of Standard ML Modules”, Department of Computer Science, University of Copenhagen, Dinamarca, Reporte Técnico, 1997. Ullman J.D., “Elements of ML Programming”, 2 nd edition, Prentice-Hall, 1998. Watt D.A., “Programming Language Concepts and Paradigms”, chapter 13, Prentice Hall, 1990.