Saltar al contenido principal

6.2 Principios de la POO

En Programación Orientada a Objetos se suelen destacar tres ideas clave que aparecen una y otra vez al diseñar clases y relaciones entre ellas. La Encapsulación, la Herencia y el Polimorfismo no son normas rígidas, pero sirven como guía mental para construir programas más claros, flexibles y fáciles de mantener.

  • La encapsulación se centra en proteger los datos internos de un objeto y exponer solo una interfaz pública bien definida.
    Permite separar el “cómo está hecho por dentro” del “cómo se utiliza desde fuera”.

  • La herencia permite crear nuevas clases a partir de otras ya existentes, reutilizando código y modelando relaciones del tipo “es un” (por ejemplo, Guerrero es un Personaje).

  • El polimorfismo hace posible que distintos objetos respondan de forma diferente al mismo mensaje (por ejemplo, varias clases distintas que implementan un método atacar() a su manera), lo que facilita escribir código más generico.

A lo largo de esta sección se verán estos tres principios por separado, con ejemplos sencillos en Python, y se analizará cómo se combinan en diseños reales. Primero encapsulando correctamente los datos y métodos de una clase, después extendiendo el comportamiento mediante herencia, y finalmente aprovechando el polimorfismo para trabajar con objetos diferentes a través de una misma interfaz.


1️⃣ Encapsulación​

La encapsulación es uno de los pilares de la POO. Consiste en proteger los datos internos de un objeto y ofrecer una forma controlada de acceder y modificarlos, a través de métodos y propiedades.

La idea clave es que el resto del programa no debe manipular directamente el interior del objeto, sino usar la “interfaz pública” que la clase proporciona.

Encapsular significa:

  • Agrupar datos (atributos) y comportamientos (métodos) dentro de una clase.
  • Decidir que partes son públicas (se pueden usar desde fuera) y que partes son internas (detalle de implementación).

Ventajas:

  • Evita estados incoherentes en los objetos.
  • Permite cambiar la implementación interna sin romper el código que los usa.
  • Mejora la seguridad y el mantenimiento del software.
Ejemplo sencillo de encapsulación
class CuentaBancaria:
def __init__(self, titular, saldo_inicial):
self.titular = titular # interfaz pública
self._saldo = saldo_inicial # detalle interno

def ingresar(self, cantidad):
if cantidad > 0:
self._saldo += cantidad

def obtener_saldo(self):
return self._saldo

🟩 Modificadores de acceso (_ y __)​

En muchos lenguajes (Java, C++, C#) existen palabras como public, private o protected para controlar el acceso a los atributos.
En Python no existen esas palabras, pero se utiliza una convención de nombres para indicar la intención del programador.

Python no impide de forma estricta que se acceda a un atributo “privado”, pero el nombre del atributo indica si debería usarse desde fuera o no.

FormaSignificado (convención)
atributoAtributo público: se puede usar libremente desde fuera
_atributoAtributo de uso interno (tipo “protegido”)
__atributoAtributo “privado”: se renombra internamente (name mangling)

1) Atributo público: atributo​

Un nombre normal, sin guion bajo inicial, se considera público:

class Jugador:
def __init__(self, nombre, nivel):
self.nombre = nombre # público
self.nivel = nivel # público

La idea es que cualquier parte del programa puede hacer:

j = Jugador("Ana", 5)
print(j.nombre)
j.nivel = 6

No hay ninguna advertencia: el propio nombre indica que forma parte de la interfaz pública de la clase.

2) Atributo “protegido” por convención: _atributo​

Cuando un atributo empieza por un solo guion bajo (_), Python está indicando:

“Este atributo es de uso interno. No deberías tocarlo desde fuera salvo que sepas muy bien lo que haces.”

class Jugador:
def __init__(self, nombre, nivel):
self.nombre = nombre
self._experiencia = 0 # uso interno

def anadir_experiencia(self, cantidad):
self._experiencia += cantidad
  • Consecuencia técnica: no hay protección real. Se puede hacer jugador._experiencia desde fuera.
  • Consecuencia Práctica: es una señal para otros desarrolladores (y para ti mismo en el futuro) de que ese atributo forma parte de los detalles internos de la clase.

