Python 1001

Intermedio · Notebook 05 de 13

Python orientado a objetos

Los cuatro pilares, el modelo de datos y los métodos especiales, propiedades, dataclasses, clases base abstractas y protocolos, composición, el MRO y __slots__.

  • clases
  • métodos dunder
  • propiedades
  • dataclasses
  • Protocol
  • composición
⏱ ~75 min 💻 se ejecuta en 5 s en una CPU ⬇ Descargar notebook Abrir en Colab Ver en GitHub

Las clases permiten agrupar los datos con el comportamiento que les corresponde. El modelo de objetos de Python es flexible: casi todo, incluidos los números y las funciones, es un objeto, y tus propias clases pueden comportarse como los tipos integrados. Este módulo va desde los cuatro pilares clásicos hasta las características que el código profesional usa a diario.

Aprenderás

  1. Los cuatro pilares: encapsulación, herencia, polimorfismo y abstracción
  2. El modelo de datos: métodos especiales ("dunder") que hacen que los objetos se puedan imprimir, comparar, recorrer y llamar
  3. Propiedades, métodos de clase y métodos estáticos
  4. dataclasses: clases para datos casi sin código repetitivo
  5. Clases base abstractas frente a Protocol (tipado estructural)
  6. Composición frente a herencia, super() y el orden de resolución de métodos
  7. __slots__: cuando importa la memoria por objeto

1. Los cuatro pilares

La encapsulación mantiene juntos los datos y las reglas que los protegen. Python no tiene atributos realmente privados; un guion bajo inicial es la convención para "interno, no tocar".

