POO: Polimorfismo en Python
En este artículo veremos qué es el polimorfismo y cuál es su función en la programación orientada a objetos. Veremos el polimorfismo con y sin herencia y cómo implementarlo correctamente.
Polimorfismo
En la programación orientada a objetos (POO), el polimorfismo es uno de los cuatro principios fundamentales, junto con la encapsulación, la abstracción y la herencia.
El polimorfismo permite la definición de interfaces para operaciones que tienen diferentes comportamientos dependiendo del tipo de objeto que lo ejecuta. Su uso evita tener que utilizar estructuras condicionales que verifican tipos reduciendo la complejidad del código y haciendo el código más mantenible y flexible.
Implementar polimorfismo hace que el programa interactúe con abstracciones en lugar de con implementaciones. Esto permite que se puedan añadir nuevos comportamientos mediante la creación de nuevas clases, sin modificar el código ya existente.
Polimorfismo con herencia
En el siguiente caso se muestra un caso de polimorfismo con herencia, supongamos que tenemos diferentes tipos de empleados en una empresa, todos tienen datos básicos compartidos y un método en común llamado calcular_salario.
La implementación de este método es diferente para cada tipo de empleado, y es se calcula a partir de datos que son particulares para cada tipo.
En el código que se encuentra a continuación se ha creado una clase base Empleado, que tendrá los atributos comunes a todos los tipos de empleado, e implementa calcular_salario. Cada tipo de empleado existente tiene su propia clase que hereda de la clase base Empleado estableciendo sus propios atributos y la implementación específica del método calcular_salario.
class Empleado:
def __init__(self, nombre: str, salario_base: float):
self.nombre = nombre
self.salario_base = salario_base
def calcular_salario(self) -> float:
raise NotImplementedError("Las subclases deben implementar el método calcular_salario")
class EmpleadoTiempoCompleto(Empleado):
def __init__(self, nombre: str, salario_base: float):
super().__init__(nombre, salario_base)
def calcular_salario(self) -> float:
return self.salario_base
class EmpleadoPorHora(Empleado):
def __init__(self, nombre: str, salario_base: float, horas_trabajadas: int):
super().__init__(nombre, salario_base)
self.horas_trabajadas = horas_trabajadas
def calcular_salario(self) -> float:
return self.salario_base * self.horas_trabajadas
class EmpleadoPracticante(Empleado):
def __init__(self, nombre: str, salario_base: float, bono_desempeno: float = 0):
super().__init__(nombre, salario_base)
self.bono_desempeno = bono_desempeno
def calcular_salario(self) -> float:
return self.salario_base + self.bono_desempeno
Si se quiere realizar una operación como calcular la nómina total, es posible recorrer una lista de empleados de todo tipo, y ejecutar el método calcular_salario.
Dependiendo del empleado que se esté utilizando se ejecutará el método correspondiente, haciendo uso de la sobreescritura de métodos.
empleados = [
EmpleadoTiempoCompleto("Ana García", 3000),
EmpleadoPorHora("Carlos López", 15.50, 160),
EmpleadoTiempoCompleto("David Torres", 3200),
EmpleadoPorHora("Elena Castro", 18.75, 120),
]
total_nomina = 0
for empleado in empleados:
salario = empleado.calcular_salario()
total_nomina += salario
print(f"TOTAL NÓMINA: ${total_nomina:.2f}")
TOTAL NÓMINA: $10930.00
Si se quiere agregar un nuevo tipo de empleado con nuevos atributos y otra forma de calcular su salario solo es necesario agregar una nueva clase que herede de la clase Empleado, y sobreescribir su método calcular_salario. El resto del código no requiere modificaciones.
Duck typing o tipado dinámico
Python permite implementar el polimorfismo mediante un enfoque basado en tipado dinámico estructural o duck typing.
Bajo este enfoque no es necesario generar herencias entre clases ni que estas dependen unas de otras. Esto permite más flexibilidad, ya que lo único que se tiene en cuenta es en sí las clases comparten o no métodos comunes.
En este caso se tienen diferentes manejadores para distintas partes del sistema (Base de Datos, Archivos JSON y Cache). Aunque todas estas clases tienen sus propios atributos y métodos que no son compartidos entre ellos si tienen en común los métodos cargar y descargar. Las clases no heredan de una clase base ni se relacionan entre sí, pero pueden utilizarse los métodos mencionados para crear polimorfismo.
La función carga_descarga_datos recibe como argumento un tipo de clase usada para almacenamiento, junto con otros datos, y ejecuta los métodos cargar y descargar del objeto correspondiente.
class ManejadorBaseDatos:
# Métodos y operaciones propias
def guardar(self, datos):
print(f"Guardando en BD: {datos}")
def cargar(self, id):
return f"Datos desde BD para {id}"
class ManejadorArchivoJSON:
# Métodos y operaciones propias
def guardar(self, datos):
print(f"Guardando en JSON: {datos}")
def cargar(self, ruta):
return f"Datos desde JSON en {ruta}"
class ManejadorCacheMemoria:
# Métodos y operaciones propias
def guardar(self, datos):
print(f"Guardando en cache: {datos}")
def cargar(self, clave):
return f"Datos desde cache con clave {clave}"
def carga_descarga_datos(almacenamiento, datos):
almacenamiento.guardar(datos)
return almacenamiento.cargar("id123")
Se puede ver en este ejemplo que, aunque las clases son diferentes y han sido creadas para un objetivo específico, pueden ejecutarse los métodos de manera indistinta.
carga_descarga_datos(ManejadorBaseDatos(), {"a": 1})
carga_descarga_datos(ManejadorArchivoJSON(), {"b": 2})
carga_descarga_datos(ManejadorCacheMemoria(), {"c": 3})
Guardando en BD: {'a': 1}
Guardando en JSON: {'b': 2}
Guardando en cache: {'c': 3}
Aunque este método provee mucha flexibilidad, no existe una relación explícita entre las clases, esto hace la documentación más compleja, y es más probable la aparición de errores cuando un método no ha sido implementado correctamente.
Buenas prácticas
La implementación del polimorfismo requiere seguir un conjunto de principios de diseño para evitar errores.
En primer lugar, es necesario programar por comportamientos y no para tipos concretos, es decir, el código debe interactuar con interfaces abstractas definidas por comportamientos esperados. Esto reduce el acoplamiento entre componentes, y permite la integración de nuevos tipos sin la necesidad de modificar el código existente.
Uno de los aspectos más importantes a tener en cuenta es que las interfaces deben ser coherentes. Los métodos polimórficos deben ser consistentes y uniformes entre si. Se debe respetar no solo el nombre y parámetros del mismo, sino también su funcionalidad, pre-condiciones y post-condiciones.
Se deben evitar herencias de más de tres niveles ya que aumentan la complejidad y reducen la claridad del código. El uso de mixins es recomendado para reutilizar comportamiento sin crear jerarquías.
En este artículo hemos visto qué es le polimorfismo y cómo permite la flexibilización y reutilización de código. Hemos explorado como aplicarlo haciendo uso de herencia y de duck typing, y cómo implementarlo correctamente para evitar errores.