Herencia en Python práctica — una nómina, una flota con un híbrido y una tienda con IGIC (y la fórmula que el híbrido copió en vez de heredar)
En una práctica de FP2, un compañero tenía un Hibrido que heredaba de Gasolina… y que, en vez de llamar a la fórmula de la madre, la había copiado y pegado con el ahorro eléctrico al final. Funcionaba. Hasta que el enunciado cambió el cálculo del consumo de los coches de gasolina, él lo arregló en Gasolina y el híbrido siguió calculando con la fórmula vieja. Dos clases, dos versiones de la misma cuenta, y solo una actualizada. La lección: si la hija necesita lo de la madre, se lo pide con super(); no lo copia.
En la teoría de herencia en Python viste super(), la sustitución de métodos, el polimorfismo y las clases abstractas con la biblioteca de la facultad. Aquí lo usamos en tres programas de nivel medio, con datos que se parecen a los de verdad (y con alguno mal a propósito):
- Una nómina con programadores, diseñadores y un manager cuyo bonus depende de su equipo.
- Una flota de coches de gasolina, eléctricos e híbridos, con herencia en dos niveles.
- Una tienda con productos físicos, libros (que en Canarias llevan IGIC 0 %), licencias digitales y servicios.
Los tres comparten un patrón que verás tres veces: la madre escribe la receta una sola vez (el método plantilla) y cada hija pone sus datos.
Tabla de Contenidos
—
Antes de empezar
Conviene tener claro:
- Cómo se hereda (
class Hija(Madre):) y por qué la hija con__init__propio llama asuper().__init__(…). - La diferencia entre sustituir un método y ampliarlo con
super().metodo(). - Qué es una clase abstracta (
ABC+@abstractmethod) y por qué daTypeErroral instanciarla. - La diferencia entre
isinstanceytype.
Si alguno te suena dudoso, repasa la teoría antes de seguir.
Copia cada programa en un archivo .py propio y ejecútalo entero antes de leer la explicación. Los tres usan solo la biblioteca estándar (from abc import ABC, abstractmethod), así que funcionan en cualquier Python 3.
—
Programa 1: una nómina con bonus distintos
Una pequeña empresa tiene programadores (cobran un bonus según su nivel: junior, mid o senior), diseñadores (120 € por proyecto entregado) y un manager (el 5 % del salario base de su equipo). Hay que imprimir la nómina de cada uno, saber quién cobra más y cuánto cuesta la plantilla al mes. Lo que lo complica: cada puesto calcula el bonus a su manera, el manager necesita a los demás para calcular el suyo y hay datos de contratación que deben rechazarse (un nivel que no existe, un salario negativo).
La idea
Lo común (nombre, salario base, cómo se calcula el total, cómo se imprime la nómina) va una sola vez en Empleado. Lo que cambia (el bonus y la categoría) son métodos abstractos que cada puesto escribe. Así salario_total() y nomina() funcionan para todos sin saber de qué puesto son: llaman a self.bonus() y cada objeto contesta lo suyo.
El manager tiene un detalle más: su nómina es la normal más la lista de su equipo. Eso es ampliar, no sustituir: super().nomina() y añadir líneas.
Antes de programar, piensa en qué partes de la nómina son iguales para todos (el formato, la suma) y cuáles cambian (el bonus, la categoría). Lo igual va en la madre; lo que cambia, en métodos que las hijas sustituyen.
El código
from abc import ABC, abstractmethod
class Empleado(ABC):
"""Lo común a toda la plantilla. No se instancia: es el contrato."""
def __init__(self, nombre, salario_base):
if salario_base <= 0:
raise ValueError(f'salario no válido para {nombre}: {salario_base}')
self.nombre = nombre
self._salario_base = salario_base # _ : las hijas lo pueden leer
@property
def salario_base(self):
return self._salario_base
@abstractmethod
def bonus(self): # cada puesto lo calcula a su manera
pass
@abstractmethod
def categoria(self):
pass
def salario_total(self): # método plantilla: usa los abstractos
return self._salario_base + self.bonus()
def nomina(self):
return (f'--- Nómina de {self.nombre} ---\n'
f'Categoría: {self.categoria()}\n'
f'Salario base: {self._salario_base:8.2f} €\n'
f'Bonus: {self.bonus():8.2f} €\n'
f'Total: {self.salario_total():8.2f} €')
def __repr__(self): # el nombre de la clase REAL del objeto
return f'{type(self).__name__}({self.nombre!r}, {self._salario_base})'
class Programador(Empleado):
NIVELES = {'junior': 0.05, 'mid': 0.10, 'senior': 0.15}
def __init__(self, nombre, salario_base, lenguaje, nivel):
if nivel not in self.NIVELES: # validamos ANTES de crear nada
raise ValueError(f'nivel desconocido: {nivel!r}')
super().__init__(nombre, salario_base)
self.lenguaje = lenguaje
self.nivel = nivel
def bonus(self):
return self._salario_base * self.NIVELES[self.nivel]
def categoria(self):
return f'Programador {self.nivel} ({self.lenguaje})'
class Disenador(Empleado):
def __init__(self, nombre, salario_base, proyectos):
super().__init__(nombre, salario_base)
self.proyectos = proyectos
def bonus(self):
return 120 * self.proyectos # 120 € por proyecto entregado
def categoria(self):
return f'Diseñador ({self.proyectos} proyectos)'
class Manager(Empleado):
def __init__(self, nombre, salario_base, equipo):
super().__init__(nombre, salario_base)
self.equipo = list(equipo) # copia: no comparte la lista de fuera
def bonus(self): # 5 % del salario base de su equipo
return 0.05 * sum(e.salario_base for e in self.equipo)
def categoria(self):
return f'Manager (equipo de {len(self.equipo)})'
def nomina(self): # AMPLÍA la nómina de la madre
lineas = [super().nomina(), 'Equipo:']
lineas += [f' - {e.nombre} ({type(e).__name__})' for e in self.equipo]
return '\n'.join(lineas)
laura = Programador('Laura', 2100, 'Python', 'mid')
pablo = Programador('Pablo', 1800, 'Java', 'junior')
ana = Disenador('Ana', 1900, 4)
elena = Manager('Elena', 2600, [laura, pablo, ana])
plantilla = [laura, pablo, ana, elena]
for e in plantilla:
print(e.nomina())
print()
print('Plantilla:', plantilla)
print('Mayor sueldo:', max(plantilla, key=lambda e: e.salario_total()).nombre)
print(f'Coste mensual: {sum(e.salario_total() for e in plantilla):.2f} €')
for datos in [('Iván', 1700, 'C', 'experto'), ('Noa', -50, 'Python', 'mid')]:
try:
Programador(*datos)
except ValueError as err:
print('No se contrata:', err)
try:
Empleado('Nadie', 1000)
except TypeError as err:
print('TypeError:', err)
Las líneas que más trabajo hacen son estas dos de Empleado:
def salario_total(self): # método plantilla: usa los abstractos
return self._salario_base + self.bonus()
def nomina(self):
return (f'--- Nómina de {self.nombre} ---\n'
f'Categoría: {self.categoria()}\n'
f'Salario base: {self._salario_base:8.2f} €\n'
f'Bonus: {self.bonus():8.2f} €\n'
f'Total: {self.salario_total():8.2f} €')
salario_total y nomina no saben qué es un programador ni un manager. Llaman a self.bonus() y self.categoria(), que en Empleado ni siquiera tienen código (son abstractos). Cuando el objeto es un Programador, Python busca bonus desde Programador y encuentra el suyo. Por eso la madre puede usar métodos que solo existen en las hijas.
El __init__ del programador valida antes de llamar a la madre:
def __init__(self, nombre, salario_base, lenguaje, nivel):
if nivel not in self.NIVELES: # validamos ANTES de crear nada
raise ValueError(f'nivel desconocido: {nivel!r}')
super().__init__(nombre, salario_base)
self.lenguaje = lenguaje
self.nivel = nivel
¿Por qué este orden, si en la teoría decíamos «super().__init__ lo primero»? Porque aquí la validación del nivel no necesita nada de la madre, y así, si el nivel está mal, no llega a crearse ni la mitad del objeto. La regla de «lo primero» es para cuando la hija usa algo que pone la madre.
Salida
--- Nómina de Laura ---
Categoría: Programador mid (Python)
Salario base: 2100.00 €
Bonus: 210.00 €
Total: 2310.00 €
--- Nómina de Pablo ---
Categoría: Programador junior (Java)
Salario base: 1800.00 €
Bonus: 90.00 €
Total: 1890.00 €
--- Nómina de Ana ---
Categoría: Diseñador (4 proyectos)
Salario base: 1900.00 €
Bonus: 480.00 €
Total: 2380.00 €
--- Nómina de Elena ---
Categoría: Manager (equipo de 3)
Salario base: 2600.00 €
Bonus: 290.00 €
Total: 2890.00 €
Equipo:
- Laura (Programador)
- Pablo (Programador)
- Ana (Disenador)
Plantilla: [Programador('Laura', 2100), Programador('Pablo', 1800), Disenador('Ana', 1900), Manager('Elena', 2600)]
Mayor sueldo: Elena
Coste mensual: 9470.00 €
No se contrata: nivel desconocido: 'experto'
No se contrata: salario no válido para Noa: -50
TypeError: Can't instantiate abstract class Empleado without an implementation for abstract methods 'bonus', 'categoria'
Comprobemos las cuentas. Laura es mid: 2100 × 0,10 = 210, total 2310. Pablo, junior: 1800 × 0,05 = 90, total 1890. Ana entregó 4 proyectos: 4 × 120 = 480, total 2380. Y el bonus de Elena es el 5 % de los salarios base de su equipo, no de los totales:
| Paso | Empleado del equipo | e.salario_base | Suma acumulada |
|---|---|---|---|
| 1 | Laura | 2100 | 2100 |
| 2 | Pablo | 1800 | 3900 |
| 3 | Ana | 1900 | 5800 |
| — | 5800 × 0,05 | 290 |
Elena cobra 2600 + 290 = 2890 y es la que más gana. El coste mensual es 2310 + 1890 + 2380 + 2890 = 9470 €, justo lo que imprime.
La lista Plantilla: sale con Programador('Laura', 2100)… aunque __repr__ está escrito en Empleado. Es porque usa type(self).__name__: el nombre de la clase real del objeto, no el de la clase donde está el código. Si lo hubieras escrito a mano ('Empleado(…)'), todos saldrían como Empleado.
Las tres últimas líneas son los casos malos: el nivel 'experto' no existe, el salario de Noa es negativo (lo rechaza la madre, que es quien valida el salario para todos) y Empleado no se puede instanciar porque es abstracto.
Preguntas que te puedes hacer
¿Por qué _salario_base con un guion y no con dos? Porque el Manager lee el salario base de sus compañeros y el Programador lo usa en su bonus. Con dos guiones, dentro de Programador el nombre se transformaría en _Programador__salario_base y daría AttributeError. Un guion es el convenio de «interno, pero las hijas lo pueden usar», y además lo ofrecemos con una propiedad de solo lectura para los de fuera.
¿Por qué Manager guarda list(equipo) y no equipo? Para no compartir la lista con quien la creó. Si después alguien hiciera equipo.append(…) fuera, el equipo del manager cambiaría sin que él lo supiera (lo vimos con las referencias en el tema de clases).
¿Por qué Manager.nomina llama a super().nomina() en lugar de escribir la nómina entera? Porque la parte de arriba es igual que la de todos. Si mañana se añade la línea «Retención IRPF» en Empleado.nomina, el manager la tendrá sin tocar su clase. Copiarla sería repetir el error del híbrido del principio.
¿Qué pasa si añado un Becario y olvido escribir categoria? Que Becario('Iván', 900) dará TypeError al crearlo, con el nombre del método que falta. Para eso son abstractos.
¿max(plantilla, key=…) sabe que son empleados distintos? No le hace falta: la lambda llama a e.salario_total() y cada objeto contesta. Es polimorfismo dentro de una función de la biblioteca estándar.
Calcular el bonus del manager con e.salario_total() en vez de e.salario_base. Parece lo mismo y no lo es: el total ya incluye el bonus de cada uno (saldrían 329 € en lugar de 290). Lee bien el enunciado: «salario base».
Pruébalo tú
- Añade
Becario(nombre, salario_base, horas)con un bonus de 8 € por hora extra y mételo en el equipo de Elena. ¿Cuánto sube el bonus de Elena? (Pista: solo cuenta su salario base.) - Haz que
Manageramplíe tambiéncategoria()para que diga «Manager (equipo de 3: 2 programadores)», contando conisinstance.
—
Programa 2: una flota de coches con un híbrido
Una empresa de alquiler de coches quiere comparar su flota: coste por kilómetro, coste de un viaje de 500 km, autonomía y valor actual de cada coche. Hay coches de gasolina, eléctricos e híbridos. Lo que lo complica: el híbrido es un coche de gasolina que hace parte del recorrido en eléctrico, así que su coste y su autonomía salen de los de gasolina con un ajuste; y hay que separar los coches que usan gasolina sin que se escape el híbrido. Los precios del litro (1,65 €) y del kWh (0,15 €) son inventados y redondos.
La idea
Tres niveles: Vehiculo (abstracta) → Gasolina y Electrico → Hibrido, que hereda de Gasolina. Vehiculo tiene lo común (matrícula, kilómetros, valor actual, coste de un viaje) y dos abstractos: el método coste_por_km() y la propiedad autonomia. El híbrido no repite ninguna fórmula: pide la de Gasolina con super() y le aplica su ahorro.
Antes de programar, piensa en qué «es» cada cosa. Un híbrido es un coche de gasolina con algo más; un eléctrico no es un coche de gasolina. Esa frase decide de quién hereda cada clase.
El código
from abc import ABC, abstractmethod
AÑO_ACTUAL = 2026
PRECIO_LITRO = 1.65 # €/L (inventado, redondo)
PRECIO_KWH = 0.15 # €/kWh cargando en casa (inventado)
class Vehiculo(ABC):
def __init__(self, matricula, modelo, anio, precio):
self.matricula = matricula
self.modelo = modelo
self.anio = anio
self.precio = precio
self._km = 0
def conducir(self, km):
if km <= 0:
raise ValueError(f'{self.matricula}: los km deben ser positivos ({km})')
self._km += km
@abstractmethod
def coste_por_km(self):
pass
@property
@abstractmethod
def autonomia(self): # km con el depósito o la batería llenos
pass
def coste_viaje(self, km): # común a todos: usa el abstracto
return km * self.coste_por_km()
def valor_actual(self): # pierde un 8 % por año, nunca menos del 10 %
factor = max(0.10, 1 - 0.08 * (AÑO_ACTUAL - self.anio))
return self.precio * factor
def __str__(self):
return f'{type(self).__name__:<9} {self.matricula} {self.modelo} ({self.anio}), {self._km} km'
class Gasolina(Vehiculo):
def __init__(self, matricula, modelo, anio, precio, litros_100km, deposito=50):
super().__init__(matricula, modelo, anio, precio)
self.litros_100km = litros_100km
self.deposito = deposito
def coste_por_km(self):
return self.litros_100km / 100 * PRECIO_LITRO
@property
def autonomia(self):
return self.deposito / self.litros_100km * 100
class Electrico(Vehiculo):
def __init__(self, matricula, modelo, anio, precio, kwh_100km, bateria):
super().__init__(matricula, modelo, anio, precio)
self.kwh_100km = kwh_100km
self.bateria = bateria
def coste_por_km(self):
return self.kwh_100km / 100 * PRECIO_KWH
@property
def autonomia(self):
return self.bateria / self.kwh_100km * 100
class Hibrido(Gasolina): # nieto de Vehiculo: dos niveles
def __init__(self, matricula, modelo, anio, precio, litros_100km, ahorro):
super().__init__(matricula, modelo, anio, precio, litros_100km, deposito=43)
self.ahorro = ahorro # parte del recorrido que hace en eléctrico
def coste_por_km(self): # AMPLÍA el de Gasolina, no lo repite
return super().coste_por_km() * (1 - self.ahorro)
@property
def autonomia(self): # la propiedad también se amplía
return super().autonomia / (1 - self.ahorro)
flota = [
Gasolina('4821KLM', 'Seat Ibiza', 2019, 16500, 5.8),
Electrico('7310MNB', 'Renault Zoe', 2023, 29900, 17.2, 52),
Hibrido('1975LPR', 'Toyota Yaris', 2022, 23400, 4.0, 0.35),
]
print(f'{"Vehículo":<35}{"€/km":>7}{"500 km":>10}{"Autonomía":>11}{"Valor hoy":>11}')
for v in flota:
print(f'{v.matricula} {v.modelo:<14}{type(v).__name__:<13}{v.coste_por_km():7.4f}'
f'{v.coste_viaje(500):8.2f} €{v.autonomia:8.0f} km{v.valor_actual():9.0f} €')
print('Hibrido busca coste_por_km en:', [c.__name__ for c in Hibrido.__mro__])
gasolina = [v.matricula for v in flota if isinstance(v, Gasolina)]
print('isinstance(v, Gasolina):', gasolina)
print('type(v) == Gasolina: ', [v.matricula for v in flota if type(v) == Gasolina])
flota[2].conducir(320)
print(flota[2])
try:
flota[0].conducir(-40)
except ValueError as err:
print('Error:', err)
El corazón del programa es el híbrido:
class Hibrido(Gasolina): # nieto de Vehiculo: dos niveles
def __init__(self, matricula, modelo, anio, precio, litros_100km, ahorro):
super().__init__(matricula, modelo, anio, precio, litros_100km, deposito=43)
self.ahorro = ahorro # parte del recorrido que hace en eléctrico
def coste_por_km(self): # AMPLÍA el de Gasolina, no lo repite
return super().coste_por_km() * (1 - self.ahorro)
@property
def autonomia(self): # la propiedad también se amplía
return super().autonomia / (1 - self.ahorro)
super().coste_por_km() ejecuta el de Gasolina con el consumo del Yaris (4,0 L/100 km) y devuelve 0,066; el híbrido lo multiplica por 0,65. Lo mismo con la autonomía: super().autonomia (sin paréntesis, es una propiedad) da los 1075 km que haría solo con gasolina, y el híbrido los divide entre 0,65. El dibujo sigue las dos llamadas:
Fíjate también en el __init__ del híbrido: le pasa a Gasolina el depósito de 43 litros con deposito=43. La madre tiene un valor por defecto (50) y la hija lo cambia al llamarla, sin necesidad de guardar ella el depósito.
Salida
Vehículo €/km 500 km Autonomía Valor hoy 4821KLM Seat Ibiza Gasolina 0.0957 47.85 € 862 km 7260 € 7310MNB Renault Zoe Electrico 0.0258 12.90 € 302 km 22724 € 1975LPR Toyota Yaris Hibrido 0.0429 21.45 € 1654 km 15912 € Hibrido busca coste_por_km en: ['Hibrido', 'Gasolina', 'Vehiculo', 'ABC', 'object'] isinstance(v, Gasolina): ['4821KLM', '1975LPR'] type(v) == Gasolina: ['4821KLM'] Hibrido 1975LPR Toyota Yaris (2022), 320 km Error: 4821KLM: los km deben ser positivos (-40)
Comprobemos una fila de cada tipo:
| Coche | coste_por_km() | × 500 km | autonomia | valor_actual() |
|---|---|---|---|---|
| Ibiza (gasolina) | 5,8 / 100 × 1,65 = 0,0957 | 47,85 € | 50 / 5,8 × 100 = 862 km | 16500 × (1 − 0,08 × 7) = 7260 € |
| Zoe (eléctrico) | 17,2 / 100 × 0,15 = 0,0258 | 12,90 € | 52 / 17,2 × 100 = 302 km | 29900 × (1 − 0,08 × 3) = 22724 € |
| Yaris (híbrido) | 0,066 × 0,65 = 0,0429 | 21,45 € | 1075 / 0,65 = 1654 km | 23400 × (1 − 0,08 × 4) = 15912 € |
Ahora las tres líneas de diagnóstico. El MRO del híbrido es Hibrido → Gasolina → Vehiculo → ABC → object: ese es el orden en que Python busca cualquier método de un Hibrido. Y la diferencia entre isinstance y type se ve sola: con isinstance(v, Gasolina) salen el Ibiza y el Yaris (un híbrido es un coche de gasolina); con type(v) == Gasolina, solo el Ibiza. Si el informe fuera «coches que hay que llevar a la gasolinera», el type se habría olvidado del híbrido.
Por último, conducir(320) suma los kilómetros (método heredado de Vehiculo, nadie lo sustituye) y conducir(-40) lanza el ValueError con la matrícula.
Preguntas que te puedes hacer
¿Por qué autonomia es una propiedad abstracta y no un método? Porque se lee como un dato («la autonomía del coche»), no como una acción. Para hacerla abstracta se ponen los dos decoradores, @property y debajo @abstractmethod, y las hijas la escriben también con @property. Si una hija la escribiera como método normal, v.autonomia devolvería la función en lugar del número.
¿Por qué Hibrido hereda de Gasolina y no de Vehiculo directamente? Porque tiene depósito, consume litros y su coste parte del de gasolina. Si heredara de Vehiculo, tendría que repetir las fórmulas de Gasolina, que es justo lo que le pasó al compañero del principio.
¿Qué pasaría si en Hibrido.coste_por_km escribiera self.coste_por_km() en vez de super().coste_por_km()? Que se llamaría a sí mismo una y otra vez: self es un Hibrido, así que Python vuelve a encontrar el método del híbrido. Acabaría en RecursionError. super() es la forma de decir «el de la siguiente clase del MRO, no el mío».
¿Por qué __str__ usa type(self).__name__? Para que la misma línea diga Gasolina, Electrico o Hibrido sin escribir tres __str__. Fíjate en que el híbrido sale como Hibrido aunque __str__ esté en Vehiculo.
¿Y si el precio del litro cambia? Se cambia PRECIO_LITRO arriba y lo notan los coches de gasolina y el híbrido, porque el híbrido usa la fórmula de la madre. Esa es la diferencia con copiarla.
Comparar con type(v) == Gasolina para «todos los que gastan gasolina». Te deja fuera al híbrido. Si la pregunta es «¿es un…?», la respuesta es isinstance.
Pruébalo tú
- Añade
Diesel(Gasolina)con un precio del litro de 1,55 €. ¿Necesitas sustituircoste_por_kmentero o basta con cambiar un dato? Piensa cómo dejar el precio en un atributo de clase para que la hija solo lo cambie. - Haz que
valor_actual()deElectricopierda solo un 6 % por año ampliando el de la madre (pista: necesitarás que el 0,08 sea un atributo de clase).
—
Programa 3: una tienda con IGIC
Una tienda canaria vende productos físicos (con gastos de envío según el peso), libros (que son productos físicos, pero con IGIC 0 %), licencias digitales (con descuento a partir de 5) y servicios por horas. Hay que imprimir cada línea del carrito con su precio sin impuestos, el IGIC aplicado y el total, más el total del carrito y un recuento por tipo. Lo que lo complica: el impuesto depende del tipo de producto y el libro es «un físico especial» que solo cambia una cosa.
El IGIC es el impuesto de Canarias que hace el papel del IVA; el tipo general es el 7 % y los libros están en el tipo cero. Los precios y los envíos del programa son inventados.
La idea
Producto (abstracta) escribe la fórmula final una sola vez: precio_con_igic() = precio_final() × (1 + tasa_igic()). Cada hija contesta precio_final() a su manera. tasa_igic() no es abstracta: es un gancho con valor por defecto (7 %) que solo el libro sustituye. Igual pasa con detalle(), que por defecto devuelve una cadena vacía.
Antes de programar, piensa en qué tiene que decidir cada hija sí o sí (su precio: abstracto) y qué tiene un valor normal que casi nadie cambia (el impuesto: gancho). No todo tiene que ser abstracto.
El código
from abc import ABC, abstractmethod
IGIC_GENERAL = 0.07 # IGIC general de Canarias
class Producto(ABC):
def __init__(self, nombre, precio_base):
self.nombre = nombre
self.precio_base = precio_base
@abstractmethod
def precio_final(self): # sin impuestos: cada tipo lo calcula
pass
@abstractmethod
def tipo(self):
pass
def tasa_igic(self): # gancho con valor por defecto: se PUEDE sustituir
return IGIC_GENERAL
def detalle(self): # gancho vacío por defecto
return ''
def precio_con_igic(self): # la madre orquesta, las hijas ponen los datos
return round(self.precio_final() * (1 + self.tasa_igic()), 2)
def linea(self):
extra = f' ({self.detalle()})' if self.detalle() else ''
return (f'[{self.tipo():<8}] {self.nombre:<24}{self.precio_final():8.2f} € '
f'+ {self.tasa_igic():.0%} = {self.precio_con_igic():8.2f} €{extra}')
class Fisico(Producto):
def __init__(self, nombre, precio_base, peso_kg):
super().__init__(nombre, precio_base)
self.peso_kg = peso_kg
def envio(self):
return 4.95 if self.peso_kg < 2 else 7.95
def precio_final(self):
return self.precio_base + self.envio()
def tipo(self):
return 'Físico'
def detalle(self):
return f'{self.peso_kg} kg, envío {self.envio():.2f} €'
class LibroFisico(Fisico): # en Canarias los libros tienen IGIC 0 %
def tasa_igic(self):
return 0.0
def tipo(self):
return 'Libro'
class Digital(Producto):
def __init__(self, nombre, precio_base, licencias=1):
super().__init__(nombre, precio_base)
self.licencias = licencias
def precio_final(self): # 20 % menos por licencia a partir de 5
descuento = 0.20 if self.licencias >= 5 else 0
return self.precio_base * self.licencias * (1 - descuento)
def tipo(self):
return 'Digital'
def detalle(self):
return f'{self.licencias} licencias' if self.licencias > 1 else ''
class Servicio(Producto):
def __init__(self, nombre, precio_hora, horas):
super().__init__(nombre, precio_hora)
self.horas = horas
def precio_final(self):
return self.precio_base * self.horas
def tipo(self):
return 'Servicio'
def detalle(self):
return f'{self.horas} h a {self.precio_base:.2f} €/h'
carrito = [
Fisico('Teclado mecánico', 59.90, 1.1),
LibroFisico('Python para todos', 24.00, 0.6),
Fisico('Monitor 24 pulgadas', 139.00, 4.3),
Digital('Licencia de antivirus', 19.99, 5),
Servicio('Clase particular de FP2', 18.00, 3),
]
for p in carrito:
print(p.linea())
total = sum(p.precio_con_igic() for p in carrito)
print(f'Total con IGIC: {total:.2f} €')
por_tipo = {}
for p in carrito:
por_tipo[p.tipo()] = por_tipo.get(p.tipo(), 0) + 1
print('Por tipo:', por_tipo)
print('Físicos (con libros):', sum(isinstance(p, Fisico) for p in carrito))
El método plantilla y los ganchos de la madre:
def tasa_igic(self): # gancho con valor por defecto: se PUEDE sustituir
return IGIC_GENERAL
def detalle(self): # gancho vacío por defecto
return ''
def precio_con_igic(self): # la madre orquesta, las hijas ponen los datos
return round(self.precio_final() * (1 + self.tasa_igic()), 2)
def linea(self):
extra = f' ({self.detalle()})' if self.detalle() else ''
return (f'[{self.tipo():<8}] {self.nombre:<24}{self.precio_final():8.2f} € '
f'+ {self.tasa_igic():.0%} = {self.precio_con_igic():8.2f} €{extra}')
linea() llama cuatro veces a cosas que decide cada hija (tipo, precio_final, tasa_igic, detalle), y LibroFisico solo sustituye dos de ellas en tres líneas de código: el impuesto y el nombre del tipo. El envío lo hereda de Fisico y el resto, de Producto. El dibujo muestra quién contesta cada pregunta en cada producto:
Salida
[Físico ] Teclado mecánico 64.85 € + 7% = 69.39 € (1.1 kg, envío 4.95 €)
[Libro ] Python para todos 28.95 € + 0% = 28.95 € (0.6 kg, envío 4.95 €)
[Físico ] Monitor 24 pulgadas 146.95 € + 7% = 157.24 € (4.3 kg, envío 7.95 €)
[Digital ] Licencia de antivirus 79.96 € + 7% = 85.56 € (5 licencias)
[Servicio] Clase particular de FP2 54.00 € + 7% = 57.78 € (3 h a 18.00 €/h)
Total con IGIC: 398.92 €
Por tipo: {'Físico': 2, 'Libro': 1, 'Digital': 1, 'Servicio': 1}
Físicos (con libros): 3
Comprobemos el carrito:
| Producto | precio_final() | IGIC | precio_con_igic() |
|---|---|---|---|
| Teclado | 59,90 + 4,95 = 64,85 | 7 % | 64,85 × 1,07 = 69,39 |
| Libro | 24,00 + 4,95 = 28,95 | 0 % | 28,95 |
| Monitor (4,3 kg) | 139,00 + 7,95 = 146,95 | 7 % | 157,24 |
| Antivirus (5 licencias) | 19,99 × 5 × 0,80 = 79,96 | 7 % | 85,56 |
| Clase de FP2 (3 h) | 18,00 × 3 = 54,00 | 7 % | 57,78 |
Y el total, vuelta a vuelta del sum:
| Vuelta | Producto | Suma acumulada |
|---|---|---|
| 1 | Teclado | 69,39 |
| 2 | Libro | 98,34 |
| 3 | Monitor | 255,58 |
| 4 | Antivirus | 341,14 |
| 5 | Clase | 398,92 |
El recuento por tipo dice que hay 2 «Físico» y 1 «Libro», pero la última línea cuenta 3 físicos: isinstance(p, Fisico) también es cierto para el libro, porque LibroFisico hereda de Fisico. Son dos preguntas distintas y las dos son correctas: tipo() es la etiqueta que eligió cada clase; isinstance mira la jerarquía.
Preguntas que te puedes hacer
¿Por qué round(…, 2) en precio_con_igic y no solo al imprimir? Porque el total del carrito suma los precios redondeados, como en un tique real. Si sumaras los valores sin redondear, el total podría diferir en un céntimo de la suma de las líneas que ve el cliente.
¿Por qué Servicio guarda la tarifa en precio_base? Para reutilizar el __init__ de la madre: le pasa precio_hora como precio_base y guarda las horas. El nombre del parámetro cambia en la hija (precio_hora se entiende mejor), pero el atributo es el mismo.
¿Por qué detalle() tiene un valor por defecto y precio_final() no? Porque un producto sin detalle tiene sentido (la línea sale sin paréntesis), pero un producto sin precio no. Si olvidas precio_final en una clase nueva, prefieres el TypeError al crearla.
¿Qué pasaría si LibroFisico heredara de Producto? Tendría que repetir el peso y el envío de Fisico. Hereda de Fisico porque un libro en papel es un producto físico que se envía igual.
¿El formato {:.0%} multiplica por 100? Sí: 0.07 se imprime como 7%. Es un formato de las f-strings, no tiene nada que ver con la herencia, pero ahorra hacer la cuenta a mano.
Poner el IGIC dentro de cada precio_final(). Entonces el libro tendría que «quitar» un impuesto que ya está puesto, cada hija repetiría la misma multiplicación y cambiar el tipo general obligaría a tocar todas. El impuesto va en un solo sitio, en la madre.
Pruébalo tú
- Añade
Revista(LibroFisico)cuyo envío sea siempre 2,95 € (sustituyeenvio()). ¿Qué IGIC le sale y por qué? - Haz que los servicios de más de 10 horas tengan un 10 % de descuento ampliando
precio_final()consuper()en una claseBono(Servicio).
—
Errores típicos en esta práctica
- Copiar la fórmula de la madre en la hija en vez de llamarla con
super(): cuando la madre cambia, la hija se queda con la versión vieja. - Usar
type(x) == Clasepara preguntar «¿es un…?»: deja fuera a las hijas (el híbrido, el libro). Usaisinstance. - Olvidar el
returnal ampliar un método (super().nomina()sin devolver nada): la nómina del manager saldríaNone. - Llamar a
self.metodo()dentro del override del mismo método: recursión infinita. Para el de la madre,super().metodo(). - Escribir una propiedad abstracta como método en la hija:
v.autonomiadevolvería un objeto función, no un número. - Guardar con dos guiones bajos lo que las hijas necesitan:
AttributeErrorcon_Hija__dato. Un guion o una propiedad.
—
Míralo paso a paso en Python Tutor
pythontutor.com dibuja la pila de llamadas, que es justo lo que hace falta para ver super(). Elige Python 3 y pega esta versión reducida del híbrido:
class Vehiculo:
def __init__(self, modelo, litros):
self.modelo = modelo
self.litros = litros
def coste_km(self):
return self.litros / 100 * 1.65
class Hibrido(Vehiculo):
def __init__(self, modelo, litros, ahorro):
super().__init__(modelo, litros)
self.ahorro = ahorro
def coste_km(self):
base = super().coste_km()
return base * (1 - self.ahorro)
h = Hibrido('Yaris', 4.0, 0.35)
print(round(h.coste_km(), 4))
Salida:
0.0429
Qué observar paso a paso:
- Al crear el
Hibrido, la pila tiene dos marcos de__init__(el del híbrido y el del vehículo) con el mismoself. - Al llamar a
h.coste_km(), entra en el deHibrido. En la línea desuper().coste_km()aparece un segundo marco, el deVehiculo.coste_km, encima del primero. - Mira el valor de retorno del segundo marco (0,066 en la pila) antes de que desaparezca, y cómo llega a la variable
basedel primero. - Cambia
super().coste_km()porself.coste_km()y vuelve a ejecutar: verás cómo se apilan marcos deHibrido.coste_kmhasta que Python Tutor se rinde. Así se ve una recursión infinita.
—
Resumen
- La madre escribe la receta común una sola vez (método plantilla:
salario_total,coste_viaje,precio_con_igic) y las hijas ponen los datos con métodos que sustituyen. - Lo que cada hija debe decidir va como método o propiedad abstracta; lo que tiene un valor normal, como gancho con valor por defecto.
- Ampliar es
return super().metodo() …; sustituir es escribir el método de nuevo con la misma firma. super()funciona en varios niveles:Hibrido → Gasolina → Vehiculo, y el MRO dice el orden.isinstancerespeta la jerarquía (el libro es un físico; el híbrido, un gasolina);typeno.type(self).__name__en la madre da el nombre de la clase real del objeto.
Checklist: ¿lo has practicado?
- ¿Has ejecutado los tres programas y comprobado las salidas?
- ¿Sabes explicar por qué
Empleado.salario_totalpuede usarbonus()si enEmpleadono tiene código? - ¿Sabes predecir qué devuelve
Hibrido.coste_por_kmsin ejecutarlo? - ¿Entiendes por qué hay 3 físicos y solo 2 con la etiqueta «Físico»?
- ¿Has intentado los «Pruébalo tú» sin mirar la teoría?
- ¿Sabrías decir qué pasa si una hija olvida un método abstracto?
Si has respondido que sí a casi todo, estás listo para los ejercicios de herencia en Python, donde ya no te doy el código hecho. ¡Nos vemos allí!
Siguiente paso: ejerciciosHerencia en Python ejercicios — animales, cuentas bancarias y los préstamos de la biblioteca, con soluciones, test y chuletario (y la s de más que tumbó un simulacro)¿Te ha servido este artículo? Así puedes apoyar SergioLearns →




Qué es un sistema operativo — teoría con tu portátil en una tarde de estudio, una llamada a write() y la CPU que nunca descansa (y los tres programas que no se ejecutaban a la vez)