Listas, tuplas, diccionarios, conjuntos y cadenas: el arsenal básico está completo, y el catálogo de Papyrus vive por fin en una sola estructura. Pero la biblioteca estándar (03-05) guarda un módulo pensado exactamente para pulir los patrones que hemos ido improvisando: collections. Aquí saldamos la promesa de 04-02 con namedtuple a fondo, contamos los libros más vendidos con Counter, agrupamos pedidos por cliente con defaultdict y montamos una cola de reservas eficiente con deque — y cerramos el módulo con la tabla de criterio definitiva: qué estructura elegir en cada situación.

Contenido

  1. namedtuple: registros con nombre, a fondo
  2. Counter: contar sin bucles
  3. defaultdict: diccionarios con valor por defecto
  4. deque: colas eficientes por los dos extremos
  5. OrderedDict: nota histórica
  6. Criterios de elección: la tabla-resumen del módulo

namedtuple: registros con nombre, a fondo

En 04-02 la probamos como aperitivo; ahora, el plato completo. Una namedtuple es una fábrica de clases de tuplas: defines el nombre del registro y sus campos, y obtienes un tipo nuevo cuyas instancias son tuplas con campos accesibles por nombre.

from collections import namedtuple

Libro = namedtuple("Libro", ["titulo", "precio", "stock"])

fausto = Libro("Fausto", 21.00, 0)
odisea = Libro(titulo="La Odisea", precio=12.50, stock=4)   # también con keywords

print(fausto.precio)        # 21.0  → por nombre: se acabaron los índices mudos
print(fausto[1])            # 21.0  → sigue siendo una tupla: índice, slicing, len()...
titulo, precio, stock = fausto                    # y desempaquetado
print(fausto)               # Libro(titulo='Fausto', precio=21.0, stock=0) → repr legible ¡gratis!

Tres utilidades extra que conviene conocer:

print(fausto._asdict())          # {'titulo': 'Fausto', 'precio': 21.0, 'stock': 0} → a dict
rebajado = fausto._replace(precio=18.90)   # "modificar" un inmutable: crea OTRA namedtuple
print(rebajado.precio, fausto.precio)      # 18.9 21.0 → el original, intacto
Libro2 = namedtuple("Libro2", "titulo precio stock")   # los campos también valen como cadena

¿Y el catálogo? La namedtuple compite con el dict anidado de 04-03, y la elección es una decisión de diseño real:

# Opción A (04-03): dict de dicts — flexible, campos como cadenas
catalogo = {"Fausto": {"precio": 21.00, "stock": 0}}

# Opción B: dict de namedtuples — campos con nombre real y protegidos contra typos
catalogo_nt = {"Fausto": Libro("Fausto", 21.00, 0)}
print(catalogo_nt["Fausto"].precio)          # 21.0
# catalogo_nt["Fausto"].stock = 3            # AttributeError: es inmutable

Con la opción B, un error tipográfico (ficha["precoi"]) que en el dict crearía silenciosamente una clave nueva, aquí falla al instante (AttributeError). A cambio, actualizar el stock exige _replace() y reasignar — incómodo para datos que cambian a cada venta. Para Papyrus mantenemos el dict anidado como estructura canónica mutable y usamos namedtuple para registros de solo lectura, como las líneas de un ticket ya cobrado.

Counter: contar sin bucles

Contar apariciones es tan común que collections lo trae resuelto. Counter es un diccionario especializado: claves = elementos, valores = cuántas veces aparecen.

from collections import Counter

# Ventas del mes en Papyrus, tal como se registraron
ventas = ["Hamlet", "El Quijote", "Hamlet", "La Odisea", "Hamlet", "El Quijote"]

contador = Counter(ventas)
print(contador)                    # Counter({'Hamlet': 3, 'El Quijote': 2, 'La Odisea': 1})
print(contador["Hamlet"])          # 3
print(contador["Fausto"])          # 0 → clave inexistente devuelve 0, NO KeyError
print(contador.most_common(2))     # [('Hamlet', 3), ('El Quijote', 2)] → top ventas, ordenado

contador.update(["Fausto", "Hamlet"])   # llegan más ventas
print(contador["Hamlet"])               # 4

