La relación entre una Lambda de Python y los Simpsons

Pues, aparentemente, has leído bien. Hace aproximadamente un mes empecé un Bootcamp de Big Data y Machine Learning en Core Scool que está siendo una pasada (casi no se ha notado la publicidad). El caso es que en una de las clases se nos presentó el siguiente código cuando hablábamos de currying:

potencias = {}

for number in [2, 4, 16]:
    potencias[number] = lambda x: number**x

print(potencias[2](4))

Todos en clase pensamos (ilusos de nosotros...) que el resultado de ese print iba a ser 16 (\(2^4\)) pero cuál fue nuestra sorpresa (y la tuya si pruebas ese código) al ver que el resultado era nada más y nada menos que 65536. Pero... ¿por qué?

Para resolver esa cuestión tengo que hablar del scope y, para eso, voy a usar a Los Simpons. Vamos al lío.

A por las metáforas

Podría hacerte una definición técnica de lo que es el scope, cómo funciona y cómo afecta, pero la verdad es que ese no es mi estilo en absoluto, así voy con una metáfora.

Como definición súper general, simplificada y (si la analizas con detalle) parcialmente incorrecta, puedo decir que el scope es una forma que tiene python (y prácticamente cualquier lenguaje) de compartimentar los accesos a las variables.

Dicho esto, imagina que el código es una casa. Concretamente, la casa de los Simpsons.

Plano simpsons

En esa casa hay distintas habitaciones e incluso distintas plantas. Imagina también (por simplificar) que todas las estancias son independientes entre sí y que, lo único común a todas ellas son los pasillos y las escaleras.

Y, por último, imagina que si tú estás en una habitación y necesitas algo, solo puedes ir a buscarlo saliendo de habitaciones.

Con esto vas a entender el scope perfectamente. Trasladando esto a código, podríamos decir que cada habitación de la casa es un scope distinto. Por simplicidad, vamos a quedarnos solo con la planta de arriba. Si pinto los scopes que habría en la casa de Los Simpsons, quedaría algo como:

Plano de la casa con scopes superpuestos

Si te fijas, cada habitación tiene su propio color, porque es un scope distinto. Incluso las habitaciones que están dentro de otras (el baño que está dentro de la habitación de Homer y Marge), ¡tienen su propio color!

Una cosa más es que todo el pasillo y las escaleras tienen el mismo scope, el rojo intenso.

Si yo te digo que como tienes una cama en la habitación de Bart y otra en la de Lisa, tienes 2 camas juntas, me llamarías loco, ¿verdad? Porque no están juntas, están en habitaciones (scopes) diferentes. Pues con las variables es lo mismo.

Vamos a detallar esto un poco más, colocando objetos en algunas habitaciones:

Plano de la casa con objetos superpuestos

Puedes ver que he colocado una bici en el cuarto de Homer y Marge, un jarrón (ánfora) en el de Bart y un tambor en el pasillo.

Ahora imagina que, como dijimos antes, no puedes ir a buscar nada entrando a otra habitación, solo saliendo. Si estuvieses en el cuarto de Lisa y necesitases un jarrón, ¿podrías ir al cuarto de Bart a por él? Veamos...

  1. Sales de la habitación de Lisa al pasillo ✔
  2. Entras al cuarto de Bart ❌

Has tenido que entrar en una habitación, así que no puedes ir. Por lo tanto podemos decir que, estando en el cuarto de Lisa, no tienes ningún jarrón.

Vamos a intentar lo mismo, desde le baño de la habitación de Homer y Marge, pero con la bici.

  1. Sales del baño a la habitación de Homer y Marge ✔

Ya tienes la bici!! Y, una vez que la tienes, puedes usarla todo lo que necesites.

Una última prueba! Vamos a intentar ir a por el tambor desde el baño de Homer y Marge:

  1. Sales del baño a la habitación de Homer y Marge ✔
  2. Sales de la habitación al pasillo ✔

Pues también puedes! Entonces, como regla general puedo decir que, desde cualquier habitación, puedes coger lo que esté en el pasillo.

Sin embargo no al contrario. Desde el pasillo, no puedes coger nada de ninguna habitación (porque no puedes entrar).

¿Qué tiene todo esto que ver con Python?

Lo bonito de todo esto es que es aplicable a Python y a (prácticamente) cualquier lenguaje moderno.

Si el lenguaje solo tuviese un único scope, desde cualquier parte del código podrías modificar cualquier variable y sería un poco caótico y difícil de depurar.

En Python hay 4 scopes distintos y hay un acrónimo para recordarlos: LEGB. Son las siglas (en inglés) de:

  • Local
  • Envoltura (Enclosing)
  • Global
  • Incorporado (Built-in)

