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
- Los cuatro pilares: encapsulación, herencia, polimorfismo y abstracción
- El modelo de datos: métodos especiales ("dunder") que hacen que los objetos se puedan imprimir, comparar, recorrer y llamar
- Propiedades, métodos de clase y métodos estáticos
dataclasses: clases para datos casi sin código repetitivo- Clases base abstractas frente a
Protocol(tipado estructural) - Composición frente a herencia,
super()y el orden de resolución de métodos __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)
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))
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)
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__
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])))
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)
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))
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))
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"))
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
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")
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
- Haz que
Vectoradmita la resta y el producto escalar (v @ wllama a__matmul__). - Escribe una dataclass inmutable
Money(amount, currency)cuyo+lance un error si las monedas son distintas. - Sustituye la jerarquía de
Animalpor unProtocoly comprueba que una clase que no hereda de nada sigue funcionando.