Ricardo Pérez López
IES Doñana, curso 2024/2025
Las expresiones lambda (también llamadas abstracciones lambda o funciones anónimas en algunos lenguajes) son expresiones que capturan la idea abstracta de «función».
Son la forma más simple y primitiva de describir funciones en un lenguaje funcional.
Su sintaxis (simplificada) es:
lambda
[⟨lista_parámetros⟩]:
⟨expresión⟩identificador
(,
identificador
)*Por ejemplo, la siguiente expresión lambda captura la idea general de «suma»:
Los identificadores que aparecen entre la palabra clave lambda
y el
carácter de dos puntos (:
) son los
parámetros de la expresión lambda.
La expresión que aparece tras los dos puntos (:
) es
el cuerpo de la expresión lambda.
En el ejemplo anterior:
Los parámetros son x
e
y
.
El cuerpo es x + y
.
Esta expresión lambda captura la idea general de sumar dos
valores (que en principio pueden ser de cualquier tipo, siempre y cuando
admitan el operador +
).
En sí misma, esa expresión devuelve un valor válido que representa a una función.
De la misma manera que podemos aplicar una función a unos argumentos, también podemos aplicar una expresión lambda a unos argumentos.
Por ejemplo, la aplicación de la función max
sobre los
argumentos 3
y 5
es una
expresión que se escribe como max(3, 5)
y que denota el valor cinco.
Igualmente, la aplicación de una expresión lambda como
sobre los argumentos 4
y 3
se representa
así:
O sea, que la expresión lambda representa el papel de una función.
En nuestro modelo de sustitución, la evaluación de la aplicación de una expresión lambda consiste en sustituir, en el cuerpo de la expresión lambda, cada parámetro por su argumento correspondiente (por orden) y devolver la expresión resultante parentizada (entre paréntesis).
A esta operación se la denomina aplicación funcional o β-reducción.
Siguiendo con el ejemplo anterior:
sustituimos en el cuerpo de la expresión lambda los parámetros x
e y
por los argumentos 4
y 3
,
respectivamente, y parentizamos la expresión resultante, lo que da:
que simplificando (según las reglas del operador +
) da
7
.
Es importante hacer notar que el cuerpo de una expresión lambda sólo se evalúa cuando se lleva a cabo una β-reducción (es decir, cuando se aplica la expresión lambda a unos argumentos), y no antes.
Por tanto, el cuerpo de la expresión lambda no se evalúa cuando se define la expresión.
Por ejemplo, al evaluar la expresión:
el intérprete no evalúa la expresión del cuerpo (x + y
), sino
que crea un valor de tipo «función» pero sin entrar a ver «qué hay» en
el cuerpo.
Sólo se mira lo que hay en el cuerpo cuando se aplica la expresión lambda a unos argumentos.
En particular, podemos tener una expresión lambda como la siguiente, que sólo dará error cuando se aplique a un argumento, no antes:
Si hacemos la siguiente definición:
le estamos dando un nombre a la expresión lambda, es decir, a una función.
A partir de ese momento podemos usar suma
en lugar de su valor (la expresión
lambda), por lo que podemos hacer:
en lugar de
Cuando aplicamos a sus argumentos una función así definida
también podemos decir que estamos invocando o
llamando a la función. Por ejemplo, en suma(4, 3)
estamos llamando a la función suma
, o hay una llamada a la
función suma
.
La evaluación de la llamada a suma(4, 3)
implicará realizar los siguientes tres pasos y en este orden:
Sustituir el nombre de la función suma
por su definición, es decir, por
la expresión lambda a la cual está ligado.
Evaluar los argumentos que aparecen en la llamada.
Aplicar la expresión lambda a sus argumentos.
Esto implica la siguiente secuencia de reescrituras:
Como una expresión lambda es una función, aplicar una expresión lambda a unos argumentos es como llamar a una función pasándole dichos argumentos.
Por tanto, también podemos decir que llamamos o invocamos una expresión lambda, pasándole unos argumentos durante esa llamada.
En consecuencia, ampliamos ahora nuestra gramática de las expresiones en Python incorporando las expresiones lambda como un tipo de función:
(
[⟨lista_argumentos⟩])
identificador
(
⟨expresión_lambda⟩)
lambda
[⟨lista_parámetros⟩]:
⟨expresión⟩identificador
(,
identificador
)*,
⟨expresión⟩)*Dado el siguiente código:
¿Cuánto vale la expresión siguiente?
Según el modelo de sustitución, reescribimos:
suma(4, 3) * suma(2, 7) # definición de suma
= (lambda x, y: x + y)(4, 3) * suma(2, 7) # evaluación de 4
= (lambda x, y: x + y)(4, 3) * suma(2, 7) # evaluación de 3
= (lambda x, y: x + y)(4, 3) * suma(2, 7) # aplicación a 4 y 3
= (4 + 3) * suma(2, 7) # evaluación de 4 + 3
= 7 * suma(2, 7) # definición de suma
= 7 * (lambda x, y: x + y)(2, 7) # evaluación de 2
= 7 * (lambda x, y: x + y)(2, 7) # evaluación de 7
= 7 * (lambda x, y: x + y)(2, 7) # aplicación a 2 y 7
= 7 * (2 + 7) # evaluación de 2 + 7
= 7 * 9 # evaluación de 7 * 9
= 63
Si un identificador de los que aparecen en el cuerpo de una expresión lambda también aparece en la lista de parámetros de esa expresión lambda, a ese identificador le llamamos variable ligada de la expresión lambda.
En caso contrario, le llamamos variable libre de la expresión lambda.
En el ejemplo anterior:
los dos identificadores que aparecen en el cuerpo (x
e y
) son variables ligadas, ya que ambos
aparecen también en la lista de parámetros de la expresión
lambda.
En cambio, en la expresión lambda:
x
e y
son variables ligadas mientras que
z
es una variable libre.
En realidad, una variable ligada y un parámetro son la misma cosa.
Tan sólo cambia su denominación dependiendo del lugar donde aparece su identificador en la expresión lambda:
Cuando aparece antes del «:
», le
llamamos «parámetro».
Cuando aparece después del «:
», le
llamamos «variable ligada».
Por ejemplo: en la siguiente expresión lambda:
el identificador x
aparece dos
veces, pero en los dos casos representa la misma cosa. Tan sólo se llama
de distinta forma («parámetro» o «variable ligada»)
dependiendo de dónde aparece.
El que se llame «variable ligada» no tiene nada que ver con las ligaduras que hemos estudiado hasta ahora.
Son conceptos totalmente distintos:
A las variables ligadas se las llama así porque están ligadas a un parámetro.
En cambio, una ligadura es la asociación que se establece entre un identificador y un valor.
Un ámbito léxico (también llamado ámbito estático) es una porción del código fuente de un programa.
Decimos que ciertas construcciones sintácticas determinan ámbitos léxicos.
Cuando una construcción determina un ámbito léxico, la sintaxis del lenguaje establece dónde empieza y acaba ese ámbito léxico en el código fuente.
Por tanto, siempre se puede determinar sin ambigüedad si una instrucción está dentro de un determinado ámbito léxico, tan sólo leyendo el código fuente del programa y sin necesidad de ejecutarlo.
Eso significa que el concepto de ámbito léxico es un concepto estático.
Por ejemplo: en el lenguaje de programación Java, los
bloques son estructuras sintácticas delimitadas por llaves
{
y }
que contienen instrucciones.
Los bloques de Java definen ámbitos léxicos; por tanto, si una
instrucción está dentro de un bloque (es decir, si está situada entre
las llaves {
y }
que delimitan el bloque),
entonces esa instrucción se encuentra dentro del ámbito léxico que
define el bloque.
Además de los ámbitos léxicos, existen también los llamados ámbitos dinámicos, que funcionan de otra forma y que no estudiaremos en este curso.
La mayoría de los lenguajes de programación usan ámbitos léxicos, salvo excepciones (como LISP o los shell scripts) que usan ámbitos dinámicos.
Por esa razón, a partir de ahora, cuando hablemos de «ámbitos» sin especificar de qué tipo, nos estaremos siempre refiriendo a «ámbitos léxicos».
Los ámbitos se pueden anidar recursivamente, o sea, que pueden estar contenidos unos dentro de otros.
Por tanto, una instrucción puede estar en varios ámbitos al mismo tiempo (anidados unos dentro de otros).
De todos ellos, el ámbito más interno es el que no contiene, a su vez, a ningún otro ámbito.
En un momento dado, el ámbito actual es el ámbito más interno en el que se encuentra la instrucción que se está ejecutando actualmente (es decir, la instrucción actual).
El concepto de ámbito no es nada trivial y, a medida que vayamos incorporando nuevos elementos al lenguaje, tendremos que ir adaptándolo para tener en cuenta más condicionantes.
Hasta ahora sólo hemos tenido un ámbito, llamado ámbito global:
Si se está ejecutando un script en el intérprete por
lotes (con python script.py
), el ámbito global
abarca todo el script, desde la primera instrucción hasta la
última.
Si estamos en el intérprete interactivo (con python
o ipython3
), el ámbito global abarca toda nuestra
sesión con el intérprete, desde que arrancamos la sesión hasta que
finalizamos la misma.
Por tanto:
En el momento en que se empieza a ejecutar un script o se arranca una sesión con el intérprete interactivo, se entra en el ámbito global.
Del ámbito global sólo se sale cuando se finaliza la ejecución del script o se cierra el intérprete interactivo.
El ámbito de una definición es el ámbito actual de esa definición (es decir, el ámbito más interno donde aparece esa definición).
Por extensión, llamamos ámbito de una ligadura al ámbito de la definición que, al ejecutarse, creará la ligadura (es decir, el ámbito más interno donde aparece la definición que, al ejecutarse, creará la ligadura en tiempo de ejecución).
Es lo mismo hablar del «ámbito de una definición» que del «ámbito de la ligadura que se creará al ejecutar la definición», ya que son la misma cosa.
Decimos que la definición (y la ligadura correspondiente que se creará al ejecutar esa definición) es local a su ámbito.
Si ese ámbito es el ámbito global, decimos que la definición (y la ligadura que se creará al ejecutar esa definición) es global.
Por ejemplo, en el siguiente script se ejecutan cuatro definiciones.
El ámbito actual de cada una de las instrucciones es el ámbito global, que es el único ámbito que existe en el script:
En consecuencia:
Las cuatro definiciones tienen ámbito global (y son, por tanto, definiciones globales).
Cuando se ejecuten, esas definiciones crearán ligaduras globales.
Como estamos usando un lenguaje de programación que trabaja con ámbitos léxicos, el ámbito de una definición siempre vendrá determinado por una construcción sintáctica del lenguaje.
Por tanto:
Sus límites vienen marcados únicamente por la sintaxis de la construcción que determina el ámbito de esa definición.
El ámbito de la definición se puede determinar simplemente leyendo el código fuente del programa, observando dónde empieza y dónde acaba esa construcción, sin tener que ejecutarlo.
Es decir, que se puede determinar de forma estática.
La visibilidad de una ligadura representa la porción de código donde es visible esa ligadura.
La visibilidad de una ligadura depende del contexto de creación de la ligadura, es decir, del contexto en el que se crea la ligadura.
Ese contexto de creación puede ser:
Un objeto.
Un ámbito.
dependiendo de si el identificador que se va a ligar es un atributo o no, es decir, dependiendo del contexto usado para resolver el identificador que se liga en la ligadura.
Si el identificador ligado es un atributo de un objeto, el contexto de creación será el objeto que contiene al atributo, por lo que la ligadura sólo será visible dentro del objeto.
En tal caso, decimos que la visibilidad de la ligadura (y del correspondiente atributo ligado) es local al objeto que contiene el atributo.
Eso significa que debemos indicar el objeto que contiene a la ligadura para poder acceder a ella, para lo cual también debemos tener acceso al propio objeto que la contiene.
En caso contrario, el contexto de creación será el ámbito de la ligadura, el cual representa una «región» cuyas fronteras limitan la porción del código fuente en la que es visible esa ligadura.
En tal caso, decimos que la visibilidad de la ligadura es local a su ámbito.
Eso significa que no es posible acceder a esa ligadura fuera de su ámbito; sólo es visible dentro de él.
Si el ámbito es el global, decimos que la ligadura tiene visibilidad global.
El tiempo de vida de una ligadura representa el periodo durante el cual existe esa ligadura, es decir, el periodo comprendido desde su creación y almacenamiento en la memoria hasta su posterior destrucción.
En la mayoría de los lenguajes (incluyendo Python y Java), una ligadura empieza a existir justo donde se crea, es decir, en el punto donde se ejecuta la instrucción que define la ligadura.
Por tanto, no es posible acceder a esa ligadura antes de ese punto, ya que no existe hasta entonces.
Por otra parte, el momento en que una ligadura deja de existir depende de su contexto de creación:
Si el contexto de creación es un objeto, es porque la ligadura está ligando un atributo de ese objeto. En tal caso, la ligadura dejará de existir cuando se elimine el objeto de la memoria, o bien, cuando se elimine el atributo ligado.
Si el contexto de creación es un ámbito, la ligadura dejará de existir allí donde termina su ámbito.
En el siguiente ejemplo vemos cómo hay varias definiciones que, al ejecutarse, crearán ligaduras cuyo contexto es un ámbito, no un objeto (ya que no se están creando atributos dentro de ningún objeto):
Todas esas definiciones son globales y, por tanto, las ligaduras que crean al ejecutarse son ligaduras globales o de ámbito global.
Como el contexto de definición es un ámbito, la visibilidad y el tiempo de vida de las ligaduras vendrán determinadas por sus ámbitos.
En consecuencia, la visibilidad de todas esas ligaduras será el ámbito global, ya que son ligaduras globales. Por tanto, decimos que su visibilidad es global.
Por otra parte, como el contexto de creación de esas ligaduras viene determinado por un ámbito, las ligaduras empezarán a existir justo donde se crean, y terminarán de existir al final de su ámbito.
Por ejemplo, la ligadura y
→ 99
empezará a existir en la línea 2 y terminará al final del
script, que es donde termina su ámbito (que, en este ejemplo,
es el ámbito global).
En consecuencia, el tiempo de vida de la ligadura será el periodo comprendido desde su creación (en la línea 2) hasta el final de su ámbito.
Cuando la ligadura se crea sobre un atributo de un objeto de Python, entonces ese objeto representa el contexto de creación de la ligadura.
Recordemos que, por ejemplo, cuando importamos un módulo usando
la sentencia import
, podemos
acceder al objeto que representa ese módulo usando su nombre, lo que nos
permite acceder a sus atributos y crear otros nuevos.
Esos atributos y sus ligaduras correspondientes sólo son visibles
cuando accedemos a ellos usando el operador punto (.
) a
través del objeto que lo contiene, que es el que determina el contexto
en el que esos atributos son visibles.
Por tanto, los atributos no son visibles fuera del objeto:
Igualmente, si creamos un nuevo atributo dentro del objeto, la ligadura entre el atributo y su valor sólo existirá en el propio objeto y, por tanto, sólo será visible cuando accedamos al atributo a través del objeto donde se ha creado.
Por tanto:
El contexto usado a la hora de resolver el nombre de un atributo de un objeto no viene determinado por un ámbito, sino por el objeto que contiene a ese atributo.
Sólo podremos acceder al atributo si usamos el contexto adecuado, que en este caso es el objeto que contiene a ese atributo.
Por tanto, la visibilidad de su ligadura correspondiente no vendrá determinada por un ámbito, sino por el objeto que contiene al atributo (y que, por consiguiente, también contiene a su ligadura).
Las ligaduras se almacenan en espacios de nombres.
Si una instrucción crea una ligadura, se denomina espacio de nombres actual al espacio de nombres donde se almacena dicha ligadura.
El espacio de nombres actual (es decir, el espacio de nombres que el compilador o el intérprete selecciona para almacenar una ligadura), depende del contexto usado para resolver el identificador que se liga en la ligadura.
Tenemos dos posibilidades:
Si el identificador es un atributo de un objeto, entonces el contexto viene determinado por el propio objeto, y no por un ámbito.
En caso contrario, el contexto viene determinado por el ámbito de la ligadura.
Si el contexto viene determinado por un objeto, y no por un ámbito, entonces el espacio de nombres será el propio objeto.
Esto es lo que ocurre cuando se crea una ligadura dentro de un
objeto en Python usando el operador punto (.
), ya que los
objetos son espacios de nombres en este lenguaje.
Por ejemplo, si en Python hacemos:
estamos creando la ligadura x
→ 75
en el
espacio de nombres que representa el módulo math
(que es un
objeto en Python y que, por tanto, es quien almacena la ligadura), no en
el espacio de nombres global ni en ningún otro.
Por tanto, el espacio de nombres ha sido seleccionado a través del
operador punto (.
), no en función del ámbito donde se ha
ejecutado la sentencia math.x = 75
.
Si el contexto viene determinado por el ámbito donde se crea la ligadura, tenemos que:
Si el ámbito donde se crea la ligadura lleva asociado un espacio de nombres, ese espacio de nombres almacenará las ligaduras que se crean dentro de ese ámbito.
Si no, entonces la ligadura se almacenará en el espacio de nombres del ámbito más interno que contenga al actual y que sí lleve asociado un espacio de nombres.
Por tanto, a la hora de almacenar una ligadura, se van mirando todos los ámbitos desde el ámbito actual, pasando por todos los ámbitos que incluyen a éste (en orden, de más interno a más externo), hasta encontrar el primer ámbito que lleve asociado un espacio de nombres.
En todo caso, en Python, cuando el lugar donde se almacena la ligadura depende del ámbito donde se crea la ligadura, el espacio de nombres seleccionado será siempre un marco.
Cuando la ligadura se almacena en el marco global, se dice que tiene almacenamiento global.
Ámbito (léxico):
Porción del código fuente de un programa. Los límites de ese ámbito sólo vienen determinados por la sintaxis del lenguaje, ya que ciertas construcciones sintácticas determinan su propio ámbito.
Ámbito de una definición:
El ámbito actual de la definición; es decir: el ámbito más interno donde aparece la definición.
Ámbito de una ligadura:
El ámbito de la instrucción que crea la ligadura.
Visibilidad de una ligadura:
Determina dónde es visible una ligadura dentro del programa.
Esa visibilidad depende del contexto de creación de la ligadura:
Si es un ámbito, la visibilidad será el ámbito de la ligadura.
Si es un objeto, la visibilidad será el objeto que contiene la ligadura.
Tiempo de vida de una ligadura:
El periodo durante el cual existe esa ligadura, es decir, el periodo comprendido desde su creación y almacenamiento en la memoria hasta su posterior destrucción.
Su tiempo de vida empieza siempre en el momento en que se crea la ligadura, y su final depende del contexto de creación de la ligadura:
Si es un ámbito, el tiempo de vida acabará al final de su ámbito.
Si es un objeto, el tiempo de vida acabará cuando se destruya el objeto que lo contiene (o cuando se elimine el atributo ligado).
Almacenamiento de una ligadura:
Determina el espacio de nombres donde se almacenará la ligadura.
En Python, puede ser un objeto o un marco.
A veces, por economía del lenguaje, se suele hablar del «ámbito de un identificador», en lugar de hablar del «ámbito de la ligadura que liga ese identificador con un valor».
Por ejemplo, en el siguiente script:
tenemos que:
En el ámbito global, hay una definición que liga al identificador
x
con el valor 25
.
Por tanto, se dice que el ámbito de esa ligadura es el ámbito global.
Pero también se suele decir que «el identificador x
es global» o, simplemente, que
«x
es global».
O sea, se asocia al ámbito no la ligadura, sino el identificador en sí.
Pero hay que tener cuidado, ya que ese mismo identificador puede aparecer en ámbitos diferentes y, por tanto, ligarse en ámbitos diferentes.
Así que no tendría sentido hablar del ámbito que tiene ese identificador (ya que podría tener varios) sino, más bien, del ámbito que tiene una aparición concreta de ese identificador.
Por eso, sólo deberíamos hablar del ámbito de un identificador cuando no haya ninguna ambigüedad respecto a qué aparición concreta nos estamos refiriendo.
Por ejemplo, en el siguiente script:
el identificador x
que aparece
en la línea 1 y el identificador x
que aparece en la línea 2 pertenecen
a ámbitos distintos (como veremos en breve) aunque sea el mismo
identificador.
El cuerpo de la expresión lambda determina un ámbito.
Por ejemplo, supongamos la siguiente llamada a una expresión lambda:
Al llamar a la expresión lambda (es decir, al aplicar la expresión lambda a unos argumentos), se empieza a ejecutar su cuerpo y, por tanto, se entra en dicho ámbito.
En ese momento, se crea un nuevo marco en la memoria, que representa esa ejecución concreta de dicha expresión lambda.
Lo que ocurre justo a continuación es que cada parámetro de la expresión lambda se liga a uno de los argumentos en el orden en que aparecen en la llamada a la expresión lambda (primer parámetro con primer argumento, segundo con segundo, etcétera).
En el ejemplo anterior, es como si el intérprete ejecutara las siguientes definiciones dentro del ámbito de la expresión lambda:
Las ligaduras que crean esas definiciones se almacenan en el marco de la expresión lambda.
Ese marco se eliminará de la memoria al salir del ámbito de la expresión lambda, es decir, cuando se termine de ejecutar el cuerpo de la expresión lambda.
Por tanto, las ligaduras se destruyen de la memoria al eliminarse el marco que las almacena.
La próxima vez que se llame a la expresión lambda, se volverán a ligar sus parámetros con los argumentos que haya en esa llamada.
Por ejemplo, supongamos que tenemos esta situación:
En la primera llamada, se entrará en el ámbito determinado por el cuerpo la expresión lambda, se creará el marco que representa a esa llamada, y se ejecutarán las siguientes definiciones dentro del ámbito:
lo que creará las correspondientes ligaduras y las almacenará en el marco de esa llamada.
Despues, evaluará el cuerpo de la expresión lambda y devolverá el resultado, saliendo del cuerpo de la expresión lambda y, por tanto, del ámbito que determina dicho cuerpo, lo que hará que se destruya el marco y, en consecuencia, las ligaduras que contiene.
En la siguiente llamada ocurrirá lo mismo pero, esta vez, las definiciones que se ejecutarán serán las siguientes:
lo que creará otras ligaduras, que serán destruidas luego cuando se destruya el marco que las contiene, al finalizar la ejecución del cuerpo de la expresión lambda.
Es importante hacer notar que en ningún momento se está haciendo un rebinding de los parámetros, ya que cada vez que se llama de nuevo a la expresión lambda, se está creando una ligadura nueva sobre un identificador que no estaba ligado.
En consecuencia, podemos decir que:
El ámbito de la ligadura entre un parámetro y su argumento es el cuerpo de la expresión lambda, así que la visibilidad del parámetro (y de la ligadura) es ese cuerpo.
Esa ligadura se crea justo después de entrar en ese ámbito, así que se puede acceder a ella en cualquier parte del cuerpo de la expresión lambda.
El espacio de nombres que almacena las ligaduras entre parámetros y argumentos es el marco que se crea al llamar a la expresión lambda.
Esto se resume diciendo que «el ámbito de un parámetro es el cuerpo de su expresión lambda».
También se dice que el parámetro tiene un ámbito local y un almacenamiento local al cuerpo de la expresión lambda.
Resumiendo: el parámetro es local a dicha expresión lambda.
Por tanto, sólo podemos acceder al valor de un parámetro dentro del cuerpo de su expresión lambda.
Por ejemplo, en el siguiente código:
el cuerpo de la expresión lambda ligada a suma
determina su propio
ámbito.
Por tanto, en el siguiente código tenemos dos ámbitos: el ámbito global (más externo) y el ámbito del cuerpo de la expresión lambda (más interno y anidado dentro del ámbito global):
Además, cada vez que se llama a suma
, la ejecución del programa entra
en su cuerpo, lo que crea un nuevo marco que almacena las ligaduras
entre sus parámetros y los argumentos usados en esa llamada.
En resumen:
El ámbito de un parámetro es el ámbito de la ligadura que se establece entre éste y su argumento correspondiente, y se corresponde con el cuerpo de la expresión lambda donde aparece.
Por tanto, el parámetro sólo existe dentro del cuerpo de la expresión lambda y no podemos acceder a su valor fuera del mismo; por eso se dice que tiene un ámbito local a la expresión lambda.
Además, la ligadura entre el parámetro y su argumento se almacena en el marco de la llamada a la expresión lambda, y por eso se dice que tiene un almacenamiento local a la expresión lambda.
Los ámbitos léxicos permiten ligaduras locales a ciertas construcciones sintácticas, lo cual nos permite programar definiendo partes suficientemente independientes entre sí (que es la base de la programación modular).
Por ejemplo, nos permite crear funciones sin preocuparnos de si los nombres de los parámetros ya han sido utilizados en otras partes del programa.
Hemos visto que a los parámetros de una expresión lambda se les llama variables ligadas cuando aparecen dentro del cuerpo de dicha expresión lambda.
Por tanto, todo lo que se dijo sobre el ámbito de un parámetro se aplica exactamente igual al ámbito de una variable ligada.
Recordemos que el ámbito de un parámetro es el cuerpo de su expresión lambda, que es la porción de código donde podemos acceder al valor del argumento con el que está ligado.
Por tanto, el ámbito de una variable ligada es el cuerpo de la expresión lambda donde aparece, y es el único lugar dentro del cual podremos acceder al valor de la variable ligada (que también será el valor del argumento con el que está ligada).
Por eso también se dice que la variable ligada tiene un ámbito local al cuerpo de la expresión lambda o que es local a dicha expresión lambda.
En resumen:
El ámbito de una variable ligada es el ámbito de la ligadura que se crea entre ésta y su argumento correspondiente , y se corresponde con el cuerpo de la expresión lambda donde aparece.
Por tanto, la variable ligada sólo existe dentro del cuerpo de la expresión lambda y no podemos acceder a su valor fuera del mismo; por eso se dice que tiene un ámbito local a la expresión lambda.
Además, la ligadura entre la variable ligada y su argumento se almacena en el marco de la llamada a la expresión lambda, y por eso se dice que tiene un almacenamiento local a la expresión lambda.
En el siguiente script:
Hay dos ámbitos: el ámbito global y el ámbito local definido por
el cuerpo de la expresión lambda (la expresión x * x
).
La expresión lambda de la línea 2 tiene un parámetro (x
) que aparece como la variable ligada
x
en el cuerpo de la expresión
lambda.
El ámbito de la variable ligada x
es el cuerpo de la
expresión lambda.
Por tanto, fuera del cuerpo de la expresión lambda, no es posible
acceder al valor de la variable ligada x
, al encontrarnos fuera de su
ámbito (la ligadura sólo es visible dentro del
cuerpo de la expresión lambda).
Por eso, la línea 4 dará un error al intentar acceder al valor
del identificador x
, cuya
ligadura no es visible fuera de la expresión lambda.
Por contraste, las variables, identificadores y ligaduras que no tienen ámbito local se dice que tienen un ámbito no local o, a veces, un ámbito más global.
Si, además, ese ámbito resulta ser el ámbito global, decimos directamente que esa variable, identificador o ligadura es global.
Por ejemplo, las variables libres que aparecen en una expresión lambda no son locales a dicha expresión (ya que no representan parámetros de la expresión) y, por tanto, tienen un ámbito más global que el cuerpo de dicha expresión lambda y se almacenarán en otro espacio de nombres distinto al marco que se crea al llamar a la expresión lambda.
El entorno es una extensión del concepto de marco, usado por los lenguajes interpretados en la resolución de identificadores, ya que:
El entorno nos da acceso a todas las ligaduras (almacenadas en marcos, es decir, no de atributos de objetos) que son visibles en un momento concreto de la ejecución de un programa interpretado.
El intérprete usa el entorno para resolver los identificadores que se encuentran ligados mediante ligaduras cuya visibilidad depende de un ámbito y que estén, por tanto, almacenadas en un marco.
Por tanto, no lo usa para resolver los identificadores asociados a atributos de objetos.
Durante la ejecución del programa, se van creando y destruyendo marcos a medida que se van entrando y saliendo de ciertos ámbitos; en concreto, a medida que se van ejecutando scripts, funciones o métodos.
Asimismo, en esos marcos se van almacenando ligaduras.
Según se van creando en memoria, esos marcos van enlazándose unos con otros creando una secuencia de marcos que se denomina entorno (del inglés, environment).
En un momento dado, el entorno contendrá más o menos marcos dependiendo de por dónde haya pasado la ejecución del programa hasta ese momento.
El entorno, por tanto, es un concepto dinámico que depende del momento en el que se calcule, es decir, de por dónde va la ejecución del programa.
Más concretamente: depende de qué scripts, funciones, métodos y definiciones se han ejecutado hasta ahora.
Por tanto, el entorno depende de qué partes del programa se han ido ejecutando hasta llegar a la instrucción actual.
El entorno siempre contendrá, al menos, un marco: el marco global, que siempre será el último de la secuencia de marcos que forman el entorno.
Asimismo, el primer marco del entorno se denomina el marco actual.
Si el marco global es el único que existe, entonces el marco actual será el marco global.
Gráficamente, representaremos los entornos como una lista enlazada de marcos conectados entre sí formando secuencias, de manera que:
Usaremos la letra E como un indicador que siempre apunta al primer marco de la lista.
Ese primer marco es el marco actual.
El último marco siempre será el marco global.
Si sólo hay un marco en el entorno, ése será necesariamente el marco global, el cual será también al mismo tiempo el marco actual:
Por ejemplo:
Cuando entramos a ejecutar un script, se crea su marco global.
Si dentro de ese script llamamos a una expresión lambda, se creará un marco para esa ejecución concreta de la expresión lambda, por lo que en ese caso habrá dos marcos en la memoria: el global y el de esa llamada a la expresión lambda.
El marco de la expresión lambda será el marco actual, que será el primer marco del entorno y apuntará a su vez al marco global.
El marco de la expresión lambda se eliminará de la memoria cuando termine esa ejecución de la expresión lambda.
A su vez, el marco global sólo se eliminará de la memoria cuando se finalice la ejecución del script.
Hagamos un resumen rápido de todo lo visto hasta ahora.
El entorno contiene todas las ligaduras visibles en un punto concreto de la ejecución del programa interpretado, siempre que sean ligaduras cuya visibilidad dependa de un ámbito y estén, por tanto, almacenadas en un marco (o sea, no es el caso de los atributos de objetos).
Un marco contiene un conjunto de ligaduras (ya que es un espacio de nombres), y un entorno es una secuencia de marcos.
Los marcos se van creando y destruyendo a medida que se van ejecutando y terminando de ejecutar ciertas partes del programa (scripts, funciones o métodos).
Una expresión lambda representa una función.
Cuando se llama a una función, se crea un nuevo marco que contiene las ligaduras que ligan a los parámetros con los valores de esos argumentos.
El cuerpo de una expresión lambda determina su propio ámbito, de forma que las ligaduras que ligan a los parámetros con los argumentos se definen dentro de ese ámbito y son, por tanto, locales a ese ámbito.
Es decir: los parámetros (y las ligaduras entre los parámetros y los argumentos) tienen un ámbito local al cuerpo de la expresión lambda y sólo son visibles dentro de él.
Además, esas ligaduras tienen un almacenamiento local al marco que se crea al llamar a la expresión lambda.
Ese marco y ese ámbito van ligados:
Cuando se empieza a ejecutar el cuerpo de la expresión lambda, se entra en el ámbito y, por tanto, se crea el marco en la memoria.
Cuando se termina de ejecutar el cuerpo de la expresión lambda, se sale del ámbito y, por tanto, se elimina el marco de la memoria.
Todo marco lleva asociado un ámbito (lo contrario no siempre es cierto).
Cuando se crea el nuevo marco, éste se enlaza con el marco que hasta ese momento había sido el marco actual, en cadena.
El último marco de la cadena es siempre el marco global.
Se va formando así una secuencia de marcos que representa el entorno del programa allí donde se está ejecutando la instrucción actual.
A partir de ahora ya no vamos a tener un único marco (el marco global) sino que tendremos, además, al menos uno más cada vez que se llame a una expresión lambda y mientras dure la ejecución de la misma.
El ámbito es un concepto estático: es algo que existe y se reconoce simplemente leyendo el código del programa, sin tener que ejecutarlo.
El marco es un concepto dinámico: es algo que se crea y se destruye a medida que se van ejecutando y terminando de ejecutar ciertas partes del programa: scripts, funciones y métodos.
Un marco se crea cuando se entra en el ámbito de un script, función o método, y se destruye cuando se sale de ese ámbito.
Por ejemplo, en el siguiente código:
el cuerpo de la función suma
define un nuevo ámbito.
Por tanto, en el siguiente código tenemos dos ámbitos: el ámbito global (más externo) y el ámbito del cuerpo de la expresión lambda (más interno y anidado dentro del ámbito global):
Además, cada vez que se llama a suma
, la ejecución del programa entra
en su cuerpo, lo que crea un nuevo marco que almacena las ligaduras
entre sus parámetros y los argumentos usados en esa llamada.
El concepto de entorno refleja el hecho de que los ámbitos se contienen unos a otros (están anidados unos dentro de otros).
Si un marco A apunta a un marco B en el entorno, significa que el ámbito de A está contenido en el ámbito de B.
El primer marco en la cadena del entorno siempre será el último marco que se ha creado y que todavía no se ha destruido.
Ese marco es el marco actual, y se corresponde con el ámbito actual, es decir, con el ámbito más interno de la instrucción actual.
Por otra parte, el último marco del entorno siempre es el marco global.
Por ejemplo, si en cierto momento de la ejecución del programa anterior tenemos el siguiente entorno:
Podemos afirmar que:
El marco de la función suma
apunta al marco global en el
entorno.
El ámbito de la expresión lambda a la que está ligado suma
está contenido en el ámbito
global.
El marco actual es el marco de la expresión lambda.
Por tanto, el programa se encuentra actualmente ejecutando el cuerpo de la expresión lambda.
De hecho, está ejecutando la llamada suma(3, 5)
.
Al evaluar una expresión, el intérprete buscará en el entorno el valor al que está ligado cada identificador que aparezca en la expresión.
Para ello, el intérprete buscará en el primer marco del entorno (el marco actual) una ligadura para ese identificador y, si no la encuentra, irá pasando por toda la secuencia de marcos hasta encontrarla.
Si no aparece en ningún marco, querrá decir que:
o bien el identificador no está ligado (porque aún no se ha creado la ligadura),
o bien su ligadura está fuera del entorno y por tanto no es visible actualmente (al encontrarse en otro ámbito inaccesible desde el ámbito actual).
En cualquiera de estos casos, generará un error de
tipo NameError
(«nombre no definido»).
Por ejemplo:
A medida que vamos ejecutando cada línea del código, tendríamos los siguientes entornos:
Para que una expresión lambda funcione, sus variables libres deben estar ligadas a algún valor en el entorno en el momento de evaluar la aplicación de la expresión lambda sobre unos argumentos.
Por ejemplo:
>>> prueba = lambda x, y: x + y + z # aquí no da error
>>> prueba(4, 3) # aquí sí
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 1, in <lambda>
NameError: name 'z' is not defined
da error porque z
no está
definido (no está ligado a ningún valor en el entorno) en el momento de
llamar a prueba
en la línea
2.
En cambio:
sí funciona (y devuelve 16
) porque, en
el momento de evaluar la aplicación de la expresión lambda (en la línea
3), el identificador z
está
ligado a un valor en el entorno (en este caso, 9
).
Observar que no es necesario que las variables libres estén ligadas en el entorno cuando se crea la expresión lambda, sino cuando se evalúa el cuerpo de la expresión lambda, o sea, cuando se llama a la expresión lambda.
En el siguiente script:
existen cuatro ámbitos:
Recordemos que la resolución de identificadores es el proceso por el cual el compilador o el intérprete determinan qué ligadura se corresponde con una aparición concreta de un determinado identificador.
¿Qué ocurre cuando una expresión lambda contiene como parámetros algunos identificadores que ya están ligados en el entorno, en un espacio de nombres asociado a un ámbito más global?
Por ejemplo:
¿Cómo resuelve el intérprete de Python las distintas
x
que aparecen en el código? ¿Son la misma x
?
¿Se corresponden con la misma ligadura? ¿Están todas esas x
ligadas al mismo valor?
La x
que aparece en la
línea 1 es distinta a las que aparecen en la 2:
La x
de la línea 1 es un
identificador ligado a un valor en el ámbito global (el ámbito de esa
ligadura es el ámbito global). Esa ligadura, se almacena en el marco
global, y por eso decimos que esa x
es global.
Por tanto, la aparición de la x
en la línea 1 representa
a la x
cuya ligadura se encuentra almacenada en el marco
global (es decir, la x
global) y que está ligada al valor
4
.
Las x
de la línea 2
representan el parámetro y la variable ligada (que ya sabemos que son la
misma cosa) de la expresión lambda. Ese parámetro está ligado al
argumento de la llamada, el ámbito de esa ligadura es el cuerpo de la
expresión lambda y esa ligadura se almacena en el marco de la llamada a
la expresión lambda.
En consecuencia, las apariciones de la x
en la línea 2
representan a la x
local a la expresión lambda,
cuya ligadura se encuentra almacenada en el marco de la llamada a la
expresión lambda y que está ligada a 3
.
Por tanto, la x
que aparece en el cuerpo de la
expresión lambda no se refiere al identificador x
que está fuera de la expresión lambda
(y que aquí está ligado al valor 4
), sino al
parámetro x
que, en la llamada de
la línea 2, está ligado al valor 3
(el argumento
de la llamada).
Es decir:
Dentro del cuerpo de la expresión lambda, x
vale 3
.
Fuera del cuerpo de la expresión lambda, x
vale 4
.
Para determinar cuánto vale cada aparición de la x
en ese código (es decir, para resolver la aparición de cada
x
), el intérprete de Python consulta el
entorno.
La x
que está en la línea 1 y las x
que
están en la línea 2 son apariciones distintas que se corresponden con
ligaduras distintas que tienen ámbitos distintos y se almacenan en
espacios de nombres distintos.
Por tanto, la misma x
podrá tener un valor u otro
dependiendo de cuál es el espacio de nombres actual en el momento de
evaluar la x
.
Cuando un mismo identificador está ligado dos veces en dos ámbitos anidados uno dentro del otro, decimos que:
El identificador que aparece en el ámbito más externo está sombreado (y su ligadura está sombreada) por el del ámbito más interno.
El identificador que aparece en el ámbito más interno hace sombra al identificador sombreado (y también se dice que su ligadura hace sombra a la ligadura sombreada) que aparece en el ámbito más externo.
En nuestro ejemplo, podemos decir que el parámetro x
de la expresión lambda hace sombra al
identificador x
que aparece en el
ámbito global.
Eso significa que no podemos acceder a ese identificador x
global desde dentro del cuerpo de la
expresión lambda como si fuera una variable libre, porque la x
dentro del cuerpo siempre se referirá
a la x
local (el parámetro de la
expresión lambda).
Esto ocurre así porque, al buscar un valor para x
, la primera ligadura que se encuentra
el intérprete para el identificador x
al recorrer la secuencia de marcos
del entorno, es precisamente la que está en el marco de la expresión
lambda, que es el marco actual cuando se está ejecutando su
cuerpo.
Si desde dentro de la expresión lambda necesitáramos acceder al
valor de la x
que está fuera de ese expresión lambda, lo
que podríamos hacer es cambiarle el nombre al parámetro
x
. Por ejemplo:
Así, en la expresión lambda tendríamos una variable ligada (el
parámetro w
) y una variable libre
(el identificador x
ligado en el
ámbito global) al que ahora sí podemos acceder al no estar sombreada y
encontrarse dentro del entorno.
Los parámetros se pueden renombrar sin que se altere el significado de la expresión lambda, siempre que ese renombrado se haga de forma adecuada.
A esta operación se la denomina α-conversión.
Un ejemplo de α-conversión es la que hicimos antes.
La α-conversión hay que hacerla correctamente para evitar efectos indeseados. Por ejemplo, en:
si renombramos x
a z
tendríamos:
lo que es claramente incorrecto. A este fenómeno indeseable se le denomina captura de variables.
Pythontutor es una herramienta online muy interesante y práctica que nos permite ejecutar un script paso a paso y visualizar sus efectos.
Muestra la pila de control, los marcos dentro de ésta, las ligaduras dentro de éstos y los datos almacenados en el montículo.
Entrando en http://pythontutor.com/visualize.html se abre un área de texto donde se puede teclear (o copiar y pegar) el código fuente del script a ejecutar.
Pulsando en «Visualize Execution» se pone en marcha, pudiendo ejecutar todo el script de una vez o hacerlo paso a paso.
Conviene elegir las siguientes opciones:
Hide exited frames (default)
Render all objects on the heap (Python/Java)
Draw pointers as arrows (default)
En el script anterior:
indicar:
Ya estudiamos que el acceso a un atributo de un objeto suponía buscar la correspondiente ligadura únicamente en el espacio de nombres asociado a ese objeto, y no en ningún otro.
Por tanto, dicha resolución requiere de un mecanismo algo distinto a lo visto hasta ahora, ya que las ligaduras que ligan el nombre del atributo con su valor se almacenan en el propio objeto, no en un marco.
En consecuencia, el acceso a un atributo de un objeto usando el
operador punto (.
), como en la expresión math.pi
de este ejemplo:
no requiere usar el entorno.
De hecho, el lenguaje ni siquiera tiene por qué tener entornos. Recordemos que los lenguajes compilados no usan entornos para resolver identificadores y pueden resolver perfectamente los atributos de los objetos.
Concretamente, resolver el identificador pi
en la
expresión math.pi
requerirá de
los siguientes pasos:
Se busca el valor de math
en el entorno, que
devuelve el objeto que representa al módulo math
.
Una vez que sabemos que el operando izquierdo del operador punto
(.
) es un objeto, procedemos a resolver el identificador
pi
, pero para ello sólo se considera el espacio de nombres
asociado al objeto math
.
Es decir: buscamos el valor de pi
en el espacio de
nombres de math
, y sólo ahí.
Una vez localizado, se devolverá el valor ligado al nombre
pi
en el espacio de nombres de math
, o se
lanzará un error NameError
en
caso de que no haya ninguna ligadura para pi
en
math
.
Como se puede observar, en ningún momento se usa el entorno para
resolver el identificador pi
dentro de
math
.
A la hora de evaluar una expresión (cualquier expresión) existen varias estrategias diferentes que se pueden adoptar.
Cada lenguaje implementa sus propias estrategias de evaluación que están basadas en las que vamos a ver aquí.
Básicamente se trata de decidir, en cada paso de reducción, qué subexpresión hay que reducir, en función de:
El orden de evaluación:
De fuera adentro o de dentro afuera.
De izquierda a derecha o de derecha a izquierda.
La necesidad o no de evaluar dicha subexpresión.
En un lenguaje de programación funcional puro se cumple la transparencia referencial, según la cual el valor de una expresión depende sólo del valor de sus subexpresiones (también llamadas redexes, del inglés, reducible expression).
Pero eso también implica que no importa el orden en el que se evalúen las subexpresiones: el resultado debe ser siempre el mismo.
Gracias a ello podemos usar nuestro modelo de sustitución como modelo computacional.
Hay dos estrategias básicas de evaluación:
Orden aplicativo: reducir siempre el redex más interno (y más a la izquierda).
Orden normal: reducir siempre el redex más externo (y más a la izquierda).
Python usa el orden aplicativo, salvo excepciones.
El orden aplicativo consiste en evaluar las expresiones de dentro afuera, es decir, empezando por el redex más interno y a la izquierda.
El redex más interno es el que no contiene a otros redexes. Si existe más de uno que cumpla esa condición, se elige el que está más a la izquierda.
Eso implica que los operandos y los argumentos se evalúan antes que los operadores y las aplicaciones de funciones.
Corresponde a lo que en muchos lenguajes de programación se denomina paso de argumentos por valor (call-by-value).
Por ejemplo, si tenemos la siguiente función:
según el orden aplicativo, la expresión cuadrado(3 + 4)
se reduce así:
El orden normal consiste en evaluar las expresiones de fuera adentro, es decir, empezando siempre por el redex más externo y a la izquierda.
El redex más externo es el que no está contenido en otros redexes. Si existe más de uno que cumpla esa condición, se elige el que está más a la izquierda.
Eso implica que los operandos y los argumentos se evalúan después de las aplicaciones de los operadores y las funciones.
Por tanto, los argumentos que se pasan a las funciones lo hacen sin evaluarse previamente.
Corresponde a lo que en muchos lenguajes de programación se denomina paso de argumentos por nombre (call-by-name).
Por ejemplo, si tenemos la siguiente función:
según el orden normal, la expresión cuadrado(3 + 4)
se reduce así:
cuadrado(3 + 4) # definición de cuadrado
= (lambda x: x * x)(3 + 4) # aplicación a (3 + 4)
= ((3 + 4) * (3 + 4)) # evalúa 3 y devuelve 3
= ((3 + 4) * (3 + 4)) # evalúa 4 y devuelve 4
= ((3 + 4) * (3 + 4)) # evalúa (3 + 4) y devuelve 7
= 7 * (3 + 4) # evalúa 3 y devuelve 3
= 7 * (3 + 4) # evalúa 4 y devuelve 4
= 7 * (3 + 4) # evalúa (3 + 4) y devuelve 7
= 7 * 7 # evalúa 7 * 7 y devuelve 49
= 49
Podemos crear una función que use otra función. Por ejemplo, para calcular el área de un círculo usamos otra función que calcule el cuadrado de un número:
La expresión area(11 + 1)
se evaluaría así según el orden aplicativo:
area(11 + 1) # definición de area
= (lambda r: 3.1416 * cuadrado(r))(11 + 1) # evalúa 11 y devuelve 11
= (lambda r: 3.1416 * cuadrado(r))(11 + 1) # evalúa 1 y devuelve 1
= (lambda r: 3.1416 * cuadrado(r))(11 + 1) # evalúa 11 + 1 y devuelve 12
= (lambda r: 3.1416 * cuadrado(r))(12) # aplicación a 12
= (3.1416 * cuadrado(12)) # evalúa 3.1416 y devuelve 3.1416
= (3.1416 * cuadrado(12)) # definición de cuadrado
= (3.1416 * (lambda x: x * x)(12)) # aplicación a 12
= (3.1416 * (12 * 12)) # evalúa (12 * 12) y devuelve 144
= (3.1416 * 144) # evalúa (3.1416 * 11) y...
= 452.3904 # ... devuelve 452.3904
En detalle:
Línea 1: Se evalúa area
, que devuelve su definición (una
expresión lambda).
Líneas 2–4: Lo siguiente a evaluar es la
aplicación de area
sobre su
argumento, por lo que primero evaluamos éste (es el redex más
interno).
Línea 5: Ahora se aplica la expresión lambda a
su argumento 12
.
Línea 6: El redex más interno y a la
izquierda es el 3.1416
, que ya
está evaluado.
Línea 7: El redex más interno que queda
por evaluar es la aplicación de cuadrado
sobre 12
. Primero se
evalúa cuadrado
, sustituyéndose
por su definición…
Línea 8: … y ahora se aplica la expresión lambda
a su argumento 12
.
Lo que queda es todo aritmética.
La expresión area(11 + 1)
se evaluaría así según el orden normal:
area(11 + 1) # definición de area
= (lambda r: 3.1416 * cuadrado(r))(11 + 1) # aplicación a (11 + 1)
= (3.1416 * cuadrado(11 + 1)) # evalúa 3.1416 y devuelve 3.1416
= (3.1416 * cuadrado(11 + 1)) # definición de cuadrado
= (3.1416 * (lambda x: x * x)(11 + 1)) # aplicación a (11 + 1)
= (3.1416 * ((11 + 1) * (11 + 1))) # evalúa (11 + 1) y devuelve 12
= (3.1416 * (12 * (11 + 1))) # evalúa (11 + 1) y devuelve 12
= (3.1416 * (12 * 12)) # evalúa (12 * 12) y devuelve 144
= (3.1416 * 144) # evalúa (3.1416 * 144) y...
= 452.3904 # ... devuelve 452.3904
En ambos casos (orden aplicativo y orden normal) se obtiene el mismo resultado.
En detalle:
Línea 1: Se evalúa el redex más
externo, que es area(11 + 1)
.
Para ello, se reescribe la definición de area
…
Línea 2: … y se aplica la expresión lambda al
argumento 11 + 1
.
Línea 3: El redex más externo es el
*
, pero para evaluarlo hay que evaluar primero todos sus
argumentos, por lo que primero se evalúa el izquierdo, que es 3.1416
.
Línea 4: Ahora hay que evaluar el derecho (cuadrado(11 + 1)
),
por lo que se reescribe la definición de cuadrado
…
Línea 5: … y se aplica la expresión lambda al
argumento 11 + 1
.
Lo que queda es todo aritmética.
A veces no resulta fácil determinar si un redex es más interno o externo que otro, sobre todo cuando se mezclan funciones y operadores en una misma expresión.
En ese caso, puede resultar útil reescribir los operadores como funciones, cuando sea posible.
Por ejemplo, la siguiente expresión:
se puede reescribir como:
lo que muestra claramente que la suma es más externa que el valor absoluto y el máximo (que están, a su vez, al mismo nivel de profundidad).
Un ejemplo más complicado:
se reescribiría como:
donde se aprecia claramente que el orden de las operaciones, de más interna a más externa, sería:
Suma (+
o add
).
Potencia (**
o pow
).
Valor absoluto (abs
) y máximo
(max
) al
mismo nivel.
Producto (*
o mul
).
Existe otra forma de ver la evaluación de una expresión:
Evaluación estricta o impaciente: Reducir todos los redexes aunque no hagan falta para calcular el valor de la expresión.
Evaluación no estricta o perezosa: Reducir sólo los redexes que sean estrictamente necesarios para calcular el valor de la expresión.
Sabemos que la expresión 1 / 0
da un error de división por cero:
Supongamos que tenemos la siguiente definición:
de forma que primero
es una
función que simplemente devuelve el primero de sus argumentos.
Es evidente que la función primero
no necesita evaluar nunca su
segundo argumento, ya que no lo utiliza (simplemente devuelve el primero
de ellos). Por ejemplo, primero(4, 3)
devuelve 4
.
Sabiendo eso… ¿qué valor devolvería la siguiente expresión?
Curiosamente, el resultado dependerá de si la evaluación es estricta o perezosa:
Si es estricta, el intérprete evaluará todos los
argumentos de la expresión lambda aunque no se utilicen luego en su
cuerpo. Por tanto, al evaluar 1 / 0
devolverá un error.
Es lo que ocurre cuando se evalúa siguiendo el orden aplicativo.
En cambio, si es perezosa, el intérprete
evaluará únicamente aquellos argumentos que se usen en el cuerpo de la
expresión lambda, y en este caso sólo se usa el primero, así que dejará
sin evaluar el segundo, no dará error y devolverá directamente 4
.
Es lo que ocurre cuando se evalúa siguiendo el orden normal:
Hay un resultado teórico que avala lo que acabamos de observar:
Teorema de estandarización:
Si una expresión tiene forma normal, el orden normal de evaluación conduce seguro a la misma.
En cambio, el orden aplicativo es posible que no encuentre la forma normal de la expresión.
En Python la evaluación es estricta, salvo algunas excepciones:
El operador ternario:
if
⟨condición⟩
else
⟨valor_si_falso⟩evalúa perezosamente ⟨valor_si_cierto⟩ y ⟨valor_si_falso⟩ dependiendo del valor de la ⟨condición⟩.
Los operadores lógicos and
y or
también son
perezosos (se dice que evalúan en cortocircuito):
True or
\;\underline{x}
siempre es igual a True
, valga lo
que valga \underline{x}.
False and
\;\underline{x}
siempre es igual a False
, valga lo
que valga \underline{x}.
En ambos casos no es necesario evaluar \underline{x}.
En Java también existe un operador ternario (?
:
) y unos operadores lógicos (||
y
&&
) que se evalúan de igual forma que en
Python.
La mayoría de los lenguajes de programación usan evaluación estricta y paso de argumentos por valor (siguen el orden aplicativo).
Haskell, por ejemplo, es un lenguaje funcional puro que usa evaluación perezosa y sigue el orden normal.
La evaluación perezosa en Haskell permite resultados muy interesantes, como la posibilidad de manipular estructuras de datos infinitas.
Si el cuerpo de una expresión lambda no contiene variables libres, el valor que obtendremos al aplicarla a unos argumentos dependerá únicamente del valor que tengan esos argumentos (no dependerá de nada más que sea «exterior» a la expresión lambda).
En cambio, si el cuerpo de una expresión lambda sí contiene variables libres, el valor que obtendremos al aplicarla a unos argumentos no sólo dependerá del valor de esos argumentos, sino también de los valores a los que estén ligadas las variables libres en el momento de evaluar la aplicación de la expresión lambda.
Es el caso del ejemplo anterior, donde tenemos una expresión
lambda que contiene una variable libre (z
) y, por tanto, cuando la aplicamos a
los argumentos 4
y 3
obtenemos un
valor que depende, no sólo de los valores de x
e y
, sino también del valor de z
:
En este otro ejemplo, escribimos una expresión lambda que calcula la suma de tres números a partir de otra expresión lambda que calcula la suma de dos números:
En este caso, hay un identificador (suma
) que no aparece en la lista de
parámetros de la expresión lambda suma3
, por lo que es una variable libre
en el cuerpo de la expresión lambda de suma3
.
En consecuencia, el valor de dicha expresión lambda dependerá de lo
que valga suma
en el entorno
actual.
Se dice que una expresión lambda es pura si, siempre que la apliquemos a unos argumentos, el valor obtenido va a depender únicamente del valor de esos argumentos, es decir, de sus parámetros o variables ligadas.
Podemos decir que hay distintos grados de pureza:
Una expresión lambda que contiene sólo variables ligadas es más pura que otra que también contiene variables libres.
Una expresión lambda cuyas variables libres representan funciones que se usan en el cuerpo de la expresión lambda, es más pura que otra cuyas variables libres representan cualquier otro tipo de valor.
En el ejemplo anterior, tenemos que la expresión lambda de suma3
, sin ser totalmente
pura, a efectos prácticos se la puede considerar
pura, ya que su única variable libre (suma
) se usa como una
función.
Por ejemplo, las siguientes expresiones lambda están ordenadas de mayor a menor pureza, siendo la primera totalmente pura:
# producto es una expresión lambda totalmente pura:
producto = lambda x, y: x * y
# cuadrado es casi pura; a efectos prácticos se la puede
# considerar pura ya que sus variables libres (en este
# caso, sólo una: producto) son funciones:
cuadrado = lambda x: producto(x, x)
# suma es impura, porque su variable libre (z) no es una función:
suma = lambda x, y: x + y + z
La pureza de una función es un rasgo deseado y que hay que tratar de alcanzar siempre que sea posible, ya que facilita el desarrollo y mantenimiento de los programas, además de simplificar el razonamiento sobre los mismos, permitiendo aplicar directamente nuestro modelo de sustitución.
Es más incómodo trabajar con suma
porque hay que recordar
que depende de un valor que está fuera de la expresión lambda,
cosa que no resulta evidente a no ser que mires en el cuerpo de la
expresión lambda.
Recordemos la definición de la función area
:
Aunque es muy sencilla, la función area
ejemplifica la propiedad más
potente de las funciones definidas por el programador: la
abstracción.
La función area
está
definida sobre la función cuadrado
, pero sólo necesita saber de
ella qué resultados de salida devuelve a partir de sus argumentos de
entrada (o sea, qué calcula y no
cómo lo calcula).
Podemos escribir la función area
sin preocuparnos de cómo calcular
el cuadrado de un número, porque eso ya lo hace la función cuadrado
.
Los detalles sobre cómo se calcula el cuadrado
están ocultos dentro de la definición de cuadrado
. Esos detalles se
ignoran en este momento al diseñar area
, para considerarlos más tarde si
hiciera falta.
De hecho, por lo que respecta a area
, cuadrado
no representa una definición
concreta de función, sino más bien la abstracción de una función, lo que
se denomina una abstracción funcional, ya que a area
le sirve igual de bien cualquier
función que calcule el cuadrado de un número.
Por tanto, si consideramos únicamente los valores que devuelven,
las tres funciones siguientes son indistinguibles e igual de válidas
para area
. Ambas reciben un
argumento numérico y devuelven el cuadrado de ese número:
En otras palabras: la definición de una función debe ser capaz de ocultar sus detalles internos de funcionamiento, ya que para usar la función no debe ser necesario conocer esos detalles.
«Abstraer» es centrarse en lo importante en un determinado momento e ignorar lo que en ese momento no resulta importante.
«Crear una abstracción» es meter un mecanismo más o menos complejo dentro de una caja negra y darle un nombre, de forma que podamos referirnos a todo el conjunto simplemente usando su nombre y sin tener que conocer su composición interna ni sus detalles internos de funcionamiento.
Por tanto, para usar la abstracción nos bastará con conocer su nombre y lo que hace, sin necesidad de saber cómo lo hace ni de qué elementos está formada internamente.
La abstracción es el principal instrumento de control de la complejidad, ya que nos permite ocultar detrás de un nombre los detalles que componen una parte del programa, haciendo que esa parte actúe (a ojos del programador que la utilice) como si fuera un elemento predefinido del lenguaje.
Las funciones son, por tanto, abstracciones porque nos permiten usarlas sin tener que conocer los detalles internos del procesamiento que realizan.
Por ejemplo, si queremos usar la función cubo
(que calcula el cubo de un
número), nos da igual que dicha función esté implementada de cualquiera
de las siguientes maneras:
Para usar la función, nos basta con saber que calcula el cubo de un número, sin necesidad de saber qué cálculo concreto realiza para obtener el resultado.
Los detalles de implementación quedan ocultos y por eso también
decimos que cubo
es una
abstracción.
Las funciones también son abstracciones porque describen operaciones compuestas a realizar sobre ciertos valores sin importar cuáles sean esos valores en concreto (son generalizaciones de casos particulares).
Por ejemplo, cuando definimos:
no estamos hablando del cubo de un número en particular, sino más bien de un método para calcular el cubo de cualquier número.
Por supuesto, nos las podemos arreglar sin definir el concepto de
cubo, escribiendo siempre expresiones explícitas (como 3*3*3
,
y*y*y
,
etc.) sin usar la palabra «cubo», pero eso nos obligaría siempre a
expresarnos usando las operaciones primitivas de nuestro lenguaje (como
*
), en vez de poder usar términos de más alto nivel.
Es decir: nuestros programas podrían calcular el cubo de un número, pero no tendrían la habilidad de expresar el concepto de elevar al cubo.
Una de las habilidades que deberíamos pedir a un lenguaje potente es la posibilidad de construir abstracciones asignando nombres a los patrones más comunes, y luego trabajar directamente usando dichas abstracciones.
Las funciones nos permiten esta habilidad, y esa es la razón de que todos los lenguajes (salvo los más primitivos) incluyan mecanismos para definir funciones.
Por ejemplo: en el caso anterior, vemos que hay un patrón (multiplicar algo por sí mismo tres veces) que se repite con frecuencia, y a partir de él construimos una abstracción que asigna un nombre a ese patrón (elevar al cubo).
Esa abstracción la definimos como una función que describe la regla necesaria para elevar algo al cubo.
Por tanto, algunas veces, analizando ciertos casos particulares, observamos que se repite el mismo patrón en todos ellos, y de ahí extraemos un caso general que agrupa a todos los posibles casos particulares que cumplen el mismo patrón.
A ese caso general le damos un nombre y ocultamos sus detalles internos en una «caja negra».
Eso es una abstracción.
En resumen, creamos abstracciones:
Cuando creamos casos generales a partir de patrones que se repiten en varios casos particulares.
Cuando queremos reducir la complejidad, dándole un nombre a un mecanismo complejo para poder referirnos a todo el conjunto a través de su nombre sin tener que recordar continuamente qué piezas contiene el mecanismo o cómo funciona éste por dentro.
Cuando queremos que nuestro programa pueda expresar un concepto abstracto, como el de «elevar al cubo».
Por ejemplo, cuando vemos que en nuestros programas es frecuente tener que multiplicar una cosa por sí misma tres veces, deducimos que ahí hay un patrón común que se repite en todos los casos.
De ahí, creamos la abstracción que describe ese patrón general y le llamamos «elevar al cubo»:
La especificación de una función es la descripción de qué hace la función sin entrar a detallar cómo lo hace.
La implementación de una función es la descripción de cómo hace lo que hace, es decir, los detalles de su algoritmo interno.
Para poder usar una función, un programador no debe necesitar saber cómo está implementada.
Eso es lo que ocurre, por ejemplo, con las funciones predefinidas
del lenguaje (como max
, abs
o len
): sabemos
qué hacen pero no necesitamos saber cómo lo
hacen.
Incluso puede que el usuario de una función no sea el mismo que la ha escrito, sino que la puede haber recibido de otro programador como una «caja negra», que tiene unas entradas y una salida pero no se sabe cómo funciona por dentro.
Para poder usar una abstracción funcional nos basta con conocer su especificación, porque es la descripción de qué hace esa función.
Igualmente, para poder implementar una abstracción funcional necesitamos conocer su especificación, ya que necesitamos saber qué tiene que hacer la función antes de diseñar cómo va a hacerlo.
La especificación de una abstracción funcional describe tres características fundamentales de dicha función:
El dominio: el conjunto de datos de entrada válidos.
El rango o codominio: el conjunto de posibles valores que devuelve.
El propósito: qué hace la función, es decir, la relación entre su entrada y su salida.
Hasta ahora, al especificar programas, hemos llamado «entrada» al dominio, y hemos agrupado el rango y el propósito en una sola propiedad que llamamos «salida».
Por ejemplo, cualquier función cuadrado
que usemos para implementar
area
debe satisfacer esta
especificación:
\begin{cases} \text{\textbf{Entrada}}: n \in \mathbb{R} \\ \texttt{cuadrado} \\ \text{\textbf{Salida}}: n^2 \end{cases}
La especificación no concreta cómo se debe llevar a cabo el propósito. Esos son detalles de implementación que se abstraen a este nivel.
Este esquema es el que hemos usado hasta ahora para especificar programas, y se podría seguir usando para especificar funciones, ya que éstas son consideradas subprogramas (programas que forman parte de otros programas más grandes).
Pero para especificar funciones resulta más adecuado usar el siguiente esquema, al que llamaremos especificación funcional:
\left\{\begin{array}{ll} \text{\textbf{Pre}}: & \texttt{True} \\[0.5em] & \texttt{cuadrado(\(n\):\,float)\;->\;float} \\[0.5em] \text{\textbf{Post}}: & \texttt{cuadrado(\(n\))} = n^2 \end{array}\right.
«Pre» representa la precondición: la propiedad que debe cumplirse justo en el momento de llamar a la función.
«Post» representa la postcondición: la propiedad que debe cumplirse justo después de que la función haya terminado de ejecutarse.
Lo que hay en medio es la signatura: el nombre de la función, el nombre y tipo de sus parámetros y el tipo del valor de retorno.
La especificación se lee así: «Si se llama a la función respetando su signatura y cumpliendo su precondición, la llamada termina cumpliendo su postcondición».
En este caso, la precondición es True
, que
equivale a decir que cualquier condición de entrada es buena para usar
la función.
Dicho de otra forma: no hace falta que se dé ninguna condición
especial para usar la función. Siempre que la llamada respete la
signatura de la función, el parámetro n
puede tomar cualquier valor de tipo float
y no hay
ninguna restricción adicional.
Por otro lado, la postcondición dice que al
llamar a la función cuadrado
con
el argumento n se debe devolver n^2.
Tanto la precondición como la postcondición son predicados, es decir, expresiones lógicas que se escriben usando el lenguaje de las matemáticas y la lógica.
La signatura se escribe usando la sintaxis del lenguaje de programación que se vaya a usar para implementar la función (Python, en este caso).
Recordemos la diferencia entre:
Dominio y conjunto origen de una función.
Rango (o codominio) y conjunto imagen de una función.
¿Cómo recoge la especificación esas cuatro características de la función?
La signatura expresa el conjunto origen y el conjunto imagen de la función.
El dominio viene determinado por los valores del conjunto origen que cumplen la precondición.
El codominio viene determinado por los valores del conjunto imagen que cumplen la postcondición.
En el caso de la función cuadrado
tenemos que:
El conjunto origen es float
, ya que
su parámetro n está declarado de tipo
float
en
la signatura de la función.
Por tanto, los datos de entrada a la función deberán pertenecer al
tipo float
.
El dominio coincide con el conjunto origen, ya que su
precondición es True
. Eso
quiere decir que cualquier dato de entrada es válido siempre que
pertenezca al dominio (en este caso, el tipo float
).
El conjunto imagen también es float
, ya que
así está declarado el tipo de retorno de la función.
Las pre y postcondiciones no es necesario escribirlas de una manera formal y rigurosa, usando el lenguaje de las Matemáticas o la Lógica.
Si la especificación se escribe en lenguaje natural y se entiende bien, completamente y sin ambigüedades, no hay problema.
El motivo de usar un lenguaje formal es que, normalmente, resulta mucho más conciso y preciso que el lenguaje natural.
El lenguaje natural suele ser:
Más prolijo: necesita más palabras para decir lo mismo que diríamos matemáticamente usando menos caracteres.
Más ambiguo: lo que se dice en lenguaje natural se puede interpretar de distintas formas.
Menos completo: quedan flecos y situaciones especiales que no se tienen en cuenta.
En este otro ejemplo, más completo, se especifica una función
llamada cuenta
:
\left\{\begin{array}{ll} \text{\textbf{Pre}}: & car \mathrel{\char`≠} \text{\texttt{""}} \land \texttt{len(}car\texttt{)} = 1 \\[0.5em] & \texttt{cuenta(\(cadena\):\,str,\;\(car\):\,str)\;->\;int} \\[0.5em] \text{\textbf{Post}}: & \texttt{cuenta(\(cadena\),\;\(car\))} \geq 0\ \land\\[0.1em] & \texttt{cuenta(\(cadena\),\;\(car\))} = cadena\texttt{.count(\(car\))} \end{array}\right.
Con esta especificación, estamos diciendo que cuenta
es una función que recibe una
cadena y un carácter (otra cadena con un único carácter
dentro).
Ahora bien: esa cadena y ese carácter no pueden ser cualesquiera, sino que tienen que cumplir la precondición.
Eso significa, entre otras cosas, que aquí el dominio y el conjunto origen de la función no coinciden (no todos los valores pertenecientes al conjunto origen sirven como datos de entrada válidos para la función).
En esta especificación, count
se usa como un método
auxiliar.
Las operaciones auxiliares se puede usar en una especificación siempre que estén perfectamente especificadas, aunque no estén implementadas.
En este caso, se usa en la postcondición para decir que
la función cuenta
, la que se está
especificando, debe devolver el mismo resultado que devuelve el método
count
(el cual ya conocemos
perfectamente y sabemos qué hace, puesto que es un método que ya existe
en Python).
Es decir: la especificación anterior describe con total precisión
que la función cuenta
cuenta el número de veces que el carácter \underline{\textbf{\textit{car}}} aparece en
la cadena \underline{\textbf{\textit{cadena}}}.
En realidad, las condiciones de la especificación anterior se podrían simplificar aprovechando las propiedades de las expresiones lógicas, quedando así:
\left\{\begin{array}{ll} \text{\textbf{Pre}}: & \texttt{len(\(car\))} = 1 \\[0.5em] & \texttt{cuenta(\(cadena\):\,str,\;\(car\):\,str)\;->\;int} \\[0.5em] \text{\textbf{Post}}: & \texttt{cuenta(\(cadena\),\;\(car\))} = cadena\texttt{.count(\(car\))} \end{array}\right.
Finalmente, podríamos escribir la misma especificación en lenguaje natural:
\left\{\begin{array}{ll} \text{\textbf{Pre}}: & car \text{ debe ser un único carácter} \\[0.5em] & \texttt{cuenta(\(cadena\):\,str,\;\(car\):\,str)\;->\;int} \\[0.5em] \text{\textbf{Post}}: & \texttt{cuenta(\(cadena\),\;\(car\))} \text{ devuelve el número de veces}\\[0.1em] & \text{que aparece el carácter } car \text{ en la cadena } cadena.\\[0.1em] & \text{Si } cadena \text{ es vacía o } car \text{ no aparece nunca en la}\\[0.1em] & \text{cadena } cadena \text{, debe devolver } 0. \end{array}\right.
Probablemente resulta más fácil de leer (sobre todo para los novatos), pero también es más largo y prolijo.
Es como un contrato escrito por un abogado en lenguaje jurídico.