Además, hay un pequeño efecto extra:

  • from modulo import * no importa los nombres que empiezan por _, por lo que estos atributos y funciones quedan un poco más “escondidos”.

3) Atributo “privado” con name mangling: __atributo​

Cuando un atributo empieza con doble guion bajo (__), Python aplica una transformacion llamada name mangling:

Convención de nombres para el acceso
class Ejemplo:
publico = 1 # se puede usar libremente
_interno = 2 # uso interno de la clase o modulo
__muy_interno = 3 # se renombra internamente

Desde fuera:

e = Ejemplo()

print(e.publico) # 1
print(e._interno) # 2 (se puede, aunque no deberia tocarse)

# print(e.__muy_interno) # ❌ AttributeError: el nombre ya no es exactamente ese

Python renombra internamente __muy_interno a _Ejemplo__muy_interno. Por eso, si se quiere, aún se puede acceder:

print(e._Ejemplo__muy_interno)  # 3

Es decir:

  • No es “seguridad fuerte”, pero dificulta el acceso accidental.
  • También evita posibles choques de nombres en herencia (si una subclase define su propio __muy_interno, Python los mantiene separados).

4) ¿Cuándo usar cada uno?​

Regla simple:

  • atributo → forma parte de la interfaz pública de la clase. Se puede usar sin miedo desde fuera.

  • _atributo → detalle interno. Se puede leer desde fuera en casos puntuales, pero no debería modificarse directamente. Es lo habitual cuando se acompaña con getters/setters o propiedades (@property).

  • __atributo → se quiere una ocultacion más fuerte dentro de la propia jerarquia de herencia. Se usa menos en el día a día y puede complicar un poco la depuración, pero es útil cuando se desea evitar colisiones de nombres en subclases.

🟦 Métodos getter y setter​

Los métodos getter y setter son funciones que sirven para leer y modificar atributos internos de forma controlada.

Getters y setters clásicos
class CuentaBancaria:
def __init__(self, titular, saldo_inicial):
self.titular = titular
self._saldo = saldo_inicial

def obtener_saldo(self): # getter
return self._saldo

def cambiar_saldo(self, nuevo_saldo): # setter
if nuevo_saldo >= 0:
self._saldo = nuevo_saldo

cuenta = CuentaBancaria("Ana", 1000)
print(cuenta.obtener_saldo()) # 1000
cuenta.cambiar_saldo(1200)

Ventajas de usar getters y setters:

  • Se pueden añadir validaciones (por ejemplo, no aceptar saldos negativos).
  • Se puede cambiar la forma de guardar el dato sin cambiar el resto del código.

🟨 Propiedades (@property)​

En Python es muy habitual usar el decorador @property para crear propiedades.
Una propiedad es algo que parece un atributo normal desde fuera, pero por dentro se comporta como un método (un getter, y opcionalmente un setter).

La idea es:

  • Desde fuera se escribe objeto.edad y objeto.edad = 30 como si fueran accesos directos a un atributo.
  • Por dentro se ejecuta código que puede validar, transformar o proteger el dato.

Esto permite combinar:

  • Sintaxis sencilla de atributos.
  • Control y validación tipicos de getters y setters.

1) El problema sin @property​

Imagina que empiezas con una clase muy sencilla:

class Persona:
def __init__(self, nombre, edad):
self.nombre = nombre # atributo público
self.edad = edad # atributo público

En todo tu código haces cosas como:

p = Persona("Luis", 30)
p.edad = -5 # esto es posible...

Mas tarde te das cuenta de que no debería permitirse una edad negativa. Si cambias el acceso directo por métodos tipo get_edad y set_edad, tendrias que modificar todo el código que usa p.edad.

2) Solución con @property​

Con @property puedes encapsular el atributo real y seguir usando la misma sintaxis externa.

Uso de @property
class Persona:
def __init__(self, nombre, edad):
self._nombre = nombre
self._edad = edad # atributo "interno"

@property
def edad(self):
"""Getter: devuelve la edad."""
return self._edad

@edad.setter
def edad(self, nueva_edad):
"""Setter: valida y actualiza la edad."""
if nueva_edad < 0:
print("La edad no puede ser negativa")
else
self._edad = nueva_edad

