Principios Fundamentales de la POO

Los Pilares de la Programación Orientada a Objetos

La POO se basa en cuatro principios fundamentales que nos permiten crear sistemas más organizados, mantenibles y reusables: encapsulamiento, herencia, polimorfismo y abstracción.

En esta sección exploraremos los tres primeros principios, mientras que la abstracción se cubrirá con mayor detalle en la siguiente sección.

¿Por qué son importantes estos principios?

Estos principios nos permiten:

  • Crear código más organizado y fácil de mantener
  • Proteger los datos y asegurar su integridad
  • Reutilizar código de manera eficiente
  • Diseñar sistemas flexibles y extensibles

Encapsulamiento

¿Qué es el Encapsulamiento?

El encapsulamiento consiste en ocultar los detalles internos de una clase y proporcionar una interfaz controlada para interactuar con ella. Esto nos permite:

  • Proteger los datos contra modificaciones no deseadas
  • Ocultar la complejidad interna del objeto
  • Proporcionar una interfaz clara y bien definida
Convenciones de visibilidad en Python

Python utiliza convenciones de nomenclatura para indicar la visibilidad:

  • atributo - Atributo público (accesible desde cualquier lugar)
  • _atributo - Atributo protegido (debería ser usado solo dentro de la clase y sus subclases)
  • __atributo - Atributo privado (name mangling: solo accesible dentro de la clase)
encapsulamiento.py
class CuentaBancaria:
    def __init__(self, titular, saldo_inicial=0):
        self.titular = titular       # Atributo público
        self._saldo = saldo_inicial  # Atributo protegido
        self.__pin = "1234"          # Atributo privado
    
    def depositar(self, cantidad):
        if cantidad > 0:
            self._saldo += cantidad
            return True
        return False
    
    def retirar(self, cantidad, pin):
        if pin != self.__pin:
            print("PIN incorrecto. Operación denegada.")
            return False
        
        if cantidad <= 0:
            return False
        
        if cantidad > self._saldo:
            print("Saldo insuficiente.")
            return False
        
        self._saldo -= cantidad
        return True
    
    def consultar_saldo(self, pin):
        if pin == self.__pin:
            return self._saldo
        else:
            print("PIN incorrecto. Operación denegada.")
            return None
    
    # Propiedades (getters y setters)
    @property
    def saldo(self):
        return self._saldo
    
    # No permitimos modificar el saldo directamente
    
    @property
    def titular(self):
        return self._titular
    
    @titular.setter
    def titular(self, nuevo_titular):
        self._titular = nuevo_titular

# Creamos una cuenta
cuenta = CuentaBancaria("Ana López", 1000)

# Operaciones permitidas
print(f"Titular: {cuenta.titular}")
cuenta.depositar(500)
print(f"Saldo: {cuenta.saldo}")  # Usando property

# Intentamos acceder a atributos protegidos y privados
print("\nIntentando acceder directamente:")
print(f"Acceso a _saldo: {cuenta._saldo}")  # No recomendado pero posible
try:
    print(f"Acceso a __pin: {cuenta.__pin}")
except AttributeError as e:
    print(f"Error: {e}")

# La forma correcta de consultar el saldo
print("\nConsulta correcta:")
print(f"Saldo (con PIN correcto): {cuenta.consultar_saldo('1234')}")
print(f"Saldo (con PIN incorrecto): {cuenta.consultar_saldo('0000')}")
>>> Ejecuta el código para ver la salida
Propiedades en Python

Las propiedades (usando el decorador @property) son una forma elegante de implementar el encapsulamiento en Python. Permiten:

  • Acceder a atributos como si fueran públicos
  • Ejecutar código al obtener o establecer un valor
  • Validar valores antes de asignarlos
  • Cambiar la implementación interna sin modificar la interfaz

Herencia

¿Qué es la Herencia?

La herencia es un mecanismo que permite crear nuevas clases basadas en clases existentes, heredando sus atributos y métodos. La herencia permite:

  • Reutilizar código existente
  • Extender la funcionalidad de clases existentes
  • Crear jerarquías de clases para modelar relaciones "es-un"
Sintaxis de herencia en Python
class ClaseBase:
    # Atributos y métodos de la clase base
    pass

class ClaseDerivada(ClaseBase):
    # Atributos y métodos de la clase derivada
    # Puede añadir nuevos o sobrescribir los existentes
    pass
