5. Programación orientada a objetos

Este curso prefiere funciones, y lo defiende. Pero la orientación a objetos es parte del lenguaje, aparece en casi todo el código que vas a leer y más adelante la vamos a usar de verdad. Saltárnosla sería dejarte con un hueco. Así que aquí la ves bien: qué es, cómo se escribe en Python moderno y, sobre todo, cuándo una clase gana su sitio y cuándo es mejor una función.

Ya te has cruzado con clases sin darle importancia: el dataclass que agrupa campos y el Enum de valores cerrados. Eran clases usadas como registros de datos. Ahora damos el paso completo: una clase que junta estado (los datos) y comportamiento (las funciones que operan sobre esos datos) en una sola pieza.

Clases y objetos: estado y comportamiento juntos

Una clase es el molde; un objeto es cada pieza fabricada con ese molde. La clase define qué datos guarda y qué sabe hacer; cada objeto, cada instancia, tiene sus propios datos.

class Buoy:
    """A buoy identified by a code that stores its wave readings."""

    def __init__(self, buoy_id: str):
        self.buoy_id = buoy_id
        self.readings: list[float] = []

    def add_reading(self, wave_height_m: float) -> None:
        """Store a new wave-height reading, in meters."""
        self.readings.append(wave_height_m)
north_sea = Buoy("north-sea-1")
north_sea.add_reading(2.1)
north_sea.add_reading(3.4)
north_sea.buoy_id  # "north-sea-1"

Tres cosas que entender de golpe:

  • __init__ es el constructor: se ejecuta al crear el objeto y prepara su estado inicial.
  • self es el propio objeto. Es el primer parámetro de cada método, y Python te lo pasa solo cuando llamas north_sea.add_reading(...). No es una palabra reservada, es una convención, pero respétala a rajatabla.
  • Los atributos (self.buoy_id, self.readings) son los datos de esa instancia concreta. Dos boyas distintas tienen sus propias lecturas, no se pisan.

Fíjate en por qué aquí una clase aporta de verdad: Buoy tiene identidad (su buoy_id) y estado que cambia con el tiempo (va acumulando lecturas). Eso es justo lo que una función pura no modela bien, y la primera pista de cuándo tirar de objetos.

Atributos de instancia y de clase

Un dato puede vivir en dos sitios. Un atributo de instancia (self.algo) es propio de cada objeto. Un atributo de clase se declara en el cuerpo de la clase y lo comparten todas las instancias:

class Buoy:
    manufacturer = "Datawell"  # atributo de clase, compartido por todas

    def __init__(self, buoy_id: str):
        self.buoy_id = buoy_id  # atributo de instancia, propio de cada una

Y aquí reaparece una trampa que ya viste con los argumentos por defecto mutables: nunca uses un contenedor mutable como atributo de clase si querías que fuera propio de cada objeto.

# MAL: la lista es un atributo de clase, se comparte entre TODAS las boyas
class Buoy:
    readings: list[float] = []


# BIEN: cada boya crea su propia lista en el constructor
class Buoy:
    def __init__(self):
        self.readings: list[float] = []

Es el mismo error de la lista por defecto de la lección sobre funciones, con otro disfraz. La regla es idéntica: los datos mutables por instancia se crean dentro de __init__.

Métodos de instancia, de clase y estáticos

Un método de instancia recibe self y trabaja con los datos del objeto. Es el caso normal, el que ya has visto. Pero hay dos variantes que conviene reconocer.

Un método de clase (@classmethod) recibe la clase (cls) en lugar de la instancia. Su uso estrella es el constructor alternativo: fabricar un objeto desde otro formato.

class Buoy:
    def __init__(self, buoy_id: str):
        self.buoy_id = buoy_id

    @classmethod
    def from_log_line(cls, line: str) -> "Buoy":
        """Build a buoy from a log line like 'id=north-sea-1'."""
        buoy_id = line.strip().removeprefix("id=")
        return cls(buoy_id)


# La forma normal: le pasas el id ya limpio
buoy = Buoy("north-sea-1")
buoy.buoy_id  # "north-sea-1"

# El constructor alternativo: partes de una línea de texto
same_buoy = Buoy.from_log_line("id=north-sea-1")
same_buoy.buoy_id  # "north-sea-1"

Fíjate en las dos formas de crear la boya. Buoy("north-sea-1") recibe el buoy_id directamente; Buoy.from_log_line("id=north-sea-1") lo extrae de una línea de texto. Las dos terminan llamando a __init__ (por eso el método hace return cls(buoy_id)), pero cada una arranca de un dato distinto.

