Herencia en Python — teoría con la biblioteca de la facultad: super(), override, polimorfismo y clases abstractas (y el libro digital que se quedó sin título)

La semana en que terminamos el catálogo de la biblioteca de la facultad (el del tema de clases en Python), alguien propuso añadir los libros digitales. «Fácil», dije: una clase LibroDigital que herede de Libro, con su propio __init__ para guardar los megas. La creé, imprimí los megas, todo bien. Y en cuanto el catálogo pidió la ficha del libro, esto:

class Libro:
    def __init__(self, titulo, autor):
        self.titulo = titulo
        self.autor = autor

    def ficha(self):
        return f'«{self.titulo}», de {self.autor}'


class LibroDigital(Libro):
    def __init__(self, titulo, autor, megas):
        # falta super().__init__(titulo, autor): titulo y autor nunca se guardan
        self.megas = megas


ebook = LibroDigital('Python para todos', 'Severance', 2.4)
print(ebook.megas)
print(ebook.ficha())

La salida, real:

2.4
Traceback (most recent call last):
  File "gancho.py", line 18, in <module>
    print(ebook.ficha())
          ~~~~~~~~~~~^^
  File "gancho.py", line 7, in ficha
    return f'«{self.titulo}», de {self.autor}'
               ^^^^^^^^^^^
AttributeError: 'LibroDigital' object has no attribute 'titulo'

¿Cómo que LibroDigital no tiene titulo, si hereda de Libro y Libro lo guarda en su __init__? Ahí está la trampa: al escribir mi propio __init__, el de Libro ya no se ejecuta solo. Nadie guardó el título. Ese olvido es el error más repetido del tema en FP2, y en este artículo vas a ver por qué pasa, cómo se arregla con super() y todo lo demás que entra en el examen: sustitución de métodos (override), polimorfismo, isinstance frente a type, la clase object, herencia múltiple y el orden de búsqueda (MRO), atributos privados en las subclases y clases abstractas con ABC.

Seguimos con la biblioteca de la facultad: libros, revistas y tres tipos de socio (estudiantes, PDI y visitantes) con plazos y multas distintos. Los datos son inventados (no son las normas reales de la biblioteca de la ULPGC), pero se parecen a los de verdad.

—

¿Qué es la herencia en Python?

La herencia permite crear una clase nueva a partir de otra que ya existe. La clase de la que se parte se llama superclase, clase base o «madre»; la nueva, subclase, clase derivada o «hija». La hija recibe todos los atributos y métodos de la madre sin escribirlos otra vez, y puede añadir los suyos o sustituir los que no le sirvan.

En Python se escribe poniendo la madre entre paréntesis detrás del nombre de la hija:

class Libro:
    """Lo que tiene cualquier libro del catálogo de la biblioteca."""

    def __init__(self, titulo, autor):
        self.titulo = titulo
        self.autor = autor

    def ficha(self):
        return f'«{self.titulo}», de {self.autor}'

    def dias_prestamo(self):
        return 15                      # plazo normal de la biblioteca


class Revista(Libro):                  # Revista ES UN Libro: hereda todo
    pass                               # sin escribir ni una línea más


r = Revista('Investigación y Ciencia', 'Varios autores')
print(r.ficha())                       # método heredado de Libro
print(r.dias_prestamo())               # también heredado
print(type(r).__name__, 'hereda de', Revista.__bases__[0].__name__)
print('¿Revista tiene ficha propia?', 'ficha' in Revista.__dict__)
print('¿Libro tiene ficha propia?  ', 'ficha' in Libro.__dict__)

Salida:

«Investigación y Ciencia», de Varios autores
15
Revista hereda de Libro
¿Revista tiene ficha propia? False
¿Libro tiene ficha propia?   True