p = Persona("Luis", 30)

# Desde fuera, se sigue usando como un atributo normal:
print(p.edad) # internamente llama al getter

p.edad = 31 # internamente llama al setter

Fíjate en los detalles importantes:

  • El atributo real se guarda como _edad (convención de “uso interno”).
  • @property convierte el método edad(self) en un atributo de solo lectura.
  • @edad.setter añade la parte de escritura, con validación incluida.

Desde el resto del programa no se nota el cambio: se sigue escribiendo p.edad y p.edad = valor, pero ahora hay lógica por dentro.

Esto es muy útil cuando al principio se usaban atributos públicos y, más tarde, se necesitan validaciones sin cambiar la sintaxis en todo el proyecto.

3) Propiedades de solo lectura​

Si solo se define el método con @property y no se define un setter, la propiedad pasa a ser de solo lectura:

Propiedad de solo lectura
class Cuenta:
def __init__(self, titular, saldo_inicial):
self._titular = titular
self._saldo = saldo_inicial

@property
def saldo(self):
"""Saldo solo de lectura desde fuera."""
return self._saldo

cuenta = Cuenta("Ana", 1000)
print(cuenta.saldo) # 1000

# cuenta.saldo = 2000 # ❌ AttributeError: no hay setter definido

Esto es útil cuando:

  • El dato puede cambiar internamente (por métodos de la clase).
  • Pero no quieres que se pueda cambiar directamente desde fuera.

🟧 Ejemplo práctico​

Control de acceso a un atributo privado
class Termometro:
def __init__(self, temperatura_inicial=20.0):
self.__temperatura = temperatura_inicial # atributo “privado”

@property
def temperatura(self):
"""Devuelve la temperatura actual."""
return self.__temperatura

@temperatura.setter
def temperatura(self, valor):
"""Establece la temperatura, comprobando rangos validos."""
if -50 <= valor <= 60:
self.__temperatura = valor
else:
print("Temperatura fuera de rango")

def mostrar(self):
print(f"Temperatura: {self.__temperatura} ºC")

t = Termometro()
t.mostrar() # Temperatura: 20.0 ºC
t.temperatura = 25
t.mostrar() # Temperatura: 25.0 ºC

# t.__temperatura = 999 # no recomendable: no modifica el atributo interno real

2️⃣ Herencia​

La herencia es un mecanismo que permite crear una clase nueva (clase hija o subclase) basada en otra clase existente (clase padre o superclase). La subclase hereda atributos y métodos de la clase padre y puede:

  • Añadir nuevos atributos y métodos.
  • Modificar (sobrescribir) el comportamiento heredado.

La herencia se usa para reutilizar código y organizar mejor las clases que comparten características comunes.

Ejemplo de idea:

  • Clase Personaje: define lo que todos los personajes jugables tienen en común (nombre, vida, nivel, etc.).
  • Clases Guerrero y Mago: heredan de Personaje y añaden o adaptan comportamientos específicos (distintos tipos de ataque, recursos especiales, etc.).

Ventajas:

  • Evita duplicar código.
  • Facilita extender programas grandes.
  • Permite trabajar con jerarquías de tipos (por ejemplo, tratar a todos los Guerrero y Mago como Personaje).

🟩 Herencia simple​

La herencia simple es cuando una clase hereda de una única clase base.

✍️ Sintaxis básica de herencia simple
class ClasePadre:
def metodo_padre(self):
print("Método de la clase padre")

class ClaseHija(ClasePadre):
def metodo_hija(self):
print("Método de la clase hija")

objeto = ClaseHija()
objeto.metodo_padre() # heredado
objeto.metodo_hija() # definido en la hija

🟦 Sobrescritura de métodos​

Una subclase puede modificar el comportamiento de un método heredado definiendo de nuevo un método con el mismo nombre.

Sobrescritura de métodos
class Personaje:
def atacar(self):
print("El personaje realiza un ataque básico")

class Guerrero(Personaje):
def atacar(self): # sobrescritura
print("El guerrero ataca con su espada")

class Mago(Personaje):
def atacar(self): # sobrescritura
print("El mago lanza un hechizo")