Cómo se crea un scope en python

Python crea scopes cuando se define:

  • Una función lambda: Cuando haces lambda x: print(x) esa lambda está definiendo un scope local en el que existe la variable x.
  • Una función: Cuando creas una función con la palabra def, estás creando un scope. En el siguiente código:
def my_function(param_1, param_2):
  add = param_1 + param_2

  return add

Estás creando una función que define un scope en el que se definen las variables param_1, param_2 y add.

  • Clases: Al definir una clase con la palabra reservada class, también se crea un scope. En este caso es un poco especial porque desde fuera sí puedes acceder al scope de la clase (más o menos). Así que vamos a dejar este caso aparte.

En cualquiera de estos 3 casos, estarás creando un scope local y, por tanto, un código como este:

def local_scope(a):
  print(a)

local_scope("Hola") # >> "Hola"

Funciona perfectamente y estaría creando un scope local en local_scope que contendría la variable a.

IMPORTANTE: Un scope se crea EN LA LLAMADA a la función y NO en su definición. Por eso, si llamas 100 veces a la función local_scope, tendrás 100 scopes diferentes. Esto es así incluso si haces llamadas recursivas.

Otra cosa importante es que, cuando tú defines una variable en la cabecera de una función, esa variable ya existirá en el scope de esa función. O lo que es lo mismo, ya está definida y puedes usarla en el scope local de esa función todo lo que necesites.

Scope local

Es el que acabas de ver. Equivalente a una habitación. Defines una función (lambda o normal), y ahí tienes tu scope. Todo lo que definas en la cabecera o en el cuerpo de la función formará parte de tu scope local.

Envoltura

Esto no es más que un scope dentro de un scope. Lo mismo que pasaba con el baño en el cuarto de Homer y Marge. Para hacer esto, lo único que tienes que hacer es crear un scope dentro de otro. Por ejemplo, anidando funciones:

def enclosing(param_out):
  other_var = "HI"
  def enclosed(param_in):
    print(other_var)
    print(param_out)
    print(param_in)
  
  return enclosed

Vamos a ver los scopes de este código de una manera un poco más gráfica:

Código con scopes superpuestos

La función enclosing estaría definiendo el scope naranja y la enclosed un scope dentro del anterior.

Por lo tanto, si ejecutamos ese código:

>>> function_in = enclosing("Out") 
>>> function_in("In") 
HI
Out
In

Pero sin embargo, si intentamos acceder directamente a la función interna:

>>> enclosed("In")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'enclosed' is not defined

Se queja porque no podemos entrar en habitaciones, solo salir de ellas!

Global

El scope global sería el pasillo. Es un scope que está disponible para todos, es el mas general y también se le llama scope de módulo (module scope). Es el scope general que tienes en tu archivo de Python. Un ejemplo:

super_global = "Estoy fuera de todo"

def my_function():
  print(super_global)

def my_function_enclosing():
  def my_function_enclosed():
    print(super_global)

  return my_function_enclosed()
>>> print(super_global)
Estoy fuera de todo
>>> my_function()
Estoy fuera de todo
>>> my_function_enclosing()
Estoy fuera de todo

Todas tienen acceso a la variable super_global porque sólo tienen que salir de habitaciones para ir a buscarla.

Incorporado (Built-in)

Este es un scope especial, es un scope en el que hay cosas que vienen por defecto con python y que no es necesario declarar, importar ni nada. Un ejemplo de lo que hay en este scope son las funciones len, sorted, max...

Por eso podemos hacer cosas como:

>>> lst = [1, 2, 3, 4, 5]
>>> len(lst) 
5

Sin necesidad de definir ni importar len en ninguna parte.

¿Cómo podemos añadir elementos a un scope?

Hay varias maneras de hacer esto. La primera y la más evidente es declarando una nueva variable. Al hacer my_var = "Hola" estás añadiendo una nueva variable my_var a tu scope.

Otra manera es usar un import. Cuando tú haces import math estás trayendo todo lo que haya en el scope global del módulo math a tu scope. Por eso puedes usar funciones como sqrt en tu scope solo si importas primero math.

Por último, me gustaría remarcar que la única forma de declarar una nueva variable en un scope no es con la forma var = "algo". Cuando haces:

def func(param):
  print(param)

Estás añadiendo la variable param al scope de la función func aunque no estés asignándola a nada en ese momento. En el momento de la llamada se creará un scope para esa función y esa variable se creará automáticamente en el scope con el valor que haya recibido el parámetro.

Un último apunte es que al hacer:

for number in range(1, 20):
  print(number)