Revista no tiene ni una línea propia (pass) y, sin embargo, r.ficha() y r.dias_prestamo() funcionan. Las dos últimas líneas lo demuestran: ficha no está en el diccionario de Revista (Revista.__dict__), está en el de Libro. Cuando pides r.ficha(), Python no la encuentra en la clase del objeto y sube a la madre. Esa búsqueda hacia arriba es todo el secreto de la herencia: los métodos no se copian, se buscan.

¿Cuándo tiene sentido heredar? Cuando entre las dos clases hay una relación «es un»: una revista es un material de la biblioteca, un estudiante es un socio. Si la relación es «tiene un» (una biblioteca tiene libros, un socio tiene préstamos), no se hereda: se guarda un objeto dentro de otro, como hicimos con Biblioteca en el tema de clases.

RelaciónEjemploQué se hace
«es un»Estudiante es un SocioHerencia: class Estudiante(Socio)
«tiene un»Biblioteca tiene LibrosComposición: una lista de libros como atributo
«es un», pero no se comporta igualUn Dvd que no se puede prestar a domicilioHerencia + sustituir el método que cambia

En los apuntes de FP2 verás la misma idea en Java con extends: class Two extends One. En Python son los paréntesis: class Two(One). Y una diferencia que cae en el test: Python permite herencia múltiple (varias madres separadas por comas); Java no.

—

Inicialización de subclases: super()

Volvamos al gancho. Si la hija no escribe __init__, hereda el de la madre y no hay problema. Pero en cuanto escribe el suyo, ese __init__ tapa al de la madre: Python encuentra uno en la hija y ya no sube. Por eso, cuando la hija necesita su propio __init__, lo primero que hace es llamar al de la madre con super():

class Libro:
    def __init__(self, titulo, autor):
        self.titulo = titulo
        self.autor = autor

    def ficha(self):
        return f'«{self.titulo}», de {self.autor}'


class LibroDigital(Libro):
    def __init__(self, titulo, autor, megas):
        super().__init__(titulo, autor)    # 1) la madre guarda lo común
        self.megas = megas                 # 2) la hija añade lo suyo


ebook = LibroDigital('Python para todos', 'Severance', 2.4)
print(ebook.ficha())
print(vars(ebook))                         # los tres atributos, en el mismo objeto

Salida:

«Python para todos», de Severance
{'titulo': 'Python para todos', 'autor': 'Severance', 'megas': 2.4}

Fíjate en vars(ebook): los tres atributos viven en el mismo objeto. No hay un «objeto Libro» dentro del «objeto LibroDigital»: hay un solo objeto, y el __init__ de la madre le pone titulo y autor mientras el de la hija le pone megas. El dibujo compara los dos casos:

El objeto con y sin super().__init__Dos objetos LibroDigital. Sin llamar a super().__init__ solo tiene megas y ficha() falla con AttributeError; con la llamada tiene titulo, autor y megas.Sin super().__init__LibroDigital.__init__(…)# super().__init__(…)self.megas = megasebook (objeto)megas 2.4titulo … no existeebook.ficha()→ AttributeErrorCon super().__init__LibroDigital.__init__(…)super().__init__(titulo, autor)self.megas = megasebook (objeto)titulo 'Python para todos'autor 'Severance'megas 2.4ebook.ficha()→ funciona
Lo que guarda la madre (violeta) solo llega al objeto si la hija llama a super().__init__; lo suyo (coral) lo guarda ella.

La regla práctica tiene dos partes:

  1. El __init__ de la hija recibe sus parámetros y los de la madre (aquí titulo, autor, megas).
  2. Pasa a super().__init__(…) los de la madre y guarda él los suyos.

¿Importa el orden? Casi siempre conviene llamar a super().__init__ lo primero, porque así, si la hija necesita usar algo que pone la madre (por ejemplo, calcular algo con el título), ya existe. En la práctica verás una excepción con sentido: el Programador valida su nivel antes de llamar a la madre, para no crear nada si el dato está mal.

