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'}
  • *tags junta los argumentos posicionales sobrantes en una tupla.
  • Todo lo que va después de *tags solo se puede pasar por nombre, así que debug es keyword-only.
  • **extra junta los argumentos por nombre desconocidos en un dict.
  • En la llamada, *args y **opts hacen 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.

Diagrama de dos filas. Fila de arriba: la sentencia def se ejecuta una vez y crea una sola lista por defecto, guardada en add_item.__defaults__; la primera llamada add_item('a') le agrega un elemento y devuelve ['a']; la segunda llamada add_item('b') agrega a la misma lista y devuelve ['a', 'b']. Fila de abajo, la solución: usar bucket=None como valor por defecto y crear una lista nueva dentro de la función cuando bucket es None, así cada llamada devuelve una lista nueva.
Por qué bucket=[] se filtra entre llamadas: el valor por defecto se construye una vez, cuando se ejecuta def, y cada llamada que omite el argumento recibe esa misma lista.

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.

Diagrama de flujo de la búsqueda de nombres. Un nombre usado dentro de una función se busca primero en el ámbito Local, las variables propias de la función. Si no está, en el ámbito Enclosing, las variables de las funciones exteriores, que nonlocal puede reasignar. Luego en el ámbito Global, los nombres de nivel superior del módulo, que global puede reasignar. Luego en Builtins, como len y print. Si el nombre no está en ninguno, se lanza NameError.
LEGB: Python busca un nombre en Local, luego Enclosing, luego Global y luego Builtins, y se detiene en el primero que lo tenga. Asignar a un nombre en cualquier parte de una función lo vuelve Local en toda la función.
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
Gráfico de barras de nanosegundos por iteración. Comprensión de listas con sum: 42 ns. Bucle en línea: 44 ns. sum(map(square, …)): 50 ns. Llamada a lambda en el bucle: 80 ns. Llamada a función def: 83 ns. Llamada con argumento por nombre square(x=i): 92 ns.
Tiempo por iteración de t += i*i sobre 1,000,000 de elementos, según el estilo. timeit, mejor de 7, Python 3.9, Apple M1 Pro. Llamar a una función casi duplica el costo de un cuerpo de bucle trivial.

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 el SyntaxError en 3.9; no ejecutamos 3.10 en sí.
  • El mensaje de UnboundLocalError cambió 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 type y 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

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].

  • #python
  • #principiante
  • #funciones
  • #ámbito
  • #closures
  • #match

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

Acerca de · Apps

↑