Saltar a contenido

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:

[2] Revisar el contrato de la API
Revisar el contrato de la API

Nota es la clase (la plantilla) y nota es una instancia (un objeto concreto creado con ella). Qué ocurre, paso a paso:

  1. Nota("Revisar...", prioridad=2) crea un objeto nuevo y llama automáticamente a __init__ para inicializarlo.
  2. Dentro de __init__, self es ese objeto recién creado: self.texto = texto guarda el dato en el objeto, como un atributo.
  3. nota.resumen() llama al método. Python pasa nota como self sin que lo escribas: equivale a Nota.resumen(nota). Por eso todo método de instancia lleva self como 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:

[1] Preparar la demo
[2] Revisar el contrato de la API

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:

Ana Luis
estudiante estudiante
docente docente

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:

Punto(x=2, y=3)
True

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:

15.0
El saldo no puede ser negativo

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:

20.0
False

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:

[2, 3]

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:

EstadoNota.PENDIENTE
pendiente
True
['pendiente', 'hecha', 'archivada']

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:

abierta: [3] Preparar la práctica
cerrada: [3] Preparar la práctica

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 self o 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 Enum con una cadena sin usar .value.
  • Crear una jerarquía profunda antes de probar un modelo sencillo.

Práctica de transferencia

  1. Define una clase Contacto con nombre, email y un método resumen().
  2. Añade a Contacto un atributo prioridad protegido por una propiedad que impida valores fuera de 1–3.
  3. Define un Enum para el estado del contacto (NUEVO, CONTACTADO, ARCHIVADO).
  4. Crea dos clases de fuente que implementen el mismo método leer() y una función que las procese sin comprobar su tipo.
  5. 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:

  1. Un Enum Categoria con al menos tres valores (por ejemplo, ROPA, HOGAR y ACCESORIOS).
  2. Una clase Producto con nombre, precio, categoría y unidades en stock. Las unidades y el precio se protegen con propiedades: nunca pueden ser negativos.
  3. Una clase Inventario que tiene productos (composición: un diccionario {nombre: Producto}), con métodos anadir(producto), retirar(nombre, unidades) y valor_total().
  4. retirar lanza un ValueError con 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:

  1. Separe el modelo de tareas de la interfaz de terminal.
  2. Use una clase con al menos una propiedad validada y un método de cambio de estado.
  3. Use un Enum y convierta su valor solo en la frontera de salida o guardado.
  4. Muestre dos implementaciones compatibles mediante polimorfismo.
  5. 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.