Escribir el __init__ de la hija y olvidar super().__init__(…). No da error al crear el objeto: el error sale más tarde, en el primer método heredado que use un atributo de la madre (AttributeError: 'LibroDigital' object has no attribute 'titulo'). Si ves ese mensaje con un atributo que «debería» estar, mira el __init__ de la hija.

Por qué super() y no el nombre de la clase

También funciona llamar a la madre por su nombre:

class Libro:
    def __init__(self, titulo, autor):
        self.titulo = titulo
        self.autor = autor


class LibroDigital(Libro):
    def __init__(self, titulo, autor, megas):
        Libro.__init__(self, titulo, autor)   # funciona, pero con self a mano
        self.megas = megas                    # y el nombre de la madre escrito


ebook = LibroDigital('Python para todos', 'Severance', 2.4)
print(vars(ebook))

Salida:

{'titulo': 'Python para todos', 'autor': 'Severance', 'megas': 2.4}

El resultado es el mismo, entonces ¿por qué insisten los apuntes en super()? Por tres razones:

  • No escribes self a mano. Con Libro.__init__(self, …) estás llamando a la función de la clase y tienes que pasarle el objeto tú. Con super().__init__(…) el self va solo, como en cualquier método.
  • No repites el nombre de la madre. Si mañana LibroDigital pasa a heredar de Material en vez de Libro, con super() no tocas nada; con el nombre, tienes que buscar todas las llamadas.
  • Respeta el orden de búsqueda (MRO) cuando hay herencia múltiple, que veremos más abajo. super() no significa «mi madre», sino «la siguiente clase en el orden de búsqueda de este objeto». Con una sola madre coinciden; con varias, no.

—

Sustitución de métodos: override

La hija puede redefinir un método heredado escribiendo uno con el mismo nombre. Se llama override o sustitución: cuando el objeto es de la hija, Python encuentra primero el suyo y ya no sube.

class Socio:
    def __init__(self, nombre):
        self.nombre = nombre

    def limite(self):                  # cuántos libros puede llevarse a la vez
        return 3

    def dias_prestamo(self):
        return 15

    def descripcion(self):
        return f'{self.nombre}: {self.limite()} libros, {self.dias_prestamo()} días'


class Estudiante(Socio):
    pass                               # usa todo lo de Socio


class SocioPDI(Socio):                 # profesorado e investigación
    def limite(self):                  # SUSTITUYE (override) el de Socio
        return 10

    def dias_prestamo(self):
        return 60

    def descripcion(self):             # AMPLÍA el de Socio: lo llama y añade
        return super().descripcion() + ' (PDI, sin multas)'


for s in [Estudiante('Laura'), SocioPDI('Dra. Ramos')]:
    print(s.descripcion())

Salida:

Laura: 3 libros, 15 días
Dra. Ramos: 10 libros, 60 días (PDI, sin multas)

Aquí pasan tres cosas a la vez, y conviene separarlas:

  • Estudiante no sustituye nada: laura.limite() sube hasta Socio y devuelve 3.
  • SocioPDI sustituye limite y dias_prestamo: sus objetos ya no ven los de Socio.
  • descripcion está definido en Socio, pero llama a self.limite(). Cuando el objeto es un SocioPDI, ese self.limite() busca desde la clase del objeto, así que encuentra el 10 de SocioPDI. La madre llama a un método que la hija ha cambiado, y sale el de la hija. Esto es lo que más sorprende la primera vez.
Jerarquía de socios y búsqueda de métodosÁrbol con object, Socio y sus hijas Estudiante y SocioPDI. A la derecha, cuatro llamadas y las clases que Python revisa en orden hasta encontrar el método.La jerarquíaobjectSociolimite()dias_prestamo()descripcion()EstudianteSocioPDIlimite()dias_prestamo()descripcion()La flecha va de la hija a la madre¿Dónde encuentra Python el método?laura.limite()EstudianteSocio→ 3ramos.limite()SocioPDI→ 10ramos.descripcion()SocioPDIsuper() → Socio→ …sin multas)laura.__str__()EstudianteSocioobject→ <…object at 0x…>
Python busca primero en la clase del objeto y sube hacia la madre hasta encontrar el método: el primero que encuentra, gana.

