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,
Guerreroes unPersonaje). -
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.
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.
| Forma | Significado (convención) |
|---|---|
atributo | Atributo público: se puede usar libremente desde fuera |
_atributo | Atributo de uso interno (tipo “protegido”) |
__atributo | Atributo “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._experienciadesde 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:
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.
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.edadyobjeto.edad = 30como 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.
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”). @propertyconvierte el métodoedad(self)en un atributo de solo lectura.@edad.setterañ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:
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
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
GuerreroyMago: heredan dePersonajey 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
GuerreroyMagocomoPersonaje).
🟩 Herencia simple
La herencia simple es cuando una clase hereda de una única clase base.
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.
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().
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.
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:
- En la instancia (atributos propios del objeto)
- En la clase del objeto
- En las clases base, siguiendo el orden definido por el MRO
- Finalmente en
object
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()
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.
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:
-
Mismo método, diferentes comportamientos Clases distintas implementan el mismo método (por ejemplo,
atacar), pero cada una lo hace de forma diferente. -
Uso uniforme El código que llama a ese método (
objeto.atacar()) no necesita saber siobjetoes unGuerrero, unMagou otro tipo de personaje.
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
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.
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.
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))
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:
- Parametros por defecto
*argsy**kwargs
🟩 Sobrecarga simulada con parámetros 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
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.
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:
- CriaturaRobot: Añade el atributo
_bateria. Sobrescribe el métodoalimentarpara que en lugar de comida, necesitecargar_energia. - 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.
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
- 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".
- En
- Crea una función genérica llamada
ritual_amanecer(lista_criaturas)que recorra una lista de criaturas y llame al métodorealizar_accion()de cada una, sin importar su tipo.
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
- Crea una clase llamada CyborgDragon que herede simultáneamente de
CriaturaRobotyCriaturaOrganica. - Asegúrate de que el constructor maneje correctamente los atributos de ambas clases.
- Usa el método
.mro()para investigar en qué orden Python buscará los métodos de esta nueva clase.
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'>]