OOP, Decorators e Iterators
Domina la programación orientada a objetos, los decoradores y el protocolo de iteración
La Programación Orientada a Objetos (OOP) es un tema tan vasto que se han escrito libros enteros sobre él. En este capítulo exploraremos los fundamentos que te permitirán escribir código Python profesional: decoradores para extender funcionalidades, clases y objetos para modelar el mundo real, e iteradores para controlar el flujo de datos.
Como dice el proverbio italiano: "La classe non è acqua" (La clase siempre se nota). Python tiene soporte completo para OOP, y de hecho, todo en Python es un objeto.
Decoradores
Cada vez que nos encontramos repitiendo código, una alarma debería sonar. Los decoradores nos permiten envolver funciones con comportamiento adicional sin modificar su código original. Es una técnica tan popular que Python añadió una sintaxis especial para ella.
¿Qué es un Decorador?
Un decorador es una función que recibe otra función como argumento y retorna una nueva función con funcionalidad extendida. Es el patrón de diseño "decorador" implementado de forma pythónica.
Medidor de Tiempo de Ejecución
from time import sleep, time from functools import wraps def measure(func): @wraps(func) def wrapper(*args, **kwargs): t = time() result = func(*args, **kwargs) print(f"{func.__name__} took: {time() - t}") return result return wrapper @measure def f(sleep_time=0.1): """I'm a cat. I love to sleep!""" sleep(sleep_time) f(sleep_time=0.3) print(f.__name__) # f (preservado por @wraps) print(f.__doc__) # I'm a cat. I love to sleep!
f
I'm a cat. I love to sleep!
@wraps de functools preserva el nombre y docstring de la función original. Sin él, f.__name__ sería "wrapper".
Aplicando Múltiples Decoradores
from time import time from functools import wraps def measure(func): @wraps(func) def wrapper(*args, **kwargs): t = time() result = func(*args, **kwargs) print(func.__name__, "took:", time() - t) return result return wrapper def max_result(func): @wraps(func) def wrapper(*args, **kwargs): result = func(*args, **kwargs) if result > 100: print(f"Result is too big ({result}). Max allowed is 100.") return result return wrapper @measure @max_result def cube(n): return n ** 3 print(cube(2)) # 8 print(cube(5)) # 125 (con advertencia)
8
Result is too big (125). Max allowed is 100.
cube took: 3.1e-06
125
cube = measure(max_result(cube))
Decorator Factory
Cuando necesitamos que un decorador acepte argumentos, creamos una "fábrica de decoradores". Esta función externa recibe los argumentos y retorna el decorador real.
from functools import wraps def max_result(threshold): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): result = func(*args, **kwargs) if result > threshold: print( f"Result is too big ({result}). " f"Max allowed is {threshold}." ) return result return wrapper return decorator @max_result(75) def cube(n): return n ** 3 @max_result(100) def square(n): return n ** 2 print(cube(5)) # 125 (threshold: 75) print(square(9)) # 81 (threshold: 100)
125
81
closure. Las funciones creadas dinámicamente tienen acceso completo a las variables del namespace local donde fueron creadas, incluso después de que la función contenedora haya terminado de ejecutarse.
Programación Orientada a Objetos
OOP es un paradigma basado en el concepto de "objetos", que son estructuras de datos que contienen datos (atributos) y código (métodos). Una característica distintiva es que los métodos pueden acceder y modificar los atributos del objeto con el que están asociados (los objetos tienen noción de "self").
La Clase Python más Simple
class Simplest: pass print(type(Simplest)) # <class 'type'> simp = Simplest() # Creamos una instancia print(type(simp)) # <class '__main__.Simplest'> print(type(simp) is Simplest) # True
type. Cuando defines una clase, Python crea un objeto clase y le asigna un nombre. Es similar a cuando declaras una función con def.
Namespaces de Clase e Instancia
class Person: species = "Human" # Atributo de clase print(Person.species) # Human Person.alive = True # Añadido dinámicamente man = Person() print(man.species) # Human (heredado) print(man.alive) # True (heredado) Person.alive = False print(man.alive) # False (refleja el cambio) man.name = "Darth" # Atributo de instancia man.surname = "Vader" print(man.name, man.surname) # Darth Vader
El Argumento self
class Square: side = 8 def area(self): return self.side ** 2 sq = Square() print(sq.area()) # 64 print(Square.area(sq)) # 64 (equivalente) sq.side = 10 print(sq.area()) # 100
sq.area() y Square.area(sq) son equivalentes. Python traduce la primera forma automáticamente, pasando la instancia como primer argumento (self).
Inicializando Instancias con __init__
class Rectangle: def __init__(self, side_a, side_b): self.side_a = side_a self.side_b = side_b def area(self): return self.side_a * self.side_b r1 = Rectangle(10, 4) print(r1.side_a, r1.side_b) # 10 4 print(r1.area()) # 40 r2 = Rectangle(7, 3) print(r2.area()) # 21
Herencia y Composición
OOP ofrece dos mecanismos principales para reutilizar código: herencia (relación Is-A) y composición (relación Has-A).
class Engine: def start(self): pass def stop(self): pass class ElectricEngine(Engine): # Is-A Engine pass class V8Engine(Engine): # Is-A Engine pass class Car: engine_cls = Engine def __init__(self): self.engine = self.engine_cls() # Has-A Engine def start(self): print( f"Starting {self.engine.__class__.__name__} for " f"{self.__class__.__name__}... Wroom!" ) self.engine.start() class RaceCar(Car): # Is-A Car engine_cls = V8Engine class CityCar(Car): # Is-A Car engine_cls = ElectricEngine cars = [Car(), RaceCar(), CityCar()] for car in cars: car.start()
Starting V8Engine for RaceCar... Wroom!
Starting ElectricEngine for CityCar... Wroom!
Usando super() para Acceder a la Clase Base
class Book: def __init__(self, title, publisher, pages): self.title = title self.publisher = publisher self.pages = pages class Ebook(Book): def __init__(self, title, publisher, pages, format_): super().__init__(title, publisher, pages) self.format_ = format_ ebook = Ebook( "Learn Python Programming", "Packt Publishing", 500, "PDF" ) print(ebook.title) # Learn Python Programming print(ebook.format_) # PDF
super() retorna un objeto proxy que delega llamadas a métodos de la clase padre. Esto evita duplicar código y hace que los cambios en la clase base se propaguen automáticamente.
Métodos Estáticos y de Clase
class StringUtil: @classmethod def is_palindrome(cls, s, case_insensitive=True): s = cls._strip_string(s) if case_insensitive: s = s.lower() return cls._is_palindrome(s) @staticmethod def _strip_string(s): return "".join(c for c in s if c.isalnum()) @staticmethod def _is_palindrome(s): for c in range(len(s) // 2): if s[c] != s[-c - 1]: return False return True print(StringUtil.is_palindrome("A man a plan a canal Panama")) # True print(StringUtil.is_palindrome("radar")) # True
| Tipo | Decorador | Primer Argumento | Uso |
|---|---|---|---|
| Instancia | ninguno | self | Accede a datos de la instancia |
| Clase | @classmethod | cls | Accede a la clase, factory methods |
| Estático | @staticmethod | ninguno | Utilidades que no necesitan instancia |
El Decorador @property
class Person: def __init__(self, age): self._age = age @property def age(self): return self._age @age.setter def age(self, value): if 18 <= value <= 99: self._age = value else: raise ValueError("Age must be between 18 and 99") person = Person(30) print(person.age) # 30 person.age = 35 # Usa el setter con validación print(person.age) # 35
@property permite acceder a métodos como si fueran atributos. Puedes agregar validación o lógica sin cambiar cómo se usa la clase externamente.
Sobrecarga de Operadores
class Weird: def __init__(self, s): self._s = s def __len__(self): return len(self._s) def __bool__(self): return "42" in self._s weird = Weird("Hello! I am 9 years old!") print(len(weird)) # 24 print(bool(weird)) # False weird2 = Weird("Hello! I am 42 years old!") print(bool(weird2)) # True
Polimorfismo
La palabra polimorfismo viene del griego polys (muchos) y morphē (forma). Es la provisión de una única interfaz para entidades de diferentes tipos. En Python, el polimorfismo es implícito gracias al duck typing.
Data Classes
Introducidas en Python 3.7, las data classes son como named tuples mutables con valores por defecto.
from dataclasses import dataclass @dataclass class Body: """Class to represent a physical body.""" name: str mass: float = 0.0 # Kg speed: float = 1.0 # m/s def kinetic_energy(self) -> float: return (self.mass * self.speed ** 2) / 2 body = Body("Ball", 19, 3.1415) print(body.kinetic_energy()) # 93.755711375 Joule print(body) # Body(name='Ball', mass=19, speed=3.1415)
@dataclass genera automáticamente __init__(), __repr__() y métodos de comparación. No necesitas escribir boilerplate.
Iteradores Personalizados
Un iterador es un objeto que representa un flujo de datos. Para crear uno personalizado, debemos implementar dos métodos especiales: __iter__() y __next__().
__iter__() o __getitem__() también lo son.__iter__() (retorna el objeto mismo) y __next__() (retorna el siguiente item o lanza StopIteration).Implementando un Iterador
Vamos a crear un iterador que retorna primero los caracteres en posiciones pares de un string, y luego los impares:
class OddEven: def __init__(self, data): self._data = data self.indexes = ( list(range(0, len(data), 2)) + list(range(1, len(data), 2)) ) def __iter__(self): return self def __next__(self): if self.indexes: return self._data[self.indexes.pop(0)] raise StopIteration # Uso con for loop oddeven = OddEven("0123456789") print("".join(c for c in oddeven)) # 0246813579 # Uso manual oddeven = OddEven("ABCD") it = iter(oddeven) # Llama a oddeven.__iter__ print(next(it)) # A (índice 0) print(next(it)) # C (índice 2) print(next(it)) # B (índice 1) print(next(it)) # D (índice 3)
A
C
B
D
__next__() debe lanzar StopIteration cuando no hay más elementos. Esto es parte del protocolo de iteración y es lo que permite usar el iterador en bucles for.
Iterador para un Equipo de Fútbol
class FootballTeamIterator: def __init__(self, members): self.members = members self.index = 0 def __iter__(self): return self def __next__(self): if self.index < len(self.members): val = self.members[self.index] self.index += 1 return val raise StopIteration() class FootballTeam: def __init__(self, members): self.members = members def __iter__(self): return FootballTeamIterator(self.members) # Uso members = [f"player{x}" for x in range(1, 4)] + ["coach"] team = FootballTeam(members) for member in team: print(member)
player2
player3
coach
Resumen del Capítulo
01 // Decoradores
Funciones que envuelven otras funciones para extender su comportamiento. Usa @wraps para preservar metadatos y crea factories para decoradores con argumentos.
02 // OOP
Clases, objetos, herencia, composición, métodos estáticos/de clase, properties y data classes. Todo en Python es un objeto.
03 // Iteradores
Implementa __iter__() y __next__() para crear iteradores personalizados. Lanza StopIteration al finalizar.