Ejercicio de examen de POO en Python: un ticket de compra con herencia, clase abstracta y métodos especiales __eq__, __hash__, __add__, __len__ e __iter__.
Actualizado el
Código de Examen Python Programación orientada a objetos Nivel examen Código probado

Una tienda de ultramarinos quiere imprimir el ticket de cada compra. En la caja se registran productos que se venden por unidades (un café, tres cruasanes) y productos que se venden a granel (queso, aceitunas), cuyo precio se expresa por kilo. Todas las cantidades de dinero se manejan en céntimos enteros, para no arrastrar errores de coma flotante.
Debes escribir un programa orientado a objetos con esta estructura mínima: una clase base abstracta Linea con los atributos nombre, precio y cantidad; dos subclases, LineaUnidad y LineaPeso, que redefinan el cálculo del importe; y una clase Ticket que agrupe las líneas. Para una LineaUnidad, el importe es precio * cantidad. Para una LineaPeso, la cantidad son gramos y el importe es el precio por kilo multiplicado por los gramos y dividido entre 1000, redondeado al céntimo con las mitades hacia arriba, usando solo aritmética entera.
Dos líneas representan el mismo producto si son del mismo tipo y tienen el mismo nombre y el mismo precio. Esa igualdad debe implementarse con __eq__, junto con un __hash__ coherente. Además, dos líneas del mismo producto se fusionan con el operador + (__add__). El resultado es una línea nueva del mismo tipo cuya cantidad es la suma de ambas. Si se intenta sumar productos distintos, se lanza ValueError. Cuando se añade al ticket una línea de un producto que ya está, se fusiona con la existente en lugar de añadirse aparte.
Ticket debe implementar __len__ (número de líneas tras fusionar), __iter__ (recorre las líneas en orden de impresión) y __str__ (el ticket completo). El orden de impresión es por importe de mayor a menor. Los empates se resuelven por nombre en orden alfabético, después por tipo (las de peso antes que las de unidad) y, por último, por precio ascendente.
Entrada (por la entrada estándar): una línea por registro de caja, con el formato U nombre precio cantidad para productos por unidad (precio en céntimos por unidad) o P nombre precio gramos para productos a granel (precio en céntimos por kilo). Los nombres no contienen espacios. Precio y cantidad son enteros mayores o iguales que 1. Las líneas en blanco se ignoran, y la entrada puede estar vacía.
Salida: una línea por producto en el orden indicado. Para unidades se escribe nombre cantidad x precio = importe. Para granel se escribe nombre gramos g a precio/kg = importe. Al final van dos líneas, Lineas: n y TOTAL: importe, donde el total es la suma de los importes de las líneas. Todo importe se escribe en euros con coma decimal y exactamente dos decimales; por ejemplo, 1462 céntimos se escribe 14,62.
Entrada de ejemplo
U cafe 120 2
P queso 1890 250
U croissant 95 3
P queso 1890 130
U cafe 120 1
P aceitunas 640 155Salida esperada
queso 380 g a 18,90/kg = 7,18
cafe 3 x 1,20 = 3,60
croissant 3 x 0,95 = 2,85
aceitunas 155 g a 6,40/kg = 0,99
Lineas: 4
TOTAL: 14,62- Jerarquía correcta: clase base abstracta Linea con atributos comunes y subclases LineaUnidad y LineaPeso que redefinen importe() y el detalle
- 0,75
- Métodos especiales de Linea: __eq__ coherente con __hash__ y __add__ que fusiona líneas devolviendo un objeto nuevo del mismo tipo
- 0,75
- Clase Ticket con __len__, __iter__ y __str__, y orden de las líneas según el criterio pedido
- 0,5
- Lectura de la entrada, redondeo entero al céntimo y formato exacto de importes y totales
- 0,5
Pistas
Pista 1 · ¿Qué parte es común y qué parte cambia entre los dos tipos de línea?
Los dos tipos de línea comparten los atributos, la igualdad, la fusión y la forma de imprimirse: «nombre, detalle, signo igual, importe». Solo cambian dos cosas, el cálculo del importe y el texto del detalle. Pon todo lo común en Linea y declara esas dos operaciones como @abstractmethod. Así cada subclase solo escribe lo que la distingue, y nadie puede instanciar una Linea genérica.
Pista 2 · ¿Cómo redondeo al céntimo sin usar float ni round()?
Si tuvieras que redondear x / 1000 con las mitades hacia arriba, bastaría con sumar la mitad del divisor antes de la división entera: (x + 500) // 1000. Comprueba a mano que 1500 gramos-céntimo dan 2 y que 1499 dan 1. Ojo: al fusionar dos líneas a granel hay que sumar los gramos y redondear una sola vez, no sumar dos importes ya redondeados.
Pista 3 · ¿Cómo creo, dentro de la clase base, un objeto de la subclase correcta?
En __add__ no sabes si self es de unidad o de peso. No escribas el nombre de ninguna clase concreta: type(self) devuelve la clase real del objeto, y puedes llamarla como a un constructor. Para ordenar, define en Linea un método que devuelva una tupla con los criterios en orden, por ejemplo con el importe cambiado de signo, y pásalo como key a sorted.
Solución
Solución explicada
La solución se apoya en tres ideas. La primera es una clase base abstracta que concentra todo el comportamiento común. La segunda son dos subclases mínimas que solo aportan el cálculo del importe y el texto del detalle. La tercera es un contenedor, Ticket, que se comporta como una colección de Python gracias a __len__ e __iter__. El dinero se trata siempre en céntimos enteros, y solo se convierte a texto en la función euros, en el último momento.
Si quieres repasar antes los conceptos básicos de clase, constructor y herencia, en el blog tienes el mismo tema explicado en Java en el ejercicio de herencia, constructores y super. La traducción a Python es casi directa, salvo por super() y por los métodos especiales.
import sys
from abc import ABC, abstractmethod
def euros(centimos):
"""Convierte céntimos enteros (>= 0) al formato 14,62."""
return f"{centimos // 100},{centimos % 100:02d}"
class Linea(ABC):
TIPO = "?"
def __init__(self, nombre, precio, cantidad):
if precio <= 0 or cantidad <= 0:
raise ValueError("precio y cantidad deben ser positivos")
self.nombre = nombre
self.precio = precio
self.cantidad = cantidad
@abstractmethod
def importe(self):
"""Importe de la línea en céntimos."""
@abstractmethod
def detalle(self):
"""Texto entre el nombre y el signo igual."""
def __eq__(self, otro):
if not isinstance(otro, Linea):
return NotImplemented
return (type(self) is type(otro)
and self.nombre == otro.nombre
and self.precio == otro.precio)
def __hash__(self):
return hash((type(self), self.nombre, self.precio))
def __add__(self, otro):
if not isinstance(otro, Linea):
return NotImplemented
if self != otro:
raise ValueError("no se pueden sumar productos distintos")
return type(self)(self.nombre, self.precio,
self.cantidad + otro.cantidad)
def clave_orden(self):
return (-self.importe(), self.nombre, self.TIPO, self.precio)
def __str__(self):
return f"{self.nombre} {self.detalle()} = {euros(self.importe())}"
class LineaUnidad(Linea):
TIPO = "U"
def importe(self):
return self.precio * self.cantidad
def detalle(self):
return f"{self.cantidad} x {euros(self.precio)}"
class LineaPeso(Linea):
TIPO = "P"
def importe(self):
# precio en céntimos/kg, cantidad en gramos; mitades hacia arriba
return (self.precio * self.cantidad + 500) // 1000
def detalle(self):
return f"{self.cantidad} g a {euros(self.precio)}/kg"
class Ticket:
def __init__(self):
self._lineas = []
def agregar(self, linea):
for i, existente in enumerate(self._lineas):
if existente == linea:
self._lineas[i] = existente + linea
return
self._lineas.append(linea)
def __len__(self):
return len(self._lineas)
def __iter__(self):
return iter(sorted(self._lineas, key=Linea.clave_orden))
def total(self):
return sum(linea.importe() for linea in self._lineas)
def __str__(self):
filas = [str(linea) for linea in self]
filas.append(f"Lineas: {len(self)}")
filas.append(f"TOTAL: {euros(self.total())}")
return "\n".join(filas)
TIPOS = {"U": LineaUnidad, "P": LineaPeso}
def main():
ticket = Ticket()
for texto in sys.stdin:
partes = texto.split()
if not partes:
continue
clase = TIPOS[partes[0]]
ticket.agregar(clase(partes[1], int(partes[2]), int(partes[3])))
print(ticket)
main()La clase base. Linea hereda de ABC y declara importe y detalle como métodos abstractos. Con eso, Linea("x", 1, 1) lanza TypeError, y cualquier subclase que olvide uno de los dos métodos tampoco se podrá instanciar. El constructor valida que precio y cantidad sean positivos. El enunciado garantiza entradas válidas, pero un objeto no debería poder nacer en un estado imposible. El atributo de clase TIPO sirve para el desempate en la ordenación, y cada subclase lo redefine.
Igualdad y hash. __eq__ devuelve NotImplemented si el otro operando no es una línea. Así Python puede intentar la comparación inversa y, si tampoco funciona, acaba devolviendo False. La comparación usa type(self) is type(otro) y no isinstance, porque una línea de peso y otra de unidad con el mismo nombre y precio no son el mismo producto. Al redefinir __eq__, Python pone __hash__ a None. Por eso lo redefinimos con la misma tupla de campos que la igualdad. Objetos iguales tienen así el mismo hash, que es lo que exigen set y dict.
Fusión con +. __add__ reutiliza la igualdad (self != otro) para rechazar productos distintos y construye el resultado con type(self)(...). Ese detalle es lo que permite escribir la fusión una sola vez en la base: si self es una LineaPeso, el resultado es una LineaPeso. Los operandos no se modifican. Se devuelve un objeto nuevo, como hace + con números y cadenas. Eso evita sorpresas si alguien guarda una referencia a la línea original.
Las subclases. LineaUnidad.importe multiplica sin más. LineaPeso.importe calcula (self.precio * self.cantidad + 500) // 1000, que es el redondeo con las mitades hacia arriba en aritmética entera. El caso «redondeo y fusión a granel» lo pone a prueba. Una línea de 1 g de nueces a 15,00/kg vale 1,5 céntimos y se imprime como 0,02. Las dos líneas de 2 g de uvas, al fusionarse, suman 4 g y valen 1 céntimo, no 2. El método __str__ de la base compone la línea llamando a detalle() e importe(), que se resuelven por enlace dinámico en la subclase.
El contenedor. Ticket.agregar recorre la lista y, si encuentra una línea igual (existente == linea), la sustituye por existente + linea. Si no, añade la nueva al final. __len__ permite escribir len(ticket). __iter__ devuelve un iterador sobre la lista ordenada con key=Linea.clave_orden, cuya tupla (-importe, nombre, TIPO, precio) codifica el criterio del enunciado. El importe va cambiado de signo para que quede de mayor a menor. Finalmente, __str__ recorre self con un for, lo que invoca __iter__, y añade el recuento y el total.
Comprobación con los tests. En el ejemplo, el queso aparece dos veces (250 g y 130 g) y se fusiona en una línea de 380 g que vale 7,18. Los cafés se fusionan en 3 unidades. El caso «mismo nombre, distinto producto» tiene tres líneas de pan que no se fusionan entre sí: dos por unidades con precios distintos y una a granel. También tiene un empate a 3,00 entre pan a granel y zumo, que se resuelve por nombre. La entrada vacía imprime Lineas: 0 y TOTAL: 0,00, porque sum de una secuencia vacía es 0. El caso de importes grandes muestra que euros no necesita separador de miles.
Casos de prueba
| Caso | Entrada | Salida esperada | Salida real | Resultado |
|---|---|---|---|---|
| Ejemplo del enunciado | U cafe 120 2
P queso 1890 250
U croissant 95 3
P queso 1890 130
U cafe 120 1
P aceitunas 640 155 | queso 380 g a 18,90/kg = 7,18
cafe 3 x 1,20 = 3,60
croissant 3 x 0,95 = 2,85
aceitunas 155 g a 6,40/kg = 0,99
Lineas: 4
TOTAL: 14,62 | queso 380 g a 18,90/kg = 7,18
cafe 3 x 1,20 = 3,60
croissant 3 x 0,95 = 2,85
aceitunas 155 g a 6,40/kg = 0,99
Lineas: 4
TOTAL: 14,62 | OK |
| Entrada vacía | (vacía) | Lineas: 0
TOTAL: 0,00 | Lineas: 0
TOTAL: 0,00 | OK |
| Un solo producto | U agua 100 1 | agua 1 x 1,00 = 1,00
Lineas: 1
TOTAL: 1,00 | agua 1 x 1,00 = 1,00
Lineas: 1
TOTAL: 1,00 | OK |
| Mismo nombre, distinto producto y empates | U pan 150 2
U pan 140 2
P pan 3000 100
U zumo 300 1
U pan 150 1 | pan 3 x 1,50 = 4,50
pan 100 g a 30,00/kg = 3,00
zumo 1 x 3,00 = 3,00
pan 2 x 1,40 = 2,80
Lineas: 4
TOTAL: 13,30 | pan 3 x 1,50 = 4,50
pan 100 g a 30,00/kg = 3,00
zumo 1 x 3,00 = 3,00
pan 2 x 1,40 = 2,80
Lineas: 4
TOTAL: 13,30 | OK |
| Redondeo y fusión a granel | P uvas 250 2
P nueces 1500 1
P uvas 250 2 | nueces 1 g a 15,00/kg = 0,02
uvas 4 g a 2,50/kg = 0,01
Lineas: 2
TOTAL: 0,03 | nueces 1 g a 15,00/kg = 0,02
uvas 4 g a 2,50/kg = 0,01
Lineas: 2
TOTAL: 0,03 | OK |
| Líneas en blanco e importes grandes |
U portatil 99999 2
U raton 1500 10 | portatil 2 x 999,99 = 1999,98
raton 10 x 15,00 = 150,00
Lineas: 2
TOTAL: 2149,98 | portatil 2 x 999,99 = 1999,98
raton 10 x 15,00 = 150,00
Lineas: 2
TOTAL: 2149,98 | OK |
Salidas reales: código compilado con Python 3.13.16 y ejecutado en un contenedor aislado el 6 de noviembre de 2026.
Complejidad
Sea r el número de registros de entrada y n el número de líneas distintas tras fusionar (n ≤ r). Cada llamada a agregar recorre la lista en el peor caso, así que la carga del ticket cuesta O(r·n). La impresión ordena una vez, en O(n log n), y calcula el total en O(n). Para un ticket de caja, con decenas de líneas, es más que suficiente.
Si el número de productos distintos fuera grande, la mejora natural es aprovechar el __hash__ que ya hemos escrito: un diccionario indexado por la propia línea reduce cada inserción a O(1) amortizado y la carga total a O(r). La memoria es O(n) en ambas versiones, más la copia ordenada que crea sorted en cada iteración.
Errores típicos
- Redefinir
__eq__y olvidar__hash__. La clase deja de ser hashable y cualquier intento de meter líneas en unseto usarlas como clave de undictlanzaTypeError: unhashable type. Otro error es definir un hash que incluya la cantidad, de modo que dos objetos iguales tengan hashes distintos. - Escribir
LineaUnidad(...)oLinea(...)dentro de__add__. En el primer caso, fusionar dos líneas a granel las convierte en una línea por unidades. En el segundo, Python lanzaTypeErrorporque la clase es abstracta. La forma correcta estype(self)(...). - Fusionar líneas a granel sumando sus importes ya redondeados en lugar de sumar los gramos. En el ejemplo, el queso daría 4,73 + 2,46 = 7,19 en vez de 7,18. En el test de uvas daría 0,02 en vez de 0,01.
- Usar
floatyround()para los céntimos. Python redondea las mitades al par (round(2.5)es 2), y además 0,1 no se representa exactamente en binario. Hay que trabajar en enteros y redondear con(x + 500) // 1000. - Comparar tipos con
isinstanceen__eq__. Como ambas subclases son instancias deLinea, una línea de peso y otra de unidad con el mismo nombre y precio se considerarían el mismo producto y se fusionarían. - Hacer que
__add__modifiqueself.cantidady devuelvaself. Funciona en este programa, pero viola la semántica de+: trasc = a + b, el objetoahabría cambiado. Si se quiere modificar en sitio, el método adecuado es__iadd__.
Variantes
Añadir un tercer tipo: LineaOferta con «3x2»
Se pide una subclase LineaOferta (código O en la entrada) para productos por unidad con la promoción «lleva 3, paga 2». Basta con heredar de LineaUnidad, poner TIPO = "O" y redefinir importe como self.precio * (self.cantidad - self.cantidad // 3). Puede ajustarse también detalle para que añada (3x2). Después solo hay que registrar la clase en el diccionario TIPOS. No se toca ni Ticket ni __add__, y fusionar dos ofertas sigue dando una LineaOferta gracias a type(self). Esa es la prueba de que la jerarquía está bien diseñada.
Permitir sum() sobre líneas del mismo producto
sum(lineas) empieza sumando 0 + lineas[0]. El __add__ del entero devuelve NotImplemented, y Python prueba entonces lineas[0].__radd__(0). Basta con definir en Linea un __radd__ que devuelva self si el otro operando es 0 y delegue en __add__ en cualquier otro caso. Con eso, sum([a, b, c]) fusiona tres líneas iguales sin necesidad de pasar un valor inicial.
Inserción en tiempo constante con un diccionario
Se sustituye la lista de Ticket por un diccionario self._lineas = {}. En agregar se escribe self._lineas[linea] = self._lineas[linea] + linea si la clave existe y self._lineas[linea] = linea si no. En __iter__ y total se recorre self._lineas.values(). Funciona precisamente porque __eq__ y __hash__ usan los mismos campos, y ninguno de ellos cambia al fusionar.
En los temarios
La creación de clases y la herencia figuran como temas propios en el temario de Programación (1236) de la Facultad de Ciencias de la UNAM. La programación orientada a objetos es una de las unidades de Paradigmas de Programación del plan ISC 2020 del IPN. Este ejercicio trabaja justo esos contenidos: una jerarquía con clase base abstracta y un contenedor que se integra con el lenguaje mediante métodos especiales.
El programa de ISIS1209 (APO II Honores) de la Universidad de los Andes incluye el estudio de mecanismos de reutilización de clases como la herencia y de definición de contratos como las interfaces. En Python, el papel de ese contrato lo cumple aquí la clase abstracta Linea con sus métodos @abstractmethod.
Historial de cambios
- Reescrito con la plantilla de Código de Examen: enunciado de examen, pistas, solución explicada, casos de prueba y código compilado y ejecutado; versión en inglés enlazada.
- Publicación original.
Artículo generado mediante AI.larebelion · Revisado por Kernel