g = Guerrero()
m = Mago()

g.atacar() # El guerrero ataca con su espada
m.atacar() # El mago lanza un hechizo

🟨 Uso de super()​

Cuando una clase hija redefine un método (por ejemplo, __init__), es habitual querer aprovechar parte del código de la clase padre. Para eso se usa la función integrada super().

Uso de super() en __init__
class Personaje:
def __init__(self, nombre, vida):
self.nombre = nombre
self.vida = vida

def mostrar_informacion(self):
print(f"Nombre: {self.nombre} - Vida: {self.vida}")

class Guerrero(Personaje):
def __init__(self, nombre, vida, fuerza):
# Llamar al constructor de la clase padre
super().__init__(nombre, vida)
self.fuerza = fuerza

def mostrar_informacion(self):
# Reutilizar mostrar_informacion() de Personaje y ampliar
super().mostrar_informacion()
print(f"Fuerza: {self.fuerza}")

guerrero = Guerrero("Arthas", 120, 18)
guerrero.mostrar_informacion()

Salida:

Nombre: Arthas - Vida: 120
Fuerza: 18

🟧 Herencia multiple​

Python permite que una clase herede de varias clases a la vez. Esto se llama herencia multiple.

Herencia múltiple básica
class PuedeVolar:
def moverse(self):
print("El enemigo se desplaza volando")

class PuedeDisparar:
def atacar(self):
print("El enemigo dispara proyectiles")

class EnemigoVolador(PuedeVolar, PuedeDisparar):
pass

enemigo = EnemigoVolador()
enemigo.moverse() # heredado de PuedeVolar
enemigo.atacar() # heredado de PuedeDisparar

Herencia multiple y resolución de métodos y atributos (MRO)​

Cuando una clase hereda de varias clases, puede ocurrir que dos o más clases base definan el mismo metodo o atributo. Para decidir cual usar, Python aplica un orden llamado MRO (Method Resolution Order), es decir, el orden de busqueda de metodos y atributos.

La busqueda se realiza siempre en este orden:

  1. En la instancia (atributos propios del objeto)
  2. En la clase del objeto
  3. En las clases base, siguiendo el orden definido por el MRO
  4. Finalmente en object
Ejemplo de uso de MRO
class PuedeVolar:
def moverse(self):
print("Se desplaza volando")

class PuedeNadar:
def moverse(self):
print("Se desplaza nadando")
def atacar(self):
print("El enemigo lanza un coletazo")

class Quimera(PuedeVolar, PuedeNadar):
def atacar(self):
print("El enemigo clava su aguijón")

q = Quimera()
q.moverse()
en este ejemplo

Salida:

Se desplaza volando

Python utiliza el metodo moverse() de PuedeVolar porque aparece antes en el MRO. Si se cambia el orden de herencia:

class Quimera(PuedeNadar, PuedeVolar):
pass

Entonces se ejecutara el metodo de PuedeNadar.

Prioridad de la instancia​

Si un método o atributo existe en la instancia, tiene prioridad sobre cualquier clase:

Q = Quimera()
Q.atacar()

# El enemigo clava su aguijón

Comó consultar el MRO​

Se puede ver el MRO de una clase con:

print(Q.mro())

Esto muestra el orden exacto que Python sigue para buscar metodos y atributos.

cuidado

En proyectos complejos, la herencia multiple puede introducir problemas de ambiguedad tanto en metodos como en atributos. El comportamiento final depende del orden de herencia y del MRO, por lo que se recomienda usar herencia multiple solo cuando el diseño lo requiera claramente.


3️⃣ Polimorfismo​

El polimorfismo es la capacidad que tienen distintos objetos de responder de forma diferente a un mismo mensaje (es decir, a la llamada de un mismo método).

En términos simples:

Distintas clases pueden tener un método con el mismo nombre, pero cada una lo implementa a su manera. El código que los usa no necesita saber de que clase concreta es cada objeto.

Esto permite escribir funciones y estructuras de control más generales y flexibles, que trabajen con objetos de distintos tipos siempre que compartan ciertos métodos.

