La base fundamental de Scheme y los lenguajes que obedecen el paradigma funcional es el cálculo lambda. Esta entrada no es sobre ese tema, que es un tema avanzado que formaliza la solución de problemas mediante la secuenciación y composición de funciones, sino sobre cómo es que Scheme usa éste concepto y en especial cómo es que éste lenguaje funcional puede usar funciones como un dato primitivo adicional. Disfrútenlo.

Cálculo Lambda

Antes de continuar, es muy importante notar que los lenguajes funcionales, así como los procedimentales, tienen un modelo matemático que fundamenta tanto su posibilidad de existir como la calidad de problemas que podría resolver. El modelo computacional de los lenguajes imperativos es el desarrollado por Alan Turing con sus máquinas de Turing, las cuales terminaron en lenguajes basados en las arquitecturas de computadoras con una memoria, un procesador y un conjunto de instrucciones que resuelven en problema particular.

Simultáneamente, otro teórico llamado Alonzo Church (profesor del primero) desarrolló un modelo computacional llamado cálculo lambda, éste consiste en definir unas reglas con las cuales se exprese cualquier problema mediante funciones, es decir, mediante expresiones que convierten unos parámetros y los convierten en resultados que pueden ser una parte de la solución al problema o la solución final del mismo. La parte interesante del cálculo lambda es que se basa en un lenguaje bien conocido: la lógica matemática, con la cual el ser humano tiene experiencia de miles de años tanto en su tratamiento como en su formalización.
Ésta entrada no es sobre el cálculo lambda, sino sobre la expresión lambda de Scheme, pero los párrafos anteriores invitan a una investigación sobre qué es exactamente el cálculo lambda y qué implicaciones ha tenido para el desarrollo de la computación.

¿Qué es una función?

Una función, matemáticamente hablando, es una expresión que relaciona un valor incierto (entrada) con el resultado de alguna operación sobre tal entrada.  Por ejemplo, sea f(x) la función x^2, una función es entonces f(x)=x*x. Una función no necesariamente tiene una sola entrada, puede convertir varios valores inciertos (parámetros) a un resultado, por ejemplo: g(x,y)=x^2/5+y^2/10 y no sólo se reserva para operaciones matemáticas como las funciones anteriores, por ejemplo, yo podría definir que x es una persona y decir que hijo(x,y) es una función que dice si x es hijo de y o no, es decir, un resultado buleano (boolean).
La función en sí misma no es útil hasta que no se le dan valores concretos a las entradas, por ejemplo, f(2) se evalúa o convierte en 4 una vez que se sabe qué operación hace f sobre x o g(3,2) se evalúa o convierte en el valor 3,4 cuando conocemos cómo evaluar g.

Finalmente la finalidad de evaluar una función puede obedecer a varias necesidades, por ejemplo, para saber el área de una esfera a cierta distancia x  se podría necesitar f(x) (el área es 4*pi*f(x), si x es el radio de la esfera) por otro lado para saber cómo dibujar la curva f(x) en un plano cartesiano también podríamos necesitar evaluar sucesivamente f(x) con diferentes valores de x. La principal propiedad de una función es que dado un valor para x, la función siempre debe retornar el mismo resultado.

¿Qué es una función en Scheme?

Así como en matemáticas, Scheme permite definir funciones de manera arbitraria, es más, allí se entiende la verdadera generalidad de una función dado que los parámetros de éstas no siempre son números, por ejemplo, (primera cadena) puede ser una función que devuelva la primera letra de la cadena de texto contenida o especificada en el parámetro cadena. Veamos cómo aplican las definiciones precedentes al ejemplo dado.
La función primera consiste en conseguir la primera letra de su argumento, por ejemplo, la operación para obtener la primera letra de una cadena de texto es (substring «Nombre» 0 1) –> retorna la cadena «N». Dado que ya sabemos la operación que hay que hacer sobre una cadena particular, ahora la generalizamos para definir la función primera de la siguiente manera:

(define (primera cadena) (substring cadena 0 1))