También estás añadiendo la variable number a scope global (si el for lo tienes dentro de una función, lo añadirías a esa función).

¿Y qué leches es eso del shadowing?

El shadowing no es más que "eclipsar" una variable de un scope externo con una variable de un scope interno. Vamos a verlo con un ejemplo:

var = "Soy globalísima"

def modify():
  var = "Soy localísima"
  print(var)

Párate un minuto y piensa qué debería imprimir ese código...

Te pongo la respuesta:

>>> print(var)
Soy globalísima
>>> modify()
Soy localísima
>>> print(var)
Soy globalísima

Un momento, un momento... ¿no debería aparecer "Soy localísima" dos veces? Porque estoy modificando la variable del scope global en la función, ¿cierto?

Lo cierto es que no. Lo que estoy haciendo es definir una variable nueva en el scope local que eclipsa (hace shadowing) a la del scope global.

De la misma manera, con scopes de envoltura, si hago esto:

def outter():
  var = "Estoy fuerísima"
  def inner():
    var = "Estoy dentro del tó"
    print(var)
  
  print(var)
  inner()
  print(var)

Y ejecuto esa función, habrá un comportamiento similar al anterior:

>>> outter()
Estoy fuerísima
Estoy dentro del tó
Estoy fuerísima

Al re-asignar la variable var en la función inner, lo que estoy haciendo es declarar una variable nueva que hace shadowing a la externa.

¿Pero todo esto no iba de una lambda?

Pues sí!! Y ahora puedes entender perfectamente por qué pasaba aquello en nuestro código. Vamos a volver al código inicial:

potencias = {}

for number in [2, 4, 16]:
    potencias[number] = lambda x: x**number

print(potencias[2](4))

Según nuestra intuición eso debería dar 16, pero da 65535. Vamos a analizar el código con los conocimientos que tenemos ahora.

Sabes que tenemos un scope global que envuelve a todo el código.

Además sabes que una lambda, como buena función, crea otro scope.

Pero sabes una cosa más... Que los scopes de una función se crean en el momento de su llamada y no en su definición.

Eso significa que, cuando creas la primera lambda, el number que tiene como exponente será 2, en la segunda lambda será 4 y en la tercera 16.

Pero una vez que termina el bucle, number permanece definido como 16, pues es el último valor de la lista. Entonces cuando llamas a potencias[2] se llama la primera lambda, en ese momento se crea un scope, y se busca number en el scope superior (el global) y, como es 16, al hacer potencias[2](4) estamos haciendo \(16^4\), que es nada más y nada menos que nuestro resultado, 65535.

¿Cómo solucionarlo?

Sabes que necesitas que number, el exponente, quede definida en el scope de la lambda en el momento de la creación, en vez de acceder a la del scope global.

Para eso sanes que si defines esa variable como argumento de la lambda y la igualamos a la superior, esa variable quedará definida y fijada con ese valor al crearla, incluso si la llamamos igual (por el shadowing).

Si cambias el código a:

potencias = {}

for number in [2, 4, 16]:
    potencias[number] = lambda x, number=number: x**number

print(potencias[2](4))

Estás definiendo una variable number dentro de ese scope, que tomará por defecto el valor que tenga la variable del bucle homónima en ese momento. Por eso cuando llames a potencias[2](4), se creará un scope para esa función, pero con la variable number prefijada al valor que tenía en el momento de la creación de la lambda, pues ese valor por defecto es fijo.

Ahora sí, el resultado de esa ejecución es:

>>> print(potencias[2](4))
16

Cómo modificar el scope

Hay un par de opciones para modificar el scope de Python y, aunque útiles, debemos tratarlas con mucho cuidado.

Imagina que tienes, como en el ejemplo del shadowing, este código:

var = "Soy globalísima"

def modify():
  var = "Soy localísima"
  print(var)

Y tú necesitas irremediablemente modificar la variable var dentro de la función. Hay una forma de modificar el scope de esa función para que esa variable se "enlace" bidireccionalmente con la del scope global.

Esto significa que tanto si la lees como si la modificas estarás modificando la del global. Para esto se usa la palabra global seguida del nombre de la variable que queremos enlazar con una global. Si bien vimos que el código anterior devolvía:

>>> print(var)
Soy globalísima
>>> modify()
Soy localísima
>>> print(var)
Soy globalísima

Al cambiar el código a:

var = "Soy globalísima"

def modify():
  global var
  var = "Soy localísima"
  print(var)

El resultado de ejecutarlo nuevamente será:

>>> print(var)
Soy globalísima
>>> modify()
Soy localísima
>>> print(var)
Soy localísima

Hemos modificado la variable global satisfactoriamente!