El polimorfismo genera las siguientes ventajas:

  • Permite escribir código más generico y reutilizable.
  • Facilita extender un programa: basta crear nuevas clases que implementen los métodos necesarios.
  • Reduce la cantidad de condicionales del tipo if tipo == ....
  • Trabaja muy bien junto con la herencia y las interfaces informales (duck typing).

🟩 Concepto general​

En POO, el polimorfismo se basa en dos ideas:

  1. Mismo método, diferentes comportamientos Clases distintas implementan el mismo método (por ejemplo, atacar), pero cada una lo hace de forma diferente.

  2. Uso uniforme El código que llama a ese método (objeto.atacar()) no necesita saber si objeto es un Guerrero, un Mago u otro tipo de personaje.

Idea básica de polimorfismo
class Guerrero:
def atacar(self):
print("El guerrero ataca con su espada")

class Mago:
def atacar(self):
print("El mago lanza una bola de fuego")

grupo = [Guerrero(), Mago()]

for personaje in grupo:
personaje.atacar()

El polimorfismo se puede observar:

  • Entre clases que heredan de una misma base (polimorfismo clásico con herencia).
  • Entre clases sin relacion de herencia, siempre que implementen los mismos métodos (polimorfismo por duck typing en Python).

🟦 Polimorfismo con herencia​

Polimorfismo con una clase base
class Personaje:
def atacar(self):
print("El personaje realiza un ataque básico")

class Guerrero(Personaje):
def atacar(self):
print("El guerrero realiza un espadazo potente")

class Mago(Personaje):
def atacar(self):
print("El mago lanza una bola de fuego")

def realizar_turno(personaje):
personaje.atacar()

realizar_turno(Guerrero()) # El guerrero realiza un espadazo potente
realizar_turno(Mago()) # El mago lanza una bola de fuego

🟨 Polimorfismo sin herencia (duck typing)​

En Python, no es obligatorio que las clases hereden de una misma base para ser polimórficas. Basta con que implementen los mismos métodos.

Duck typing: polimorfismo sin herencia
class Cofre:
def interactuar(self):
print("Abres el cofre y obtienes botín")

class Palanca:
def interactuar(self):
print("Accionas la palanca y se abre una puerta")

def activar(objeto_interactivo):
objeto_interactivo.interactuar()

activar(Cofre())
activar(Palanca())

🟧 Funciones que aceptan objetos de distintas clases​

El polimorfismo resulta especialmente útil cuando se escriben funciones que trabajan con colecciones de objetos distintos.

Función polimórfica con una lista de objetos
class Circulo:
def __init__(self, radio):
self.radio = radio

def area(self):
return 3.14 * (self.radio ** 2)

class Rectangulo:
def __init__(self, ancho, alto):
self.ancho = ancho
self.alto = alto

def area(self):
return self.ancho * self.alto

def area_total(figuras):
total = 0
for figura in figuras:
total += figura.area()
return total

figuras = [
Circulo(2),
Rectangulo(3, 4),
Circulo(1)
]

print(area_total(figuras))
en este ejemplo

Circulo y Rectangulo son clases distintas, pero ambas implementan el método area(). La función area_total solo necesita saber que cada objeto de la lista tiene un método area, sin importar su tipo concreto.


4️⃣ Sobrecarga de métodos​

En muchos lenguajes (como Java o C++) se habla de sobrecarga de métodos cuando una clase tiene varios métodos con el mismo nombre, pero con distintos parámetros:

mismo nombre  →  métodos diferentes según la firma

En Python no existe la sobrecarga clásica por firma. Si se define dos veces un método con el mismo nombre, la segunda definición pisará a la primera.

Sin embargo, se puede conseguir un efecto similar usando:

  1. Parametros por defecto
  2. *args y **kwargs

🟩 Sobrecarga simulada con parámetros por defecto​

Simular sobrecarga con valores por defecto
class Calculadora:
def sumar(self, a, b=0, c=0):
return a + b + c

calc = Calculadora()
print(calc.sumar(5)) # 5 (solo a)
print(calc.sumar(5, 2)) # 7 (a + b)
print(calc.sumar(5, 2, 1)) # 8 (a + b + c)

El método sigue siendo uno solo (sumar), pero se comporta de forma flexible según la cantidad de argumentos.

