Unidad 1: Introducción y Racket
|
Borrador generado con IA. El texto de esta unidad fue generado automáticamente a partir de las transcripciones de las clases y está pendiente de revisión y edición por parte del equipo docente. Puede contener errores, omisiones o imprecisiones; ante cualquier discrepancia, prevalecen las presentaciones y el material oficial del curso. |
Esta unidad tiene un doble propósito. Primero, motivar el estudio de los lenguajes de
programación como disciplina: por qué existen tantos, qué los distingue y por qué el
concepto central que vamos a estudiar es la semántica. Segundo, dotarnos de la
herramienta con la que trabajaremos durante casi todo el curso: el lenguaje Racket
(en su variante docente #lang play), con énfasis en programación funcional, funciones de
orden superior, pattern matching y tipos de datos inductivos.
Todo lo que aprendamos aquí es un medio para un fin: construir intérpretes ejecutables que definan y expliquen la semántica de otros lenguajes. Como se repite en clases, no perdamos de vista esa meta.
Motivación: ¿por qué estudiar lenguajes de programación?
En teoría, todos los lenguajes de programación son equivalentes: son Turing-completos y, por lo tanto, pueden computar exactamente lo mismo. Si esto fuera lo único relevante, un solo lenguaje bastaría. Sin embargo, se siguen creando lenguajes hasta el día de hoy. ¿Por qué?
Porque en la práctica el poder expresivo no es lo único que importa. Un lenguaje también se elige por atributos técnicos (eficiencia en el hardware, seguridad, confiabilidad), sociológicos (que sea placentero de escribir --- lo que hoy se llama developer experience ---, comunicativo, con disponibilidad de programadores) y, cada vez más, por su interacción con modelos de lenguaje (cuántos tokens consume, qué tan fácil es de generar y verificar). Algunos ejemplos que muestran que la elección del lenguaje tiene consecuencias técnicas, sociales y económicas:
-
C sigue siendo campeón en velocidad de ejecución, al costo de un manejo de memoria inseguro (de ahí el surgimiento de Rust).
-
COBOL aún sostiene buena parte de la industria bancaria, pero quedan muy pocas personas que sepan programarlo.
-
El backend de WhatsApp, que maneja miles de millones de mensajes, está construido en Erlang, cuyas características de concurrencia y eficiencia lo hacen idóneo para sistemas escalables con comunicación masiva.
-
Durante años, programar para iOS obligaba a usar Objective-C (hoy Swift).
Un lenguaje de programación es, ante todo, una herramienta, y las herramientas influyen en los hábitos de quienes las usan.
The tools we use have a profound (and devious!) influence on our thinking habits, and therefore on our thinking abilities.
Como dice el refrán, "si sólo tienes un martillo, todo te parece un clavo". Conocer los fundamentos nos permite elegir la herramienta adecuada para cada tarea y aprender rápidamente lenguajes nuevos, distinguiendo lo que es genuinamente novedoso de lo que sólo es sintaxis distinta para ideas ya conocidas.
¿Qué define a un lenguaje de programación?
Podemos identificar tres componentes:
- Sintaxis
-
La forma de los programas: qué secuencias de símbolos constituyen un programa válido.
- Semántica
-
El significado de los programas: cómo se comportan, qué acciones realizan y qué valores producen. Es el foco de este curso.
- Bibliotecas
-
El ecosistema de algoritmos, estructuras de datos y acceso al sistema. Son decisivas para la adopción de un lenguaje (por ejemplo, Python en ciencia de datos), pero no son relevantes para el estudio fundamental de los lenguajes.
La sintaxis no determina el significado
La sintaxis, por sí sola, dice muy poco sobre el comportamiento. Consideremos "acceder al i-ésimo elemento de un arreglo `x`" en tres lenguajes:
| Sintaxis | Semántica al estar i fuera de rango |
|---|---|
|
comportamiento indefinido: puede funcionar hoy y fallar mañana |
|
comportamiento especificado (error controlado) |
|
lanza una excepción bien definida |
Dos expresiones sintácticamente idénticas (x[i] en C y Java) tienen semánticas distintas.
A la inversa, la misma acción idealizada de sumar 3 y 4 puede escribirse de muchas formas:
3 + 4 (notación infija) (+ 3 4) (notación prefija -- la de Racket) 3 4 + (notación posfija)
Todas denotan lo mismo y todas se corresponden con un mismo árbol de sintaxis abstracta (AST). La moraleja: no hay que apegarse emocionalmente a una sintaxis en particular.
|
Por eso Racket usa notación prefija con muchos paréntesis: es una notación uniforme y muy fácil de parsear. Como el foco del curso no es la sintaxis, elegimos una que nos deje llegar rápido al AST, donde ocurre lo interesante. |
Semántica: "just semantics"
¿Cómo describimos la semántica de un lenguaje? El lenguaje natural es demasiado impreciso. Existen varios enfoques rigurosos:
-
Denotacional: asocia a cada programa un objeto matemático dentro de un dominio semántico apropiado.
-
Axiomático: establece una relación lógica entre las propiedades que valen antes y después de ejecutar el programa (por ejemplo, la lógica de Hoare).
-
Operacional: interpreta el programa como una secuencia de pasos computacionales (transiciones en un sistema de transición).
-
Intérpretes (enfoque "intermedio", el nuestro): se escribe un programa que ejecuta al programa paso a paso y produce su resultado.
Just semantics. That’s all there is.
El enfoque de intérpretes
Para explicar un lenguaje, escribimos un intérprete para él. Este enfoque tiene ventajas pragmáticas decisivas:
-
Escribir obliga a entender (igual que en matemáticas): no se puede implementar lo que no se comprende del todo.
-
Una vez escrito, el intérprete se puede ejecutar, probar e inspeccionar bajo distintos escenarios.
-
Permite modificaciones incrementales: extenderemos un lenguaje base agregando una característica a la vez.
Ahora bien, un intérprete es a su vez un programa escrito en algún lenguaje. Conviene, por tanto, usar un lenguaje simple y con fundamentos matemáticos sólidos. Ese lenguaje será Racket.
El flujo: del código fuente al valor
La mayoría de los lenguajes trabajan sobre este flujo, y es exactamente el que construiremos en el curso:
código fuente --parse--> AST --interp--> valor
|
+-- transform --> AST' (optimización, azúcar sintáctico)
+-- analyze --> info (chequeo de tipos, análisis de flujo)
+-- compile --> AST(L2) (p. ej. bytecode) --> valor
Visto en términos de tipos, cada operación es una función:
-
parse : código-fuente → AST -
interp : AST → Valor -
transform: AST → AST -
compile : AST(L1) → AST(L2)
El AST es la estructura central: sobre él se define la interpretación, el análisis y la
transformación. Nosotros nos concentraremos en parse e interp. Empezaremos con una
"calculadora que sólo suma" y terminaremos con un lenguaje que tiene variables, funciones,
recursión y estado.
|
Al principio, ejecutar un programa será una función matemática pura (mismas entradas, mismo resultado). Más adelante incorporaremos efectos: errores, no-terminación, aleatoriedad, cambio de estado y entrada/salida. Muchas veces ejecutamos un programa no por su valor de retorno, sino precisamente por sus efectos. |
El lenguaje Racket
Racket es un descendiente moderno de Scheme (a su vez un dialecto de Lisp de fines de los años 70), un lenguaje funcional basado en el cálculo lambda, de diseño minimalista y extensible mediante macros. Su lema captura su filosofía: para resolver un problema, construye el lenguaje que te ayuda a resolverlo.
En el curso usamos el lenguaje docente #lang play (de Éric Tanter), que es Racket más
algunas utilidades para el curso (test, deftype, def, etc.). Todo archivo comienza
con:
#lang play
(print-only-errors #t) ;; sólo reporta los tests que fallan
Estilo funcional vs. imperativo
Comparemos la función factorial en ambos estilos. En C (estilo imperativo), el cálculo se expresa como una secuencia de comandos que modifican almacenamiento mutable (variables):
int factorial(int n) {
int result = 1;
for (int c = 1; c <= n; c++) {
result = result * c;
}
return result;
}
En Racket (estilo funcional), no hay variables ni asignación: hay evaluación de expresiones y aplicación de funciones sobre datos inmutables:
(define (factorial n)
(if (zero? n)
1
(* n (factorial (- n 1)))))
El estilo imperativo describe cómo hacer el cálculo (está cerca del hardware: registros, memoria mutable, tabla de ruteo de variables). El estilo funcional describe qué se quiere calcular y está más cerca de las definiciones matemáticas.
|
No hay palabra clave |
DrRacket, el REPL y #lang play
El entorno oficial es DrRacket, con dos zonas: la ventana de definiciones (arriba) y la ventana de interacción (abajo), que expone un REPL (read-eval-print loop). El botón Run evalúa las definiciones, limpia la interacción y deja las definiciones disponibles para probarlas.
Notación prefija y s-expresiones
La sintaxis de Racket se basa en paréntesis. Lo primero que aparece tras un paréntesis de apertura está en posición de aplicación (la operación o función a aplicar); lo demás son los argumentos (cero o más):
(<posición-de-aplicación> <argumento> ...)
(+ 1 2) ;; => 3
(* (+ 1 2) 4) ;; => 12
(- 10 3) ;; => 7 (la resta también es prefija)
(define (square x) ;; 'define' está en posición de aplicación: define una función
(* x x))
(square 5) ;; => 25
Definir funciones de varios argumentos es igual de directo:
;; linear :: Number Number Number -> Number
;; Calcula el valor a*x + b
(define (linear x a b)
(+ (* a x) b))
(linear 1 2 3) ;; => 5
|
Aplicar algo que no es una función es un error. |
Tipos primitivos, condicionales y definiciones
Racket es de tipos chequeados dinámicamente. Sus datos primitivos incluyen:
-
Números de precisión arbitraria (enteros, fracciones exactas, etc.), con
+ - * /,quotient,sqrt, comparadores< ⇐ =, predicadoszero?,even?,odd?, … -
Booleanos
#ty#f, conand,or,not. -
Strings, con
string-length,string-append,substring, … -
Símbolos, que se escriben con una comilla simple:
'hola.
|
Un símbolo ( |
Para el control de flujo tenemos dos condicionales. El if toma guarda, rama verdadera y
rama falsa:
;; abs-value-if :: Number -> Number
(define (abs-value-if x)
(if (> x 0)
x
(if (= x 0)
0
(- x))))
Cuando el análisis es caso por caso (muy común, y muy matemático), cond evita el
anidamiento. Cada cláusula es [guarda resultado]; se evalúan de arriba hacia abajo y gana
la primera guarda verdadera:
;; abs-value-cond :: Number -> Number
(define (abs-value-cond x)
(cond
[(> x 0) x]
[(= x 0) 0]
[(< x 0) (- x)]))
|
Como las cláusulas se prueban en orden, si dos guardas se solapan gana la primera. Lo ideal es que las guardas sean mutuamente excluyentes, para que el caso realmente aplicable no quede "tapado" por uno anterior. |
Identificadores locales: let y let*
define introduce identificadores y funciones globales (disponibles sin anidación). Para
nombrar resultados locales usamos let, que mejora la legibilidad y evita recomputar. Sin
let, la fórmula cuadrática recomputa el discriminante y queda ilegible:
;; solve-cuadratic :: Number Number Number -> Number
;; Retorna una raíz real de a*x^2 + b*x + c, o lanza error si no existe.
(define (solve-cuadratic a b c)
(let ([discriminant (- (* b b) (* 4 a c))])
(if (> discriminant 0)
(/ (+ (- b) (sqrt discriminant)) (* 2 a))
(error "No real solution"))))
El identificador discriminant sólo existe en el cuerpo del let, se calcula una vez y se
reutiliza. let* es como let pero cada ligadura puede usar las anteriores.
|
|
Pares, listas y vectores
La estructura más simple es el par, que une dos valores arbitrarios con cons; se accede
con car (primero) y cdr (segundo):
(define p1 (cons 1994 "hola"))
(car p1) ;; => 1994
(cdr p1) ;; => "hola"
Una lista es, conceptualmente, una secuencia; internamente es una cadena de pares
anidados que termina en la lista vacía '() (también empty). Estas tres definiciones son
equivalentes:
(define l1 (cons 1 (cons 2 (cons 3 '()))))
(define l2 (list 1 2 3))
(define l3 '(1 2 3))
Operan sobre listas first/car, rest/cdr, append, length, empty?, reverse,
list-ref, … Como una lista es una cadena de pares, el acceso a una posición arbitraria
no es O(1) (a diferencia de un arreglo): list-ref avanza secuencialmente.
Los vectores (vector, vector-ref, vector-set!) son arreglos de tamaño fijo,
mutables y con acceso directo.
|
En las tareas y controles del curso está prohibido usar estructuras mutables (vectores,
|
Quotation: código como datos
El operador quote (comilla simple ') le dice a Racket que considere lo que sigue como
datos, sin evaluarlo. Esto es distinto de construir una lista con list, que sí evalúa
sus argumentos:
(list (+ 1 2) 'a) ;; => '(3 a) -- (+ 1 2) se evalúa
'(+ 1 2) ;; => '(+ 1 2) -- NO se evalúa; es una lista de 3 elementos:
;; el símbolo '+, el número 1 y el número 2
|
|
Funciones como valores y de orden superior
En Racket las funciones son valores (de tipo procedure), igual que los números o los
strings. Si en el REPL evaluamos + o una función propia, el intérprete responde que es un
procedure: no podemos "ver dentro", pero podemos tratarla como cualquier otro valor. En
concreto, una función puede:
-
definirse de forma anónima (como un valor literal);
-
almacenarse en cualquier estructura de datos;
-
pasarse como argumento a otra función;
-
retornarse como resultado de otra función.
Las dos últimas capacidades habilitan las funciones de orden superior (HOF): funciones que reciben o producen funciones. En un lenguaje orientado a objetos esto requiere andamiaje (objetos, interfaces); aquí es tan directo como pasar un número.
La función map
map construye una nueva lista aplicando una función a cada elemento, respetando las
posiciones (la lista resultante tiene el mismo largo). Primero, cómo se vería sin map,
con recursión explícita sobre la lista:
(define l1 '(1 -1 0 -32 14))
;; abs-list :: (listof Number) -> (listof Number)
(define (abs-list l)
(cond
[(empty? l) '()] ;; caso base
[else (cons (abs (car l)) (abs-list (cdr l)))])) ;; caso recursivo
La ejecución "desdobla" un cons por elemento hasta el caso base:
(abs-list '(1 -1 0 -32 14)) => (cons (abs 1) (abs-list '(-1 0 -32 14))) => (cons (abs 1) (cons (abs -1) (abs-list '(0 -32 14)))) => ... => (cons 1 (cons 1 (cons 0 (cons 32 (cons 14 '()))))) => '(1 1 0 32 14)
Este patrón (caso base + caso recursivo que reconstruye con cons) se repite en toda
función que procesa listas. map lo abstrae:
(map abs l1) ;; => '(1 1 0 32 14)
(map add1 '(1 2 3)) ;; => '(2 3 4)
Funciones anónimas: lambda
Cuando la función que pasamos a un HOF es pequeña y no se reutilizará, no vale la pena
nombrarla. Se escribe como valor literal con lambda (o λ):
(λ (arg1 ... argn) cuerpo)
(map (λ (n) (+ n 1)) '(1 2 3)) ;; => '(2 3 4)
;; 'define' de una función es en realidad azúcar para ligar un lambda a un nombre:
(define add2 (lambda (n) (+ n 2))) ;; equivale a (define (add2 n) (+ n 2))
;; Aplicación de una función anónima "en el sitio":
;; ( <posición de función> <arg> )
((lambda (x) (+ x 1)) 10) ;; => 11
|
Si notas que reutilizas mucho una misma función anónima, dale un nombre con |
La función filter
filter construye una lista con los elementos que satisfacen un predicado (una función
que retorna #t o #f), manteniendo el orden relativo. Por convención de Racket, los
predicados terminan en ?: even?, odd?, string?, …
(filter even? '(1 2 3 4 5 6)) ;; => '(2 4 6)
(filter odd? '(1 2 3 4 5 6)) ;; => '(1 3 5)
(filter (λ (n) (> n 4)) '(1 5 2 7)) ;; => '(5 7)
(filter string? '(1 2 3)) ;; => '()
El largo de la lista resultante es siempre menor o igual al de la original. Como con map,
filter generaliza el patrón recursivo de "conservar unos, descartar otros".
foldl y foldr
map y filter no bastan para combinar los elementos de una lista en un único resultado
(una suma, un producto, la propia lista reconstruida). Para eso están los folds, que
capturan por completo el esquema de recursión sobre listas. En Racket, ambos reciben la
función combinadora en el orden (elemento acumulador):
(foldr f z (list a b c)) == (f a (f b (f c z))) ;; asocia a la derecha (foldl f z (list a b c)) == (f c (f b (f a z))) ;; asocia a la izquierda (tail-recursive)
(foldr + 0 '(1 2 3)) ;; => 6
(foldr * 1 '(1 2 3 4)) ;; => 24
(foldr cons '() '(1 2 3)) ;; => '(1 2 3) -- reconstruye la lista
(foldl cons '() '(1 2 3)) ;; => '(3 2 1) -- la invierte
foldr calca el template natural de la recursión sobre listas (el caso cons es
(f (first l) (recur (rest l)))), lo que lo conecta directamente con los tipos inductivos
que veremos más abajo. Por ejemplo, "duplicar los elementos" se puede escribir con map o
con foldr:
(map (λ (x) (* x 2)) '(1 2 3)) ;; => '(2 4 6)
(foldr (λ (x acc) (cons (* x 2) acc)) '() '(1 2 3)) ;; => '(2 4 6)
|
Estos patrones no son exclusivos de Racket: |
Funciones que retornan funciones
Cualquier función puede retornar otra función. Este patrón (una "fábrica" de funciones) permite construir funciones durante la ejecución, que dependen de valores no conocidos de antemano:
;; addn :: Number -> (Number -> Number)
(define (addn n)
(λ (m) (+ m n)))
(define add1 (addn 1))
(define add2 (addn 2))
(add1 10) ;; => 11
((addn 5) 100) ;; => 105
;; Una fábrica de predicados:
;; less-than :: Number -> (Number -> Boolean)
(define (less-than n)
(λ (m) (< m n)))
(filter (less-than 4) '(1 5 2 7 3)) ;; => '(1 2 3)
La clave está en el lambda: sólo porque las funciones pueden escribirse como valores
literales es posible retornarlas. Si tuviéramos que darles nombre de antemano, no tendría
sentido "retornarlas".
Currificación
La currificación transforma una función de varios argumentos en una cadena de funciones de un argumento. Para el caso de dos argumentos:
f : A B -> C f# : A -> (B -> C)
;; my-curry :: (A B -> C) -> (A -> (B -> C))
(define (my-curry f)
(λ (a) (λ (b) (f a b))))
(define add (my-curry +))
((add 3) 4) ;; => 7
(map ((my-curry +) 1) '(1 2 3)) ;; => '(2 3 4) -- (add 1) es "sumar 1"
Su beneficio es la aplicación parcial: fijar unos argumentos y dejar otros abiertos, lo que mejora la reutilización y composición.
|
Una pregunta clásica de examen: "un lenguaje sólo soporta funciones de un argumento, ¿es malo para la expresividad?". La respuesta es que no: encadenando funciones de un argumento (currificación) se simulan las de múltiples argumentos. Retomaremos esto en Haskell (Unidad 4), que tiene currificación automática. |
Metodología de diseño de programas
En el curso seguimos una metodología para definir funciones, resumida del libro How to Design Programs. No seguirla afecta la nota de las tareas. Los pasos son:
-
Entender qué hace la función (ocurre en tu mente).
-
Escribir el contrato: los tipos de entrada y salida, como comentario.
-
Escribir el propósito: una línea que describe qué hace.
-
Proveer casos de prueba (tests) que cubran los casos "significativos".
-
Proveer la implementación (¡este es el último paso!).
-
Verificar que los tests pasen.
- El contrato se escribe como comentario `nombre
-
Tipos-de-argumentos → Tipo-de-retorno`. No es rígido ni formal (Racket es de tipos dinámicos), pero debe entenderse. Apliquémoslo a
double-list:
;; double-list :: (listof Number) -> (listof Number) <- contrato
;; Doubles the elements of a given list <- propósito
;; Tests (se escriben ANTES de implementar):
(test (double-list '()) '())
(test (double-list '(1 2 3)) '(2 4 6))
;; Implementación (última):
(define (double-list lst)
(cond
[(empty? lst) '()]
[else (cons (* (car lst) 2) (double-list (cdr lst)))]))
|
El contrato es un compromiso entre quien invoca y quien implementa (una idea real en
investigación de LP: la noción de blame, "de quién es la culpa"). Si |
|
La implementación va al final a propósito. Si programas primero, tenderás a escribir tests
que confirmen lo que ya hiciste (sesgo). Al fijar primero el comportamiento esperado, tus
tests son independientes de cualquier implementación --- y de hecho varias
implementaciones distintas pueden satisfacerlos (aquí, la versión con |
Un truco práctico mientras desarrollas: implementa un dummy que siempre falle (por
ejemplo, que retorne #f) y corre los tests para ver los mensajes de fallo antes de
programar de verdad.
El lenguaje #lang play provee test y también test/exn, que verifica que una expresión
lance una excepción cuyo mensaje contenga cierto substring:
;; my-sqrt :: Number -> Number
;; Computes the square root of a non-negative number.
;; Raises an exception if the argument is negative.
(define (my-sqrt n)
(if (< n 0)
(error "my-sqrt: negative argument")
(sqrt n)))
(test (my-sqrt 0) 0)
(test (my-sqrt 4) 2)
(test (my-sqrt 9) 3)
(test/exn (my-sqrt -1) "negative") ;; "negative" debe ser substring del error
Pattern matching
El pattern matching (calce de patrones) es una técnica que hace tres cosas a la vez:
-
Verifica si un valor calza con un patrón dado.
-
Si calza, deconstruye el valor y liga sus componentes internos a identificadores locales.
-
Ejecuta una acción usando esos identificadores.
Es, en esencia, un condicional orientado a la forma (estructura) de un valor. La expresión
es match:
(match target-expr [patrón_1 acción_1] ... [patrón_n acción_n])
Las cláusulas se prueban de arriba hacia abajo; la primera que calza ejecuta su acción. Un valor literal como patrón calza por igualdad:
(define (f v)
(match v
["hola" (print "v es el string 'hola'")]
[23 (print "v es el número 23")]
[else (print "v es otra cosa")]))
|
Un |
El lenguaje de patrones
Racket tiene un lenguaje de patrones rico. Los ingredientes básicos son:
-
Valores literales de tipos primitivos (calzan por igualdad).
-
Constructores
consylistpara calzar pares y listas, con subpatrones. -
Variables de patrón: comodines que calzan cualquier valor y lo ligan a un nombre. Una variable sola siempre calza.
-
El comodín
_: como una variable, pero sin nombre (calza y descarta).
La potencia surge de combinar estructura, literales y variables:
;; Combinando literal + variable: distingue por la "forma":
(define (j2 v)
(match v
[(cons 1 a) (print "par que comienza con 1")]
[(cons a b) (print "par que no comienza con 1")]
[else (print "no es un par")]))
;; Patrones anidados arbitrariamente:
(define (j3 v)
(match v
[(cons _ (cons 3 (cons _ 7))) (print "par extraño")]
[else (print "otra cosa")]))
Como las cláusulas se prueban en orden, en j2 el segundo patrón (cons a b) sólo se
alcanza si el primero falló: allí tenemos la garantía de que el par no empieza en 1.
Trabajando con listas
Al procesar listas, casi siempre nos interesa uno de dos casos:
-
Primer elemento y resto (el patrón clásico de la recursión sobre listas).
-
Lista de largo fijo con literales o variables en posiciones específicas (así parsearemos el código fuente de nuestros intérpretes).
Podemos tratar una lista como pares anidados (cons) o con el constructor list. Ambas
formas calzan la lista vacía con el literal '():
;; Con cons: 'r' es el resto, que también es una lista.
(define (my-length-cons l)
(match l
['() 0]
[(cons f r) (+ 1 (my-length-cons r))]))
;; Con list y ELIPSIS (...): 't' captura cero o más elementos restantes.
(define (my-length-list l)
(match l
['() 0]
[(list h t ...) (+ 1 (my-length-list t))]))
(test (my-length-cons '(1 2 3)) 3)
(test (my-length-list '(1 2 3)) 3)
|
Hay una sutileza que confunde a muchos: en el patrón |
(define (fg l)
(match l
['() "vacia"]
[(list h t ... 9) (printf "parte con h: ~a y termina con 9" h)]
[else "no calza"]))
El caso de largo fijo es el que usaremos para parsear. Un patrón como (list '+ a b)
calza cualquier suma binaria en la sintaxis concreta, ligando los operandos:
(define (match-binop l)
(match l
[(list '+ a b) (printf "sum of ~a and ~a" a b)]
[(list '- a b) (printf "sub of ~a and ~a" a b)]
[(list '* a b) (printf "prod of ~a and ~a" a b)]))
(match-binop '(+ 1 2)) ;; sum of 1 and 2
Esto es exactamente lo que necesita un parser: distinguir '{+ 1 2} de '{- 3 4} y
extraer sus partes. Lo desarrollaremos en la Unidad 2.
Guardas y patrones-predicado
A veces el requisito no es sólo estructural sino computacional. Podemos añadir una guarda
#:when, que tiene acceso a las variables ya ligadas por el patrón:
(define (ggg p)
(match p
[(cons a _) #:when (even? a) (print "par que comienza con número par")]
[(cons _ _) (print "par que no comienza con número par")]
[else (print "otra cosa")]))
De forma más compacta, en cualquier posición de patrón podemos aplicar un predicado con
(? p):
(define (ggg2 p)
(match p
[(cons (? even?) _) (print "par que comienza con número par")]
[(cons _ _) (print "par que no comienza con número par")]
[else (print "otra cosa")]))
|
La guarda |
Definición por matching: def
Cuando sabemos que un valor tiene una única forma y sólo queremos acceder a sus componentes,
un match de una sola cláusula queda anidado y feo. #lang play provee def (alias de
match-define de Racket): liga las variables del patrón y las deja disponibles de ahí en
adelante.
(def (cons a b) (cons 10 20))
;; a vale 10, b vale 20 en el resto del programa
Tipos de datos inductivos y recursión
Para construir intérpretes necesitamos definir nuestras propias estructuras de datos. Las más importantes son inductivas: se definen con un caso base y uno o más casos recursivos.
Conjuntos inductivos
Los números naturales, las listas y los árboles binarios son conjuntos inductivos. Cada definición inductiva genera "ciegamente" dos cosas:
- Principio de inducción
-
para demostrar una propiedad P sobre todos los elementos (base
paso inductivo). No lo usaremos en este curso. - Esquema de recursión
-
para definir funciones que procesan el tipo (definir el caso base y el caso recursivo, asumiendo el resultado sobre las sub-partes). Este sí nos interesa.
Por ejemplo, para las listas: el caso base es '() y el caso inductivo es (cons v l) con
l lista. De ahí sale mecánicamente el esquema: toda función sobre listas trata el caso
vacío y el caso cons, con una llamada recursiva sobre el resto (que es más pequeño, por lo
que la recursión termina). Ya lo vimos en my-length.
Los árboles binarios (con valores en todos los nodos) se definen así:
<BinTree> ::= (leaf <value>)
| (in-node <value> <BinTree> <BinTree>)
deftype y la gramática
#lang play provee deftype para crear tipos inductivos. La traducción de la gramática al
deftype es mecánica: se copian el nombre y las variantes, dando un nombre a cada campo
(incluidos los campos recursivos):
(deftype BinTree
(leaf v)
(in-node v left right))
(define bt1 (leaf 5))
(define bt2 (in-node 9 (leaf 4) (in-node 12 (leaf 2) (leaf 3))))
Cada variante define una función constructora (leaf, in-node) y deftype genera
predicados (BinTree?, leaf?, in-node?). Se cumplen tres propiedades fundamentales:
-
Los constructores son inyectivos:
(leaf a)es igual a(leaf b)sólo siaes igual ab. -
Valores de constructores distintos son siempre distintos: una hoja nunca es igual a un nodo interno (ni a un número, string, etc.).
-
La única forma de construir valores del tipo es mediante sus constructores.
|
La tercera propiedad es la que hace que |
Esquema de recursión: sigue la gramática
Del deftype se deriva el template de toda función que procesa el tipo: un match con
una cláusula por variante, y llamadas recursivas sobre los campos que son del mismo tipo:
;; height :: BinTree -> Number
;; Computes the height of a binary tree.
(define (height bt)
(match bt
[(leaf _) 0]
[(in-node _ left right)
(+ 1 (max (height left) (height right)))]))
;; sum-bintree :: BinTree -> Number
;; Sums all values of a numeric binary tree.
(define (sum-bintree bt)
(match bt
[(leaf v) v]
[(in-node v l r) (+ v (sum-bintree l) (sum-bintree r))]))
(test (height bt2) 2)
(test (sum-bintree bt2) 30)
Como las variantes son mutuamente excluyentes, el orden de las cláusulas no altera el resultado; por convención se respeta el orden de la gramática. Lo único que cambia entre una función y otra es la acción de cada caso y el valor de retorno; el andamiaje estructural es siempre el mismo. Este patrón aparece en cerca del 80% del curso.
Capturando el esquema: fold
Así como foldl/foldr capturan la recursión sobre listas, podemos capturar la recursión
sobre árboles en una única función de orden superior: un fold. La receta "sigue la
gramática" nos dice cuántos argumentos necesita: una función por variante. Para BinTree,
fold-bintree recibe una función para las hojas (f) y otra para los nodos internos (g),
y retorna una función BinTree → A:
;; fold-bintree :: (Number -> A) (Number A A -> A) -> (BinTree -> A)
;; Fold over numeric binary trees.
(define (fold-bintree f g)
(λ (bt)
(match bt
[(leaf v) (f v)]
[(in-node v left right)
(g v
((fold-bintree f g) left) ;; la recursión ya está aquí...
((fold-bintree f g) right))]))) ;; ...en ambos subárboles
Con fold-bintree, definir funciones se reduce a decir qué hacer en cada variante, sin
volver a escribir el match ni las llamadas recursivas (que ya viven dentro del fold):
(define sum-bintree-fold
(fold-bintree (λ (v) v) (λ (v vl vr) (+ v vl vr))))
(define max-bintree-fold
(fold-bintree (λ (v) v) (λ (v vl vr) (max v vl vr))))
(test (sum-bintree-fold bt2) 30)
(test (max-bintree-fold bt2) 12)
f dice qué hacer con el valor de una hoja; g dice cómo combinar el valor de un nodo
con los resultados ya calculados de sus subárboles (vl, vr). Nótese que
sum-bintree-fold se define sin paréntesis de aplicación extra: fold-bintree retorna la
función, que luego aplicamos al árbol.
|
"Sigue la gramática". Toda la cadena es mecánica: la gramática BNF define el tipo
inductivo; de ella se deriva el |
Ejercicios propuestos
Aplica la metodología de diseño (contrato, propósito, tests antes de implementar) en todos.
-
Procesamiento de listas con recursión explícita. Define
contains?,list-sum,list-productycount(número de elementos que satisfacen un predicado dado), usando pattern matching sobre'()y(cons h t). -
Reimplementa los HOF. Define
my-mapymy-filtercon recursión explícita, y verifica con tests que coinciden conmapyfilterde Racket. -
Funciones de orden superior. Define
negate(dado un predicado, retorna su negación),reject(comofilter, pero elimina los que satisfacen el predicado),compose(la composiciónf ∘ g) yapply-f-n(retorna una función que componefconsigo mismanveces). Ninguna debe usar recursión que no sea la estrictamente necesaria. -
Folds sobre listas. Reescribe
list-sumydouble-listusandofoldr. Luego explica, con un ejemplo, la diferencia entre(foldr cons '() l)y(foldl cons '() l). -
Tipo inductivo propio. Define con
deftypeun tipoAExprpara expresiones aritméticas con números, sumas y multiplicaciones. Escribe primero su gramática BNF, luego eldeftype. -
Intérprete de juguete. Para el tipo
AExpranterior, implementaeval-aexpr :: AExpr → Numbercon pattern matching, siguiendo el esquema de recursión. Provee tests que cubran expresiones anidadas. -
Árboles binarios. Define
sum-bin-treeymax-bin-treecon recursión explícita (comoheight). Luego definecontains-bintree?(si un valorvaparece en el árbol) usandofold-bintree. -
Currificación. Usando
my-curry, defineadd1yadd2a partir de+sin escribir ningúnlambdaadicional, yless-than-10a partir de<. Argumenta por qué un lenguaje con sólo funciones de un argumento no pierde poder expresivo.