herencia.py
class Vehiculo:
    def __init__(self, marca, modelo, año):
        self.marca = marca
        self.modelo = modelo
        self.año = año
        self.encendido = False
    
    def encender(self):
        if not self.encendido:
            self.encendido = True
            return f"{self.marca} {self.modelo} encendido"
        return f"{self.marca} {self.modelo} ya está encendido"
    
    def apagar(self):
        if self.encendido:
            self.encendido = False
            return f"{self.marca} {self.modelo} apagado"
        return f"{self.marca} {self.modelo} ya está apagado"
    
    def informacion(self):
        return f"Vehículo: {self.marca} {self.modelo} ({self.año})"


class Automovil(Vehiculo):
    def __init__(self, marca, modelo, año, tipo_carroceria):
        # Llamada al constructor de la clase base
        super().__init__(marca, modelo, año)
        
        # Atributos específicos de Automovil
        self.tipo_carroceria = tipo_carroceria
        self.velocidad = 0
    
    def acelerar(self, incremento):
        if self.encendido:
            self.velocidad += incremento
            return f"Acelerando a {self.velocidad} km/h"
        else:
            return "No se puede acelerar. El vehículo está apagado."
    
    # Sobrescribimos el método informacion de la clase base
    def informacion(self):
        info_base = super().informacion()  # Reutilizamos el método de la clase base
        return f"{info_base}, Tipo: {self.tipo_carroceria}"


class Motocicleta(Vehiculo):
    def __init__(self, marca, modelo, año, cilindrada):
        super().__init__(marca, modelo, año)
        self.cilindrada = cilindrada
    
    def hacer_caballito(self):
        if self.encendido:
            return "¡Haciendo un caballito con la moto!"
        return "No puedes hacer un caballito. La moto está apagada."
    
    def informacion(self):
        return f"{super().informacion()}, Cilindrada: {self.cilindrada}cc"


# Crear instancias
auto = Automovil("Toyota", "Corolla", 2022, "Sedán")
moto = Motocicleta("Honda", "CBR", 2021, 600)

# Usar métodos de la clase base
print(auto.encender())
print(moto.encender())

# Usar métodos específicos de cada clase
print(auto.acelerar(60))
print(moto.hacer_caballito())

# Llamar al método sobrescrito
print("\nInformación de los vehículos:")
print(auto.informacion())
print(moto.informacion())
>>> Ejecuta el código para ver la salida
Conceptos importantes de herencia
  • Clase base/padre: La clase original de la que se hereda.
  • Clase derivada/hija: La nueva clase que hereda de la clase base.
  • super(): Función que permite acceder a métodos de la clase padre.
  • Sobrescritura (override): Reemplazar un método heredado con una nueva implementación.
  • Herencia múltiple: En Python, una clase puede heredar de múltiples clases base.

Herencia múltiple

A diferencia de otros lenguajes como Java o C#, Python admite herencia múltiple, lo que significa que una clase puede heredar de varias clases base.

herencia_multiple.py
class Dispositivo:
    def __init__(self, fabricante, modelo):
        self.fabricante = fabricante
        self.modelo = modelo
        self.encendido = False
    
    def encender(self):
        self.encendido = True
        return "Dispositivo encendido"
    
    def apagar(self):
        self.encendido = False
        return "Dispositivo apagado"


class DispositivoConectado:
    def __init__(self, direccion_ip="192.168.1.1"):
        self.direccion_ip = direccion_ip
        self.conectado = False
    
    def conectar(self):
        self.conectado = True
        return f"Conectado a la red con IP {self.direccion_ip}"
    
    def desconectar(self):
        self.conectado = False
        return "Desconectado de la red"


class SmartTV(Dispositivo, DispositivoConectado):
    def __init__(self, fabricante, modelo, pulgadas, direccion_ip="192.168.1.100"):
        Dispositivo.__init__(self, fabricante, modelo)
        DispositivoConectado.__init__(self, direccion_ip)
        self.pulgadas = pulgadas
        self.canal = 1
    
    def cambiar_canal(self, canal):
        if self.encendido and self.conectado:
            self.canal = canal
            return f"Canal cambiado a {canal}"
        return "No se puede cambiar el canal. Verifica que la TV esté encendida y conectada."
    
    def informacion(self):
        estado_encendido = "encendido" if self.encendido else "apagado"
        estado_conexion = "conectado" if self.conectado else "desconectado"
        return f"{self.fabricante} {self.modelo} {self.pulgadas}\" - {estado_encendido}, {estado_conexion}"


# Crear una Smart TV
tv = SmartTV("Samsung", "Neo QLED", 55)

# Usar métodos de ambas clases base
print(tv.encender())  # De Dispositivo
print(tv.conectar())  # De DispositivoConectado

# Usar método propio de SmartTV
print(tv.cambiar_canal(5))

# Mostrar información
print(tv.informacion())
>>> Ejecuta el código para ver la salida

Polimorfismo

¿Qué es el Polimorfismo?

