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.selfes el propio objeto. Es el primer parámetro de cada método, y Python te lo pasa solo cuando llamasnorth_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__tokenpara 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.
Sensorhereda deABCy marcareadcon@abstractmethod. Con eso no puedes instanciarSensora secas y obligas a cada hija a implementarread. Defines el contrato, no el cómo. - Herencia.
WaveSensoryWindSensorparten deSensory se quedan con su__init__. Si una hija necesitara ampliar el constructor, llamaría al del padre consuper().__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
Protocolen 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
dataclassbasta 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.
Este trabajo está bajo una licencia Attribution-NonCommercial-NoDerivatives 4.0 International.
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 libroAyúdame a seguir escribiendo
Cada café me da un empujón para escribir el siguiente artículo.
¡Claro, te invito!
Comentarios
Todavía no hay ningún comentario.