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.

— Edsger W. Dijkstra

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

x[i] ©

comportamiento indefinido: puede funcionar hoy y fallar mañana

(vector-ref x i) (Scheme)

comportamiento especificado (error controlado)

x[i] (Java)

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.

— Shriram Krishnamurthi

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 return. Una función retorna el valor de la última expresión que evalúa. En el factorial, el cuerpo es una única expresión if, cuyo valor es el de la rama seleccionada.

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. (define x 199) liga a x un número; si se escribe (x 10), Racket responde que x no es un procedure. La evaluación de la ventana de definiciones es de arriba hacia abajo y de izquierda a derecha: una función debe estar definida antes de usarse.

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 < ⇐ =, predicados zero?, even?, odd?, …​

  • Booleanos #t y #f, con and, or, not.

  • Strings, con string-length, string-append, substring, …​

  • Símbolos, que se escriben con una comilla simple: 'hola.

Un símbolo ('hola) y un string ("hola") no son iguales: (equal? 'hola "hola") es #f. Los símbolos son valores atómicos cuya comparación de igualdad es O(1); los strings son secuencias de caracteres y su comparación es O(n). Usaremos símbolos como etiquetas.

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.

let no introduce variables (mutables), sino identificadores: le da un nombre a una expresión para reutilizarla. Como los cálculos son puros, nombrar un resultado nunca cambia el significado, sólo la claridad y, a veces, la eficiencia.

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, set!, etc.). Trabajaremos sólo con datos inmutables: primitivos, pares, listas y --- más adelante --- tipos definidos con deftype. Estas estructuras además ofrecen acceso seguro: no hay segmentation faults como en C.

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 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

quote no es el constructor de listas. '(+ 1 2) es un dato: la lista (list '+ 1 2). Esta dualidad código/datos (poder escribir código y tratarlo como dato) es la que nos permitirá representar el código fuente de nuestros intérpretes como valores de Racket quoteados, y así parsearlo con facilidad. Volveremos a esto en la Unidad 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 define: un principio básico de ingeniería de software es definir cada cosa en un solo lugar (no repetirse), para poder cambiarla en un único sitio.

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: map, filter y reduce/fold fueron adoptados por lenguajes de uso masivo (Python, JavaScript/TypeScript, etc.), aunque allí operan sobre arreglos en lugar de listas.

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:

  1. Entender qué hace la función (ocurre en tu mente).

  2. Escribir el contrato: los tipos de entrada y salida, como comentario.

  3. Escribir el propósito: una línea que describe qué hace.

  4. Proveer casos de prueba (tests) que cubran los casos "significativos".

  5. Proveer la implementación (¡este es el último paso!).

  6. 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 (double-list 3) falla, la culpa es del invocador (pasó un argumento que viola el contrato). Si a (double-list '(3)) la función responde 5, la culpa es del implementador. Y recuerda: garbage in, garbage out.

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 cond o una con (map (λ (x) (* x 2)) lst)).

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:

  1. Verifica si un valor calza con un patrón dado.

  2. Si calza, deconstruye el valor y liga sus componentes internos a identificadores locales.

  3. 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 match debe ser exhaustivo: si ningún patrón calza, se produce el error match: no matching clause. Usa else (o el comodín _) como caso por defecto cuando no puedas enumerar todos los casos.

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 cons y list para 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:

  1. Primer elemento y resto (el patrón clásico de la recursión sobre listas).

  2. 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 list, (list h t) calza una lista de exactamente dos elementos, ligando t al segundo elemento (no al resto). Para capturar "un primer elemento y el resto de la lista" hay que usar la elipsis …​: (list h t …​) liga t a la lista con los elementos restantes. Con cons, en cambio, (cons f r) siempre da en r el resto como lista. La elipsis además permite patrones potentes como "empieza con h y termina en `9`":

(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 #:when y el patrón (? p) se evalúan como parte del calce: si fallan, el match continúa con la siguiente cláusula. Esto es distinto de poner un if en la acción: un if dentro de la acción ya "consumió" la cláusula y no reintenta las demás.

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 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 si a es igual a b.

  • 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 match sea exhaustivo gratis: un BinTree o es leaf o es in-node, no hay más. Si un match cubre todas las variantes, cubre todos los casos posibles --- sin necesidad de else.

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 deftype; del deftype se derivan el esquema de recursión y el fold; y --- como veremos --- también el parser. Cada variante requiere un constructor distinto y una función en el fold. Interiorizar este flujo (gramática → deftype → recursión/fold) es una de las destrezas centrales del curso.

Ejercicios propuestos

Aplica la metodología de diseño (contrato, propósito, tests antes de implementar) en todos.

  1. Procesamiento de listas con recursión explícita. Define contains?, list-sum, list-product y count (número de elementos que satisfacen un predicado dado), usando pattern matching sobre '() y (cons h t).

  2. Reimplementa los HOF. Define my-map y my-filter con recursión explícita, y verifica con tests que coinciden con map y filter de Racket.

  3. Funciones de orden superior. Define negate (dado un predicado, retorna su negación), reject (como filter, pero elimina los que satisfacen el predicado), compose (la composición f ∘ g) y apply-f-n (retorna una función que compone f consigo misma n veces). Ninguna debe usar recursión que no sea la estrictamente necesaria.

  4. Folds sobre listas. Reescribe list-sum y double-list usando foldr. Luego explica, con un ejemplo, la diferencia entre (foldr cons '() l) y (foldl cons '() l).

  5. Tipo inductivo propio. Define con deftype un tipo AExpr para expresiones aritméticas con números, sumas y multiplicaciones. Escribe primero su gramática BNF, luego el deftype.

  6. Intérprete de juguete. Para el tipo AExpr anterior, implementa eval-aexpr :: AExpr → Number con pattern matching, siguiendo el esquema de recursión. Provee tests que cubran expresiones anidadas.

  7. Árboles binarios. Define sum-bin-tree y max-bin-tree con recursión explícita (como height). Luego define contains-bintree? (si un valor v aparece en el árbol) usando fold-bintree.

  8. Currificación. Usando my-curry, define add1 y add2 a partir de + sin escribir ningún lambda adicional, y less-than-10 a partir de <. Argumenta por qué un lenguaje con sólo funciones de un argumento no pierde poder expresivo.