La relación entre una Lambda de Python y los Simpsons
Published: 2021-06-23 15:00:39

Pues, aparentemente, has leído bien. Hace aproximadamente un mes empecé un Bootcamp de Big Data y Machine Learning en [Core Scool](https://www.corecode.school/bootcamp/big-data-machine-learning) 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](https://en.wikipedia.org/wiki/Currying):

```python
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 ({% katex inline %}2^4{% endkatex %}) 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2ezahlpg9ckariyakk3b.jpg)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hwjx7hqxutlqewjbt0io.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/oznc6kitfmkmoirwc50r.png)

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:

```python
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:

```python
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:

```python
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mriwxb3gylylv4wpx8pf.png)

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:

```bash
>>> 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:

```python
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:

```python
>>> 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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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 {% katex inline %}16^4{% endkatex %}, 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:

```python
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:

```python
>>> 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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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`:

```python
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í!!

```python
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.

```python
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:

```python
[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:

```python
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:

```python
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](https://www.corecode.school/bootcamp/big-data-machine-learning).

Nos vemos en el próximo post!