🔎 Buscar

⚡ Python Moderno

Type hints con TypedDict, Protocol, Generic y TypeVar, dataclasses con slots, pattern matching, walrus operator, f-strings avanzadas, argumentos posicionales y keyword-only, y singledispatch.

Wiki / Apuntes📖 Contenido

Python Moderno

Python 3.10+ trajo un cambio cultural: el lenguaje pasó de “tipado dinámico a la fuerza” a ofrecer anotaciones de tipos potentes, match/case, dataclasses maduras y azúcar sintáctico que hace el código más seguro y legible. Este artículo cubre las herramientas de Python moderno que usan las librerías y frameworks de producción.

Type hints: por qué importan

Los type hints anotan los tipos esperados. No los aplica el runtime: son para herramientas (mypy, pyright), el editor (autocompletado) y los humanos que leen tu código.

def sumar(a: int, b: int) -> int:
    return a + b

edad: int = 30
nombre: str = "Ana"

💡 Con mypy o pyright (mypy archivo.py) puedes verificar los tipos antes de desplegar. Los hints convierten errores de runtime en errores de editor y de CI.

Tipos de colecciones

from typing import Optional, Union, Any, Callable, Literal

def procesar(ids: list[int]) -> dict[str, int]:
    """Cuenta cuántas veces aparece cada id."""
    return {str(i): i for i in ids}

def opcional(valor: Optional[int] = None) -> int:
    """Optional[int] es lo mismo que Union[int, None]."""
    return valor if valor is not None else 0

def union(valor: Union[int, str]) -> str:
    """Acepta int O str."""
    return str(valor)

def generico(valor: Any) -> None:
    """Any: sin restricción, desactiva la verificación."""
    print(valor)

def aplicar(operacion: Callable[[int, int], int], a: int, b: int) -> int:
    """Una función que recibe dos int y devuelve int."""
    return operacion(a, b)

def nivel(dificultad: Literal["bajo", "medio", "alto"]) -> str:
    """Literal: solo esos tres valores exactos."""
    return f"Nivel {dificultad}"

⚠️ En Python 3.8 y anteriores se escribía List[int], Dict[str, int], Optional[int] importado de typing. Desde 3.9 los contenedores nativos (list[int]) funcionan y desde 3.10 X | None reemplaza a Optional[X]. Si tu código debe correr en 3.9+, usa list[int]; si es 3.10+, int | None.

Genéricos propios: TypeVar y Generic

Cuando tu función debe preservar el tipo del argumento (p. ej. first([1, 2, 3]) debe devolver int), usa TypeVar:

from typing import TypeVar, Generic

T = TypeVar("T")

def primero(items: list[T]) -> T:
    return items[0]

n = primero([1, 2, 3])        # mypy infiere: n es int
s = primero(["a", "b"])       # s es str