print(sum(contador.values()))      # 8 → total de ejemplares vendidos
letras = Counter("papyrus")        # también cuenta caracteres de una cadena

Compara con la alternativa manual — el bucle con if titulo in cuentas: ... else: ... que habrías escrito en el módulo 2 — y aprecia la diferencia: most_common() te da el ranking de superventas de Ana en una llamada, listo para el escaparate. Como es un dict con esteroides, todo lo de 04-03 funciona: items(), comprensiones, in...

defaultdict: diccionarios con valor por defecto

Patrón clásico: agrupar cosas. Ana quiere los pedidos organizados por cliente. Con un dict normal hay que comprobar si la clave existe antes de hacer append:

pedidos = [("Luis", "El Quijote"), ("Marta", "Hamlet"),
           ("Luis", "Fausto"), ("Marta", "La Odisea"), ("Luis", "Hamlet")]

# Con dict normal: la comprobación estorba
por_cliente = {}
for cliente, titulo in pedidos:
    if cliente not in por_cliente:
        por_cliente[cliente] = []          # la ceremonia de inicializar
    por_cliente[cliente].append(titulo)

defaultdict elimina la ceremonia: se construye con una fábrica (una función sin argumentos, como list, int o set) que se invoca automáticamente la primera vez que se accede a una clave inexistente:

from collections import defaultdict

por_cliente = defaultdict(list)            # clave nueva → se crea [] automáticamente
for cliente, titulo in pedidos:
    por_cliente[cliente].append(titulo)    # sin if: la primera vez crea la lista sola

print(dict(por_cliente))
# {'Luis': ['El Quijote', 'Fausto', 'Hamlet'], 'Marta': ['Hamlet', 'La Odisea']}

gasto = defaultdict(float)                 # float() → 0.0: acumuladores sin inicializar
gasto["Luis"] += 15.71
gasto["Luis"] += 20.75
print(gasto["Luis"])                       # 36.46
Fábrica Valor inicial Uso típico
list [] Agrupar (append por clave)
int 0 Contar (aunque Counter suele ser mejor)
float 0.0 Acumular importes
set set() Agrupar sin duplicados

Matiz importante: leer también crea. por_cliente["Nadie"] inserta "Nadie": [] solo por consultarlo. Si vas a hacer muchas consultas de solo lectura, conviértelo antes a dict normal (dict(por_cliente)) o usa get().

deque: colas eficientes por los dos extremos

En 04-01 montamos la cola de pedidos con una lista y avisamos: pop(0) desplaza todos los elementos restantes. deque (double-ended queue, se pronuncia "dec") está optimizada para añadir y quitar por ambos extremos sin desplazar nada:

from collections import deque

reservas = deque()                    # la cola de reservas de "Fausto" (agotado, ¿recuerdas?)
reservas.append("Luis")               # entra por la derecha, como en una lista
reservas.append("Marta")
reservas.appendleft("Pau")            # ¡urgente! entra por la izquierda

print(reservas)                       # deque(['Pau', 'Luis', 'Marta'])

siguiente = reservas.popleft()        # sale por la izquierda: el primero de la cola
print(siguiente)                      # 'Pau' → llegan ejemplares de Fausto: aviso a Pau
ultimo = reservas.pop()               # también puede salir por la derecha

ultimas_ventas = deque(maxlen=3)      # con maxlen: al llenarse, expulsa por el otro extremo
for t in ["Hamlet", "La Odisea", "El Quijote", "Fausto"]:
    ultimas_ventas.append(t)
print(ultimas_ventas)                 # deque(['La Odisea', 'El Quijote', 'Fausto'], maxlen=3)

El maxlen es una joya para "las N últimas operaciones" (historial del menú de Papyrus, por ejemplo): se mantiene solo. La contrapartida de deque: acceder por índice al medio (reservas[500]) es lento comparado con una lista. Regla: extremos → deque; posiciones interiores → lista.

graph LR
    A["appendleft()"] --> D["deque: Pau · Luis · Marta"]
    D --> B["pop()"]
    C["popleft()"] --- D
    D --- E["append()"]

OrderedDict: nota histórica