¿Cuándo lo usarías? Cuando hay más de una forma de construir el objeto. Dejas que __init__ se quede con la vía canónica (recibe ya los datos limpios) y cada @classmethod añade una alternativa desde otro formato: una línea de log, un diccionario, un JSON, una fila de CSV. Así no llenas __init__ de condicionales para adivinar qué le has pasado. Es el mismo patrón de la librería estándar: datetime.fromisoformat, que usaste en la lección de rutas y fechas, es exactamente esto, un constructor alternativo que fabrica un datetime a partir de texto.

Un método estático (@staticmethod) no recibe ni self ni cls: es una función normal que vive dentro de la clase por afinidad temática. Si no toca el estado del objeto, plantéate si no sería más claro sacarla a una función suelta del módulo, que es más fácil de encontrar y de probar.

Encapsulación: en Python nada es privado

La POO se suele resumir en cuatro pilares: encapsulación, herencia, polimorfismo y abstracción. Vamos con el primero.

Encapsulación es agrupar datos y comportamiento en una pieza y controlar qué se toca desde fuera. En muchos lenguajes existe private de verdad. En Python no: todo es accesible. Lo que hay son convenciones, y funcionan porque la comunidad las respeta.

  • Un guion bajo al principio (_cache) dice "esto es interno, no lo toques desde fuera". Nadie te lo impide, pero es un contrato entre personas adultas.
  • Dos guiones bajos (__token) activan el name mangling: Python renombra el atributo a _Clase__token para evitar choques de nombres al heredar. No es seguridad, es prevención de colisiones.

Cuando quieras validar un dato antes de asignarlo, o exponer uno calculado, usa @property. Convierte un método en algo que se lee y se escribe como si fuera un atributo:

class OperationalLimit:
    def __init__(self, wave_limit_m: float):
        self._wave_limit_m = wave_limit_m

    @property
    def wave_limit_m(self) -> float:
        return self._wave_limit_m

    @wave_limit_m.setter
    def wave_limit_m(self, value: float) -> None:
        if value <= 0:
            raise ValueError("wave limit must be positive")
        self._wave_limit_m = value

Aquí pasan dos cosas. El @property sobre wave_limit_m lo convierte en un getter: cuando alguien lee limit.wave_limit_m, por debajo se ejecuta ese método, pero se escribe sin paréntesis, como si fuera un atributo normal. Y @wave_limit_m.setter define el setter: el método que se ejecuta cuando alguien asigna con limit.wave_limit_m = 3.0, y que aquí aprovechamos para validar antes de guardar. Los dos métodos se llaman igual a propósito, wave_limit_m: uno gobierna la lectura y el otro la escritura del mismo atributo. El dato real vive en _wave_limit_m (con guion bajo, interno), y la property es la puerta controlada para llegar a él.

limit = OperationalLimit(2.5)
limit.wave_limit_m = 3.0  # pasa por el setter y se valida
limit.wave_limit_m = -1.0  # ValueError: wave limit must be positive

Desde fuera se usa como limit.wave_limit_m, sin paréntesis. Ganas control sin ensuciar la interfaz con get_ y set_ por todas partes.

Herencia, polimorfismo y abstracción

Los otros tres pilares se entienden mejor juntos, con un ejemplo. Imagina que una boya lleva varios tipos de sensor y todos comparten la idea de "dar una lectura", pero cada uno la obtiene a su manera.

from abc import ABC, abstractmethod


class Sensor(ABC):
    """Contrato común a todos los sensores de la boya."""

    def __init__(self, sensor_id: str):
        self.sensor_id = sensor_id

    @abstractmethod
    def read(self) -> float:
        """Devuelve la última medida del sensor."""


class WaveSensor(Sensor):
    def read(self) -> float:
        # Habla con el hardware y devuelve la altura de ola, en metros
        return 2.3


class WindSensor(Sensor):
    def read(self) -> float:
        # Devuelve la velocidad del viento, en nudos
        return 14.0
  • Abstracción. Sensor hereda de ABC y marca read con @abstractmethod. Con eso no puedes instanciar Sensor a secas y obligas a cada hija a implementar read. Defines el contrato, no el cómo.
  • Herencia. WaveSensor y WindSensor parten de Sensor y se quedan con su __init__. Si una hija necesitara ampliar el constructor, llamaría al del padre con super().__init__(...) para no repetir la inicialización.
  • Polimorfismo. El código que consume no distingue el tipo concreto:
sensors: list[Sensor] = [WaveSensor("wave-1"), WindSensor("wind-1")]
for sensor in sensors:
    print(sensor.sensor_id, sensor.read())

Cada objeto responde a read() a su manera y el bucle no necesita saber cuál es cuál. Esto enlaza directamente con el Protocol de la lección de dependencias externas: allí conseguiremos el mismo polimorfismo sin herencia, solo por tener el método con la firma correcta (el famoso duck typing). ABC obliga por herencia; Protocol confía en la forma. Dos caminos al mismo sitio.

Composición sobre herencia

