El aeropuerto de las etiquetas: el modelo de objetos de Python
·
Resumen del curso
1
La etiqueta no es la maleta: nombres y objetos
2
Maletas selladas: mutable e inmutable
3
Maletas dentro de maletas: contenedores y copias
4
Al entregar una maleta: funciones y argumentos
5
Zonas y pases: ámbitos
6
Equipaje de mano: clausuras
7
La especificación de la maleta: clases e instancias
8
Procedimientos estándar del aeropuerto: métodos dunder
9
La especificación de la especificación: herencia y MRO
10
El control: el secreto del acceso a atributos
11
Maletas sin dueño: memoria y GC
12
Números de taquilla: hash, igualdad y repaso final
Tiempo 9:10–10:00
Parte de equipaje perdido
LOST-012
Lo reporta
Han, de la oficina de objetos perdidos
Qué se perdió
El equipaje de la maleta fantasma B. Se registró el mes pasado en la taquilla de objetos perdidos (un dict), pero cuando su dueño vino a buscarlo, salió «no está». La taquilla L7 sigue cerrada.
Para cerrar el caso
Explicar la causa con el mecanismo que usa la taquilla para encontrar las casillas, abrir L7 y volver a guardarlo con una clave que no cambie.
Aeropuerto Internacional Haneulseom · Centro de equipajes
Ayer retiramos las dos maletas fantasma de la cinta 13. Solo queda una línea del registro de depósitos. El mes pasado, B se registró en la taquilla de objetos perdidos, pero cuando su dueño vino a buscarla, el registro respondió «no está». B volvió a la cinta, y lo que pasó después es la historia hasta ayer.
Han vuelve a abrir el registro de entonces. Al imprimir la lista, B aparece claramente, pero al preguntar con in sale False. La supervisora Do Miran recibe el último parte y dice: «El número de taquilla lo decide la maleta. Si la maleta cambia después de guardarla, el número también cambia.»
== (8 min)dict y set son taquillas con muchas casillas. Cada vez que se guarda o se busca una clave, se siguen los mismos pasos:
hash(key).==. Si no, se mira la siguiente casilla según una regla fija.Por eso hace falta una promesa: si a == b, entonces tiene que cumplirse hash(a) == hash(b). Si dos claves iguales empezaran a buscar en casillas distintas, nunca se encontrarían.
print(1 == 1.0 == True, hash(1) == hash(1.0) == hash(True))
# True True
print({1: "int", 1.0: "float", True: "bool"})
# {1: 'bool'}1, 1.0 y True son == entre sí, así que también tienen el mismo hash, y en un dict son la misma clave. Se queda la clave que se guardó primero, 1, y solo el valor cambia al último.
Lo contrario no tiene por qué cumplirse. Una colisión, mismo hash con valores distintos, es normal. El == del paso 4 las distingue.
print(hash(42), hash(-1), hash(-2))
# 42 -2 -2
print(hash(-1) == hash(-2), -1 == -2)
# True False
lockers = {-1: "A", -2: "B"}
print(lockers[-1], lockers[-2])
# A BEn CPython, el hash de un entero pequeño es el propio entero. Pero -1 es el valor que dentro de C significa «error», así que hash(-1) da -2. Es una colisión, pero el dict distingue los dos sin problema.
El hash de las cadenas cambia cada vez que se ejecuta el programa. Es una protección contra ataques que intentan predecir los valores de hash. Si necesitas el mismo valor (para reproducir una prueba, por ejemplo), fija la variable de entorno PYTHONHASHSEED.
$ python -c "print(hash('T1'))"
# p. ej.: 4646783657884298203
$ python -c "print(hash('T1'))"
# p. ej.: -1769311965495050168
$ PYTHONHASHSEED=0 python -c "print(hash('T1'))"
# el mismo valor en cada ejecuciónPor eso no hay que guardar valores de hash en archivos ni intercambiarlos con otros procesos. El hash es un número que solo vale dentro de una misma ejecución.
La taquilla de abajo está simplificada a 8 casillas. Empieza en la casilla que da el resto de dividir el hash entre 8 y, si está ocupada, pasa directamente a la siguiente. Guarda por orden 7 y 15, y -1 y -2, para provocar colisiones.
Taquillas
Una tabla hash simplificada de 8 casillas. El dict de CPython elige las casillas en un orden más complejo, pero la idea es la misma: buscar la casilla por hash y confirmar con ==.
La taquilla deja la clave en la casilla del número calculado al guardarla y no vuelve a calcular el número después. ¿Qué pasa si, después de guardarla, el valor de la clave cambia y su hash es otro? Lo comprobamos con una clave mal hecha a propósito.
class Tag:
def __init__(self, n):
self.n = n
def __eq__(self, other):
return isinstance(other, Tag) and self.n == other.n
def __hash__(self):
return hash(self.n) # error: el hash se basa en un valor que puede cambiar
def __repr__(self):
return f"Tag({self.n})"
key = Tag(3)
lockers = {key: "bag B"}
print(Tag(3) in lockers)
# True
key.n = 13
print(lockers)
# {Tag(13): 'bag B'}
print(Tag(13) in lockers, Tag(3) in lockers, key in lockers)
# False False FalseEn la lista se ve Tag(13), pero se pregunte como se pregunte, da False.
Tag(13): empieza en la casilla del hash 13. La maleta está en la casilla del hash 3, así que no se encuentran.Tag(3): sí llega a la casilla del hash 3. Pero la clave de esa casilla ahora es Tag(13), así que == da False.key que guardaste: su hash ahora es 13, así que sigue el mismo camino que en el primer caso.# continuación del ejemplo anterior
key.n = 3
print(Tag(3) in lockers)
# TrueSi devuelves el valor a 3, vuelve a encontrarse. La maleta estuvo todo el tiempo en la casilla donde se guardó. Lo único que cambió fue el número para encontrar la casilla.
Taquillas
Una tabla hash simplificada de 8 casillas. El dict de CPython elige las casillas en un orden más complejo, pero la idea es la misma: buscar la casilla por hash y confirmar con ==.
Por eso Python directamente no acepta como claves los tipos integrados cuyo valor puede cambiar. list, dict y set no tienen hash.
lockers = {}
lockers[[1, 2]] = "bag"
# TypeError: cannot use 'list' as a dict key (unhashable type: 'list')print(hash((1, 2)) == hash((1, 2)))
# True
hash((1, [2]))
# TypeError: unhashable type: 'list'Una tupla puede ser clave, pero si contiene una lista, no se le puede calcular el hash. Como vimos en la lección 2, la inmutabilidad de una tupla solo afecta a «las referencias que guarda», y si la lista de dentro cambiara, el hash también tendría que cambiar. Para usar algo como clave, usa valores inmutables hasta el fondo, como (1, (2,)) o frozenset.
Pin dice
→ Las claves de un dict y los elementos de un set tienen que ser «cosas que no cambian después de guardarlas». Si hay que cambiarlas, sácalas (pop) y vuelve a guardarlas con la clave nueva.
Una clase que no define nada compara la igualdad con is y calcula el hash a partir de la identidad. Por eso sus instancias se pueden usar como claves. Pero si solo defines __eq__, Python pone __hash__ a None (lo que anunciamos en la lección 8). Si el criterio de igualdad cambia y se sigue usando el hash antiguo, se rompe la promesa.
class Label:
def __init__(self, code):
self.code = code
def __eq__(self, other):
return isinstance(other, Label) and self.code == other.code
print(Label("T1") == Label("T1"), Label.__hash__)
# True None
{Label("T1")}
# TypeError: cannot use 'Label' as a set element (unhashable type: 'Label')Si necesitas hash, hay dos formas.
1. @dataclass(frozen=True): genera __eq__ y __hash__ a partir de los campos e impide modificarlos.
from dataclasses import dataclass
@dataclass(frozen=True)
class Tag:
n: int
key = Tag(3)
lockers = {key: "bag B"}
print(lockers[Tag(3)])
# bag B
key.n = 13
# dataclasses.FrozenInstanceError: cannot assign to field 'n'Si usas @dataclass sin frozen, solo se genera __eq__, así que __hash__ es None. Si la clase va a usarse como clave, ponle frozen=True.
2. Escribir __hash__ a mano: agrupa en una tupla los mismos campos que compara __eq__ y calcula su hash.
class Route:
def __init__(self, origin, dest):
self.origin = origin
self.dest = dest
def __eq__(self, other):
if not isinstance(other, Route):
return NotImplemented
return (self.origin, self.dest) == (other.origin, other.dest)
def __hash__(self):
return hash((self.origin, self.dest))
seen = {Route("ICN", "NRT")}
print(Route("ICN", "NRT") in seen, len({Route("ICN", "NRT"), Route("ICN", "NRT")}))
# True 1Si lo escribes a mano, hay tres reglas:
__eq__.NotImplemented en lugar de False (lección 8).Como último encargo, la supervisora saca el viejo programa de gestión del centro. En un solo archivo están todos los errores de los once partes anteriores. Empecemos por el resultado de ejecutarlo.
import gc
import weakref
class Bag:
stickers = [] # lista mutable como atributo de clase
def __init__(self, code, items=[]): # lista como argumento por defecto
self.code = code
self.items = items
self.partner = None
def __eq__(self, other):
return isinstance(other, Bag) and self.code == other.code
def __hash__(self):
return hash(self.code) # hash basado en un valor que puede cambiar
a = Bag("A1")
b = Bag("B1")
a.items.append("camera")
a.stickers.append("fragile")
print(b.items, b.stickers)
# ['camera'] ['fragile']
shelves = [[]] * 3 # la misma lista tres veces
shelves[0].append("A1")
print(shelves)
# [['A1'], ['A1'], ['A1']]
calls = [lambda: f"gate {g}" for g in (1, 2, 3)]
print([call() for call in calls])
# ['gate 3', 'gate 3', 'gate 3']
lockers = {b: "L7"}
b.code = "B2" # cambia la clave después de guardarla
print(Bag("B1") in lockers, Bag("B2") in lockers)
# False False
a.partner, b.partner = b, a # dos maletas que se llevan la una a la otra
watch = weakref.ref(a)
del a, b, lockers
print(watch() is None)
# False
gc.collect()
print(watch() is None)
# TrueSeñalemos la causa línea a línea y convirtámoslo en una lista de revisión.
| Síntoma | Causa | Solución | Lección |
|---|---|---|---|
| La cámara metida en A también está en B | El argumento por defecto items=[] se crea una sola vez, al definir la función | Recibir items=None y crear una lista nueva dentro | 4 |
| La pegatina aparece en todas las maletas | Todas las instancias comparten el atributo de clase stickers = [] | Convertirlo en atributo de instancia en __init__ | 7 |
| Las tres baldas cambian a la vez | [[]] * 3 son tres referencias a la misma lista | [[] for _ in range(3)] | 3 |
| Todos los avisos dicen gate 3 | La lambda lleva la variable g, no su valor (enlace tardío) | lambda g=g: ... | 6 |
| No se encuentra B en la taquilla | Se cambió el valor de la clave después de guardarla, y su hash cambió | Usar como clave un valor que no cambie y, si hay que cambiarlo, trasladarlo | 12 |
| Aunque se quiten todas las etiquetas, la maleta se queda | Referencia circular entre los partner | Hacer que uno de los lados sea weakref.ref | 11 |
Este es el programa corregido. Todas las salidas son ahora las esperadas.
import weakref
class Bag:
def __init__(self, code, items=None):
self.code = code
self.items = [] if items is None else list(items)
self.stickers = []
self._partner = None
@property
def partner(self):
return self._partner() if self._partner is not None else None
@partner.setter
def partner(self, other):
self._partner = weakref.ref(other)
a = Bag("A1")
b = Bag("B1")
a.items.append("camera")
a.stickers.append("fragile")
print(b.items, b.stickers)
# [] []
shelves = [[] for _ in range(3)]
shelves[0].append("A1")
print(shelves)
# [['A1'], [], []]
calls = [lambda g=g: f"gate {g}" for g in (1, 2, 3)]
print([call() for call in calls])
# ['gate 1', 'gate 2', 'gate 3']
lockers = {b.code: "L7"} # la clave es una cadena, que no cambia
lockers["B2"] = lockers.pop(b.code) # al cambiar el código, se traslada la clave
b.code = "B2"
print("B1" in lockers, "B2" in lockers, lockers[b.code])
# False True L7
a.partner, b.partner = b, a
watch = weakref.ref(a)
del a, b, lockers
print(watch() is None)
# TrueCopiar lo recibido con list(items) también es la copia defensiva de la lección 4. Como hemos quitado __eq__ y __hash__ de Bag, las maletas en sí se comparan por identidad y el papel de clave lo cumple el código, que es una cadena.
Comparemos en el tablero los dos primeros errores (argumento por defecto y atributo de clase). En bug, las flechas items de las dos instancias convergen en una sola lista, y stickers cuelga de la caja de la especificación (class).
Tablero de etiquetas
Paso 1 / 0
Lo siguiente que se ejecuta: 0
Preparando el laboratorio…
Acaba de cambiar
Maleta sellada (inmutable)
Repasemos las doce lecciones, una línea cada una.
| Lección | Título | En una línea |
|---|---|---|
| 1 | La etiqueta no es la maleta: nombres y objetos | = cuelga una etiqueta de un objeto. is, mismo objeto; ==, mismo valor. |
| 2 | Maletas selladas: mutable e inmutable | «Cambiar» un objeto inmutable es crear uno nuevo y reasignar. += tiene dos caras según el tipo. |
| 3 | Maletas dentro de maletas: contenedores y copias | Los contenedores guardan referencias. La copia superficial, un nivel; deepcopy, hasta el fondo. |
| 4 | Al entregar una maleta: funciones y argumentos | Una función cuelga una etiqueta más del mismo objeto. Los argumentos por defecto se crean una sola vez. |
| 5 | Zonas y pases: ámbitos | Los nombres se buscan en el orden L → E → G → B, y donde se asigna se decide el ámbito. |
| 6 | Equipaje de mano: clausuras | La función interior no lleva valores, sino variables (celdas). |
| 7 | La especificación de la maleta: clases e instancias | Los atributos de clase los comparten todas las instancias. Los atributos de instancia, en __init__. |
| 8 | Procedimientos estándar del aeropuerto: métodos dunder | La sintaxis llama a métodos dunder. len(x) es type(x).__len__(x). |
| 9 | La especificación de la especificación: herencia y MRO | Los atributos se buscan en el orden de __mro__, y super() es la siguiente clase del MRO. |
| 10 | El control: el secreto del acceso a atributos | property y los descriptores interceptan el acceso a atributos. Las funciones también son descriptores. |
| 11 | Maletas sin dueño: memoria y GC | Con 0 referencias, se retira; los ciclos los retira el GC. Una referencia débil es una etiqueta punteada. |
| 12 | Números de taquilla: hash, igualdad y repaso final | Si a == b, entonces hash(a) == hash(b). Una clave no debe cambiar después de guardarla. |
La maleta fantasma B calculaba su hash a partir de su número, no. Al reclasificarla después de guardarla en la taquilla, le reescribieron el número y cambió el camino para encontrar su casilla. Si se recorren las casillas una a una sin usar el hash, B sigue ahí.
from dataclasses import dataclass
class Bag:
def __init__(self, no):
self.no = no
def __eq__(self, other):
return isinstance(other, Bag) and self.no == other.no
def __hash__(self):
return hash(self.no)
ghost = Bag(3)
lockers = {ghost: "L7"}
ghost.no = 13 # le reescriben el número al reclasificarla
print(ghost in lockers)
# False
print([v for k, v in lockers.items() if k is ghost]) # recorriendo una a una sin hash, está
# ['L7']
@dataclass(frozen=True)
class BagNo:
no: int
lockers = {BagNo(13): "L7"} # se vuelve a guardar con una clave que no cambia
print(lockers[BagNo(13)], BagNo(13) in lockers)
# L7 Trueitems() no usa el hash, así que encuentra a B. A cambio, tarda tanto como casillas haya.BagNo es frozen=True, así que no se puede cambiar su número después de guardarla. Si el número cambia, se crea un BagNo nuevo y se traslada.La taquilla L7 se abre. Sale el equipaje de B, encerrado durante un mes, y Han empieza a llamar a su dueño. La supervisora Do Miran vuelve a guardar el fajo de etiquetas en el cajón. «¿Te acuerdas de lo que te dije el primer día? Una maleta, muchas etiquetas. Ahora ya ves de dónde cuelga cada etiqueta, quién sujeta cada maleta y en qué casilla está cada una, ¿verdad?» Pin señala la cinta 13. Hoy no da vueltas nada.
Informe de cierre — LOST-012. B no se encontraba en la taquilla porque su clave cambió de hash después de guardarla. Se localizó L7 recorriendo items() y se volvió a guardar con una clave @dataclass(frozen=True). Cerrado por completo el caso de las maletas fantasma de la cinta 13.
{(1, 2): "A", (2, 1): "B"}? Compara el == y el hash de las dos tuplas para predecirlo y luego compruébalo.Tag de 12-2 con @dataclass(frozen=True) y comprueba dónde se bloquea key.n = 13.La libreta de la supervisora
dict y set buscan la casilla por hash y la confirman con ==. Si a == b, entonces hash(a) == hash(b).
Las colisiones de hash son normales (hash(-1) == hash(-2)). El hash de las cadenas cambia en cada ejecución (PYTHONHASHSEED).
Si una clave cambia después de guardarla, no se encuentra ni con el valor nuevo ni con el antiguo. Las claves, inmutables.
list, dict y set no pueden ser claves. Una tupla que contenga una lista, tampoco.
Si solo defines __eq__, __hash__ es None. Usa @dataclass(frozen=True) o escribe __hash__ con los mismos campos.
Lista de revisión: objetos mutables compartidos, argumentos por defecto, enlace tardío, atributos de clase, referencias circulares, claves que cambian.
Una maleta, muchas etiquetas. Si te atascas, dibuja las etiquetas y las maletas.
Recibe avisos de nuevas lecciones
Te avisaremos por correo cuando este curso tenga lecciones nuevas o se abra un curso de continuación.
R
ReflexKR
Aprende Python y Reflex haciendo. Todos los ejemplos se ejecutaron y verificaron en Reflex 0.10.0.
ReflexKR es gratis y sin banners publicitarios. Si te ayudó, puedes apoyarlo con un café. ☕ Invítanos un café
Aviso de afiliados© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
Preguntas y respuestas
Pregunta lo que quieras sobre esta lección. Aquí se reúnen las preguntas de todas las versiones de idioma de esta página.