En código antiguo verás collections.OrderedDict: un dict que garantizaba el orden de inserción cuando los dicts normales no lo hacían. Desde Python 3.7 todos los dict conservan el orden, así que hoy casi nunca lo necesitas. Sobrevive por compatibilidad y por dos detalles menores (su igualdad sí compara el orden, y tiene move_to_end()). Si lo ves en un tutorial, ya sabes por qué está ahí — y que puedes usar un dict normal.

Criterios de elección: la tabla-resumen del módulo

La decisión de estructura es la primera decisión de diseño de cualquier programa. Todo el módulo 4, en una tabla:

Estructura Ordenada Mutable Duplicados Acceso Elígela cuando...
list Por índice Colección homogénea que crece/mengua/se ordena (cola de pedidos, títulos)
tuple No Por índice Registro fijo y heterogéneo; retorno múltiple; clave compuesta de dict
dict Inserción Claves no Por clave Asociar identidad → datos: el catálogo {título: ficha}
set No No Pertenencia Unicidad, in masivo, álgebra de conjuntos (socios por mes)
frozenset No No No Pertenencia Un set que deba ser clave o constante
str No Por índice Texto; normalizar con sus métodos; construir con join()
namedtuple No Por nombre Registro de solo lectura con campos legibles (línea de ticket)
Counter (cuenta) Por clave Contar apariciones y rankings (most_common)
defaultdict Inserción Claves no Por clave Agrupar/acumular sin inicializar claves
deque Extremos Colas y "últimos N" (reservas de Fausto)

Y como guía de decisión rápida:

graph TD
    A["¿Qué necesito guardar?"] --> B{"¿Pares clave→valor?"}
    B -- "Sí" --> C{"¿Claves que pueden faltar al agrupar?"}
    C -- "Sí" --> D["defaultdict"]
    C -- "¿Es contar?" --> E["Counter"]
    C -- "No" --> F["dict"]
    B -- "No" --> G{"¿Importan orden y posición?"}
    G -- "No: solo unicidad/pertenencia" --> H["set / frozenset"]
    G -- "Sí" --> I{"¿Cambiará tras crearse?"}
    I -- "Sí" --> J{"¿Entra/sale por los extremos?"}
    J -- "Sí" --> K["deque"]
    J -- "No" --> L["list"]
    I -- "No" --> M{"¿Campos con significado propio?"}
    M -- "Sí" --> N["namedtuple"]
    M -- "No" --> O["tuple"]

Errores Comunes y Consejos

  • Olvidar el import: NameError: name 'Counter' is not defined. Todo lo de esta lección vive en collections: from collections import Counter, defaultdict, deque, namedtuple.
  • Mutar una namedtuple: libro.stock = 3 lanza AttributeError. Es una tupla: usa _replace() y reasigna, o reconoce que ese dato pedía un dict.
  • defaultdict que "engorda" solo: cada lectura de clave nueva la crea. Para consultar sin crear, d.get(clave) o convertir a dict al terminar de construir.
  • Usar Counter como validador de existencia: contador["X"] devuelve 0 en vez de KeyError, así que if contador["X"]: es correcto pero contador["X"] jamás delatará una clave mal escrita.
  • Indexar el medio de una deque en bucle: para eso es mala; si necesitas posiciones interiores, era una lista.
  • Elegir estructura por costumbre ("todo listas"): revisa la tabla. La estructura correcta elimina código — el gran refactor de 04-03 borró más líneas de las que añadió.
  • Consejo: Counter, defaultdict y deque son dicts/colas normales en su interfaz; todo lo aprendido en 04-03 y 04-01 les aplica. No son estructuras nuevas que aprender, sino atajos sobre las que ya dominas.

Ejercicios

  1. Ranking de superventas. Con ventas = ["Hamlet", "El Quijote", "Hamlet", "La Odisea", "Hamlet", "El Quijote", "Fausto"], usa Counter para imprimir el podio (top 3) con formato 1. Hamlet — 3 uds. usando most_common() y enumerate(start=1) (módulo 2, al rescate). Añade el total de ejemplares vendidos.
  2. Agrupador de reservas. Con la lista de tuplas reservas = [("Fausto", "Luis"), ("Fausto", "Marta"), ("Ilíada", "Luis"), ("Fausto", "Pau")], construye con defaultdict(list) el dict titulo → [clientes en orden]. Después convierte la lista de "Fausto" en una deque y simula la llegada de 2 ejemplares: haz popleft() dos veces e informa a quién se avisa y quién sigue esperando.
  3. Ticket inmutable. Define la namedtuple LineaTicket con campos titulo, unidades e importe. Crea las líneas de una compra de Luis (2 "Hamlet" a 9.83 cada uno → importe 19.66; 1 "El Quijote" → 15.71), guárdalas en una lista e imprime el ticket con f-strings alineadas (04-05) y el total con sum() y una expresión generadora sobre .importe.