🟦 Sobrecarga simulada con *args​

Simular sobrecarga con *args
class CalculadoraFlexible:
def sumar(self, *numeros):
total = 0
for n in numeros:
total += n
return total

calc = CalculadoraFlexible()
print(calc.sumar(1, 2)) # 3
print(calc.sumar(1, 2, 3, 4)) # 10

⚔️ Actividad Práctica: El Ciber-Santuario​

En esta actividad aplicaremos los tres pilares de la POO (Encapsulación, Herencia y Polimorfismo) más la Herencia Múltiple, mediante la creación de un sistema de gestión para un santuario de criaturas tecnológicas y biológicas.

🧬 1. ADN Encapsulado (Encapsulación)​

El santuario necesita registrar a sus criaturas garantizando que sus datos vitales no se corrompan por accesos indebidos.

Archivo: santuario.py

📋 Tarea a realizar​

Crea una clase base llamada Criatura con las siguientes características:

  • Atributos internos: _nombre, _hambre (0-100) y _energia (0-100).
  • Propiedad hambre (@property): Getter para leer el valor.
  • Setter hambre: Debe validar que el valor esté entre 0 y 100. Si se intenta asignar un valor fuera de rango, debe ajustarse al límite más cercano o mostrar un error.
  • Método alimentar(cantidad): Reduce el hambre de forma controlada.
Ejemplo de salida Fase 1
c = Criatura("Ciber-Fénix", 50)
c.hambre = 120 # Ajuste automático o error
print(c.hambre)
# 100
c.alimentar(30)
print(c.hambre)
# 70

🌳 2. El Árbol de la Vida (Herencia)​

El santuario ha crecido y ahora habitan tipos específicos de criaturas que requieren cuidados distintos.

📋 Tarea a realizar​

Crea dos subclases que hereden de Criatura:

  1. CriaturaRobot: Añade el atributo _bateria. Sobrescribe el método alimentar para que en lugar de comida, necesite cargar_energia.
  2. CriaturaOrganica: Añade el atributo _dieta (Ej: "Carnívoro", "Herbívoro").

Usa super() en los constructores para inicializar los atributos básicos de la clase padre.

Ejemplo de salida Fase 2
r = CriaturaRobot("R2-D2", 20, 100)
o = CriaturaOrganica("Yoda", 10, "Omnívoro")
r.mostrar_estado()
# R2-D2 [Robot] - Energía: 20% | Batería: 100%

🔮 3. El Ritual del Amanecer (Polimorfismo)​

Cada criatura del santuario tiene una forma única de saludar al nuevo día.

📋 Tarea a realizar​

  1. Añade un método llamado realizar_accion() a todas las clases.
    • En CriaturaRobot: imprime "Calculando trayectoria solar y optimizando paneles".
    • En CriaturaOrganica: imprime "Buscando bayas frescas o presas en el bosque".
  2. Crea una función genérica llamada ritual_amanecer(lista_criaturas) que recorra una lista de criaturas y llame al método realizar_accion() de cada una, sin importar su tipo.
Ejemplo de salida Fase 3
Listado de criaturas preparadas:
- R2-D2: Calculando trayectoria solar y optimizando paneles
- Yoda: Buscando bayas frescas o presas en el bosque

🧪 4. Híbridos de Laboratorio (Herencia Múltiple)​

Los científicos del santuario han creado una criatura experimental: el Cyborg-Dragón.

📋 Tarea a realizar​

  1. Crea una clase llamada CyborgDragon que herede simultáneamente de CriaturaRobot y CriaturaOrganica.
  2. Asegúrate de que el constructor maneje correctamente los atributos de ambas clases.
  3. Usa el método .mro() para investigar en qué orden Python buscará los métodos de esta nueva clase.
Ejemplo de salida Fase 4
h = CyborgDragon("Rex-01", 50, 80, "Carnívoro")
h.realizar_accion()
# Debería mostrar la acción de la primera clase heredada según el MRO
print(CyborgDragon.mro())
# [<class 'CyborgDragon'>, <class 'CriaturaRobot'>, <class 'CriaturaOrganica'>, <class 'Criatura'>, <class 'object'>]