Trabajo Práctico 1 Programación Funcional Paradigmas de Lenguajes de Programación — 1o cuat. 2009 Fecha de entrega: 14 de abril 1. Introducción Este trabajo consiste en implementar en Haskell dos intérpretes para dos lenguajes de programación sencillos. Estrategia de evaluación Los dos lenguajes son muy similares entre sı́ (comparten las expresiones y los valores), pero tienen una diferencia importante: en el primer caso la estrategia de evaluación es call-by-value, y en el segundo call-by-name. Ambos lenguajes trabajan con expresiones que denotan números enteros. Las posibles expresiones son: constantes enteras, operadores binarios, un condicional (ifZero), declaraciones locales (let) y funciones de varios parámetros, posiblemente recursivas. Utilizando una estrategia de evaluación call-by-value, las funciones reciben sus parámetros por valor. Es decir, cada vez que se invoca una función, se evalúan primero los parámetros reales hasta que se obtiene un valor, y recién entonces se ligan los parámetros formales a estos valores y se evalúa el cuerpo de la función. Con una estrategia call-by-name, las funciones no reciben los valores de los parámetros, sino sus expresiones no evaluadas. Cuando se invoca una función, los parámetros se reemplazan textualmente en el cuerpo. Por ejemplo, si se tiene la siguiente definición: f(x) = x * x La evaluación de la expresión f(2 + 3) corresponderá en cada caso a un proceso como el siguiente: call-by-value: f(2 + 3) → f(5) → 5 * 5 → 25 call-by-name: f(2 + 3) → (2 + 3) * (2 + 3) → 5 * (2 + 3) → 5 * 5 → 25 En el caso de las declaraciones locales ocurre algo similar: call-by-value: let x = 2 + 3 in x * x → let x = 5 in x * x → 5 * 5 → 25 call-by-name: let x = 2 + 3 in x * x → (2 + 3) * (2 + 3) → 5 * (2 + 3) → 5 * 5 → 25 1 Técnica de implementación Por otra parte, la implementación de los intérpretes está planteada de manera muy diferente en cada caso. En el primer intérprete, la evaluación se plantea como un proceso recursivo, en el cual el valor final de una expresión se calcula a partir de los valores de las subexpresiones. Para evaluar una expresión, es necesario contar con un contexto (o entorno) que informa cuáles son los valores de las variables. Por ejemplo, la expresión x aislada sólo tiene sentido si se la considera en un contexto que le asigne algún valor a x. En el segundo intérprete, la evaluación se plantea como un proceso iterativo. Dada una expresión, se define la noción de “reducción en un paso”, que determina cómo se transforma una expresión en otra más simple. El resultado final es el que se obtiene al reducir una expresión tantas veces como sea necesario para transformarla en otra que no se puede reducir más. Notar que la estrategia de evaluación y la técnica de implementación son dos caracterı́sticas independientes. En este TP se eligió que el lenguaje call-by-value se implemente con contextos, y el lenguaje call-by-name mediante la noción de reducción en un paso, pero podrı́a haberse hecho al revés. Módulos del TP La resolución del TP se separará en módulos, ubicados en cuatro archivos distintos: Dict.hs: implementación de un diccionario. Lenguaje.hs: definiciones comunes a ambos lenguajes. ByName.hs: definiciones especı́ficas del intérprete call-by-name. ByValue.hs: definiciones especı́ficas del intérprete call-by-value. 2. Módulo Dict Un diccionario se representa con una función que toma claves y devuelve Nothing (cuando la clave no está definida) o Just v (cuando el valor asociado es v): data Dict key val = DD (key → Maybe val) Ejercicio 1 Sin utilizar recursión explı́cita, definir las siguientes expresiones: a) Denota un diccionario vacı́o: emptyDict :: Dict key val b) Define (o redefine) el valor asociado a una clave: extendDict :: Eq key ⇒ key → val → Dict key val → Dict key val c) Devuelve el valor asociado a la clave: lookupDict :: key → Dict key val → Maybe val 2 d) Elimina una clave del diccionario: removeDict :: Eq key ⇒ key → Dict key val → Dict key val e) Crea un diccionario a partir de una lista de tuplas (clave, valor): makeDict :: Eq key ⇒ [(key, val )] → Dict key val 3. Módulo Lenguaje Las expresiones de los lenguajes con los que se trabajará denotan valores del tipo Value. Se trabajará además con nombres de funciones (por ejemplo “factorial”) y nombres de variables (por ejemplo “x”). type Value = Int type FuncId = String type VarId = String Una expresión del lenguaje es un valor del tipo de datos recursivo Exp. data Op = Add | Sub | Mul data Exp = Const Value | Var VarId | BinOp Op Exp Exp | IfZero Exp Exp Exp | Let VarId Exp Exp | Call FuncId [Exp] Los significados de las expresiones son los siguientes: Const val: la constante entera val. Var v: el valor de la variable v. Las variables se ligan en las llamadas a funciones (son los parámetros) y en las declaraciones locales. BinOp op e1 e2: el resultado de la operación binaria entre las expresiones e1 y e2. El valor de op determina cuál es el operador. IfZero e1 e2 e3: si el valor de e1 es 0, devuelve e2, y en caso contrario e3. Let v e1 e2: devuelve el valor de e2, considerando que la variable v está ligada al valor de e1. Call f es: aplica la función identificada por el nombre f a los parámetros es. Para que esto tenga sentido, la expresión debe estar acompañada de un conjunto de definiciones de funciones (ver abajo). Un programa es un conjunto de definiciones de funciones. Más concretamente, se representa con un diccionario que, dado el nombre de una función, devuelve la definición asociada. La definición de una función consta de la lista de parámetros, acompañada del cuerpo de la función. 3 type ProgramDef = Dict FuncId FuncDef data FuncDef = FuncDef [VarId] Exp Para que un conjunto de definiciones sea considerado un programa válido, en el cuerpo de cada una de las funciones sólo pueden figurar libres aquellas variables que estén declaradas en la lista de parámetros. Además, cada vez que se invoque a una función refiriéndola por su nombre, el nombre debe estar definido en el conjunto y las aridades deben coincidir. El criterio es similar para determinar cuándo es válido evaluar una expresión dado un conjunto de definiciones. En general, puede asumirse como precondición las expresiones y el conjunto de definiciones dados son válidos. Por ejemplo, dadas las siguientes definiciones: cubo(x) = x * x * x sr(x, y) = let cx = cubo(x) in cx + cubo(y) fact(n) = ifzero n then 1 else n * fact(n - 1) El valor denotado por la expresión sr(9, 10) es 1729. El valor de fact(5) es 120. La expresión fact(z, 10) es inválida porque z está libre, y porque la aridad no coincide con la definida arriba. La expresión func(10) es inválida porque func no está definida como función. Ejercicio 2 Dar el tipo y definir la función foldExp, que implemente un esquema de recursión para el tipo de datos Exp. En este ejercicio, obviamente se puede utilizar recursión explı́cita. 4. Intérprete call-by-value El intérprete call-by-value se centra en una función que toma una expresión y devuelve el resultado final. El razonamiento es recursivo. Por ejemplo, el resultado final de evaluar ifZero M then N else O será o bien el resultado final de evaluar N o bien el resultado final de evaluar O, dependiendo de cuál sea el resultado final de evaluar M. Esta noción de evaluación requiere conocer también un entorno que asigne valores a las variables: type Environment = Dict VarId Value Por ejemplo, si se tiene definida la siguiente función: sumar(x, y) = x + y, el resultado de evaluar sumar(2, 3) deberı́a ser el mismo que el resultado de evaluar x + y en el contexto que le asigna 2 a x y 3 a y. Ejercicio 3 Definir la función eval :: ProgramDef → Exp → Value Dado un conjunto de definiciones de funciones y una expresión e1, eval devuelve el valor denotado por la expresión e1. Para este ejercicio, se permite utilizar recursión explı́cita sólo para los casos en los que sea necesario evaluar una expresión que no sea subexpresión de la original. 4 Sugerencia: implementar primero una función auxiliar: eval ’ :: ProgramDef → Environment → Exp → Value que tome adicionalmente el entorno en el que se está evaluando la expresión. Ejemplo: Si tenemos el módulo definido en la sección 6 cargado, los siguientes serian resultados esperados: eval programa (Call ”cubo” [(Const 2)]) debe reducir a 8 eval programa (Call ”sr” [( Const 2), (Const 3)]) debe reducir a 35 eval programa (BinOp Add (Call ”fact” [(Const 4)]) (Const 1)) debe reducir a 25 Ejercicio 4 Verificar si el intérprete escrito en el punto anterior utiliza realmente la estrategia de evaluación call-by-value. Considerar por ejemplo las siguientes definiciones: colgarse() = colgarse() primero(x, y) = x Y comprobar que primero(10, colgarse()) se cuelgue. Si el intérprete implementado en el punto anterior no utiliza la estrategia de evaluación call-by-value, modificarlo para que lo haga. Sugerencia: utilizar la función seq :: a → b → b de Haskell. Dadas dos expresiones, esta función las evalúa secuencialmente y devuelve la segunda. Es decir que, al momento de evaluar la segunda expresión, el lenguaje garantiza que la primera fue efectivamente evaluada. Esta función pertenece a uno de los “rincones oscuros” de Haskell, y se utiliza como herramienta de bajo nivel para garantizar algunas cuestiones de eficiencia. Lo más importante de este ejercicio no es la modificación al intérprete en sı́, sino observar cómo el hecho de que Haskell utilice una estrategia de evaluación call-by-name “propaga” esta propiedad a procesos inclusive tan complejos como un intérprete. Vale también aclarar que no es necesario utilizar seq para implementar un proceso de evaluación call-by-value en Haskell, pero requiere un planteo diferente al de este ejercicio. 5. Intérprete call-by-name El intérprete call-by-name se centra en una función que toma una expresión y la reduce en un paso. Por ejemplo, la expresión ifZero 2 + 3 then 10 else 20 reduce en un paso a ifZero 5 then 10 else 20. Esta expresión a su vez reduce en un paso a 20. La expresión 20 es un valor, y por lo tanto es el resultado final del cómputo. Las reglas para reducir en un paso una expresión son las siguientes (M → N se lee “M reduce en un paso a N ”): Const val: es un valor, por lo tanto no se puede reducir. 5 Var v: esta expresión corresponde a un término de error, que no es un valor y no se puede reducir. Corresponde al caso en el que figuran variables libres en una expresión (como z + 1). BinOp op e1 e2: pueden darse tres casos: • Si e1 → e1’, entonces BinOp op e1 e2 → BinOp op e1’ e2. • Si e1 es un valor y e2 → e2’, entonces BinOp op e1 e2 → BinOp op e1 e2’. • Si e1 y e2 son valores, BinOp op e1 e2 reduce en un paso al resultado de calcular la operación identificada por op sobre los valores e1 y e2. IfZero e1 e2 e3: • Si e1 → e1’, entonces IfZero e1 e2 e3 → IfZero e1’ e2 e3. • Si e1 es un valor, la expresión completa reduce en un paso a e2 o a e3, dependiendo del valor de e1. Let v e1 e2: la expresión reduce en un paso a e2, sustituyendo todas las apariciones de v por e1. Notar que en este punto no se reduce e1 porque la estrategia de evaluación es call-by-name. Call f es: la expresión reduce en un paso al cuerpo de la función identificada por f, reemplazando los parámetros formales (dados en la definición) por los parámetros reales (la lista es). Notar que no se reducen los parámetros porque la estrategia de evaluación es call-by-name. Para implementar las reglas de reducción, es necesario implementar primero la noción de sustituir una variable por una expresión. Una sustitución es un mapeo de variables a expresiones: type Substitution = Dict VarId Exp Ejercicio 5 Sin utilizar recursión explı́cita, definir la siguiente función: substitute :: Substitution → Exp → Exp Dadas una sustitución σ y una expresión e1, substitute reemplaza todas las apariciones libres de x por σ(x), para todas las variables x en el dominio de σ. Observación: no deben sustituirse las variables que estén ligadas por un let. Por ejemplo, substitute sust (Let ”x” (Const 1) (Var ”x”)) siempre es igual a Let ”x” (Const 1) (Var ”x”), sin importar cuál sea el valor de sust. El siguiente tipo de datos representa los posibles resultados de una reducción en un paso: data Result exp = ReducesTo exp | Done Al reducir una expresión e1, el resultado puede ser o bien ReducesTo e1’ (i.e. e1 → e1’) o bien Done (i.e. e1 es un valor). 6 Ejercicio 6 Sin utilizar recursión explı́cita, implementar la función: reduceOneStep :: ProgramDef → Exp → Result Exp Dado un conjunto de definiciones de funciones y una expresión e1, reduceOneStep devuelve el resultado de tratar de reducir e1 en un paso. Sugerencia: implementar primero una función auxiliar: reduceOneStep’ :: ProgramDef → Exp → (Exp, Result Exp) que devuelva una tupla (e1, r1), donde e1 es la expresión original. Ejercicio 7 Sin utilizar recursión explı́cita, implementar la función: reduce :: ProgramDef → Exp → Value Dado un conjunto de definiciones de funciones y una expresión, reduce devuelve el valor que resulta de aplicar sucesivamente reduceOneStep hasta llegar a una expresión irreducible. Sugerencia: puede resultar útil la función iterate definida en el preludio. Ejemplo: Si tenemos el módulo definido en la sección 6 cargado, los siguientes serian resultados esperados: reduce programa (Call ”cubo” [(Const 2)]) debe reducir a 8 reduce programa (Call ”sr” [( Const 2), (Const 3)]) debe reducir a 35 reduce programa (BinOp Add (Call ”fact” [(Const 4)]) (Const 1)) debe reducir a 25 6. Modulo Ejemplo module Ejemplo where import Dict import Lenguaje uno = Const 1 varN = Var ”n” varX = Var ”x” varY = Var ”y” add = BinOp Add mul = BinOp Mul sub = BinOp Sub programa : : ProgramDef programa = makeDict [(”cubo”, FuncDef [”x”] (mul varX (mul varX varX))) , (”sr”, FuncDef [”x”, ”y”] (Let ”cx” (Call ”cubo” [varX]) (add (Var ”cx”) (Call ”cubo” [varY])))) , (”fact”, FuncDef [”n”] (IfZero varN uno (mul varN (Call ”fact” [sub varN uno]))))] 7 En la siguiente sección se muestra la utilización del parser que se les entrega para simplificar la creación de programas. 7. Observaciones y funciones útiles maybe :: a → (b → a) → Maybe b → a iterate :: (a → a) → a → [a] dropWhile :: (a → Bool) → [a] → [a] Usando import Maybe: fromJust :: Maybe a → a Usando import Parser (provisto por la cátedra): parser :: String → Exp Por ejemplo: parser ” ifzero f (x) then 1 else 2 ∗ x” devuelve IfZero ( Call ”f” [Var ”x”]) (Const 1) (BinOp Mul (Const 2) (Var ”x”)) Haskell define la sintaxis para cadenas multilı́nea terminando cada lı́nea con “\” y empezando la siguiente también con “\”. Por ejemplo: "hola mu\ \ndo" Para probar el funcionamiento de una función implementada en términos de foldExp, no es necesario escribirla completa. Puede probarse una implementación parcial escribiendo algunos de los parámetros del foldExp y completando los demás con ( error ”no implementado”). Observar que error msg tiene tipo a para todo a. Observar también que como Haskell utiliza una estrategia de evaluación call-by-name, un término de la forma error msg no se evalúa a menos que se lo necesite. Pautas de entrega Se debe entregar el código impreso con la implementación de las funciones pedidas. Cada función debe contar con un comentario donde se explique su funcionamiento. Cada función asociada a los ejercicios debe contar con ejemplos que muestren que exhibe la funcionalidad solicitada. Además, se debe enviar un e-mail conteniendo el código fuente en Haskell a la dirección plp-docentes@dc.uba.ar. Dicho mail debe cumplir con el siguiente formato: El subject debe ser “[PLP;TP-PF]” seguido inmediatamente del nombre del grupo sin acentos. El código Haskell debe acompañar el e-mail y lo debe hacer en forma de attachment (puede adjuntarse un .zip o .tar.gz). El código debe poder ser ejecutado en Haskell98. No es necesario entregar un informe sobre el trabajo, alcanza con que el código esté adecuadamente comentado. Los objetivos a evaluar en la implementación de las funciones son: 8 Corrección. Declaratividad. Reuso de funciones previamente definidas (tener en cuenta tanto las funciones definidas en el enunciado como las definidas por ustedes mismos). Uso de funciones de alto orden, currificación y esquemas de recursión. Salvo donde se indique lo contrario, no se permite utilizar recursión explı́cita, dado que la idea del TP es aprender a aprovechar las caracterı́sticas enumeradas en el ı́tem anterior. Se permite utilizar esquemas de recursión definidos en el preludio o pedidos como parte del TP. Pueden escribirse todas las funciones auxiliares que se requieran, pero estas no pueden ser recursivas (ni mutuamente recursivas). Importante: Se admitirá una única submisión, sin excepción alguna. Por favor planifiquen el trabajo para llegar a tiempo con la entrega. 9