Como vemos, Scheme es un lenguaje unificado para definir funciones. Hemos definido una función cuya entrada incierta es cadena y cuyo resultado siempre será la primera letra ésta (siempre y cuando cadena contenga una cadena de texto con base en las reglas de Scheme -letras y números encerrados en comillas-.

Estructuralmente, un define (encerrado completamente entre paréntesis) indica la forma en que se debe llamar la función, por ejemplo (primera «texto»), ésto es lo que indica la primera parte de la definición (después de la palabra clave define), sólo que, como estamos generalizando, no se sabe qué contiene cadena y por lo tanto se le da un nombre para poder definir la operación con la que se obtendrá el resultado. Por otro lado, la operación utiliza una función predefinida de Scheme: substring que toma como parámetros una cadena de texto y dos números.

Lo anterior indica una serie de importantes conceptos en Scheme y en los lenguajes funcionales en general:

  1. Una función es una relación de las entradas con los resultados o salidas
  2. La definición de una función es general y parametrizada
  3. Un llamado a una función es darle valores concretos a los parámetros para obtener un resultado concreto (siempre igual si el valor pasado como entrada es igual)
  4. El nombre de las entradas en la definición se llaman parámetros y los valores dados en el llamado se llaman argumentos.

Sintácticamente, es decir, desde el punto de vista de lo que es correcto escribir (estructuralmente), la definición de una función consiste en una cláusula define entre paréntesis y dos partes: nombre+parámetros encerrados en paréntesis seguido de las operaciones que hay que hacer sobre los parámetros cuando se les de valor concreto. Por otro lado el llamado de una función es el nombre de la función seguida por los argumentos que se quieren pasar para obtener un resultado concreto.

Funciones como tipo de datos: abstracción

Una de las características importantes de los lenguajes funcionales es que permiten que las funciones mismas sean argumentos o resultados, es decir, que exista un tipo de datos que sea en sí mismo una(s) operación(es) sobre uno o varios parámetros. Esta característica hace que una función sea muy adaptable, dado que no sólo generaliza lo que se puede hacer con datos concretos sino que generaliza el comportamiento mismo de la función, sin romper la regla de que siempre retornaría el mismo valor si los argumentos son los mismos. Por ejemplo, si existe una función matemática que modela un fenómeno, por ejemplo, dada cierta temperatura t=a, un compuesto se comporta de una manera para los valores menores que esa temperatura umbral a y de otra para los valores mayores, por ejemplo, supongamos que existe un material tal que si t<0, la resistencia es exponencial decreciente, pero para t>0 es cuadrática creciente. Diríamos para éste fenómeno que la función que modela la resistencia del material dependiendo de la temperatura se define de la siguiente manera:

(define (resistencia1 t)
  (if (< t 0) (exp (* -1 t))
     (* t t)
   )
)
(resistencia1 1) --> 1
(resistencia1 -1) --> #i2.7182 (es decir 2,7182)

Por otro lado, digamos que para otro material la resistencia es constante por debajo de cierta temperatura (cero grados en nuestro caso) pero exponencial creciente después de la misma, lo definiríamos de la siguiente manera:

(define (resistencia2 t)
  (if (< t 0) 0.1
     (exp (* 2 t))
  )
)
(resistencia2 1) --> #i7.389
(resistencia2 -1) --> 0.1

Y siguiendo con el ejemplo suponga que éste comportamiento se da similarmente para muchos materiales, sólo que cada uno tiene su propio par de funciones que determinan la resistencia por encima y por debajo de t=0. Como se observa, el fenómeno estaría bien definido diciendo que la resistencia de los materiales tiene dos comportamientos, uno por debajo de 0 y otro por encima, así que los comportamientos, todos dependientes solamente de la temperatura, son variables para el fenómeno y a su vez dependen del material del que estemos hablando.

En un caso como el anterior, podemos pensar en el poder de los lenguajes funcionales de permitir que las funciones sean variables también, pero no variables ordinarias, ya que hasta ahora una variable contiene un dato específico, en el caso de las variables que representan funciones, éstas son operaciones dadas sobre uno o varios argumentos, así que es como poder pasar a una función una(s) operación(es) con ciertos parámetros. Si examinamos las definiciones de resistencia1 y resistencia2, la estructura de ambas es igual excepto por las funciones que se ejecutan en cada caso de la decisión. En otras palabras, podría declarar sólo una función con el if y pasarle las operaciones que hay que hacer con la temperatura en cada caso (menor/mayor que 0). A las funciones que usan otras funciones como parámetros se las llama funciones abstractas. Dado que en teoría sé que Scheme permite hacer lo anterior, ahora debo resolver dos preguntas: ¿cómo paso funciones como argumentos y cómo las uso dentro de la función?.

Primero recordemos que cuando se usa una función en Scheme, la sintaxis exige escribir unos paréntesis y entre ellos el nombre de la función seguido de los argumentos sobre los que queremos operar, así que la intuición dice que si una variable es una función, sólo deberíamos escribir un par de paréntesis y dentro de ellos escribir el nombre de la función seguida por los argumentos que le vamos a pasar. Por ejemplo, si definimos una función abstracta llamada resistencia, que tome dos funciones y un número la definiríamos de la siguiente manera:

(define (res-param f1 f2 t)
 (if (< t 0) (f1 t)
   (f2 t)
 )
)

La anterior definición es correcta y entenderla es muy fácil recordando las reglas sintácticas de Scheme (uso de paréntesis y evaluación de funciones). Con lo anterior hemos resuelto la pregunta ¿cómo las uso dentro de la función abstracta?. Por otro lado, ¿cómo las paso?. La respuesta es sencilla, el lenguaje trata el nombre de la función como un dato de tipo función. A continuación voy a escribir el resto de un programa que evalúa la función abstracta res-param.

(define (cuadrado x) (* x x))
(define (1+ x) (+ 1 x))

(res-param cuadrado 1+ 1) --> 2
(res-param cuadrado 1+ -1) --> 1

Recordemos cómo opera res-param, si el parámetro t es mayor que 0 usa la función f1 sino usa la función f2, en nuestro caso es evidente que cuando t=1 se evaluó (1+ 1) y cuando t=-1 se evaluó (cuadrado -1).

Funciones anónimas y lambda

A estas alturas, hay que preguntarse: ¿dónde está el lambda?. Pues lambda es la forma de crear datos tipo función sin usar un define, en otras palabras, la forma de crear funciones sin nombre. Supongamos que en el ejemplo de la función res-param no quisiéramos definir las funciones 1+ ni cuadrado, ya que no serán las únicas que vamos a necesitar y tal vez sólo las utilicemos unas pocas veces en un programa completo, así que quisieramos pasar sólo la estructura de las operaciones (dato de tipo función). No basta con poner el cuerpo de la función cuando vamos a llamar a res-param, por ejemplo (* x x) en vez de la palabra cuadrado, dado que, como sabemos, el llamado a una función consiste en reemplazar las variables por sus valores y evaluar la operación, es decir si llamáramos (res-param (* x x) 1+ 1), el lenguaje buscaría un valor para x, lo reemplazaría en caso de existir y pasaría ese valor calculado como valor de f1. Por otro lado, un dato de tipo función tiene parámetros, por ejemplo, Scheme debe saber al interior de la función res-param si f1 tiene uno, dos o más parámetros para poder usarla bien. En otras palabras, necesitamos una notación especial para pasar datos de tipo función sin haber definido su nombre.

La notación que me permite crear datos de tipo función usa, entre paréntesis como de costumbre, la palabra clave lambda acompañada de dos partes: los parámetros (entre paréntesis) y el cuerpo de la función (muy parecido a la definición, sólo que esta vez no hay nombre de función).  Por ejemplo, vamos a construir un llamado a la función res-param equivalente al llamado (res-param cuadrado 1+ 1):

(res-param (lambda (x) (* x x)) (lambda (x) (+ 1 x)) 1)

Lo que hemos hecho es que res-param recibe en su parámetro f1 una función de un parámetro (el nombre del mismo no importa) y en f2 otra, en la primera multiplica su argumento por él mismo y en el segundo le suma 1. Es muy importante tener en cuenta, que la x es un elemento estructural que sólo importa dentro del lambda, es decir, la x de la primera función no tiene nada que ver con la x de la segunda, en ambos casos es un nombre para poder decir multiplicar por sí mismo o al cual sumarle 1.

Al final, el núcleo mismo de Scheme depende de definiciones de funciones mediante estructuras lambda. Un ejemplo clásico es cómo declarar funciones mediante un lambda en vez de una definición con la sintaxis de siempre:

(define f1 (lambda (x) (* x x))) es equivalente a (define (f2 x) (* x x))

Noten que en ambos casos puedo evaluar f de la misma manera (f1 2) –> 4, (f2 2) –> 4. ¿Podría evaluar una función sin haberle dado un nombre?, sí, evalúen la siguiente expresión y piensen el por qué del resultado:

( (lambda (x) (* x x)) 4) --> 16

¿Cómo explicaría lo que acaba de experimentar?

Ejercicios:

  1. Defina una función llamada mapear que recorra recursivamente una lista homogénea (mismo tipo de datos) y a cada elemento le aplique una función que recibe un parámetro del mismo tipo que los elementos de la lista, por ejemplo: (mapear cuadrado (list 1 2 3) ) –> (list 1 4 9), (mapear 1+ (list 1 2 3)) –> (list 2 3 4).
  2. Utilice la función anterior y la forma especial lambda para obtener una lista de números al cubo sin definir previamente la función cubo.
  3. Defina una función llamada reducir, que recorra recursivamente una lista homogénea y aplique una función (de dos parámetros) entre cada par de elementos consecutivos de la lista. Ejemplo: (reducir * (lista 1 2 3)) –> (list 2 6), (reducir string-append (list «C» «A» «C» «E»)) –> (list «CA» «AC» «CE»)
  4. Use la librería htdp/draw, la función anterior y la forma especial lambda para dibujar una figura consistente en unir los puntos de una lista de estructuras posn (pixeles en un lienzo) mediante una línea en el lienzo [Función dibujar polígono].
  5. Cree una función filtrar que tome una función que retorne verdadero o falso y una lista heterogénea (cualquier tipo de dato) y devuelva una lista compuesta por cierto tipo de valores, por ejemplo: (filtrar number? (list 1 «a» (make-posn 1 2))) –> (list 1), (filtrar string? (list 1 2 «a» «Cesar» ‘b)) –> (list «a» «Cesar»)
  6. Use la función anterior y la forma especial lambda para obtener números mayores que cierto valor a partir de una lista de números. Ejemplo: (filtrar <XXX> (list 1 2 3)) –> (list 2 3) si <XXX> es una función que indique si un número es mayor que 1.