En esta página

Clases y objetos

15 min lectura TextoCap. 4 — OOP y archivos

Programación orientada a objetos en Python

La programación orientada a objetos (POO) organiza el código alrededor de objetos — estructuras que combinan datos (atributos) y comportamiento (métodos). En Python, todo es un objeto: enteros, strings, listas, funciones.

Una clase es el molde o plantilla para crear objetos. Un objeto (o instancia) es una copia concreta creada a partir de esa plantilla.

Definiendo una clase

class Coche:
    """Representa un coche con marca, modelo y año."""

    # Atributo de clase — compartido por TODAS las instancias
    ruedas = 4
    tipo_vehiculo = "terrestre"

    def __init__(self, marca: str, modelo: str, año: int) -> None:
        """Constructor — se llama al crear una nueva instancia."""
        # Atributos de instancia — únicos por objeto
        self.marca = marca
        self.modelo = modelo
        self.año = año
        self._kilometraje = 0  # Convención: _ = "privado" (no enforced)
        self.__vin = "VIN-SECRETO"  # __ = name mangling

    def describir(self) -> str:
        """Devuelve descripción del coche."""
        return f"{self.año} {self.marca} {self.modelo}"

    def conducir(self, km: float) -> None:
        """Simula conducir el coche."""
        if km < 0:
            raise ValueError("Los kilómetros no pueden ser negativos")
        self._kilometraje += km
        print(f"Conduciendo {km} km en {self.describir()}")


# Crear instancias
coche1 = Coche("Toyota", "Corolla", 2022)
coche2 = Coche("Tesla", "Model 3", 2024)

print(coche1.describir())  # 2022 Toyota Corolla
print(coche2.marca)        # Tesla

# Atributo de clase accesible desde instancias Y la clase
print(coche1.ruedas)    # 4
print(Coche.ruedas)     # 4

coche1.conducir(100)

El parámetro self

self hace referencia a la instancia actual. En Python, se pasa explícitamente como primer parámetro de todos los métodos de instancia. Al llamar coche.describir(), Python lo traduce internamente a Coche.describir(coche).

Atributos privados y name mangling

class CuentaBancaria:
    def __init__(self, titular: str, saldo: float = 0) -> None:
        self.titular = titular          # Público
        self._historial: list[str] = [] # Convencionalmente "privado"
        self.__saldo = saldo            # Name mangling: _CuentaBancaria__saldo

    def depositar(self, monto: float) -> None:
        if monto <= 0:
            raise ValueError("El monto debe ser positivo")
        self.__saldo += monto
        self._historial.append(f"+{monto:.2f}")

    def retirar(self, monto: float) -> None:
        if monto <= 0:
            raise ValueError("El monto debe ser positivo")
        if monto > self.__saldo:
            raise ValueError("Saldo insuficiente")
        self.__saldo -= monto
        self._historial.append(f"-{monto:.2f}")

    def obtener_saldo(self) -> float:
        return self.__saldo

cuenta = CuentaBancaria("Ana", 1000)
cuenta.depositar(500)
cuenta.retirar(200)
print(cuenta.obtener_saldo())     # 1300.0
print(cuenta._historial)          # ['+500.00', '-200.00']

# Name mangling — el nombre cambia pero no es verdaderamente privado
# print(cuenta.__saldo)             # AttributeError
print(cuenta._CuentaBancaria__saldo)  # 1300.0 — accesible si quieres

@property — Atributos calculados con sintaxis de acceso

El decorador @property permite definir métodos que se acceden como atributos:

class Temperatura:
    """Almacena temperatura y permite conversión entre escalas."""

    def __init__(self, celsius: float) -> None:
        self._celsius = celsius

    @property
    def celsius(self) -> float:
        return self._celsius

    @celsius.setter
    def celsius(self, valor: float) -> None:
        if valor < -273.15:
            raise ValueError("Temperatura por debajo del cero absoluto")
        self._celsius = valor

    @celsius.deleter
    def celsius(self) -> None:
        del self._celsius

    @property
    def fahrenheit(self) -> float:
        return self._celsius * 9/5 + 32

    @fahrenheit.setter
    def fahrenheit(self, valor: float) -> None:
        self.celsius = (valor - 32) * 5/9

    @property
    def kelvin(self) -> float:
        return self._celsius + 273.15