Para saber qué método se ejecuta, no mires dónde está escrita la llamada: mira de qué clase es el objeto (self) y busca desde ahí hacia arriba. self.limite() dentro de Socio no significa «el limite de Socio».

Llamar al método de la superclase desde el override

Sustituir no obliga a tirar lo que hacía la madre. En SocioPDI.descripcion, super().descripcion() devuelve el texto de Socio («Dra. Ramos: 10 libros, 60 días») y la hija le añade « (PDI, sin multas)». Son dos formas de override:

FormaCómo se escribeCuándo
Sustituirdef limite(self): return 10El comportamiento de la madre no sirve para nada
Ampliarreturn super().descripcion() + ' (PDI…)'Lo de la madre vale y la hija añade algo antes o después

Ampliar y olvidar el return: texto = super().descripcion() + '…' y nada más. El método devuelve None y el print saca None. Lo verás con salida real en el «¿Qué falla aquí?» de los ejercicios.

En Java el override se marca con @Override y el compilador avisa si la firma no coincide. Python no tiene esa red: si en la hija escribes def limites(self) (con una s de más), no sustituyes nada, creas un método nuevo y la madre sigue usando el suyo. Las clases abstractas, al final del artículo, te protegen en parte de eso.

—

Polimorfismo: misma interfaz, comportamiento diferente

Polimorfismo significa que el mismo código funciona con objetos de clases distintas, y cada uno responde a su manera. La función multa de abajo calcula la multa de cualquier socio sin preguntar de qué tipo es: le pide los días de préstamo y la multa por día, y cada objeto contesta lo suyo.

class Socio:
    def __init__(self, nombre):
        self.nombre = nombre

    def dias_prestamo(self):
        return 15

    def multa_dia(self):               # euros por día de retraso
        return 0.20


class SocioPDI(Socio):
    def dias_prestamo(self):
        return 60

    def multa_dia(self):
        return 0.0


class Visitante(Socio):                # carné de un día, sin domiciliar
    def dias_prestamo(self):
        return 7

    def multa_dia(self):
        return 0.50


def multa(socio, dias_fuera):
    """No sabe (ni le importa) de qué clase es el socio: le pregunta."""
    retraso = max(0, dias_fuera - socio.dias_prestamo())
    return retraso * socio.multa_dia()


for s in [Socio('Laura'), SocioPDI('Dra. Ramos'), Visitante('Pablo')]:
    print(f'{type(s).__name__:<9} {s.nombre:<10} 20 días fuera -> {multa(s, 20):.2f} €')

Salida:

Socio     Laura      20 días fuera -> 1.00 €
SocioPDI  Dra. Ramos 20 días fuera -> 0.00 €
Visitante Pablo      20 días fuera -> 6.50 €

Comprueba las cuentas a mano: 20 días fuera con 15 de plazo son 5 de retraso; 5 × 0,20 = 1,00 € para Laura. Para el PDI, 20 − 60 sale negativo y max(0, …) lo deja en 0. Para el visitante, 20 − 7 = 13 días × 0,50 = 6,50 €.

Lo importante es lo que no hay en multa: ni un if type(socio) == …. Si mañana la biblioteca crea SocioAlumni con 30 días y 0,10 €/día, la función funciona sin tocarla. Esa es la ventaja práctica del polimorfismo: el código que usa los objetos no cambia cuando aparecen tipos nuevos.

En Python el polimorfismo ni siquiera necesita herencia. A multa le basta con que el objeto tenga dias_prestamo() y multa_dia(); si una clase sin relación con Socio los tiene, también funciona. Es el duck typing («si camina como un pato y hace cuac…»). La herencia sirve para no repetir código y para dejar escrito el contrato; el polimorfismo viene de llamar a los mismos métodos.