La herencia engancha, y ahí está su peligro. Es tentador resolver "una boya con estos sensores" creando una subclase por cada combinación:

# Con herencia: una subclase por cada combinación de sensores
class Buoy:
    def __init__(self, buoy_id: str):
        self.buoy_id = buoy_id


class BuoyWithGPS(Buoy):
    def read_gps(self) -> tuple[float, float]:
        ...


class BuoyWithGPSAndRadar(BuoyWithGPS):
    def read_radar(self) -> float:
        ...


# Para tener GPS y radar hay que instanciar justo esa subclase
buoy = BuoyWithGPSAndRadar("north-sea-1")
buoy.read_gps()  # heredado de BuoyWithGPS
buoy.read_radar()  # propio de esta subclase

¿Y una boya con radar pero sin GPS? Otra subclase. ¿Con GPS y batería de reserva? Otra más. Cada combinación nueva multiplica el árbol, y en dos pasos tienes una jerarquía rígida que no sabes tocar sin romper algo abajo.

Casi siempre hay una opción mejor: la composición. En vez de heredar de algo para ser ese algo, guarda ese algo dentro como un atributo y úsalo. Una boya no es un sensor: una boya tiene sensores.

# Con composición: una sola clase, los sensores entran por la lista
class Buoy:
    def __init__(self, buoy_id: str, sensors: list[Sensor]):
        self.buoy_id = buoy_id
        self._sensors = sensors

    def read_all(self) -> dict[str, float]:
        """Lee todos los sensores y devuelve sensor_id -> medida."""
        return {sensor.sensor_id: sensor.read() for sensor in self._sensors}


# Eliges los sensores al crear la boya, sin tocar la clase
buoy = Buoy("north-sea-1", [WaveSensor("wave-1"), WindSensor("wind-1")])
buoy.read_all()  # {"wave-1": 2.3, "wind-1": 14.0}

Añadir un tipo de sensor nuevo no toca la clase Buoy para nada: le pasas otro objeto en la lista y ya está. La regla práctica que te ahorra disgustos: "tiene un" (composición) casi siempre envejece mejor que "es un" (herencia).

Métodos especiales y el regalo del dataclass

Los métodos especiales (o dunder, por el doble guion bajo que los envuelve) enganchan tu objeto con la sintaxis del lenguaje. Los que verás siempre:

  • __init__: construir el objeto.
  • __repr__: cómo se muestra el objeto, clave para depurar.
  • __eq__: cuándo dos objetos se consideran iguales.

Escribirlos a mano es tedioso y fácil de equivocar. Por eso, cuando la clase es sobre todo un registro de datos, el dataclass que ya conoces te los regala:

from dataclasses import dataclass


@dataclass(frozen=True)
class Coordinate:
    latitude: float
    longitude: float

Con frozen=True el objeto es inmutable: una vez creado, no se puede cambiar. Es la POO acercándose al estilo funcional, un objeto que se comporta como un valor. Ganas __init__, __repr__ y __eq__ sin escribir una línea, y de paso la inmutabilidad que tanto defendimos en la lección funcional.

Cuándo una clase gana su sitio

Ya tienes las dos cajas de herramientas. La pregunta buena no es "¿POO o funcional?", sino "¿qué modela mejor este problema?". Una guía práctica:

Usa una clase cuando:

  • Hay estado con identidad y ciclo de vida que cambia con el tiempo: una boya que acumula lecturas, una conexión abierta.
  • Un dato y las operaciones que lo usan van siempre juntos y quieres pasarlos como una sola pieza (lo veremos con el Protocol en la lección de dependencias externas).
  • Necesitas polimorfismo: varias implementaciones intercambiables tras un mismo contrato.

Prefiere funciones (y dataclass como mero registro) cuando:

  • Solo transformas datos: entra algo, sale algo.
  • El "objeto" no tiene comportamiento real, solo guarda campos. Ahí un dataclass basta y una clase con lógica estorba.
  • Quieres el máximo de pureza y de facilidad para testear.

La trampa clásica es la clase que solo tiene un __init__ y un único método: casi siempre es una función disfrazada. Si te descubres escribiendo configurator.configure(), pregúntate si no era configure(...) a secas.

Python es multiparadigma a propósito. Lo profesional no es alistarse en un bando, es elegir a conciencia la herramienta que deja el código más claro.

Desafíos de programación atemporales y multiparadigmáticos

Desafíos de programación atemporales y multiparadigmáticos

Te encuentras ante un librillo de actividades, divididas en 2 niveles de dificultad. Te enfrentarás a los casos más comunes que te puedes encontrar en pruebas técnicas o aprender conceptos elementales de programación.

Comprar el libro

Ayúdame a seguir escribiendo

Cada café me da un empujón para escribir el siguiente artículo.

Comentarios

Todavía no hay ningún comentario.