1.13 · Programación orientada a objetos¶
Objetivos. Al terminar este capítulo podrás: modelar datos y
comportamientos con clases; distinguir atributos de instancia y de clase; usar
métodos y propiedades para proteger invariantes; reutilizar comportamiento con
herencia cuando exista una relación «es un»; y representar estados cerrados con
Enum.
Evidencia de logro. Diseñarás un pequeño modelo del asistente con objetos que mantengan sus propias reglas, una jerarquía sencilla de fuentes y estados representados con una enumeración. Explicarás por qué cada comportamiento vive en una clase concreta.
Contexto y motivación¶
Una lista de diccionarios puede representar una nota, pero no impide que otro fragmento escriba directamente un estado inválido. La programación orientada a objetos (POO) agrupa datos y operaciones relacionadas en un objeto, y permite que el propio objeto proteja sus reglas.
No todo debe ser una clase. Para transformar una entrada en una salida, una función pura suele ser más clara. Usa una clase cuando existe una entidad con estado, identidad o varias operaciones que deben mantenerse coherentes.
Vocabulario¶
| Término | Significado |
|---|---|
| Clase | Plantilla que describe datos y operaciones |
| Objeto o instancia | Valor concreto creado a partir de una clase |
| Atributo | Dato asociado a una clase o a una instancia |
| Método | Función definida dentro de una clase |
| Constructor | Método __init__ que inicializa una instancia |
| Encapsulación | Proteger las reglas de acceso y modificación del estado |
| Herencia | Crear una clase a partir de otra |
| Polimorfismo | Usar objetos distintos mediante una misma operación |
| Enumeración | Conjunto cerrado de valores con nombre (Enum) |
Prerrequisitos¶
1.8 · Funciones, 1.12 · Módulos y paquetes y diccionarios.
Ruta de estudio
El núcleo obligatorio es crear clases, usar atributos y métodos,
validar invariantes con @property, aplicar una herencia sencilla cuando
exista una relación «es un» y representar estados con Enum. Los métodos
especiales y la diferencia entre métodos de clase y estáticos son una
ampliación; no deben retrasar el modelo de la aplicación.
1. La primera clase¶
Una clase define qué datos tiene un objeto y qué puede hacer:
class Nota:
def __init__(self, texto, prioridad=1):
self.texto = texto
self.prioridad = prioridad
def resumen(self):
return f"[{self.prioridad}] {self.texto}"
nota = Nota("Revisar el contrato de la API", prioridad=2)
print(nota.resumen())
print(nota.texto)
Salida esperada:
Nota es la clase (la plantilla) y nota es una instancia (un objeto concreto
creado con ella). Qué ocurre, paso a paso:
Nota("Revisar...", prioridad=2)crea un objeto nuevo y llama automáticamente a__init__para inicializarlo.- Dentro de
__init__,selfes ese objeto recién creado:self.texto = textoguarda el dato en el objeto, como un atributo. nota.resumen()llama al método. Python pasanotacomoselfsin que lo escribas: equivale aNota.resumen(nota). Por eso todo método de instancia llevaselfcomo primer parámetro.
Cada instancia guarda sus propios datos:
otra = Nota("Preparar la demo")
print(otra.resumen())
print(nota.resumen()) # la primera nota no ha cambiado
Salida esperada:
2. Atributos de instancia y de clase¶
Los atributos de instancia suelen ser distintos para cada objeto. Un atributo de clase es compartido por la clase y sirve para una configuración común:
class Usuario:
rol_por_defecto = "estudiante" # atributo de clase
def __init__(self, nombre):
self.nombre = nombre # atributo de instancia
ana = Usuario("Ana")
luis = Usuario("Luis")
print(ana.nombre, luis.nombre)
print(ana.rol_por_defecto, luis.rol_por_defecto)
Usuario.rol_por_defecto = "docente"
print(ana.rol_por_defecto, luis.rol_por_defecto)
Salida esperada:
No uses una lista o un diccionario mutable como atributo de clase si cada objeto
debe tener su propia colección: se compartiría accidentalmente. Créala en
__init__.
3. Ampliación: representación y métodos especiales¶
Los métodos especiales, cuyos nombres empiezan y terminan por __, permiten que
un objeto colabore con operaciones de Python:
class Punto:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Punto(x={self.x}, y={self.y})"
def __eq__(self, otro):
if not isinstance(otro, Punto):
return NotImplemented
return (self.x, self.y) == (otro.x, otro.y)
punto = Punto(2, 3)
print(punto)
print(punto == Punto(2, 3))
Salida esperada:
print(punto) usa __repr__ cuando no existe __str__, y == puede delegar
en __eq__. No necesitas memorizar todos los métodos especiales: implementa
solo los que hagan que tu objeto tenga un comportamiento natural y documentado.
4. Encapsulación con propiedades¶
Python no impone atributos privados. Un guion bajo inicial (_saldo) comunica
«uso interno», pero la protección real consiste en centralizar las reglas:
class Cuenta:
def __init__(self, saldo=0.0):
self.saldo = saldo
@property
def saldo(self):
return self._saldo
@saldo.setter
def saldo(self, valor):
if valor < 0:
raise ValueError("El saldo no puede ser negativo")
self._saldo = float(valor)
def ingresar(self, cantidad):
if cantidad <= 0:
raise ValueError("La cantidad debe ser positiva")
self._saldo += cantidad
cuenta = Cuenta(10)
cuenta.ingresar(5)
print(cuenta.saldo)
try:
cuenta.saldo = -1
except ValueError as error:
print(error)
Salida esperada:
La propiedad permite escribir cuenta.saldo como si fuera un atributo, pero
valida antes de modificar _saldo: al leer cuenta.saldo, Python ejecuta el
método marcado con @property; al asignar cuenta.saldo = -1, ejecuta el
marcado con @saldo.setter. Son decoradores como los de 1.8, que convierten
métodos en un atributo con reglas. Una invariante es una regla que siempre
debe mantenerse; aquí es «el saldo no es negativo». Observa que
self.saldo = saldo dentro de __init__ también pasa por el setter, así que la
validación se aplica desde que se crea el objeto.
Propiedad no significa seguridad absoluta
El objetivo es proteger la coherencia del modelo y expresar su contrato, no ocultar datos como si fuera un sistema de seguridad. En código externo se deben controlar también permisos, validación y almacenamiento.
5. Ampliación: métodos de clase y estáticos¶
Un método de instancia recibe self. Un método de clase recibe la clase (cls)
y suele ser un constructor alternativo; un método estático es una operación
relacionada con la clase que no necesita ni instancia ni clase:
class Temperatura:
def __init__(self, celsius):
self.celsius = celsius
@classmethod
def desde_fahrenheit(cls, fahrenheit):
return cls((fahrenheit - 32) * 5 / 9)
@staticmethod
def es_valida(celsius):
return celsius >= -273.15
temperatura = Temperatura.desde_fahrenheit(68)
print(round(temperatura.celsius, 1))
print(Temperatura.es_valida(-300))
Salida esperada:
Si una función no necesita ningún estado de la clase, quizá deba ser una función
normal de un módulo. @staticmethod expresa relación conceptual, no una
obligación de diseño.
6. Herencia y polimorfismo¶
La herencia expresa una relación «es un». Una interfaz común permite procesar objetos diferentes sin preguntar por su tipo:
class Fuente:
def __init__(self, nombre):
self.nombre = nombre
def leer(self):
raise NotImplementedError
class FuenteTexto(Fuente):
def __init__(self, nombre, texto):
super().__init__(nombre)
self.texto = texto
def leer(self):
return self.texto
class FuenteLista(Fuente):
def __init__(self, nombre, elementos):
super().__init__(nombre)
self.elementos = elementos
def leer(self):
return "\n".join(self.elementos)
def contar_lineas(fuente):
return len(fuente.leer().splitlines())
fuentes = [
FuenteTexto("aviso", "uno\ndos"),
FuenteLista("tareas", ["una", "dos", "tres"]),
]
print([contar_lineas(fuente) for fuente in fuentes])
Salida esperada:
class FuenteTexto(Fuente): indica que FuenteTexto hereda de Fuente:
recibe sus atributos y métodos y puede redefinirlos. En la clase base, leer()
lanza NotImplementedError para dejar claro que cada clase hija debe
implementar su propia versión. super().__init__(nombre) llama al __init__ de
la clase base para reutilizar la inicialización común (guardar el nombre).
contar_lineas usa el mismo método leer() con dos clases distintas, sin
preguntar de qué tipo es cada una: eso es polimorfismo.
Composición antes que herencia
Si una clase tiene otra cosa, suele ser composición (Asistente tiene
una Fuente). Reserva la herencia para una relación estable «es un» y una
operación verdaderamente compartida. Muchas jerarquías profundas hacen el
programa más difícil de cambiar.
7. Enum: estados con nombres y valores cerrados¶
Una enumeración evita repetir cadenas que podrían escribirse con errores:
from enum import Enum
class EstadoNota(Enum):
PENDIENTE = "pendiente"
HECHA = "hecha"
ARCHIVADA = "archivada"
estado = EstadoNota.PENDIENTE
print(estado)
print(estado.value)
print(estado is EstadoNota.PENDIENTE)
print([opcion.value for opcion in EstadoNota])
Salida esperada:
Compara miembros de Enum con identidad (is) y guarda .value cuando
necesites serializarlo a JSON. No mezcles sin motivo Enum y cadenas: decide en
qué frontera de tu programa conviertes de uno a otro.
Una enumeración también puede tener métodos y atributos, pero conviene empezar
por miembros simples. IntEnum solo es apropiado cuando la compatibilidad con
enteros es una necesidad real; no lo uses para evitar aprender .value.
Aplicación práctica: modelo de una tarea del asistente¶
Un modelo pequeño puede combinar propiedad y Enum:
from enum import Enum
class Estado(Enum):
ABIERTA = "abierta"
CERRADA = "cerrada"
class Tarea:
def __init__(self, titulo, prioridad=1):
self.titulo = titulo
self.prioridad = prioridad
self.estado = Estado.ABIERTA
@property
def prioridad(self):
return self._prioridad
@prioridad.setter
def prioridad(self, valor):
if valor not in {1, 2, 3}:
raise ValueError("La prioridad debe ser 1, 2 o 3")
self._prioridad = valor
def cerrar(self):
self.estado = Estado.CERRADA
def resumen(self):
return f"{self.estado.value}: [{self.prioridad}] {self.titulo}"
tarea = Tarea("Preparar la práctica", prioridad=3)
print(tarea.resumen())
tarea.cerrar()
print(tarea.resumen())
Salida esperada:
El objeto mantiene la transición de estado y la validación de prioridad. La interfaz que lo utiliza no necesita conocer cómo se guardan internamente.
Errores frecuentes¶
- Crear una clase para cada dato simple, cuando una función o un diccionario sería más claro.
- Olvidar
selfo usar un atributo de instancia antes de inicializarlo. - Poner una colección mutable en la clase y compartirla entre todas las instancias.
- Confundir atributo de clase con atributo de instancia.
- Escribir una propiedad que permite estados inválidos y llamar a eso encapsulación.
- Usar herencia solo para reutilizar unas líneas, sin una relación «es un».
- Comparar un
Enumcon una cadena sin usar.value. - Crear una jerarquía profunda antes de probar un modelo sencillo.
Práctica de transferencia¶
- Define una clase
Contactocon nombre, email y un métodoresumen(). - Añade a
Contactoun atributoprioridadprotegido por una propiedad que impida valores fuera de 1–3. - Define un
Enumpara el estado del contacto (NUEVO,CONTACTADO,ARCHIVADO). - Crea dos clases de fuente que implementen el mismo método
leer()y una función que las procese sin comprobar su tipo. - Justifica por escrito si una nueva funcionalidad debe ser una función, una clase, una composición o una subclase.
Qué debes comprobar: en el punto 2, que Contacto(..., prioridad=5) falla al
crearlo, no solo al modificarlo después; en el 4, que la función que procesa
las fuentes no contiene ningún isinstance.
Mini-reto: gestor de inventario¶
Cierra el bloque de POO con un reto breve que combina todo lo anterior. Escribe
un script inventario.py para el almacén de TechShop:
- Un
EnumCategoriacon al menos tres valores (por ejemplo,ROPA,HOGARyACCESORIOS). - Una clase
Productocon nombre, precio, categoría y unidades en stock. Las unidades y el precio se protegen con propiedades: nunca pueden ser negativos. - Una clase
Inventarioque tiene productos (composición: un diccionario{nombre: Producto}), con métodosanadir(producto),retirar(nombre, unidades)yvalor_total(). retirarlanza unValueErrorcon un mensaje claro si no hay stock suficiente, y el programa principal lo captura y lo muestra.
Qué debes comprobar: el valor total antes y después de retirar unidades; que
retirar más unidades de las disponibles no deja el stock negativo; y que
Inventario no hereda de Producto (explica en un comentario por qué es
composición y no herencia).
Producto evaluable¶
Amplía el proyecto de 09_modulos.py con un cuaderno o script
09_modulos_poo.py que:
- Separe el modelo de tareas de la interfaz de terminal.
- Use una clase con al menos una propiedad validada y un método de cambio de estado.
- Use un
Enumy convierta su valor solo en la frontera de salida o guardado. - Muestre dos implementaciones compatibles mediante polimorfismo.
- Incluya una breve justificación de por qué la herencia elegida representa una relación «es un» y no solo una reutilización accidental.
Criterio de aceptación: no se puede crear una instancia en un estado inválido mediante la interfaz pública; las operaciones normales y los fallos de validación tienen una salida comprobable.
Formato de entrega: proyecto ejecutable, cuaderno marimo o scripts con salida reproducible, interpretación y conclusión.
Resumen y referencia rápida¶
| Necesitas | Patrón |
|---|---|
| Crear una clase | class Nombre: |
| Inicializar | def __init__(self, ...): |
| Método | def accion(self): |
| Propiedad validada | @property + @atributo.setter |
| Constructor alternativo | @classmethod |
| Operación sin estado | @staticmethod o función de módulo |
| Reutilizar una interfaz | Herencia y polimorfismo |
| Estado cerrado | class Estado(Enum): ... |
Siguiente: 1.14 · Biblioteca estándar.