—

isinstance vs type: la diferencia que más confunde

Dos funciones parecen hacer lo mismo y no lo hacen:

  • type(obj) devuelve la clase exacta del objeto.
  • isinstance(obj, Clase) dice si el objeto es de esa clase o de cualquier hija suya.
class Socio:
    def dias_prestamo(self):
        return 15


class Estudiante(Socio):
    pass


class SocioPDI(Socio):
    def dias_prestamo(self):
        return 60


e = Estudiante()
print(type(e) == Estudiante, type(e) == Socio)              # tipo EXACTO
print(isinstance(e, Estudiante), isinstance(e, Socio))      # respeta la herencia
print(isinstance(e, object), issubclass(SocioPDI, Socio))
print([c.__name__ for c in Estudiante.__mro__])             # orden de búsqueda
print('__str__' in dir(e), '__str__' in Socio.__dict__)     # lo pone object

Salida:

True False
True True
True True
['Estudiante', 'Socio', 'object']
True False

Línea a línea: type(e) == Socio es False porque e es exactamente un Estudiante; isinstance(e, Socio) es True porque un estudiante es un socio. issubclass hace la misma pregunta con clases en vez de objetos. Y __mro__ (Method Resolution Order) es la lista exacta de clases que Python recorre al buscar un método: Estudiante → Socio → object.

Quiero saber…UsoCon e = Estudiante()
Si puedo tratarlo como un Socioisinstance(e, Socio)True
Su clase exactatype(e)Estudiante
Si una clase hereda de otraissubclass(Estudiante, Socio)True
En qué orden se buscan los métodosEstudiante.__mro__Estudiante, Socio, object

La regla para el examen y para la vida: isinstance casi siempre. Comparar con type rompe la herencia: si filtras «los socios» con type(s) == Socio, se te escapan todos los estudiantes y PDI. En la práctica verás exactamente ese fallo con los coches de gasolina y el híbrido.

Usar isinstance para decidir qué hacer con cada tipo (if isinstance(s, SocioPDI): … elif isinstance(s, Visitante): …). Funciona, pero es lo contrario del polimorfismo: cada tipo nuevo te obliga a tocar ese if. Si te ves escribiendo esa cadena, ese comportamiento debería ser un método que cada clase sustituye.

—

La clase object: la raíz de todo

La última línea de la salida anterior tiene trampa: '__str__' in dir(e) es True, pero '__str__' in Socio.__dict__ es False. ¿De dónde sale ese __str__? De object: en Python 3 toda clase hereda de object aunque no lo escribas (class Socio: es lo mismo que class Socio(object):). Por eso cualquier objeto tiene __str__, __repr__, __eq__… aunque tú no los hayas definido, y por eso print(laura) sin __str__ saca el famoso <__main__.Estudiante object at 0x…>: es el __str__ de object.

Cuando en el tema de clases escribías __str__ o __repr__, estabas haciendo un override de los de object. Y en el tema siguiente, sobrecarga de operadores, harás lo mismo con __eq__, __lt__ o __add__.

—

Herencia múltiple y MRO

Python deja que una clase tenga varias madres. Un becario de investigación da clase (como el PDI) y a la vez estudia el máster (como un estudiante). ¿Qué límite de préstamos tiene?

class Estudiante:
    def limite(self):
        return 3

    def dias_prestamo(self):
        return 15


class SocioPDI:
    def limite(self):
        return 10

    def dias_prestamo(self):
        return 60


class Becario(SocioPDI, Estudiante):    # da clase Y estudia el máster
    def dias_prestamo(self):
        return 30                       # el suyo propio


b = Becario()
print([c.__name__ for c in Becario.__mro__])
print('límite:', b.limite())            # no lo tiene: gana SocioPDI (va primero)
print('días:', b.dias_prestamo())       # lo tiene él: gana Becario

Salida:

['Becario', 'SocioPDI', 'Estudiante', 'object']
límite: 10
días: 30

Python decide con el MRO: busca en Becario, después en las madres en el orden en que las escribiste (SocioPDI, luego Estudiante) y al final en object. limite no está en Becario, así que gana el de SocioPDI (10). dias_prestamo sí está en Becario, así que gana el suyo (30). Si hubieras escrito class Becario(Estudiante, SocioPDI), el límite sería 3.

La herencia múltiple tiene usos buenos (clases pequeñas que añaden una capacidad, llamadas mixins), pero en FP2 lo que se pide es entender el MRO y saber predecir qué método gana. Si dudas, imprime Clase.__mro__: es la respuesta exacta.

—

Atributos de las subclases

La hija ve todo lo que la madre guarda en el objeto… con una excepción que confunde mucho: los atributos con dos guiones bajos.

class Socio:
    def __init__(self, nombre, codigo):
        self.nombre = nombre           # público
        self._carne = codigo[:3]       # «protegido»: convenio, sin cambio de nombre
        self.__codigo = codigo         # privado: Python lo guarda como _Socio__codigo


class Estudiante(Socio):
    def resumen(self):
        return f'{self.nombre} ({self._carne}) {self.__codigo}'


e = Estudiante('Laura', 'EST-4471')
print([k for k in vars(e)])
print(e.resumen())

Salida:

['nombre', '_carne', '_Socio__codigo']
Traceback (most recent call last):
  File "privados.py", line 15, in <module>
    print(e.resumen())
          ~~~~~~~~~^^
  File "privados.py", line 10, in resumen
    return f'{self.nombre} ({self._carne}) {self.__codigo}'
                                            ^^^^^^^^^^^^^
AttributeError: 'Estudiante' object has no attribute '_Estudiante__codigo'

vars(e) lo explica: el atributo __codigo que escribió Socio se guardó como _Socio__codigo (name mangling, lo vimos en el tema de clases). Dentro de Estudiante, escribir self.__codigo se convierte en self._Estudiante__codigo, que no existe. Por eso el traceback nombra _Estudiante__codigo.

Cómo se llamaEjemplo¿La hija lo usa con ese nombre?
Públicoself.nombreSí
«Protegido» (un guion)self._carneSí: es un convenio, «uso interno, también de las hijas»
Privado (dos guiones)self.__codigoNo: se llama _Socio__codigo; usa una propiedad o un método de la madre

La salida limpia: si las hijas necesitan un dato, la madre lo ofrece con una propiedad (@property) o lo guarda con un solo guion. Lo que no debes hacer es escribir self._Socio__codigo en la hija: funciona, pero te salta el encapsulamiento que querías.

—

Clases abstractas: contratos que deben cumplirse

En la biblioteca no existe «un material» a secas: existen libros, revistas, DVD. Material es una idea que reúne lo común, pero no tiene sentido crear un Material('¿Qué soy?'). Para eso están las clases abstractas: una clase que hereda de ABC (Abstract Base Class, del módulo abc) y marca con @abstractmethod los métodos que cada hija está obligada a escribir.

from abc import ABC, abstractmethod


class Material(ABC):                     # ABC: Abstract Base Class
    def __init__(self, titulo):
        self.titulo = titulo

    @property
    @abstractmethod
    def tipo(self):                      # propiedad abstracta (como en los apuntes)
        pass

    @abstractmethod
    def dias_prestamo(self):
        pass

    def ficha(self):                     # método normal que USA los abstractos
        return f'[{self.tipo}] {self.titulo}: {self.dias_prestamo()} días'


class Libro(Material):
    @property
    def tipo(self):
        return 'Libro'

    def dias_prestamo(self):
        return 15


class Revista(Material):
    @property
    def tipo(self):
        return 'Revista'

    def dias_prestamo(self):
        return 7


for m in [Libro('Cálculo'), Revista('Investigación y Ciencia')]:
    print(m.ficha())