temp = Temperatura(100)
print(temp.celsius)     # 100
print(temp.fahrenheit)  # 212.0
print(temp.kelvin)      # 373.15

temp.fahrenheit = 32
print(temp.celsius)     # 0.0

@staticmethod — Métodos sin acceso a self o cls

Los métodos estáticos son funciones dentro de una clase que no necesitan ni self ni cls. Son útiles para agrupar funciones relacionadas:

class Matematicas:
    @staticmethod
    def es_primo(n: int) -> bool:
        if n < 2:
            return False
        return all(n % i != 0 for i in range(2, int(n**0.5) + 1))

    @staticmethod
    def mcd(a: int, b: int) -> int:
        """Máximo común divisor (algoritmo de Euclides)."""
        while b:
            a, b = b, a % b
        return a

    @staticmethod
    def mcm(a: int, b: int) -> int:
        """Mínimo común múltiplo."""
        return abs(a * b) // Matematicas.mcd(a, b)

# Llamar sin crear instancia
print(Matematicas.es_primo(17))  # True
print(Matematicas.mcd(48, 18))   # 6
print(Matematicas.mcm(4, 6))     # 12

@classmethod — Métodos alternativos de construcción

Los métodos de clase reciben cls (la clase, no la instancia) como primer argumento. Son útiles para crear constructores alternativos:

from datetime import date

class Persona:
    def __init__(self, nombre: str, año_nacimiento: int) -> None:
        self.nombre = nombre
        self.año_nacimiento = año_nacimiento

    @property
    def edad(self) -> int:
        return date.today().year - self.año_nacimiento

    @classmethod
    def desde_cadena(cls, datos: str) -> "Persona":
        """Constructor alternativo desde string 'nombre,año'."""
        nombre, año = datos.split(",")
        return cls(nombre.strip(), int(año.strip()))

    @classmethod
    def desde_nacimiento(cls, nombre: str, fecha_nac: date) -> "Persona":
        """Constructor alternativo desde fecha de nacimiento."""
        return cls(nombre, fecha_nac.year)

    def __repr__(self) -> str:
        return f"Persona(nombre={self.nombre!r}, edad={self.edad})"


p1 = Persona("Ana", 1995)
p2 = Persona.desde_cadena("Bruno, 1990")
p3 = Persona.desde_nacimiento("Carmen", date(1988, 3, 15))

print(p1)  # Persona(nombre='Ana', edad=31)
print(p2)  # Persona(nombre='Bruno', edad=36)

Métodos dunder (mágicos)

Los métodos dunder (double underscore) permiten que tus clases se integren con la sintaxis y las operaciones nativas de Python:

class Vector2D:
    """Vector en dos dimensiones con operaciones matemáticas."""

    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y

    def __repr__(self) -> str:
        """Representación técnica (para desarrolladores)."""
        return f"Vector2D({self.x}, {self.y})"

    def __str__(self) -> str:
        """Representación legible (para usuarios)."""
        return f"({self.x}, {self.y})"

    def __add__(self, otro: "Vector2D") -> "Vector2D":
        """Habilita v1 + v2."""
        return Vector2D(self.x + otro.x, self.y + otro.y)

    def __sub__(self, otro: "Vector2D") -> "Vector2D":
        """Habilita v1 - v2."""
        return Vector2D(self.x - otro.x, self.y - otro.y)

    def __mul__(self, escalar: float) -> "Vector2D":
        """Habilita v * escalar."""
        return Vector2D(self.x * escalar, self.y * escalar)

    def __rmul__(self, escalar: float) -> "Vector2D":
        """Habilita escalar * v (operando derecho)."""
        return self.__mul__(escalar)

    def __eq__(self, otro: object) -> bool:
        """Habilita v1 == v2."""
        if not isinstance(otro, Vector2D):
            return NotImplemented
        return self.x == otro.x and self.y == otro.y

    def __abs__(self) -> float:
        """Habilita abs(v) — magnitud del vector."""
        return (self.x ** 2 + self.y ** 2) ** 0.5

    def __len__(self) -> int:
        """Habilita len(v) — número de componentes."""
        return 2

    def __bool__(self) -> bool:
        """Habilita bool(v) — False solo si es vector nulo."""
        return self.x != 0 or self.y != 0