# Clases genéricas con Generic
class Pila(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T:
        return self._items.pop()

pila = Pila[int]()            # pila.pop() devuelve int
pila.push(10)

class Caja(Generic[T, U]):    # varios parámetros
    def __init__(self, primero: T, segundo: U) -> None:
        self.primero = primero
        self.segundo = segundo

dataclasses

Una dataclass genera automáticamente __init__, __repr__, __eq__ y más a partir de las anotaciones:

from dataclasses import dataclass

@dataclass
class Usuario:
    nombre: str
    email: str
    edad: int = 0              # campo con valor por defecto

u1 = Usuario("Ana", "ana@mail.com", 30)
u2 = Usuario("Ana", "ana@mail.com", 30)
print(u1)                      # Usuario(nombre='Ana', email='ana@mail.com', edad=30)
print(u1 == u2)                # True  (igualdad por valores)
print(u1.edad)                 # 30

Campos avanzados

from dataclasses import dataclass, field

@dataclass
class Pedido:
    id: int
    items: list[str] = field(default_factory=list)   # ¡nunca [] mutable!
    estado: str = field(default="pendiente", repr=False)
    total: float = field(init=False)                 # se calcula en __post_init__

    def __post_init__(self) -> None:
        self.total = len(self.items) * 10.0

p = Pedido(1)
p.items.append("pizza")
print(p)                     # Pedido(id=1, items=['pizza'], total=10.0)

⚠️ Nunca uses = [] como default de un dataclass: el valor mutable se comparte entre instancias. field(default_factory=list) crea una lista nueva por instancia.

slots y frozen

from dataclasses import dataclass

@dataclass(slots=True)             # atributos sin __dict__: menos memoria, más rápido
class Punto:
    x: float
    y: float

@dataclass(frozen=True)            # instancia inmutable (equivalente a tupla con nombre)
class Config:
    host: str
    puerto: int = 8080

p = Punto(1.0, 2.0)
# p.x = 5.0  → AttributeError: 'Punto' no tiene '__dict__'

c = Config("localhost")
# c.puerto = 9000  → FrozenInstanceError

💡 frozen=True + eq=True (default) produce objetos comparables y hasheables si todos los campos son inmutables: ideales como claves de dict. slots=True evita errores de typo (no puedes crear atributos nuevos por accidente) y ahorra memoria por instancia.

Pattern matching: match/case

match (3.10+) es un switch con superpoderes: puede desestructurar estructuras y usar guardas.

def describir(valor):
    match valor:
        case 0:
            return "cero"
        case 1 | 2 | 3:              # varios valores
            return "número pequeño"
        case int(n) if n > 100:      # guarda: condición extra
            return "entero grande"
        case int(n):
            return f"entero: {n}"
        case str(s):
            return f"texto: {s}"
        case [a, b]:                 # desestructura listas de 2 elementos
            return f"par: {a} y {b}"
        case {"nombre": nombre, **resto}:   # desestructura dicts
            return f"{nombre} con {len(resto)} claves más"
        case _:                      # wildcard (equivalente a else)
            return "tipo desconocido"

print(describir(150))        # entero grande
print(describir("hola"))     # texto: hola
print(describir([1, 2]))     # par: 1 y 2
print(describir({"nombre": "Ana", "edad": 30}))   # Ana con 1 claves más

Pattern de estructuras en acción

from dataclasses import dataclass

@dataclass
class Punto:
    x: float
    y: float

def cuadrante(punto):
    match punto:
        case Punto(0, 0):
            return "origen"
        case Punto(x, y) if x > 0 and y > 0:
            return "cuadrante I"
        case Punto(x, y) if x < 0 and y > 0:
            return "cuadrante II"
        case Punto(x, y):
            return f"({x}, {y})"

print(cuadrante(Punto(0, 0)))     # origen
print(cuadrante(Punto(5, 3)))     # cuadrante I
# Parsing de comandos estilo CLI sin librerías
def procesar_comando(comando: list[str]) -> str:
    match comando:
        case ["salir"]:
            return "hasta luego"
        case ["sumar", *numeros] if numeros:
            total = sum(int(n) for n in numeros)
            return f"total: {total}"
        case ["saludar", nombre]:
            return f"hola, {nombre}"
        case _:
            return "comando no reconocido"

print(procesar_comando(["sumar", "1", "2", "3"]))   # total: 6

💡 match no sustituye a if/elif para condiciones arbitrarias: es para igualdad y desestructuración de patrones. El wildcard case _ es obligatorio para cubrir todos los casos y evitar errores silenciosos.

Walrus operator (:=)

El operador morsa asigna un valor dentro de una expresión y lo devuelve. Evita calcular y guardar dos veces:

# Sin walrus: lees la línea dos veces
linea = input("> ")
while linea:
    print("Recibido:", linea)
    linea = input("> ")

# Con walrus: una sola lectura por iteración
while (linea := input("> ")):
    print("Recibido:", linea)
# En list comprehension: evita llamar parse() dos veces
def parse(valor: str) -> int | None:
    try:
        return int(valor)
    except ValueError:
        return None

datos = ["1", "no", "3", "x", "5"]
validos = [n for s in datos if (n := parse(s)) is not None]
print(validos)     # [1, 3, 5]

⚠️ El walrus asigna en el scope actual: dentro de una comprehension crea una variable visible fuera del loop en 3.8+. Úsalo solo cuando ahorre una llamada repetida o una línea de lógica; abusar de él daña la legibilidad.

f-strings avanzadas

precio = 49.99
cantidad = 3
nombre = "laptop"
descuento = 0.1

# Expresiones arbitrarias dentro de {}
print(f"Total: {precio * cantidad:.2f} €")
print(f"Con descuento: {precio * cantidad * (1 - descuento):.2f} €")

# Anidar especificadores de formato dinámicamente
ancho = 10
print(f"{'nombre':>{ancho}}")          # alineación con ancho variable

# El modo debug: f"{var=}" imprime nombre y valor
total = 149.97
print(f"{total=}")                     # total=149.97
print(f"{total=:.2f}")                 # total=149.97
print(f"{nombre=} {precio*2=}")        # nombre='laptop' precio*2=99.98

# Formato de fechas y números
from datetime import datetime
print(f"{datetime.now():%Y-%m-%d %H:%M}")   # 2026-08-26 14:30
print(f"{0.25:+.1%}")                        # +25.0%
print(f"{255:#x}")                           # 0xff

💡 f"{var=}" es el atajo de debugging más subestimado: muestra nombre, valor y formato sin escribir el nombre a mano. Con print(f"{total=} {otro=}") obtienes una línea de debug completa y legible.

Positional-only y keyword-only

Desde Python 3.8 puedes forzar cómo se pasan los argumentos:

# Los argumentos ANTES de '/' son SOLO posicionales
def dividir(a, b, /):
    return a / b

# dividir(a=10, b=2)  → TypeError: usa solo posición

# Los argumentos DESPUÉS de '*' son SOLO keyword
def conectar(host, *, puerto=80, ssl=True):
    print(host, puerto, ssl)

conectar("localhost")            # ok: host posicional, resto por keyword
conectar("localhost", 8080)      # TypeError: puerto debe ir como keyword
conectar("localhost", puerto=8080, ssl=False)   # ok

# Combinación de los tres grupos
def firmar(dato, separador=":", /, *, checksum=None):
    ...
# Caso real: evitar llamadas ambiguas y permitir evolución de API
def buscar(texto: str, *, limite: int = 10, orden: str = "asc") -> list[str]:
    return [f"{texto}-{i}" for i in range(limite)]

buscar("python")                 # ok
buscar("python", 5)              # TypeError: limite no es posicional
buscar("python", limite=5, orden="desc")

💡 El * también aparece en def f(a, *args, c) para indicar que c es keyword-only. La regla práctica: los parámetros con sentido claro de posición van antes de /; las opciones configurables van después de *.

zip strict

zip() empareja iterables. El problema histórico: con longitudes distintas, trunca silenciosamente al más corto. Desde 3.10 existe strict=True para fallar:

nombres = ["Ana", "Luis", "Marta"]
edades = [30, 25]

print(list(zip(nombres, edades)))   # [('Ana', 30), ('Luis', 25)]  ← Marta desaparece sin aviso

# zip(strict=True): lanza ValueError si las longitudes difieren
try:
    list(zip(nombres, edades, strict=True))
except ValueError as e:
    print("Error:", e)

⚠️ El truncamiento silencioso de zip es una fuente clásica de bugs difíciles de rastrear. Usa strict=True siempre que los iterables deban tener la misma longitud: conviertes un bug silencioso en un error explícito.

Structural typing con Protocol

Protocol define una forma (métodos y atributos) sin exigir herencia. Es duck typing con verificación estática: lo que importa es la estructura, no la clase.

from typing import Protocol

class Volador(Protocol):
    def volar(self) -> str: ...

class Pajaro:
    def volar(self) -> str:
        return "aleteando"

class Avion:
    def volar(self) -> str:
        return "con motores"

class Pez:
    def nadar(self) -> str:
        return "movimiento de cola"

def mostrar_vuelo(volador: Volador) -> str:
    """Acepta cualquier cosa con .volar()."""
    return volador.volar()

print(mostrar_vuelo(Pajaro()))    # aleteando
print(mostrar_vuelo(Avion()))     # con motores
# mostrar_vuelo(Pez())            # error de mypy: Pez no implementa .volar()
from typing import Protocol, TypeVar

class Comparable(Protocol):
    def __lt__(self, otro) -> bool: ...

T = TypeVar("T", bound=Comparable)

def minimo(items: list[T]) -> T:
    return min(items)

print(minimo([3, 1, 2]))          # 1
print(minimo(["b", "a", "c"]))    # a

💡 Protocol es la base del estructural typing: no necesitas que Avion herede de Volador; basta con que tenga el método. Las librerías modernas (p. ej. type stubs de os, logging) lo usan masivamente para tipar la stdlib.

TypedDict

TypedDict tipa dicts con claves conocidas — el JSON típico de una API:

from typing import TypedDict, NotRequired

class UsuarioAPI(TypedDict):
    id: int
    nombre: str
    email: NotRequired[str]      # clave opcional

def crear_usuario(datos: UsuarioAPI) -> str:
    nombre = datos["nombre"]
    email = datos.get("email", "sin email")
    return f"{nombre} ({email})"

ok = {"id": 1, "nombre": "Ana", "email": "ana@mail.com"}
print(crear_usuario(ok))         # Ana (ana@mail.com)

# mypy detecta: {"id": 1, "nombre": "Ana", "extra": True}  → clave no esperada
# Anidado y valores por defecto con class-based syntax
from typing import TypedDict, Required

class Respuesta(TypedDict):
    status: int
    body: dict[str, str]
    cacheable: Required[bool]

respuesta: Respuesta = {
    "status": 200,
    "body": {"resultado": "ok"},
    "cacheable": True,
}

💡 TypedDict es la forma de tipar el JSON que sale de json.load() o de una respuesta de API: documenta el contrato del payload y deja que mypy valide las claves. NotRequired/Required (3.11+) controlan qué claves son opcionales.

NewType

NewType crea un subtipo distinto a ojos del verificador de tipos:

from typing import NewType

UserId = NewType("UserId", int)
Email = NewType("Email", str)

def enviar_email(destinatario: Email, usuario: UserId) -> None:
    print(f"Enviando a {destinatario} (user {usuario})")

uid = UserId(42)
correo = Email("ana@mail.com")
enviar_email(correo, uid)        # ok

# enviar_email("ana@mail.com", 42)  → mypy: espera Email y UserId
# La protección es estática: en runtime, UserId es un int normal
print(uid + 1)                   # 43

⚠️ NewType no crea una clase: es una función de identidad en runtime. Su valor es la protección en tiempo de compilación — evita pasar un int crudo donde se espera un UserId (p. ej. mezclar ids de tablas distintas).

singledispatch

singledispatch implementa sobrecarga de funciones por tipo del primer argumento:

from functools import singledispatch

@singledispatch
def serializar(valor):
    """Implementación por defecto."""
    return f"tipo desconocido: {type(valor).__name__}"

@serializar.register
def _(valor: int) -> str:
    return f"int:{valor}"

@serializar.register
def _(valor: str) -> str:
    return f"str:{valor.upper()}"

@serializar.register
def _(valor: list) -> str:
    return f"list:[{', '.join(serializar(v) for v in valor)}]"

print(serializar(42))                # int:42
print(serializar("hola"))            # str:HOLA
print(serializar([1, "a"]))          # list:[int:1, str:A]
print(serializar(3.14))              # tipo desconocido: float

💡 Es el reemplazo moderno y limpio de las cadenas if isinstance(x, ...) cuando quieres dispatar por tipo. El default (serializar sin registrar) es el fallback para tipos sin caso. En 3.9+ puedes registrar con @serializar.register(float) y la anotación.

Cheatsheet

Quieres… Usas…
Tipar una colección list[str], dict[str, int], tuple[int, ...]
Valor o None int | None (3.10+) u Optional[int]
Valores exactos Literal["a", "b"]
Función como tipo Callable[[int], str]
Clase genérica Generic[T] + TypeVar
Tipar dict con claves fijas TypedDict
Forma sin herencia Protocol
Subtipo para el checker NewType
Boilerplate de clases @dataclass (con slots/frozen)
Switch con desestructuración match/case
Asignar dentro de expresión :=
Debug en print f"{var=}"
Forzar posición/keyword / y *
zip sin truncar zip(..., strict=True)
Overload por tipo @singledispatch

Para profundizar

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.