lunes, 19 de enero de 2026

Aprendiendo Python de 0 a experto - Programacion Orientada a Objetos, Decoradores e Iteradores

OOP, Decorators e Iterators en Python
Python // Chapter 06

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.

MÓDULO_01

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.

Flujo del Decorador
función original
——▶
@decorator
——▶
función extendida

Medidor de Tiempo de Ejecución

time_measure.py
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!
python time_measure.py
f took: 0.30042004585266113
f
I'm a cat. I love to sleep!
El decorador @wraps de functools preserva el nombre y docstring de la función original. Sin él, f.__name__ sería "wrapper".

Aplicando Múltiples Decoradores

two_decorators.py
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)
python two_decorators.py
cube took: 9.5e-07
8
Result is too big (125). Max allowed is 100.
cube took: 3.1e-06
125
El orden de los decoradores importa. El más cercano a la función se aplica primero: 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.

decorator_factory.py
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)
python decorator_factory.py
Result is too big (125). Max allowed is 75.
125
81
Este mecanismo se llama 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.
MÓDULO_02

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

simplest_class.py
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
Las clases son objetos de tipo 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

namespaces.py
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
Atributos de Clase vs Instancia
Person (clase)
species = "Human"
alive = False
↓ hereda
man (instancia)
name = "Darth"
surname = "Vader"

El Argumento self

self_argument.py
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__

rectangle.py
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).

Is-A vs Has-A
Engine
ElectricEngine
V8Engine
Has-A →
Car
inheritance_composition.py
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()
python inheritance_composition.py
Starting Engine for Car... Wroom!
Starting V8Engine for RaceCar... Wroom!
Starting ElectricEngine for CityCar... Wroom!

Usando super() para Acceder a la Clase Base

super_example.py
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

static_class_methods.py
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

property_example.py
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

operator_overloading.py
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.

"Si camina como pato y grazna como pato, es un pato." En Python, no necesitas interfaces explícitas. Si un objeto tiene el método que necesitas, puedes usarlo.

Data Classes

Introducidas en Python 3.7, las data classes son como named tuples mutables con valores por defecto.

dataclass_example.py
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)
El decorador @dataclass genera automáticamente __init__(), __repr__() y métodos de comparación. No necesitas escribir boilerplate.
MÓDULO_03

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__().

ITERABLE
Un objeto es iterable si puede retornar sus miembros uno a la vez. Listas, tuplas, strings y diccionarios son iterables. Objetos que definen __iter__() o __getitem__() también lo son.
ITERATOR
Un objeto que representa un flujo de datos. Debe implementar __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:

Protocolo de Iteración
iter(obj)
——▶
__iter__()
——▶
__next__()
——▶
StopIteration
custom_iterator.py
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)
python custom_iterator.py
0246813579
A
C
B
D
El método __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

football_iterator.py
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)
python football_iterator.py
player1
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.

Basado en "Learn Python Programming" 4th Edition // Fabrizio Romano & Heinrich Kruger

Chapter 6: OOP, Decorators, and Iterators