class BankAccount:
    def __init__(self, owner: str, balance: float = 0.0):
        self.owner = owner
        self._balance = balance                     # interno por convención

    def deposit(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("el depósito debe ser positivo")
        self._balance += amount

    def withdraw(self, amount: float) -> None:
        if amount > self._balance:
            raise ValueError("fondos insuficientes")
        self._balance -= amount

    @property
    def balance(self) -> float:                      # solo lectura desde fuera
        return self._balance

acct = BankAccount("Alice", 100)
acct.deposit(50); acct.withdraw(30)
print(acct.balance)
try:
    acct.balance = 1_000_000
except AttributeError as e:
    print("no se puede asignar directamente:", e)
120
no se puede asignar directamente: property 'balance' of 'BankAccount' object has no setter

La herencia reutiliza y amplía una clase base; el polimorfismo significa que el código puede llamar al mismo método sobre tipos distintos y obtener el comportamiento correcto para cada uno.

class Animal:
    def __init__(self, name: str):
        self.name = name
    def speak(self) -> str:
        raise NotImplementedError

class Dog(Animal):
    def speak(self) -> str:
        return f"{self.name}: woof"

class Cat(Animal):
    def speak(self) -> str:
        return f"{self.name}: meow"

for pet in [Dog("Rex"), Cat("Tom"), Dog("Fido")]:
    print(pet.speak())                    # una llamada, el comportamiento correcto para cada tipo
print(isinstance(Dog("x"), Animal), issubclass(Cat, Animal))
Rex: woof
Tom: meow
Fido: woof
True True

La abstracción define una interfaz y oculta la implementación. Una clase base abstracta no se puede instanciar y obliga a las subclases a implementar sus métodos abstractos.

from abc import ABC, abstractmethod
import math

class Shape(ABC):
    @abstractmethod
    def area(self) -> float: ...

    def describe(self) -> str:            # método concreto construido sobre el abstracto
        return f"{type(self).__name__} con área {self.area():.2f}"

class Circle(Shape):
    def __init__(self, r): self.r = r
    def area(self): return math.pi * self.r ** 2

class Square(Shape):
    def __init__(self, side): self.side = side
    def area(self): return self.side ** 2

print([s.describe() for s in (Circle(1), Square(2))])
try:
    Shape()
except TypeError as e:
    print("abstracta:", e)
['Circle con área 3.14', 'Square con área 4.00']
abstracta: Can't instantiate abstract class Shape without an implementation for abstract method 'area'

2. El modelo de datos: métodos especiales

Python llama a métodos con nombres especiales para las operaciones integradas: len(x) llama a x.__len__(), a + b llama a a.__add__(b) y for item in x llama a x.__iter__(). Implementarlos hace que tus clases se sientan nativas.

from functools import total_ordering

@total_ordering                          # deduce <=, >, >= a partir de __eq__ y __lt__
class Vector:
    def __init__(self, x: float, y: float):
        self.x, self.y = x, y
    def __repr__(self):  return f"Vector({self.x}, {self.y})"         # para desarrolladores
    def __str__(self):   return f"({self.x}, {self.y})"                # para usuarios
    def __add__(self, o): return Vector(self.x + o.x, self.y + o.y)
    def __mul__(self, k): return Vector(self.x * k, self.y * k)
    __rmul__ = __mul__                                                  # para que 3 * v también funcione
    def __abs__(self):   return math.hypot(self.x, self.y)
    def __eq__(self, o): return (self.x, self.y) == (o.x, o.y)
    def __lt__(self, o): return abs(self) < abs(o)
    def __hash__(self):  return hash((self.x, self.y))
    def __iter__(self):  yield from (self.x, self.y)                   # permite el desempaquetado
    def __bool__(self):  return bool(abs(self))

v, w = Vector(3, 4), Vector(1, 1)
x, y = v
print(repr(v + w), str(3 * v), abs(v), v > w, sorted([v, w]), x, y, bool(Vector(0, 0)))
print({v, Vector(3, 4)})                 # los objetos iguales se funden en un conjunto gracias a __hash__
Vector(4, 5) (9, 12) 5.0 True [Vector(1, 1), Vector(3, 4)] 3 4 False
{Vector(3, 4)}
class Playlist:
    '''Un contenedor: admite len(), índices, rebanadas, 'in' e iteración.'''
    def __init__(self, songs): self._songs = list(songs)
    def __len__(self): return len(self._songs)
    def __getitem__(self, i): return self._songs[i]
    def __contains__(self, song): return song in self._songs

class Multiplier:
    '''Un objeto invocable: sus instancias se usan como funciones.'''
    def __init__(self, k): self.k = k
    def __call__(self, x): return x * self.k

p = Playlist(["intro", "verse", "chorus", "outro"])
print(len(p), p[1], p[1:3], "chorus" in p, [s.upper() for s in p])
double = Multiplier(2)
print(double(21), list(map(double, [1, 2, 3])))
4 verse ['verse', 'chorus'] True ['INTRO', 'VERSE', 'CHORUS', 'OUTRO']
42 [2, 4, 6]

3. Propiedades, métodos de clase y métodos estáticos

class Temperature:
    def __init__(self, celsius: float):
        self.celsius = celsius                 # pasa por el setter de abajo

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

    @celsius.setter
    def celsius(self, value: float) -> None:   # validación en cada asignación
        if value < -273.15:
            raise ValueError("por debajo del cero absoluto")
        self._c = value

    @property
    def fahrenheit(self) -> float:             # atributo calculado
        return self._c * 9 / 5 + 32

    @classmethod
    def from_fahrenheit(cls, f: float) -> "Temperature":   # constructor alternativo
        return cls((f - 32) * 5 / 9)

    @staticmethod
    def is_freezing(celsius: float) -> bool:   # función auxiliar relacionada, no necesita ni instancia ni clase
        return celsius <= 0

t = Temperature.from_fahrenheit(212)
print(t.celsius, t.fahrenheit, Temperature.is_freezing(-3))
try:
    t.celsius = -300
except ValueError as e:
    print(e)
100.0 212.0 True
por debajo del cero absoluto

4. Dataclasses

La mayoría de las clases sobre todo guardan datos. @dataclass escribe por ti __init__, __repr__ y __eq__ y, si quieres, orden, inmutabilidad (frozen=True) y slots para ahorrar memoria.

from dataclasses import dataclass, field, asdict, replace

@dataclass(frozen=True, order=True)
class Version:
    major: int
    minor: int = 0
    patch: int = 0
    def __str__(self): return f"{self.major}.{self.minor}.{self.patch}"

@dataclass
class Experiment:
    name: str
    learning_rate: float = 1e-3
    tags: list[str] = field(default_factory=list)      # nunca un valor mutable por defecto directamente
    def __post_init__(self):
        if self.learning_rate <= 0:
            raise ValueError("learning_rate debe ser positivo")

print(sorted([Version(2, 1), Version(1, 9, 3), Version(2, 0, 5)]))
e = Experiment("baseline", tags=["cpu"])
print(e, asdict(e), replace(e, learning_rate=0.01))
[Version(major=1, minor=9, patch=3), Version(major=2, minor=0, patch=5), Version(major=2, minor=1, patch=0)]
Experiment(name='baseline', learning_rate=0.001, tags=['cpu']) {'name': 'baseline', 'learning_rate': 0.001, 'tags': ['cpu']} Experiment(name='baseline', learning_rate=0.01, tags=['cpu'])

5. Clases base abstractas frente a Protocols

Una ABC exige que las clases hereden de ella (tipado nominal). Un Protocol solo exige que tengan los métodos adecuados (tipado estructural, el "duck typing" que entienden los comprobadores de tipos). Los protocolos permiten describir lo que necesitas de objetos que no controlas.

from typing import Protocol, runtime_checkable

@runtime_checkable
class SupportsArea(Protocol):
    def area(self) -> float: ...

class Rectangle:                      # NO hereda de nada
    def __init__(self, w, h): self.w, self.h = w, h
    def area(self): return self.w * self.h

def total_area(items: list[SupportsArea]) -> float:
    return sum(i.area() for i in items)

shapes = [Rectangle(2, 3), Circle(1), Square(2)]
print(total_area(shapes), all(isinstance(s, SupportsArea) for s in shapes))
13.141592653589793 True

6. Composición frente a herencia, y super()

La herencia modela "es un"; la composición modela "tiene un". Las jerarquías de herencia profundas se vuelven difíciles de cambiar, así que es preferible darle a un objeto las piezas que necesita.

class Engine:
    def __init__(self, hp): self.hp = hp
    def start(self): return f"motor ({self.hp} CV) arrancado"

class GPS:
    def route(self, to): return f"calculando ruta a {to}"

class Car:                                  # un coche TIENE un motor y un GPS
    def __init__(self, engine: Engine, gps: GPS | None = None):
        self.engine, self.gps = engine, gps
    def drive(self, to):
        steps = [self.engine.start()]
        if self.gps:
            steps.append(self.gps.route(to))
        return "; ".join(steps)

print(Car(Engine(150), GPS()).drive("Madrid"))
motor (150 CV) arrancado; calculando ruta a Madrid
class Base:
    def setup(self): return ["Base"]
class Logging(Base):
    def setup(self): return super().setup() + ["Logging"]
class Caching(Base):
    def setup(self): return super().setup() + ["Caching"]
class Service(Logging, Caching):           # herencia múltiple
    def setup(self): return super().setup() + ["Service"]

print([c.__name__ for c in Service.__mro__])   # orden de resolución de métodos
print(Service().setup())                        # super() sigue el MRO: cada clase se ejecuta una vez
['Service', 'Logging', 'Caching', 'Base', 'object']
['Base', 'Caching', 'Logging', 'Service']

7. __slots__: memoria por objeto

Cada objeto normal lleva un __dict__ para sus atributos. Con __slots__ los atributos se guardan en huecos fijos, lo que ahorra memoria cuando creas millones de objetos pequeños.

import tracemalloc

class PointDict:
    def __init__(self, x, y): self.x, self.y = x, y

class PointSlots:
    __slots__ = ("x", "y")
    def __init__(self, x, y): self.x, self.y = x, y

def measure(cls, n=200_000):
    tracemalloc.start()
    objs = [cls(i, i) for i in range(n)]
    current, _ = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    return current / n

print(f"por objeto: normal {measure(PointDict):.0f} bytes, con __slots__ {measure(PointSlots):.0f} bytes")
por objeto: normal 120 bytes, con __slots__ 88 bytes

Resumen

Herramienta Úsala cuando
clase normal comportamiento más estado con reglas (una cuenta, una conexión)
@dataclass sobre todo datos (registros, configuración, resultados)
@property validación o atributos calculados con sintaxis de atributo
@classmethod constructores alternativos (from_json, from_fahrenheit)
ABC una familia de clases propias que debe implementar una interfaz
Protocol describir lo que necesitas de cualquier objeto, incluidos los de terceros
composición combinar capacidades sin herencia profunda
__slots__ millones de objetos pequeños

Ejercicios

  1. Haz que Vector admita la resta y el producto escalar (v @ w llama a __matmul__).
  2. Escribe una dataclass inmutable Money(amount, currency) cuyo + lance un error si las monedas son distintas.
  3. Sustituye la jerarquía de Animal por un Protocol y comprueba que una clase que no hereda de nada sigue funcionando.