El polimorfismo permite que diferentes clases implementen métodos con el mismo nombre pero con comportamientos diferentes. En Python, el polimorfismo se logra de varias formas:

  • Sobrescritura de métodos en clases heredadas
  • Duck typing: "Si camina como un pato y hace cuac como un pato, entonces es un pato"
  • Interfaces y protocolos implícitos
polimorfismo.py
class Animal:
    def __init__(self, nombre):
        self.nombre = nombre
    
    def hacer_sonido(self):
        raise NotImplementedError("Las subclases deben implementar este método")
    
    def presentarse(self):
        return f"Soy {self.nombre} y hago: {self.hacer_sonido()}"


class Perro(Animal):
    def hacer_sonido(self):
        return "¡Guau!"
    
    def jugar(self):
        return f"{self.nombre} está jugando con una pelota"


class Gato(Animal):
    def hacer_sonido(self):
        return "¡Miau!"
    
    def dormir(self):
        return f"{self.nombre} está durmiendo en el sofá"


class Pato(Animal):
    def hacer_sonido(self):
        return "¡Cuac!"
    
    def nadar(self):
        return f"{self.nombre} está nadando en el lago"


# Función que muestra el polimorfismo
def hacer_sonar_animal(animal):
    return animal.hacer_sonido()


# Crear instancias de diferentes animales
perro = Perro("Rex")
gato = Gato("Whiskers")
pato = Pato("Donald")

# Lista de animales (diferentes tipos)
animales = [perro, gato, pato]

# Demostración de polimorfismo
print("Demostración de polimorfismo:")
for animal in animales:
    print(f"{animal.nombre}: {animal.hacer_sonido()}")

# Uso de la función polimórfica
print("\nUso de función polimórfica:")
print(f"El perro dice: {hacer_sonar_animal(perro)}")
print(f"El gato dice: {hacer_sonar_animal(gato)}")
print(f"El pato dice: {hacer_sonar_animal(pato)}")

# Métodos específicos de cada clase
print("\nComportamientos específicos:")
print(perro.jugar())
print(gato.dormir())
print(pato.nadar())
>>> Ejecuta el código para ver la salida
Duck Typing en Python

Python utiliza "duck typing" (tipado pato), una forma de polimorfismo donde el tipo o la clase de un objeto es menos importante que los métodos que implementa o las propiedades que tiene.

A diferencia de lenguajes como Java o C#, Python no requiere que una clase implemente explícitamente una interfaz. En su lugar, cualquier objeto que proporcione los métodos esperados funcionará, independientemente de su tipo.

duck_typing.py
class Pato:
    def volar(self):
        return "El pato está volando"
    
    def nadar(self):
        return "El pato está nadando"
    
    def hacer_sonido(self):
        return "¡Cuac!"


class Avion:
    def volar(self):
        return "El avión está volando"
    
    def nadar(self):
        return "Los aviones no nadan"
    
    def hacer_sonido(self):
        return "¡Brrrrrr!"


class Persona:
    def volar(self):
        return "Las personas no pueden volar naturalmente"
    
    def nadar(self):
        return "La persona está nadando"
    
    def hacer_sonido(self):
        return "¡Hola!"


# Función que utiliza duck typing
def hacer_volar(objeto):
    return objeto.volar()

def hacer_nadar(objeto):
    return objeto.nadar()

# Crear instancias
pato = Pato()
avion = Avion()
persona = Persona()

# Lista de objetos completamente diferentes
objetos = [pato, avion, persona]

# Demostración de duck typing
print("Demostración de Duck Typing:")
for obj in objetos:
    print(f"Volar: {hacer_volar(obj)}")
    print(f"Nadar: {hacer_nadar(obj)}")
    print(f"Sonido: {obj.hacer_sonido()}")
    print()
>>> Ejecuta el código para ver la salida

Ejercicio Práctico

Sistema de Empleados

Diseña un sistema para gestionar diferentes tipos de empleados con las siguientes características:

  • Una clase base Empleado con atributos comunes como nombre, ID y salario base.
  • Clases derivadas como EmpleadoTiempoCompleto y EmpleadoTiempoParcial.
  • Cada tipo de empleado debe calcular su salario de forma diferente (polimorfismo).
  • Implementa encapsulamiento adecuado para los atributos sensibles.
ejercicio_empleados.py
class Empleado:
    # Implementa la clase base aquí
    pass

class EmpleadoTiempoCompleto(Empleado):
    # Implementa esta clase derivada
    pass

class EmpleadoTiempoParcial(Empleado):
    # Implementa esta clase derivada
    pass

# Crea empleados y calcula sus salarios
# empleado1 = EmpleadoTiempoCompleto(...)
# empleado2 = EmpleadoTiempoParcial(...)