try:
    Material('¿Qué soy?')
except TypeError as err:
    print('TypeError:', err)

Salida:

[Libro] Cálculo: 15 días
[Revista] Investigación y Ciencia: 7 días
TypeError: Can't instantiate abstract class Material without an implementation for abstract methods 'dias_prestamo', 'tipo'

Tres cosas que mirar:

  1. tipo es una propiedad abstracta: @property encima de @abstractmethod, en ese orden (es lo que aparece en los apuntes con area). Las hijas la implementan también como propiedad, y por eso ficha escribe self.tipo sin paréntesis.
  2. ficha es un método normal dentro de la clase abstracta, y usa los abstractos. La madre deja escrita la receta y las hijas ponen los ingredientes. A este patrón se le llama método plantilla y lo vas a ver en los tres programas de la práctica.
  3. Crear un Material da TypeError y el mensaje dice exactamente qué falta: without an implementation for abstract methods 'dias_prestamo', 'tipo'.

¿Y si una hija cumple solo la mitad del contrato?

from abc import ABC, abstractmethod


class Material(ABC):
    def __init__(self, titulo):
        self.titulo = titulo

    @abstractmethod
    def dias_prestamo(self):
        pass

    @abstractmethod
    def tipo(self):
        pass


class Dvd(Material):
    def dias_prestamo(self):             # cumple solo la mitad del contrato
        return 3


d = Dvd('Documental de la EII')

Salida:

Traceback (most recent call last):
  File "incompleta.py", line 22, in <module>
    d = Dvd('Documental de la EII')
TypeError: Can't instantiate abstract class Dvd without an implementation for abstract method 'tipo'

Dvd escribió dias_prestamo pero no tipo, así que también es abstracta y tampoco se puede instanciar. Lo bueno es cuándo sale el error: al crear el objeto, no días después cuando alguien llame a tipo(). Es la red de seguridad que Python no tiene con el @Override de Java.

Una clase abstracta es un contratoMaterial hereda de ABC y declara tipo y dias_prestamo como abstractos. Libro y Revista los implementan y se pueden crear; Dvd solo implementa uno y da TypeError, igual que Material.Material(ABC)@property @abstractmethod tipo@abstractmethod dias_prestamo()ficha() # usa los dosLibrotipo -> 'Libro'dias_prestamo() -> 15Libro(…) se creaRevistatipo -> 'Revista'dias_prestamo() -> 7Revista(…) se creaDvddias_prestamo() -> 3tipo -> faltaTypeErrorMaterial('¿Qué soy?') → TypeError
Solo se crean objetos de las clases que cumplen el contrato entero: a Dvd le falta tipo y no se puede instanciar.

Heredar de ABC pero olvidar @abstractmethod. Entonces el método «abstracto» con pass es un método normal que devuelve None, la clase se puede instanciar y el fallo vuelve a salir tarde. Las dos cosas van juntas: class X(ABC) y @abstractmethod en cada método obligatorio.

Si una pregunta del test dice «¿qué pasa al ejecutar Figura() si Figura tiene un método abstracto sin implementar?», la respuesta es TypeError al crear el objeto, no al llamar al método.

—

Clase abstracta vs clase normal: cuándo usar cada una

SituaciónEligeEjemplo de la biblioteca
La madre tiene sentido sola y las hijas son variantesClase normalSocio con valores por defecto; SocioPDI cambia el plazo
La madre es solo una idea común; no existen objetos «genéricos»Clase abstracta (ABC)Material: siempre es libro, revista o DVD
Quieres que olvidar un método dé error al crear el objetoClase abstracta con @abstractmethoddias_prestamo en todos los materiales
Hay un valor razonable por defecto que algunas hijas cambianMétodo normal en la madre (gancho)multa_dia() devuelve 0,20 y el PDI lo sustituye por 0

La última fila es la más útil en la práctica: no todo tiene que ser abstracto. Un método normal con un valor por defecto que algunas hijas sustituyen (un gancho) ahorra escribir lo mismo en cinco clases.