Ahora imagina, por otro lado, el siguiente código que vimos también en el shadowing:

def outter():
  var = "Estoy fuerísima"
  def inner():
    var = "Estoy dentro del tó"
    print(var)
  
  print(var)
  inner()
  print(var)

Y, de nuevo, necesitas irremediablemente modificar la variable var dentro de la función inner. En este caso global no nos sirve, pues estaríamos enlazando var a una variable del scope global llamada var, que no existe. Si intentamos hacer esto:

def outter():
  var = "Estoy fuerísima"
  def inner():
    global var
    var = "Estoy dentro del tó"
    print(var)
  
  print(var)
  inner()
  print(var)

El resultado es:

>>> outter()
Estoy fuerísima
Estoy dentro del tó
Estoy fuerísima

Que es lo mismo que antes, solo que ahora en el scope global, tendremos una variable var con el valor "Estoy dentro del tó", que no es lo que queremos.

En este caso python nos ofrece otra palabra que se usa exactamente igual que la anterior y es nonlocal. Esto lo que hace es buscar la variable etiquetada como nonlocal en el scope superior (y en el superior de ese, y así sucesivamente) y enlazarla de manera bidireccional igual que se hacía con global. Por tanto, si cambiamos el código a:

def outter():
  var = "Estoy fuerísima"
  def inner():
    nonlocal var
    var = "Estoy dentro del tó"
    print(var)
  
  print(var)
  inner()
  print(var)

Estaremos enlazando la variable var de inner, con la variable var de outter y el resultado será:

>>> outter()
Estoy fuerísima
Estoy dentro del tó
Estoy dentro del tó

Ahora sí, una modificación de la variable exterior desde la función interior.

Importante: nonlocal busca en los scopes superiores de manera sucesiva hasta llegar al global. Si cuando llega al global no ha encontrado esa variable, dará un error. nonlocal no permite enlazar una variable interna con una global, para eso tenemos la palabra global. Un ejemplo:

var = "Hola"
def func():
  def inner():
    nonlocal var
    var = "Adios"
  return inner()
  File "<stdin>", line 3
SyntaxError: no binding for nonlocal 'var' found

Dice que no hay ninguna variable var a la que enlazar. Sin embargo al cambiar por global:

var = "Hola"
def func():
  def inner():
    global var
    var = "Adios"
  return inner()

El código funciona sin problemas.

Algunas curiosidades de Python

El scope en los bucles

Ni un bucle for ni un bucle while definen un nuevo scope. Por tanto, cualquier variable que definas dentro de ellos seguirá viva fuera del bucle. Incluso la variable que definas en el bucle en sí!!

for number in range(5):
  print(number)

print(number)

El resultado de esa ejecución será:

0
1
2
3
4
>>> print(number)
4

La variable number sigue existiendo incluso después del bucle.

El scope en los bloques if

Pasa exactamente igual que en los bucles, toda variable que definas dentro de un bloque if, estará disponible después.

if True:
  var = "Hola"

print(var)

if True:
  print(var)

El resultado será:

Hola
Hola

Algo muy similar a lo que pasaba en el anterior caso.

El scope de los list/dict comprehension

Sin embargo, en los list comprehension el scope se comporta ligeramente distinto. Al ejecutar el código:

[number for number in range(5)]

Si luego intentamos acceder a number:

>>> [number for number in range(5)]
[0, 1, 2, 3, 4]
>>> number
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'number' is not defined

Porque en un list/dict comprehension, las variables no se crean en el scope en el que estén, sino que solo existen durante el comprehesion concreto.

Scope en un try/except

Cuando tenemos un bloque try/except podemos pensar que se van a comportar igual que un if, pero hay una particularidad. Imagina el siguiente código:

try:
  a = 1
except Exception as e:
  print("Error")

Si intentamos acceder a a:

>>> a
1

La tenemos disponible en el scope sin problema. Pero ahora mira este código:

try:
  raise ValueError()
except Exception as e:
  print(e)

Si ahora intentamos acceder a e:

>>> e
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'e' is not defined

La variable e, aunque se define en el except y se entra por ahí, no se crea en el scope.

Conclusiones

Pues básicamente has entendido cómo funcionan los scopes en Python, cómo puedes usarlos a tu favor y qué cosas debes tener en cuenta.

Además, has aprendido qué estructuras crean scopes en Python, cómo traer variables nuevas a tu scope e incluso cómo modificar el comportamiento de los scopes en Python con global y nonlocal.

Para cualquier duda que tengas te leo en los comentarios y, para aprender cosas como estas y mucho más molonas, no dejes de mirar los cursos de Core Scool.

Nos vemos en el próximo post!