Soluciones

# Ejercicio 1
from collections import Counter

ventas = ["Hamlet", "El Quijote", "Hamlet", "La Odisea", "Hamlet", "El Quijote", "Fausto"]
contador = Counter(ventas)

for puesto, (titulo, unidades) in enumerate(contador.most_common(3), start=1):
    print(f"{puesto}. {titulo} — {unidades} uds.")
print(f"Total vendidos: {sum(contador.values())}")
# 1. Hamlet — 3 uds. / 2. El Quijote — 2 uds. / 3. La Odisea — 1 uds. / Total: 7

Fíjate: most_common(3) devuelve tuplas (titulo, unidades), y el for las desempaqueta dentro del par de enumerate — desempaquetado anidado con paréntesis.

# Ejercicio 2
from collections import defaultdict, deque

reservas = [("Fausto", "Luis"), ("Fausto", "Marta"), ("Ilíada", "Luis"), ("Fausto", "Pau")]

por_titulo = defaultdict(list)
for titulo, cliente in reservas:
    por_titulo[titulo].append(cliente)

cola_fausto = deque(por_titulo["Fausto"])    # deque(['Luis', 'Marta', 'Pau'])
for _ in range(2):                            # llegan 2 ejemplares
    print(f"Avisar a {cola_fausto.popleft()}: su Fausto ha llegado")
print(f"Siguen esperando: {list(cola_fausto)}")   # ['Pau']
# Ejercicio 3
from collections import namedtuple

LineaTicket = namedtuple("LineaTicket", ["titulo", "unidades", "importe"])

lineas = [
    LineaTicket("Hamlet", 2, 19.66),
    LineaTicket("El Quijote", 1, 15.71),
]
for linea in lineas:
    print(f"{linea.unidades} x {linea.titulo:<12}{linea.importe:>8.2f} EUR")
total = sum(linea.importe for linea in lineas)
print(f"{'TOTAL':<16}{total:>8.2f} EUR")     # 35.37 EUR

Consejo: las líneas de un ticket cobrado no deben cambiar jamás — la inmutabilidad de la namedtuple no es una limitación aquí, es exactamente la garantía que el negocio necesita.

Conclusión

El módulo 4 cumple lo que prometió al cerrar el módulo 3: cada libro de Papyrus es por fin una sola pieza de información. Repasaste las listas a fondo con su mutabilidad y sus trampas de alias (04-01); las tuplas te dieron registros inmutables y el retorno múltiple que 03-02 dejó prometido (04-02); los diccionarios ejecutaron el gran refactor — el catálogo es hoy {título: {"precio": ..., "stock": ...}} con acceso directo por clave (04-03); los conjuntos aportaron unicidad, pertenencia ultrarrápida y álgebra para comparar socios y deduplicar pedidos (04-04); las cadenas revelaron su caja de herramientas completa y la normalización definitiva de buscar_libro() (04-05); y collections puso nombre a los patrones que faltaban — namedtuple, Counter, defaultdict, deque — junto con la tabla de criterio para elegir estructura sin dudar. Pero mira el código de Papyrus con ojos nuevos: el catálogo vive en un diccionario y las funciones que saben operarlo — buscar_libro(), mostrar_catalogo(), precio_final() — viven aparte, en papyrus_utils.py, confiando en que nadie les pase una estructura equivocada. Datos por un lado, comportamiento por otro. El módulo 5 presenta la herramienta que los une en una sola pieza con nombre propio: las clases. Cada libro dejará de ser una entrada de diccionario para convertirse en un objeto que sabe calcular su propio precio de socio — la programación orientada a objetos empieza donde las estructuras de datos terminan.

Curso de Programación en Python

Módulo 1: Introducción a Python

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados