Funciones en Python: ámbito, defaults y closures
Funciones en Python con salidas reales: *args, **kwargs, / y *, el argumento por defecto mutable, UnboundLocalError, lambdas que dan [2, 2, 2] y match.
Serie: curso de Python 2/6 (principiante). Para quien ya sabe escribir un def pero se topó con una función que "recuerda" datos viejos o con una variable que de repente está "referenced before assignment". Al terminar vas a saber exactamente cuándo evalúa Python los valores por defecto, dónde busca los nombres y cuánto cuesta una llamada a función.
Si estás armando tu primer backend con Flask en un bootcamp, o escribiendo scripts de automatización como freelancer, casi todos los bugs con funciones que nos reportan no tienen nada que ver con la lógica. Vienen de tres reglas que nadie dice en voz alta: los valores por defecto se crean una sola vez, asignar a un nombre lo vuelve local en toda la función, y un closure lee una variable cuando se ejecuta, no cuando se crea. Cada regla se muestra abajo con código que puedes pegar en un solo archivo y la salida que imprimió de verdad en Python 3.9 y Python 3.11 (Mac con Apple Silicon, M1 Pro). Las rutas en los tracebacks se acortaron a ~/demo/.
Si no leíste el curso 1, dale una mirada rápida a su primera sección: "los nombres apuntan a objetos" es la clave de todas las trampas de aquí.
Parámetros: posicionales, por nombre, args y *kwargs
def describe(name, version, *tags, debug=False, **extra):
print(f"name={name} version={version} tags={tags} debug={debug} extra={extra}")
describe("app", "1.0")
describe("app", "1.0", "ios", "android", debug=True, store="jp")
args = ("app", "2.0", "web")
opts = {"debug": True, "region": "eu"}
describe(*args, **opts)
name=app version=1.0 tags=() debug=False extra={}
name=app version=1.0 tags=('ios', 'android') debug=True extra={'store': 'jp'}
name=app version=2.0 tags=('web',) debug=True extra={'region': 'eu'}
*tagsjunta los argumentos posicionales sobrantes en una tupla.- Todo lo que va después de
*tagssolo se puede pasar por nombre, así quedebuges keyword-only. **extrajunta los argumentos por nombre desconocidos en un dict.- En la llamada,
*argsy**optshacen lo contrario: desempaquetan una secuencia y un dict como argumentos.
Los marcadores / y *
Python 3.8 agregó /: los parámetros que van antes son solo posicionales. Un * suelto hace que todo lo que sigue sea solo por nombre.
def clamp(value, /, low=0, high=100, *, strict=False):
if strict and not (low <= value <= high):
raise ValueError(f"{value} out of range")
return max(low, min(high, value))
print(clamp(150), clamp(-5, low=-10), clamp(50, 0, 40))
100 -5 40
Y estos son los cuatro TypeError que obtienes cuando llamas mal a las funciones. Los mensajes fueron idénticos en 3.9 y 3.11:
clamp(value=5) -> TypeError: clamp() got some positional-only arguments passed as keyword arguments: 'value'
clamp(5, 0, 100, True) -> TypeError: clamp() takes from 1 to 3 positional arguments but 4 were given
describe('a') -> TypeError: describe() missing 1 required positional argument: 'version'
describe('a', '1', version='2') -> TypeError: describe() got multiple values for argument 'version'
El segundo es justamente la razón de ser de *: clamp(5, 0, 100, True) sería ilegible ("¿qué es ese True?"), así que la función lo rechaza. Cuándo usar cada uno:
| Quieres | Escribe | Por qué |
|---|---|---|
| Que el nombre del parámetro no importe a quien llama | def f(x, /) |
Puedes renombrar el parámetro después sin romper a nadie |
| Flags y opciones | def f(x, *, verbose=False) |
Obliga a escribir verbose=True en la llamada, que se lee bien |
| Envolver otra función y pasarle todo | def f(*args, **kwargs) |
Decoradores, wrappers delgados (curso 4) |
| Parámetros normales | def f(a, b=1) |
La opción por defecto |
Error común: el argumento por defecto mutable
Es la trampa más famosa de Python, y es el aliasing del curso 1 disfrazado.
def add_item(item, bucket=[]):
bucket.append(item)
return bucket
print(add_item("a"))
print(add_item("b"))
print(add_item("c", []))
print(add_item("d"))
print("defaults:", add_item.__defaults__)
['a']
['a', 'b']
['c']
['a', 'b', 'd']
defaults: (['a', 'b', 'd'],)
Qué pasa en realidad: la expresión [] se evalúa una sola vez, cuando se ejecuta la sentencia def, y la lista resultante se guarda en el objeto función, en __defaults__. Cada llamada que omite bucket recibe esa misma lista. Pasar tu propia lista ("c") funciona bien, y por eso el bug se esconde en los tests y aparece en producción, donde un handler web acumula los elementos de todas las peticiones desde que arrancó el servidor.
Solución: usa None como centinela y crea el objeto adentro:
def add_item_fixed(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket
print(add_item_fixed("a"), add_item_fixed("b"))
['a'] ['b']
La misma regla muerde con cualquier valor por defecto calculado: def log(msg, when=time.time()) registra la hora en que se importó el módulo. La llamamos, esperamos 1.1 segundos, la llamamos otra vez, y las dos llamadas devolvieron la misma marca de tiempo. Escribe when=None y calcula time.time() adentro.
Ámbito: LEGB y UnboundLocalError
Cuando una función usa un nombre, Python lo busca en cuatro lugares, en orden, y se detiene en el primero donde lo encuentra: Local, Enclosing (funciones exteriores), Global (el módulo), Builtins.
x = "global"
def outer():
x = "enclosing"
def inner():
print("inner sees:", x)
inner()
outer()
inner sees: enclosing
Leer nombres de afuera no tiene problema. Asignar es donde quien empieza se estrella. Este contador parece inofensivo:
count = 0
def hit():
count += 1
return count
hit()
Python 3.9:
Traceback (most recent call last):
File "~/demo/unbound.py", line 7, in <module>
hit()
File "~/demo/unbound.py", line 4, in hit
count += 1
UnboundLocalError: local variable 'count' referenced before assignment
Python 3.11 cambia la redacción, y la nueva se acerca más a lo que realmente pasa:
count += 1
^^^^^
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value
La decisión "¿count es local?" se toma cuando la función se compila, revisando todo el cuerpo en busca de asignaciones. count += 1 asigna, así que count es local en todo hit, incluido el lado derecho de esa misma línea. Y algo todavía más confuso: la asignación puede venir después:
total = 100
def report():
print("before:", total) # fails here
total = 0
report()
print("before:", total)
UnboundLocalError: local variable 'total' referenced before assignment
global y nonlocal
count = 0
def hit():
global count
count += 1
return count
hit(); hit()
print("count =", count)
def make_counter():
n = 0
def step():
nonlocal n
n += 1
return n
return step
c1, c2 = make_counter(), make_counter()
print(c1(), c1(), c1(), "|", c2())
count = 2
1 2 3 | 1
global reasigna un nombre a nivel de módulo; nonlocal reasigna un nombre de la función exterior más cercana. Cada llamada a make_counter crea un n nuevo, así que c1 y c2 cuentan por separado.
| Situación | Mejor opción |
|---|---|
| La función necesita un valor | pásalo como parámetro |
| La función produce un valor nuevo | devuélvelo con return |
| Un poco de estado privado entre llamadas | closure + nonlocal (o una clase) |
| Configuración del módulo que cambia poco, por ejemplo al arrancar | global, con moderación |
| Modificar una lista o dict compartido | no hace falta palabra clave (.append no es una asignación), pero piénsalo dos veces |
Closures y la trampa del enlace tardío
Un closure es una función interna que conserva acceso a las variables de la función que la creó:
def make_multiplier(k):
return lambda v: v * k
double, triple = make_multiplier(2), make_multiplier(3)
print(double(5), triple(5))
print("cell:", c1.__closure__[0].cell_contents)
10 15
cell: 3
La variable capturada vive en una celda (cell); el c1 de la sección anterior ya contó hasta 3. La trampa: un closure guarda la variable, no su valor en el momento de crearse.
fns = [lambda: i for i in range(3)]
print("late binding:", [f() for f in fns])
fns = [lambda i=i: i for i in range(3)]
print("default fix :", [f() for f in fns])
from functools import partial
def show(i): return i
fns = [partial(show, i) for i in range(3)]
print("partial fix :", [f() for f in fns])
late binding: [2, 2, 2]
default fix : [0, 1, 2]
partial fix : [0, 1, 2]
Las tres lambdas leen i cuando se llaman, después de que el bucle terminó con i == 2. La corrección i=i funciona por la regla de la sección de argumentos por defecto: los defaults se evalúan una vez, al definir la función, que es justo lo que queremos aquí. En código de interfaz gráfica esto aparece como "todos los botones hacen lo mismo":
buttons = {}
for name in ("save", "load"):
buttons[name] = lambda: f"clicked {name}"
print(buttons["save"]())
clicked load
Las anotaciones de tipo no se hacen cumplir
def area(w: float, h: float) -> float:
return w * h
print(area("ab", 3))
print(area.__annotations__)
ababab
{'w': <class 'float'>, 'h': <class 'float'>, 'return': <class 'float'>}
Ningún error: CPython guarda las anotaciones en __annotations__ y por lo demás las ignora. "ab" * 3 es una repetición de string válida, así que obtienes ababab sin aviso. Las anotaciones rinden a través de herramientas como mypy o tu editor, y como documentación. Si un valor se tiene que validar en tiempo de ejecución, valídalo tú (if not isinstance(w, (int, float)): raise TypeError(...)).
match (Python 3.10+)
El pattern matching estructural no es un switch: compara la forma de los datos. Ejecutado en Python 3.11:
def handle(cmd):
match cmd.split():
case ["quit" | "exit"]:
return "bye"
case ["go", direction] if direction in ("north", "south"):
return f"moving {direction}"
case ["go", _]:
return "unknown direction"
case ["drop", *items]:
return f"dropping {len(items)} item(s): {items}"
case _:
return f"??? {cmd!r}"
def status(resp):
match resp:
case {"status": 200, "data": data}:
return f"ok: {data}"
case {"status": 404}:
return "not found"
case {"status": int(code)} if code >= 500:
return f"server error {code}"
case _:
return "unexpected"
quit -> bye
go north -> moving north
go up -> unknown direction
drop key lamp -> dropping 2 item(s): ['key', 'lamp']
dance -> ??? 'dance'
{'status': 200, 'data': [1, 2]} -> ok: [1, 2]
{'status': 404, 'msg': 'x'} -> not found
{'status': 503} -> server error 503
{'code': 1} -> unexpected
Los patrones de mapeo ignoran las claves de más ('msg' arriba). El mismo archivo en Python 3.9 ni siquiera arranca:
File "~/demo/l2_match.py", line 2
match cmd.split():
^
SyntaxError: invalid syntax
Si tu código también tiene que correr en 3.9 (servidores viejos, el Python 3.9 que trae macOS), usa if/elif.
La trampa de match con la que tropieza quien empieza: un nombre suelto en un case es una captura, no una comparación.
NOT_FOUND = 404
def check(code):
match code:
case NOT_FOUND:
return "not found"
case 200:
return "ok"
case NOT_FOUND:
^^^^^^^^^
SyntaxError: name capture 'NOT_FOUND' makes remaining patterns unreachable
Ese error es el caso con suerte. Con un solo case, compila, coincide con todo y asigna el valor a NOT_FOUND: llamarla con 200 devolvió matched; NOT_FOUND inside = 200. Usa un nombre con punto (case HTTP.NOT_FOUND: con un IntEnum, que coincidió con 404 y dejó pasar 200) o un literal.
Una medición: ¿cuánto cuesta llamar a una función?
Sumar i * i para un millón de valores de i, escrito de seis maneras (timeit, mejor de 7, Python 3.9, Apple M1 Pro; las seis devuelven el mismo total):
import timeit
N = 1_000_000
def square(x): return x * x
def run_inline():
t = 0
for i in range(N): t += i * i
return t
def run_call():
t = 0
for i in range(N): t += square(i)
return t
# run_lambda: sq = lambda x: x * x, then t += sq(i)
# run_kw: t += square(x=i)
def run_comp(): return sum([i * i for i in range(N)])
def run_map(): return sum(map(square, range(N)))
for f in (run_inline, run_call, run_comp, run_map):
print(f.__name__, min(timeit.repeat(f, number=1, repeat=7)))
| Estilo | Ejecución 1 | Ejecución 2 | Por iteración |
|---|---|---|---|
comprensión de listas + sum |
42.3 ms | 41.0 ms | ~42 ns |
| bucle en línea | 44.2 ms | 44.8 ms | ~44 ns |
sum(map(square, …)) |
50.2 ms | 50.6 ms | ~50 ns |
| llamada a lambda en el bucle | 80.4 ms | 79.2 ms | ~80 ns |
llamada a def en el bucle |
82.9 ms | 84.0 ms | ~83 ns |
llamada por nombre square(x=i) |
92.2 ms | 91.1 ms | ~92 ns |
En Python 3.9 una llamada a función simple cuesta unos 40 ns además del trabajo en sí, y un argumento por nombre agrega ~9 ns más. Una lambda no es más rápida que un def: son el mismo tipo de objeto. Con un cuerpo de bucle tan trivial, la llamada duplica el tiempo. Para cualquier cosa real (una consulta a la base de datos, parsear un JSON, una regex), 40 ns es ruido. Usa funciones por legibilidad, y pon el código en línea solo dentro de un bucle que mediste y sabes que es caliente (el curso 6 trata de profiling). Python 3.11 abarató bastante las llamadas, pero nuestro build de 3.11 corre bajo emulación en esta Mac, así que no lo cronometramos.
Límites honestos y notas de versión
- Todo el código se ejecutó en Python 3.9.6 y Python 3.11.4. Los tiempos son solo de 3.9.
- Parámetros solo posicionales con
/: agregados en Python 3.8 (PEP 570); los ejecutamos en 3.9 y 3.11, no en 3.8. match: agregado en Python 3.10 (PEP 634). Lo ejecutamos en 3.11 y confirmamos elSyntaxErroren 3.9; no ejecutamos 3.10 en sí.- El mensaje de
UnboundLocalErrorcambió en 3.11, como se ve arriba. El código que compara contra el texto del mensaje se rompe entre versiones; atrapa el tipo de excepción en su lugar. - Python 3.12 agrega la sentencia
typey la sintaxis genérica de la PEP 695 (def first[T](xs: list[T]) -> T). Solo las describimos; no se ejecutaron, porque 3.11 rechaza esa sintaxis.
Siguiente
- Anterior: Curso de Python 1/6: tipos, mutabilidad, is vs ==
- Siguiente: Curso de Python 3/6: iteradores, generadores y memoria (en inglés)
- Los closures y
*args, **kwargsvuelven en el curso 4: decoradores y context managers (en inglés), y el costo de las llamadas en el curso 6: profiling y optimización (en inglés). - Las mismas ideas en otros lenguajes: Curso de VB.NET 2: ByVal, ByRef y control de flujo (en inglés), expresiones lambda en C# (en inglés), métodos en C# (en inglés)
Preguntas frecuentes
¿Por qué mi función de Python recuerda la lista de la llamada anterior?
Un valor por defecto se evalúa una sola vez, cuando se ejecuta la sentencia def, no en cada llamada. def add_item(item, bucket=[]) comparte una misma lista entre llamadas: la segunda llamada devolvió ['a', 'b']. Usa bucket=None y crea la lista dentro de la función.
¿Qué causa el error UnboundLocalError: local variable referenced before assignment?
Asignar a un nombre en cualquier parte de una función (incluido count += 1) hace que ese nombre sea local en toda la función, así que leerlo antes de la asignación falla. Declara global count o nonlocal count si de verdad quieres la variable de afuera, o mejor: pasa el valor como parámetro y devuelve el nuevo valor.
¿Por qué todas las lambdas que creo en un bucle devuelven el último valor?
Los closures buscan la variable cuando se llaman, no cuando se crean. [lambda: i for i in range(3)] devolvió [2, 2, 2]. Fija el valor actual con un argumento por defecto (lambda i=i: i) o con functools.partial; ambos devolvieron [0, 1, 2].