Paradigmas de Programación Programación funcional Introducción La visión tradicional de programación sólo tiene en cuenta la secuencia de instrucciones que deben suministrarse a una computadora para su operación. Una visión más amplia de programación debe contemplar además otros aspectos. Entonces, podría definirse programación como el análisis y solución de problemas mediante la descripción de valores, propiedades y métodos, el diseño de algoritmos correctos y eficientes y de estructuras de datos que faciliten las soluciones, y la codificación de los algoritmos en un formato adecuado para su ejecución por una computadora. De esta manera, la visión tradicional (programación imperativa) queda comprendida como una parte pequeña de dicha tarea. La programación funcional intenta tratar el problema de la programación desde un punto de vista matemático, utilizando la noción de función como base para la construcción de los algoritmos y estructuras de datos. Los lenguajes de programación imperativos evolucionaron a partir de los lenguajes ensambladores, cuyo único objetivo era el de proveer una forma de controlar el comportamiento de una computadora. El proceso de pensamiento de los programadores no estaba contemplado en el diseño de dichos lenguajes. De esta manera. los lenguajes imperativos reflejan en gran medida la arquitectura de las computadoras, y por lo tanto resulta complicado razonar sobre los programas (por ejemplo, conocer sus propiedades). Los lenguajes de programación imperativos de alto nivel abstraen muchas de las características particulares de la computadora en la que corren, pero no alcanzan a abstraer la arquitectura sobre la que operan. En los lenguajes imperativos existen dos tipos de construcciones: los comandos y las expresiones. Los comandos permiten manejar explícitamente el flujo de control, y las expresiones son utilizadas para calcular valores. Por otro lado, los lenguajes imperativos poseen la característica de poder realizar modificaciones implícitas a la memoria de la computadora. A estas modificaciones se las conocen como "efectos laterales", ya que no son claramente visibles en el código del programa, y tienen consecuencias no deseables para el razonamiento de propiedades. Por ejemplo, la proposición x = x, ¿es verdadera para cualquier x En matemáticas uno espera que así sea, y así lo es. Pero en un lenguaje imperativo, esta propiedad tan importante no se cumple; alcanza con reemplazar x con alguna expresión que contenga efectos laterales, tal como read o random. Esto sucede pues las expresiones y los comandos pueden entremezclarse y agregar de esa manera efectos laterales a las primeras. Dichos efectos laterales, que alteran radicalmente la capacidad de razonamiento al inducirnos a malinterpretar lo que escribimos, son la característica fundamental de operación de Programación Funcional - Página 1 Paradigmas de Programación los lenguajes imperativos. Sería altamente deseable que la mismísima estructura del lenguaje impidiese este tipo de "malentendidos". ¿Cuál es el modelo en el que se basan los lenguajes imperativos? Existen dos componentes fundamentales: la unidad de procesamiento (CPU) y la memoria, y ambas están conectadas de manera de poder intercambiar información. cpu Memoria Cuello de botella Esta conexión puede hacerse con pequeñas cantidades de información por vez (p. ej. algunos bytes), Pero actualmente se utilizan técnicas como el caching o el paralelismo, para aumentar la cantidad de información que pasa por este canal. La CPU entiende instrucciones que le permiten modificar la memoria, y las mismas deben tener un orden determinado (secuencia explícita de instrucciones) para poder conocer con exactitud los cambios a realizar. La memoria determina la existencia de un estado implícito, que puede ser alterado (mediante asignaciones a la misma), dando por resultado un lenguaje dinámico. Al finalizar la ejecución de un programa, el estado implícito contiene el valor de resultado (además de muchos otros valores, irrelevantes al resultado del cálculo que se quería realizar). Como ya se vio, la secuencia de instrucciones que modifica implícitamente el estado dificulta el proceso de razonamiento. Como ejemplo, puede considerarse el siguiente código que muestra un programa para calcular el factorial de un número. Procedure fac(x) n : = x; a : = 1; while(n > 0) do a : = a * n; n : = n − 1; end while return a end proc Una manera de aligerar estos problemas es tratar de abstraer algunas de estas nociones (p. ej. mediante procedimientos), pero cuanto mayor es el nivel de abstracción, mayor es el alejamiento del modelo subyacente. Sería deseable que todas Programación Funcional - Página 2 Paradigmas de Programación esta inconveniencias fueran evitadas, y la capacidad de razonar fuera restaurada. Para ello se pueden estudiar las características que hacen que el razonamiento sea sencillo en matemáticas. En matemáticas no existe la noción de estado implícito que puede ser modificado, haciendo innecesaria la presencia de instrucciones. En cambio existen valores (inmutables) que pueden ser expresados de maneras complejas mediante expresiones; el conjunto de valores conocidos conforman de esta manera un estado explícito, dando como resultado un lenguaje estático. El cálculo de dichos valores se realiza mediante un proceso de reemplazo de subexpresiones que no tienen un orden preestablecido, dando como resultado un control implícito de operación. Al no existir efectos laterales, dos expresiones sintácticamente iguales darán el mismo valor, propiedad conocida como transparencia referencial; esta propiedad es el pilar de la habilidad de razonamiento. Por ello, aumentar el nivel de abstracción mantiene la coherencia con el modelo subyacente. Lo siguiente es la definición matemática de la función factorial: fac (n ) 1 , si n=0 n*fac(n-1) , si n>0 Al comparar esta definición con el programa imperativo anterior, se puede ver claramente que el estado implícito dificulta la capacidad de entender y conocer las propiedades de este cómputo. Dado que las expresiones son construcciones conocidas en los lenguajes de programación, y que las matemáticas las utilizan como base para la denotación de valores y propiedades, ¿por qué no considerar un lenguaje de programación compuesto únicamente por expresiones? Un programa en tal lenguaje será un conjunto de expresiones que denotan valores (con ciertas propiedades). Para que este lenguaje la posibilidad de seguir escribiendo los mismos algoritmos que antes, debe incorporarse la noción de función como un concepto primitivo del mismo. Programar en un lenguaje funcional consiste en construir definiciones y, utilizando la computadora, evaluar expresiones. El rol principal del programador es construir una función que resuelva un problema dado. Esta función, que puede involucrar un cierto número de otras funciones, se expresa en notación basada en principios matemáticos normales. El rol principal de la computadora es actuar como una “calculadora”: su trabajo es evaluar expresiones e imprimir resultados. La diferencia con una calculadora normal es que el programador puede hacer definiciones para incrementar su poder de cálculo. Las expresiones que contienen ocurrencias de nombres de funciones definidas por el programador se evalúan usando las definiciones dadas como reglas de simplificación o reducción que convierten expresiones en formas imprimibles. Programación Funcional - Página 3 Paradigmas de Programación Una característica de la programación funcional es que si una expresión posee un valor bien definido, entonces el orden en el cual la computadora lleve a cabo la evaluación no afecta el resultado. Las funciones se agregan para suprimir las instrucciones. ¿Cómo construimos definiciones de funciones? Construyendo script. Un script es una lista de definiciones. Por ejemplo: cuadrado z = z x z min x y = x, si x<=y = y, si x>y En este script definimos dos funciones llamadas cuadrado y min. La función cuadrado toma un valor z como argumento y retorna el valor de z multiplicado por sí mismo como resultado. La función min toma dos números x e y como argumentos y retorna el valor más pequeño. Sin prestar atención a la sintaxis de las definiciones podemos decir que están escritas como ecuaciones entre cierto tipo de expresiones. Estas expresiones pueden contener variables (como en el caso de x e y). La definición introduce un binding (o ligadura) entre un nombre y un valor dado. En el ejemplo anterior, el nombre cuadrado se asocia a la dunción que eleva al cuadrado su argumento y el nombre min está asociado a la función que retorna el más pequeño de sus dos argumentos. Un conjunto de binding (o ligaduras) se denomina ambiente o contexto. Las expresiones siempre se evalúan en el mismo contexto y pueden tener ocurrencias de nombres encontrados en ese contexto. El evaluador usará las definiciones asociadas con estos nombres como reglas para simplificar las expresiones. Hay un número de operaciones que se consideran como primitivas. Por ejemplo, las operaciones aritméticas. En cualquier momento es posible agregar nuevas definiciones al script. Por ejemplo: lado = 12 área = cuadrado lado Si quiero evaluar el área, debería ingresar una expresión para que la computadora evalúe y me retorne un resultado. Por ejemplo: ó ? área 144. Programación Funcional - Página 4 Paradigmas de Programación ? min (área + 4) 150 148. Resumen: 1. Los scripts son colecciones de definiciones hechas por el programador. 2. Las definiciones se expresan como ecuaciones entre un cierto tipo de expresiones y describen funciones matemáticas. 3. Durante un sesión, se ingresan las expresiones para su evaluación. Estas expresiones pueden contener referencias a funciones definidas en el script. Expresiones y valores La noción de expresiones es central en la programación funcional. Existen muchos tipos de expresiones matemáticas, quizás no todas permiten la misma notación, pero sí todas tienen ciertas características comunes. La característica más importante de la notación matemática es que una expresión se usa para denotar (o describir) un valor. En otras palabras, el significado de una expresión es su valor y NO EXISTEN otros efectos ocultos. El valor de un expresión depende únicamente de los valores de las expresiones que la constituyen (si es que existen) y estas sub-expresiones pueden reemplazarse libremente por otras que posean el mismo valor. Una expresión puede contener ciertos “nombres” que hacen referencia a cantidades desconocidas, pero es normal, en notación matemática, asumir que diferentes ocurrencias del mismo nombre hacen referencia a la misma cantidad desconocida. Tales nombres usualmente se llaman variables. La propiedad característica de las expresiones matemáticas descripta aquí se denomina TRANSPARENCIA REFERENCIAL. Reducción La computadora evalúa una expresión reduciéndola a su forma equivalente más simple e imprimiendo su resultado. Vamos a utilizar los términos evaluación, simplificación y reducción para describir este proceso. Ejemplo: Vamos a reducir la expresión cuadrado ( 3+4) Una forma sería: cuadrado (3+4) ð cuadrado 7 ð7x7 ð 49 (+) (cuadrado) (x) ð (3+4)x(3+4) (cuadrado) Otra forma sería: cuadrado (3+4) Programación Funcional - Página 5 Paradigmas de Programación ð 7 x (3+4) ð7x7 ð 49 (+) (+) (x) No importa la forma que utilizamos, siempre el resultado final es el mismo. El proceso para evaluar una expresión básicamente es simple: sustituir y simplificar usando reglas primitivas y reglas definidas por el programador en forma de definiciones. Otro ejemplo: supongamos la definición recursiva de la función factorial: fac 0 = 1 fac (n + 1) = (n+1)*fac(n) Si reducimos la expresión fac 0 1 En un paso de computación el fac 0 se reduce a 1. Si reducimos la expresión fac 3: fac 3 ð ð ð ð ð ð ð 3*(fac 2) 3*(2*(fac 1)) 3*(2*(1*(fac 0))) 3*(2*(1*1)) 3*(2*1) 3*2 6 Una expresión es canónica (o está en forma normal) si no puede reducirse. Algunas expresiones no pueden reducirse del todo. Por ejemplo, si / es la operación de división, entonces la expresión 1 / 0 no puede reducirse. Para estos casos existe un símbolo denominado bottom (^) que significa valor indefinido. De esta manera, podemos decir que TODA expresión denota un valor (aunque sea ⊥). Otras expresiones nunca terminan de reducirse: Ejemplo: fx = f(x+1) f2 f (2+1) f(3) f(3+1) f(4) ...... Existe un orden de reducción. Las dos formas de reducción se denominan: Orden aplicativo (o ancioso) Orden normal (o "lazy", perezoso) Programación Funcional - Página 6 Paradigmas de Programación Orden aplicativo: Aunque no necesite, debo evaluar todos los argumentos. Orden normal Primero resuelvo la función aunque no conozca los argumentos. No calculo más de lo necesario. El concepto de "lazy evaluation" surge ante la necesidad de evaluar los argumentos de las expresiones para poder obtener los resultados. Esta técnica consiste en que : . La expresión no es evaluada hasta que su valor no se necesita. . Una expresión compartida no es evaluada más de una vez. El primero de los conceptos es lo que se conoce en los lenguajes imperativos como "call by name". La desventaja de este esquema es que cada vez que un mismo parámetro formal es encontrado debe ser reevaluado. Teniendo en cuenta la transparencia referencial, la reevaluación no tiene efectos laterales, con lo que sería innecesaria y el resultado de la evaluación puede compartirse. Esto último es lo que enuncia el segundo concepto y lo que transforma el "call by name" en "call by need" (evalúa cuando lo necesita y lo hace una sola vez). Tipos Existen dos clases de tipos: • tipos básicos • tipos compuestos o derivados. Los tipos básicos son aquellos cuyos valores son primitivos. Por ejemplo: num (números) bool (valores de verdad) char (caracteres). Los tipos derivados o compuestos son aquellos cuyo valores se construyen de otros tipos. Por ejemplo: (num, char), el tipo de pares de valores donde la primer componente es un número y la segunda es un char. (num->num), el tipo de funciones cuyos argumentos son números y retornan otro número. [num], una lista de números. Cada tipo tiene asociado un cierto conjunto de operaciones. TODA tipado. EXPRESIÓN TIENE ASOCIADO UN TIPO. Por lo tanto, es fuertemente Funciones y definiciones Programación Funcional - Página 7 Paradigmas de Programación El valor más importante en la programación funcional es el valor de una función. Matemáticamente hablando, una función es una regla de correspondencia que asocia cada elemento de un tipo dado A a un único miembro de un segundo tipo B. f: A B Si x denota un elemento de A, f(x) denota el resultado de aplicar la función f a x. Este valor es el único elemento de B asociado a x por la regla de correspondencia f. Existe una diferencia entre el valor de un función y la definición de ella. Para una misma función pueden existir muchas definiciones. Por ejemplo: double x =x +x double’ x =2*x Las dos definiciones describen distintos procedimientos para obtener la correspondencia pero double y double’ denotan la misma función. Cuando definimos una función en un script, podemos incluir información del tipo de la misma. Por ejemplo: cuadrado :: num num cuadrado z = z * z Aunque si no se ingresa nada con respecto al tipo, éste puede inferirse: dado que la operación * (multiplicación) está permitida para los números (tipo num). Hay funciones donde el tipo de los argumentos y/o de los resultados son generales. Por ejemplo: ID x = x el tipo acá sería variable. Para describir estas situaciones, se utilizan letras griegas para denotar variables de tipo. En este caso el tipo asignado a la función ID es α α. Otro ejemplo: Tres x = 3. El tipo asociado sería: α Νum. Si una expresión contiene variables de tipo, entonces decimos que denota un tipo polimórfico. Un sistema de tipos es el mecanismo por el cual se formaliza la introducción de tipos en un lenguaje de programación. La motivación del uso de tipos es producir código más eficiente, evitar errores en ejecución detectándolos en compilación (seguridad) y ayudar al diseño separando la representación de las operaciones (abstracción). El concepto de polimorfismo se define en contraposición con momorfismo donde un objeto pertenece a un solo tipo. La propiedad más importante de un sistema de tipos en programación funcional es que es posible determinar el tipo de una expresión sin evaluarla. Para Programación Funcional - Página 8 Paradigmas de Programación determinar el tipo de una expresión se infiere el tipo (o se deduce) el tipo de sus subexpresiones, no hay tipos en el texto. La diferencia de la inferencia de tipos con el chequeo de tipos de los lenguajes procedurales, es que éste último lo realiza el compilador "bottom up" a partir de las declaraciones. Los sistemas de tipos polimórficos tienen la capacidad de definir variables de tipo que identifican genéricamente a cualquier tipo con el que se pueda instanciar. Ejemplo Tres x = 3 Formas de definición Existen diferentes mecanismos para definir funciones: Ecuaciones simples: Defino una función mediante una única ecuación. Ejemplo: cuadrado x = x * x Análisis por casos: (guardas) La definición consiste de expresiones de valor booleano. Ejemplo: min x y = x, si x<=y = y, si x>y. Esta definición consiste de dos expresiones, cada una de las cuales se distingue por expresiones booleanas llamadas “guardas”. Otra manera de definir esta misma función sería: min x y = x, si x<=y = y, otherwise (*). Las guardas agotan todas las posibilidades y deben ser disjuntas. (*) Ojo!!! Estoy asumiendo que existe un orden en la evaluación!!! Definiciones locales: Por ejemplo: f x y = x + a, si x>10 = x - a, otherwise where a= cuadrado(y + 1) La palabra where se utiliza para introducir una definición local cuyo alcance es la expresión del lado derecho de la definición de f. Programación Funcional - Página 9 Paradigmas de Programación Patrones (pattern matching): Es posible definir funciones usando patrones sobre el lado izquierdo de la ecuación: Ejemplo: count 0 = 0 count 1 = 1 count (n+2) = 2 Si hubiese utilizado n, en lugar de n+2, los patrones no serían disjuntos porque cuando el argumento es 0 o 1, cumple también con n. Los patrones deben ser completos, cubrir los casos y disjuntos, dos patrones no pueden ser verdaderos al mismo tiempo. Otro ejemplo: And (True, x) = x And (False, x)=False Definiciones recursivas: Ejemplo: fac 0 = 1 fac (n+1)=(n+1) * fac (n) Funciones de alto orden Las funciones que manipulan otras funciones se dice que son "funciones de alto orden" o "funciones de orden superior". Este concepto puede verse desde varios puntos de vista. Ejemplo de esto es la composición de funciones. Podemos decir que la composición es una función que toma como argumentos las funciones a componer. Componer f g = f.g O, f(g) Listas Una lista es una colección ordenada de valores. Todos los elementos de una lista deben ser del mismo tipo (inclusive pueden existir listas de listas). La lista vacía se simboliza []. La lista formada por los enteros 1 al 5 sería: [1, 2, 3, 4, 5] ó [1..5] ó [1, 2, ..5] Listas por comprensión Representan otra notación para las listas. Empleo una sintáxis adoptado de la forma convencional para escribir conjuntos en matemáticas: [<expresión>|<calificador>;.....<calificador>] Programación Funcional - Página 10 Paradigmas de Programación en donde <expresión> denota una expresión arbitraria y <calificador> es una expresión booleana o un generador. Las formas usadas para los generadores son: (<variable>) <lista> (<variable>, <variable>) <lista de duplas> etc. Ejemplo: xs=[x| x xs] Operaciones sobre listas - Concatenación: dos listas pueden ser concatenadas para formar una lista más larga. Se utiliza el operador :: [α] [α] [α] Propiedades: 1) (xs ys) zs = xs (ys zs) (Prop. Asociativa) 2) [] xs = xs [] = xs - Longitud: la longitud de una lista es el número de elementos que ésta contiene. Se utiliza el operador #. # :: [α] Num Propiedad #(xs ys) = #xs + #ys - - Head y Tail: (cabeza y cola) la función hd selecciona el primer elemento de una lista, y tl selecciona el resto de la lista. hd:: [α] α tl :: [α] [α] Propiedades: 1) hd([x] xs)=x 2) tl ([x] xs)=xs 3) xs=[hd xs] tl xs. Take: La función take toma un entero no negativos y una lista xs como argumentos. El valor de take n xs es el segmento inical de longitud n de xs (o xs si #xs < n). Propiedad: take 0 xs = [] - Takewhile: Es similar a take excepto que toman como primer argumento un predicado. El valor de takewhile p xs es el segmento inicial de xs cuyos elementos satisfacen el predicado p. takewhile :: (α Bool) [α] [α] - Map y Filter: Son funciones de alto orden. La función map aplica una función a cada elemento de una lista Programación Funcional - Página 11 Paradigmas de Programación map :: (α map f xs = [f x| x β) [α] [β] x s] Propiedades: Filter toma un predicado p y una lista xs y retorna la sublista de xs cuyos elementos satisfacen p. filter ::(α Bool) [α] [α] filter p xs = [x| x xs, px] Ejemplos: impares xs = [x | x <>0 xscubos xs = [x * x * x| x xs, (x mod 2) <>0]óimpares xs = filter (x mod 2) xs]ócubo x = x * x * xcubos xs = map cubo xs Conclusiones En la programación imperativa cuando una expresión se evalúa y el resultado se almacena en una celda de memoria la cual es representada por el concepto de variable. Un lenguaje funcional no utiliza variables (como celdas de memoria) ni sentencia de asignación (para modificar esas celdas). El paradigma funcional ve a un programa como una función, la evaluación consiste en la aplicación de la función. Para describir procesos iterativos se utilizan funciones recursivas. El valor de una expresión depende sólo del valor de sus sub-expresiones si es que existen. Trabajar con lenguajes imperativos utilizando funciones tiene las siguientes dos desventajas: • transparencia referencial: estos lenguajes permiten realizar programas que produzcan efectos laterales. • tipos retornados por las funciones: por lo general, tengo bastantes restricciones con respecto a los tipos que pueden ser retornados por las funciones. Por ejemplo, no es posible retornar una función. Ejemplo de un lenguaje funcional: Lisp, Gopher. En la programación funcional se decrementa la eficiencia, por los llamados a funciones, pero se obtiene un alto nivel de programación, con menos trabajo para el programador. Programación Funcional - Página 12 Paradigmas de Programación Programación funcional Práctica Ejercicio 1: Definir y dar el tipo de las siguientes funciones: a) elevo_cuarta, que eleva a la cuarta su argumento. (utilice la función cuadrado); b) max, que retorna el mayor de sus dos argumentos; c) cinco, que dado cualquier valor devuelve 5; d) primero, que dado un par ordenado devuelve el 1er elemento; e) sign, que indica el signo de su argumento o 0 f) abs, que devuelve el valor absoluto de su argumento g) xor, que devuelve el or exclusivo de sus argumentos Ejercicio 2: Reducir de todas las formas posibles las siguientes expresiones: a) cuadrado (cuadrado(3+7)); b) cinco (3+4); c) abs(primero (-4, 2)) Ejercicio 3: Definir una función que determine si un año es o no bisiesto Ejercicio 4: Definir las siguientes funciones: a) sum: suma todos los elementos de una lista de números; b) algunVerdadero: devuelve True si algún elemento de una lista de booleanos es True, y False en caso contrario; c) todosVerdaderos: devuelve True si todos los elementos de una lista de booleanos son True, y False en caso contrario; d) sacoDuplicados: devuelve una lista con los nombre de los mismos valores que la original, pero eliminado todos aquellos ue fueran adyacentes e iguales. Ejemplo: sacoDuplicados [1,1,2,2,2,2,3,4,5,5,] sa [1,2,3,4,5] e) cuadrados: dada una lista de números devuelve la lista de los cuadrados de dichos números; f) longitudes: dada una lista de listas, devuelve la lista de las longitudes. g) pares: dada una lista de números, devuelve la lista de los elementos pares; h) menoresDe: dados una lista de listas xss y un número n devuelve la lista de aquellas listas cuya longitud es menor que n. Programación Funcional - Página 13