—

Errores típicos con herencia

Olvidar super().__init__ en una hija con __init__ propio: AttributeError en el primer método heredado que use un atributo de la madre.

Cambiar la firma al sustituir un método (def dias_prestamo(self, en_verano) en la hija cuando la madre lo llama sin argumentos): TypeError: … missing 1 required positional argument. Las hijas deben aceptar lo mismo que la madre.

Usar self.__atributo de la madre en la hija: AttributeError con _Hija__atributo. Usa una propiedad o un guion bajo.

Filtrar con type(x) == Madre: se te escapan las hijas. Usa isinstance.

Escribir mal el nombre del método que sustituyes (dias_prestamos): no sustituyes nada. Con una clase abstracta, el error salta al crear el objeto; sin ella, la madre sigue usando el suyo en silencio.

—

Míralo paso a paso en Python Tutor

pythontutor.com ejecuta el código línea a línea y dibuja los objetos, sus atributos y la pila de llamadas. Elige Python 3, pega este programa y pulsa Visualize Execution:

class Socio:
    def __init__(self, nombre):
        self.nombre = nombre

    def dias(self):
        return 15

    def ficha(self):
        return self.nombre + ': ' + str(self.dias()) + ' días'


class SocioPDI(Socio):
    def __init__(self, nombre, depto):
        super().__init__(nombre)
        self.depto = depto

    def dias(self):
        return 60


socios = [Socio('Laura'), SocioPDI('Ramos', 'Informática')]
for s in socios:
    print(s.ficha())

Salida:

Laura: 15 días
Ramos: 60 días

Qué observar paso a paso:

  1. Cuando se crea SocioPDI('Ramos', 'Informática'), fíjate en la pila de la derecha: aparecen dos marcos de __init__ a la vez, el de SocioPDI y, encima, el de Socio llamado por super(). Los dos tienen el mismo self (la flecha apunta al mismo objeto).
  2. Al terminar, el objeto tiene nombre y depto: uno lo puso la madre y otro la hija, pero es un solo objeto.
  3. En la segunda vuelta del for, entra en ficha (que está en Socio) y desde ahí llama a self.dias(). Mira a qué función salta: a la de SocioPDI, porque self es un SocioPDI.
  4. En la primera vuelta, el mismo self.dias() salta a la de Socio. La misma línea, dos destinos: eso es el polimorfismo visto en directo.

—

Resumen rápido

ConceptoCómo se escribeQué recordar
Heredarclass Hija(Madre):Los métodos no se copian: se buscan hacia arriba
Inicializarsuper().__init__(…)Obligatorio si la hija escribe su propio __init__
Sustituirmismo nombre en la hijaMisma firma que la madre
Ampliarreturn super().metodo() + …No olvides el return
Polimorfismomismas llamadas, objetos distintosNada de cadenas de if type(…)
Comprobar tipoisinstance(obj, Clase)type no ve la herencia
Orden de búsquedaClase.__mro__En herencia múltiple, de izquierda a derecha
Privados__x → _Madre__xLa hija no lo ve con ese nombre
Abstractaclass X(ABC) + @abstractmethodTypeError al crear si falta algo

Tres ideas para quedarte:

  1. Python busca los métodos desde la clase del objeto hacia arriba, y el primero que encuentra, gana. Casi todo lo del tema sale de ahí.
  2. Si la hija escribe __init__, llama a super().__init__: si no, lo de la madre no llega al objeto.
  3. Una clase abstracta convierte un olvido en un error temprano: mejor un TypeError al crear el objeto que un None raro tres días después.

En la práctica de herencia construimos tres jerarquías completas (una nómina, una flota de coches con un híbrido que hereda de un coche de gasolina y una tienda con IGIC) y verás el método plantilla en acción.

Siguiente paso: prácticaHerencia 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)

¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Sigue aprendiendo

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *