⚡ 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.
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 detyping. Desde 3.9 los contenedores nativos (list[int]) funcionan y desde 3.10X | Nonereemplaza aOptional[X]. Si tu código debe correr en 3.9+, usalist[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=Trueevita 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
💡
matchno sustituye aif/elifpara condiciones arbitrarias: es para igualdad y desestructuración de patrones. El wildcardcase _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. Conprint(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 endef f(a, *args, c)para indicar queces 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
zipes una fuente clásica de bugs difíciles de rastrear. Usastrict=Truesiempre 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
💡
Protocoles la base del estructural typing: no necesitas queAvionherede deVolador; basta con que tenga el método. Las librerías modernas (p. ej. type stubs deos,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,
}
💡
TypedDictes la forma de tipar el JSON que sale dejson.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
⚠️
NewTypeno crea una clase: es una función de identidad en runtime. Su valor es la protección en tiempo de compilación — evita pasar unintcrudo donde se espera unUserId(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 (serializarsin 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
- PEP 484 — Type Hints: la especificación original del sistema de tipos.
- PEP 622 — Structural Pattern Matching: el diseño completo de
match/case. - Python docs — typing module: la referencia de todos los tipos de
typing. - Python docs — dataclasses: API completa de
@dataclass. - Real Python — Python Type Checking: tutorial profundo sobre hints y mypy.
- Siguiente paso: OOP en Python.