v1 = Vector2D(3, 4)
v2 = Vector2D(1, 2)

print(v1 + v2)      # (4, 6)
print(v1 - v2)      # (2, 2)
print(v1 * 2)       # (6, 8)
print(3 * v2)       # (3, 6)
print(abs(v1))      # 5.0 (hipotenusa del triángulo 3-4-5)
print(len(v1))      # 2
print(bool(v1))     # True
print(bool(Vector2D(0, 0)))  # False
print(repr(v1))     # Vector2D(3, 4)

dataclasses — Clases de datos sin repetición

El módulo dataclasses (Python 3.7+) genera automáticamente __init__, __repr__, __eq__ y más:

from dataclasses import dataclass, field
from datetime import datetime

@dataclass
class Articulo:
    titulo: str
    autor: str
    contenido: str
    etiquetas: list[str] = field(default_factory=list)
    publicado: bool = False
    creado_en: datetime = field(default_factory=datetime.now)
    vistas: int = field(default=0, repr=False)  # Excluir del repr

    def publicar(self) -> None:
        self.publicado = True
        print(f"'{self.titulo}' publicado")

    @property
    def resumen(self) -> str:
        palabras = self.contenido.split()
        if len(palabras) <= 20:
            return self.contenido
        return " ".join(palabras[:20]) + "..."


# dataclass con frozen=True — inmutable (como namedtuple pero mejor)
@dataclass(frozen=True)
class CoordenadaGPS:
    latitud: float
    longitud: float

    def __post_init__(self) -> None:
        if not -90 <= self.latitud <= 90:
            raise ValueError(f"Latitud inválida: {self.latitud}")
        if not -180 <= self.longitud <= 180:
            raise ValueError(f"Longitud inválida: {self.longitud}")


articulo = Articulo("Python 3.14", "David", "Contenido del artículo...")
print(articulo)       # Articulo(titulo='Python 3.14', autor='David', ...)
articulo.publicar()

madrid = CoordenadaGPS(40.4168, -3.7038)
print(madrid)  # CoordenadaGPS(latitud=40.4168, longitud=-3.7038)

Resumen

Las clases en Python ofrecen una POO completa y expresiva. Los decoradores @property, @staticmethod y @classmethod permiten diseñar APIs limpias. Los métodos dunder integran tus objetos con la sintaxis nativa de Python. Para clases de datos simples, @dataclass elimina el código repetitivo. En la próxima lección ampliaremos estos conceptos con herencia, polimorfismo y clases abstractas.

@dataclass reduce el código repetitivo de clases de datos
El decorador @dataclass genera automáticamente __init__, __repr__ y __eq__ basándose en las anotaciones de tipo. Para clases cuya función principal es almacenar datos, es preferible a escribir estos métodos manualmente.
Usa @property para atributos calculados con acceso limpio
En lugar de métodos como get_precio_con_iva(), define una @property. Se accede como atributo (producto.precio_con_iva) sin los paréntesis, haciendo el código más legible.
Diferencia entre atributos de instancia y de clase
Los atributos definidos en __init__(self) son únicos por instancia. Los atributos definidos directamente en el cuerpo de la clase (fuera de __init__) son compartidos por TODAS las instancias. Modifica uno y afectas a todos.