Python & AI Tutorials Logo
Programación Python

31. Funcionalidades avanzadas de clases

En el Capítulo 30, aprendimos a crear clases básicas con atributos y métodos de instancia. Ahora exploraremos funcionalidades de clases más sofisticadas que te dan un control detallado sobre cómo se comportan tus objetos. Estas funcionalidades te permiten crear clases que se sienten como tipos integrados de Python, con una sintaxis natural para operaciones como suma, comparación e indexación.

31.1) Variables de clase vs variables de instancia

Cuando creamos atributos en una clase, tenemos dos lugares fundamentalmente diferentes para almacenarlos: en la propia clase o en instancias individuales. Comprender esta distinción es crucial para escribir código orientado a objetos correcto.

31.1.1) Comprender las variables de instancia

Las variables de instancia (instance variables) son atributos que pertenecen a un objeto específico. Cada instancia tiene su propia copia separada de estas variables. Hemos estado usando variables de instancia a lo largo del Capítulo 30: son los atributos que creamos en __init__ usando self:

python
class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner      # Variable de instancia
        self.balance = balance  # Variable de instancia
 
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)
 
print(account1.balance)  # Output: 1000
print(account2.balance)  # Output: 500

Cada instancia de BankAccount tiene su propio owner y balance. Cambiar account1.balance no afecta a account2.balance: son completamente independientes.

31.1.2) Comprender las variables de clase

Las variables de clase (class variables) son atributos que pertenecen a la propia clase, no a ninguna instancia en particular. Todas las instancias comparten la misma variable de clase. Definimos variables de clase directamente en el cuerpo de la clase, fuera de cualquier método:

python
class BankAccount:
    interest_rate = 0.02  # Variable de clase: compartida por todas las instancias
    
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance
    
    def apply_interest(self):
        self.balance += self.balance * BankAccount.interest_rate
 
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)
 
print(account1.interest_rate)  # Output: 0.02
print(account2.interest_rate)  # Output: 0.02
print(BankAccount.interest_rate)  # Output: 0.02

Fíjate en que podemos acceder a interest_rate a través de instancias (account1.interest_rate) o a través de la propia clase (BankAccount.interest_rate). Ambos se refieren a la misma variable.

Esto es lo que hace que las variables de clase sean potentes: cuando cambiamos la variable de clase, todas las instancias ven el cambio:

python
class BankAccount:
    interest_rate = 0.02
    
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance
 
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)
 
print(account1.interest_rate)  # Output: 0.02
print(account2.interest_rate)  # Output: 0.02
 
# Cambiar la variable de clase
BankAccount.interest_rate = 0.03
 
print(account1.interest_rate)  # Output: 0.03
print(account2.interest_rate)  # Output: 0.03

Ambas instancias ven inmediatamente la nueva tasa de interés porque todas están mirando la misma variable de clase.

31.1.3) La trampa del sombreado: cuando las variables de instancia ocultan variables de clase

Aquí hay un comportamiento sutil pero importante: si asignas a un atributo a través de una instancia, Python crea una variable de instancia que sombrea (oculta) la variable de clase:

python
class BankAccount:
    interest_rate = 0.02
    
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance
 
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)
 
# Crear una variable de instancia que sombrea la variable de clase
account1.interest_rate = 0.05
 
print(account1.interest_rate)  # Output: 0.05 (instance variable)
print(account2.interest_rate)  # Output: 0.02 (class variable)
print(BankAccount.interest_rate)  # Output: 0.02 (class variable)

Ahora account1 tiene su propia variable de instancia interest_rate que oculta la variable de clase. La variable de clase sigue existiendo, pero account1.interest_rate se refiere a la variable de instancia en su lugar. Normalmente esto no es lo que quieres: si necesitas cambiar una variable de clase, cámbiala a través del nombre de la clase, no a través de una instancia.

31.1.4) Usos prácticos de las variables de clase

Las variables de clase son útiles para datos que deben compartirse entre todas las instancias:

python
class Student:
    school_name = "Python High School"  # Igual para todos los estudiantes
    total_students = 0  # Registrar cuántos estudiantes existen
    
    def __init__(self, name, grade):
        self.name = name
        self.grade = grade
        Student.total_students += 1  # Incrementar al crear un estudiante
    
    def __str__(self):
        return f"{self.name} (Grade {self.grade}) at {Student.school_name}"
 
student1 = Student("Alice", 10)
student2 = Student("Bob", 11)
student3 = Student("Carol", 10)
 
print(student1)  # Output: Alice (Grade 10) at Python High School
print(f"Total students: {Student.total_students}")  # Output: Total students: 3

Fíjate en cómo usamos Student.total_students (no self.total_students) en __init__ para dejar claro que estamos modificando la variable de clase, no creando una variable de instancia.

Variables de clase

Definidas en el cuerpo de la clase

Compartidas por todas las instancias

Accedidas vía ClassName.variable

Variables de instancia

Definidas en init con self

Únicas para cada instancia

Accedidas vía instance.variable

31.2) Gestionar atributos con @property

A veces quieres controlar lo que ocurre cuando alguien accede o modifica un atributo. Por ejemplo, quizá quieras validar que un valor sea positivo, o calcular un valor al vuelo en lugar de almacenarlo. El decorador @property de Python te permite escribir métodos que se ven como un acceso simple a un atributo.

31.2.1) El problema: el acceso directo a atributos no puede validar

Cuando se accede a los atributos directamente, no puedes validar ni transformar los valores:

python
class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius
 
temp = Temperature(25)
print(temp.celsius)  # Output: 25
 
# Nada nos impide establecer temperaturas físicamente imposibles
temp.celsius = -500  # ¡Por debajo del cero absoluto (-273.15°C)!
print(temp.celsius)  # Output: -500
 
# O valores absurdamente altos
temp.celsius = 1000000
print(temp.celsius)  # Output: 1000000

Sin validación, podemos establecer datos inválidos por accidente, lo que provoca errores más adelante en el programa. Podríamos usar métodos como get_celsius() y set_celsius(), pero eso no es idiomático en Python. Los desarrolladores de Python esperan acceder a los atributos directamente, no mediante métodos getter/setter como en Java o C++.

31.2.2) Usar @property para atributos calculados

El decorador @property convierte un método en un "getter" al que se accede como a un atributo:

python
class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius
    
    @property
    def fahrenheit(self):
        """Convert celsius to fahrenheit on-the-fly"""
        return self.celsius * 9/5 + 32
 
temp = Temperature(25)
print(temp.celsius)  # Output: 25
print(temp.fahrenheit)  # Output: 77.0 (computed, not stored)

Fíjate en que llamamos a temp.fahrenheit sin paréntesis: parece un acceso a un atributo, pero en realidad está llamando al método. El valor de fahrenheit se calcula cada vez que accedes a él, así que siempre está sincronizado con celsius:

python
temp = Temperature(0)
print(temp.fahrenheit)  # Output: 32.0
 
temp.celsius = 100
print(temp.fahrenheit)  # Output: 212.0 (automatically updated)

31.2.3) Añadir un setter con @property_name.setter

Para permitir establecer una property, añadimos un método setter usando el decorador @property_name.setter:

python
class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius
    
    @property
    def fahrenheit(self):
        return self.celsius * 9/5 + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value):
        """Convert fahrenheit to celsius when setting"""
        self.celsius = (value - 32) * 5/9
 
temp = Temperature(0)
print(temp.celsius)  # Output: 0
print(temp.fahrenheit)  # Output: 32.0
 
# Establecer la temperatura usando fahrenheit
temp.fahrenheit = 212
print(temp.celsius)  # Output: 100.0
print(temp.fahrenheit)  # Output: 212.0

El método setter recibe el nuevo valor y puede validarlo o transformarlo antes de almacenarlo.

31.2.4) Usar propiedades para validación

Las propiedades son excelentes para imponer restricciones:

python
class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner
        self._balance = balance  # El guion bajo sugiere "uso interno"
    
    @property
    def balance(self):
        """Obtener el saldo actual"""
        return self._balance
    
    @balance.setter
    def balance(self, value):
        """Establecer el saldo, pero solo si no es negativo"""
        if value < 0:
            raise ValueError("Balance cannot be negative")
        self._balance = value
 
account = BankAccount("Alice", 1000)
print(account.balance)  # Output: 1000
 
account.balance = 1500  # Funciona bien
print(account.balance)  # Output: 1500
 
# Esto lanza un error
account.balance = -100
# Output: ValueError: Balance cannot be negative

Fíjate en la convención de nombres: almacenamos el valor real en _balance (con un guion bajo inicial) y lo exponemos mediante la property balance. El guion bajo es una convención de Python que sugiere "este es un detalle de implementación interno", aunque el atributo sigue siendo técnicamente accesible. Este patrón nos permite controlar el acceso a través de la property mientras mantenemos separado el almacenamiento real.

31.2.5) Propiedades de solo lectura

Si defines una property sin un setter, se vuelve de solo lectura:

python
class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    @property
    def area(self):
        """Propiedad calculada de solo lectura"""
        return self.width * self.height
 
rect = Rectangle(5, 3)
print(rect.area)  # Output: 15
 
rect.width = 10
print(rect.area)  # Output: 30 (automatically updated)
 
# Intentar establecer area lanza un error
rect.area = 50
# Output: AttributeError: property 'area' of 'Rectangle' object has no setter

Esto es útil para valores derivados que deben calcularse, no almacenarse.

Decorador @property

Convierte un método en getter

Accedido como atributo

Puede calcular el valor al vuelo

@property_name.setter

Añade setter para la property

Puede validar antes de almacenar

Puede transformar el valor

31.3) Métodos de clase con @classmethod

A veces necesitas métodos que trabajen con la propia clase en lugar de con instancias. Los métodos de clase (class methods) reciben la clase como su primer argumento (por convención llamado cls) en lugar de una instancia (self).

31.3.1) Definir métodos de clase

Creamos métodos de clase usando el decorador @classmethod:

python
class Student:
    school_name = "Python High School"
    
    def __init__(self, name, grade):
        self.name = name
        self.grade = grade
    
    @classmethod
    def get_school_name(cls):
        """Método de clase: recibe la clase, no una instancia"""
        return cls.school_name
 
# Llamar en la propia clase
print(Student.get_school_name())  # Output: Python High School
 
# También se puede llamar en una instancia (pero cls sigue siendo la clase)
student = Student("Alice", 10)
print(student.get_school_name())  # Output: Python High School

El parámetro cls recibe automáticamente la clase, igual que self recibe automáticamente la instancia en los métodos normales.

31.3.2) Constructores alternativos con métodos de clase

Uno de los usos más comunes de los métodos de clase es crear constructores alternativos: diferentes formas de crear instancias:

python
class Date:
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day
    
    @classmethod
    def from_string(cls, date_string):
        """Crear un Date a partir de una cadena como '2024-12-27'"""
        year, month, day = date_string.split('-')
        return cls(int(year), int(month), int(day))
    
    @classmethod
    def today(cls):
        """Crear un Date para hoy (ejemplo simplificado)"""
        # En código real, usarías el módulo datetime
        return cls(2024, 12, 27)
    
    def __str__(self):
        return f"{self.year}-{self.month:02d}-{self.day:02d}"
 
# Constructor normal
date1 = Date(2024, 12, 27)
print(date1)  # Output: 2024-12-27
 
# Constructor alternativo desde cadena
date2 = Date.from_string("2024-12-27")
print(date2)  # Output: 2024-12-27
 
# Constructor alternativo para hoy
date3 = Date.today()
print(date3)  # Output: 2024-12-27

Fíjate en que from_string y today devuelven cls(...): esto crea una nueva instancia de la clase. Usar cls en lugar de codificar Date hace que el código funcione correctamente con subclases (aprenderemos sobre herencia en el Capítulo 32).

31.3.3) Métodos de clase para patrones de fábrica

Los métodos de clase son útiles para crear instancias con diferentes configuraciones:

python
class DatabaseConnection:
    def __init__(self, host, port, database, username):
        self.host = host
        self.port = port
        self.database = database
        self.username = username
    
    @classmethod
    def for_development(cls):
        """Crear una conexión configurada para desarrollo"""
        return cls("localhost", 5432, "dev_db", "dev_user")
    
    @classmethod
    def for_production(cls):
        """Crear una conexión configurada para producción"""
        return cls("prod.example.com", 5432, "prod_db", "prod_user")
    
    def __str__(self):
        return f"Connection to {self.database} at {self.host}:{self.port}"
 
# Fácil crear conexiones preconfiguradas
dev_conn = DatabaseConnection.for_development()
prod_conn = DatabaseConnection.for_production()
 
print(dev_conn)  # Output: Connection to dev_db at localhost:5432
print(prod_conn)  # Output: Connection to prod_db at prod.example.com:5432

31.3.4) Métodos de clase para contar instancias

Los métodos de clase pueden trabajar con variables de clase para rastrear información sobre todas las instancias:

python
class Product:
    total_products = 0
    
    def __init__(self, name, price):
        self.name = name
        self.price = price
        Product.total_products += 1
    
    @classmethod
    def get_total_products(cls):
        """Devolver el número total de productos creados"""
        return cls.total_products
    
    @classmethod
    def reset_count(cls):
        """Restablecer el contador de productos"""
        cls.total_products = 0
 
product1 = Product("Laptop", 999)
product2 = Product("Mouse", 25)
product3 = Product("Keyboard", 75)
 
print(Product.get_total_products())  # Output: 3
 
Product.reset_count()
print(Product.get_total_products())  # Output: 0

31.4) Métodos estáticos con @staticmethod

Los métodos estáticos (static methods) son métodos que no reciben la instancia (self) ni la clase (cls) como su primer argumento. Son simplemente funciones normales que están definidas dentro de una clase porque están lógicamente relacionadas con esa clase.

31.4.1) Definir métodos estáticos

Creamos métodos estáticos usando el decorador @staticmethod:

python
class MathUtils:
    @staticmethod
    def is_even(number):
        """Check if a number is even"""
        return number % 2 == 0
    
    @staticmethod
    def is_prime(number):
        """Check if a number is prime (simplified)"""
        if number < 2:
            return False
        for i in range(2, int(number ** 0.5) + 1):
            if number % i == 0:
                return False
        return True
 
# Llamar métodos estáticos en la clase
print(MathUtils.is_even(4))  # Output: True
print(MathUtils.is_even(7))  # Output: False
print(MathUtils.is_prime(17))  # Output: True
print(MathUtils.is_prime(18))  # Output: False
 
# También se pueden llamar en una instancia (pero es la misma función)
utils = MathUtils()
print(utils.is_even(10))  # Output: True

Los métodos estáticos no necesitan acceso a datos de instancia o de clase: son funciones utilitarias autocontenidas.

31.4.2) Cuándo usar métodos estáticos vs métodos de clase vs métodos de instancia

Aquí tienes cómo elegir:

python
class Temperature:
    # Variable de clase
    absolute_zero_celsius = -273.15
    
    def __init__(self, celsius):
        self.celsius = celsius
    
    # Método de instancia: necesita acceso a datos de instancia (self)
    def to_fahrenheit(self):
        return self.celsius * 9/5 + 32
    
    # Método de clase: necesita acceso a datos de clase (cls)
    @classmethod
    def get_absolute_zero(cls):
        return cls.absolute_zero_celsius
    
    # Método estático: no necesita datos de instancia ni de clase
    @staticmethod
    def celsius_to_kelvin(celsius):
        return celsius + 273.15
    
    @staticmethod
    def fahrenheit_to_celsius(fahrenheit):
        return (fahrenheit - 32) * 5/9
 
temp = Temperature(25)
 
# Método de instancia: usa datos de instancia
print(temp.to_fahrenheit())  # Output: 77.0
 
# Método de clase: usa datos de clase
print(Temperature.get_absolute_zero())  # Output: -273.15
 
# Métodos estáticos: solo funciones utilitarias
print(Temperature.celsius_to_kelvin(25))  # Output: 298.15
print(Temperature.fahrenheit_to_celsius(77))  # Output: 25.0

Pautas:

  • Usa métodos de instancia (instance methods) cuando necesites acceso a atributos de instancia (self)
  • Usa métodos de clase (class methods) cuando necesites acceso a atributos de clase o quieras constructores alternativos (cls)
  • Usa métodos estáticos (static methods) cuando no necesites acceso a datos de instancia o de clase, pero la función esté lógicamente relacionada con la clase

Nota: Los métodos estáticos podrían ser funciones independientes, pero ponerlos en la clase agrupa la funcionalidad relacionada y evita ensuciar el espacio de nombres global.

Tipo de métodoPrimer parámetroÚsalo cuando
Método de instanciaselfNecesitas acceso a datos de instancia
Método de claseclsNecesitas acceso a datos de clase o constructores alternativos
Método estático(ninguno)Función utilitaria relacionada con la clase

31.4.3) Ejemplo práctico: utilidades de validación

Los métodos estáticos son geniales para validación y funciones utilitarias:

python
class User:
    def __init__(self, username, password):
        if not User.is_valid_username(username):
            raise ValueError("Invalid username")
        if not User.is_valid_password(password):
            raise ValueError("Invalid password")
        
        self.username = username
        self._password = password
    
    @staticmethod
    def is_valid_username(username):
        """Check if username meets requirements"""
        return len(username) >= 3 and username.isalnum()
        
    @staticmethod
    def is_valid_password(password):
        """Check if password meets security requirements"""
        return len(password) >= 8 and any(c.isdigit() for c in password)
 
# Estos métodos de validación pueden usarse de forma independiente
print(User.is_valid_username("alice123"))  # Output: True
print(User.is_valid_username("ab"))  # Output: False
print(User.is_valid_password("pass1234"))  # Output: True
 
# Y pueden usarse en cualquier método de la clase
try:
    user = User("ab", "short")
except ValueError as e:
    print(f"Error: {e}")  # Output: Error: Invalid username

31.5) Comprender los métodos especiales (magic methods)

Los métodos especiales (special methods) (también llamados magic methods o métodos dunder porque tienen doble guion bajo) te permiten personalizar cómo se comportan tus objetos con las operaciones integradas de Python. Ya hemos usado __init__, __str__ y __repr__ en el Capítulo 30. Ahora exploraremos muchos más.

31.5.1) Qué hacen los métodos especiales

Los métodos especiales son llamados automáticamente por Python cuando usas cierta sintaxis o funciones integradas:

python
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __str__(self):
        return f"Point({self.x}, {self.y})"
 
point = Point(3, 4)
 
# Cuando llamas a print(), Python llama a __str__()
print(point)  # Output: Point(3, 4)
# Esto es equivalente a: print(point.__str__())

Los métodos especiales te permiten hacer que tus clases se comporten como tipos integrados. Por ejemplo, puedes hacer que tus objetos:

  • Soporten operaciones aritméticas (+, -, *, /)
  • Sean comparables (<, >, ==)
  • Funcionen con len(), in e indexación
  • Actúen como contenedores o secuencias

31.5.2) Categorías comunes de métodos especiales

Estas son las principales categorías de métodos especiales:

Representación de cadena (cómo se muestran los objetos):

  • __str__() - para print() y str()
  • __repr__() - para el REPL y repr()

Comparación (comparar objetos):

  • __eq__() - para ==
  • __ne__() - para !=
  • __lt__() - para <
  • __le__() - para <=
  • __gt__() - para >
  • __ge__() - para >=

Aritmética (operaciones matemáticas):

  • __add__() - para +
  • __sub__() - para -
  • __mul__() - para *
  • __truediv__() - para /

Contenedor/Secuencia (comportamiento tipo colección):

  • __len__() - para len()
  • __contains__() - para in
  • __getitem__() - para indexación obj[key]
  • __setitem__() - para asignación obj[key] = value

Exploraremos estos en detalle en las siguientes secciones.

31.6) Ejemplo 1: Interfaz de colección (len, contains)

Vamos a crear una clase que gestione una colección de elementos y hacer que funcione con la función integrada len() y el operador in de Python.

31.6.1) Implementar len para len()

El método especial __len__() se llama cuando usas len() en tu objeto:

python
class ShoppingCart:
    def __init__(self):
        self.items = []
    
    def add_item(self, item):
        self.items.append(item)
    
    def __len__(self):
        """Devolver el número de elementos en el carrito"""
        return len(self.items)
 
cart = ShoppingCart()
cart.add_item("Apple")
cart.add_item("Banana")
cart.add_item("Orange")
 
# len() llama a __len__()
print(len(cart))  # Output: 3

Sin __len__(), llamar a len(cart) lanzaría un TypeError. Al implementarlo, nuestro ShoppingCart funciona igual que las colecciones integradas.

31.6.2) Implementar contains para el operador in

El método especial __contains__() se llama cuando usas el operador in:

python
class ShoppingCart:
    def __init__(self):
        self.items = []
    
    def add_item(self, item):
        self.items.append(item)
    
    def __len__(self):
        return len(self.items)
    
    def __contains__(self, item):
        """Comprobar si un elemento está en el carrito"""
        return item in self.items
 
cart = ShoppingCart()
cart.add_item("Apple")
cart.add_item("Banana")
 
# El operador in llama a __contains__()
print("Apple" in cart)  # Output: True
print("Orange" in cart)  # Output: False

Ahora nuestro carrito soporta la sintaxis natural de Python para comprobación de pertenencia.

31.6.3) Construir una clase de colección más completa

Vamos a crear una clase de colección más realista que haga seguimiento de las notas de estudiantes:

python
class GradeBook:
    def __init__(self):
        self.grades = {}  # student_name: list of grades
    
    def add_grade(self, student, grade):
        """Añadir una nota para un estudiante"""
        if student not in self.grades:
            self.grades[student] = []
        self.grades[student].append(grade)
    
    def __len__(self):
        """Devolver el número de estudiantes"""
        return len(self.grades)
    
    def __contains__(self, student):
        """Comprobar si un estudiante tiene alguna nota"""
        return student in self.grades
    
    def get_average(self, student):
        """Obtener la nota media de un estudiante"""
        if student not in self:
            return None
        grades = self.grades[student]
        return sum(grades) / len(grades)
    
    def __str__(self):
        return f"GradeBook with {len(self)} students"
 
gradebook = GradeBook()
gradebook.add_grade("Alice", 85)
gradebook.add_grade("Alice", 90)
gradebook.add_grade("Bob", 78)
gradebook.add_grade("Bob", 82)
gradebook.add_grade("Bob", 88)
 
print(gradebook)  # Output: GradeBook with 2 students
print(len(gradebook))  # Output: 2
 
print("Alice" in gradebook)  # Output: True
print("Carol" in gradebook)  # Output: False
 
print(f"Alice's average: {gradebook.get_average('Alice')}")  # Output: Alice's average: 87.5
print(f"Bob's average: {gradebook.get_average('Bob')}")  # Output: Bob's average: 82.66666666666667

Fíjate en que get_average() usa if student not in self: esto llama a nuestro método __contains__(), haciendo que el código se lea de forma natural.

31.7) Ejemplo 2: Sobrecarga de operadores (add, eq, lt)

La sobrecarga de operadores (operator overloading) significa definir qué hacen operadores como +, == y < para tus clases personalizadas. Esto hace que tus objetos funcionen de forma natural con la sintaxis de Python.

31.7.1) Implementar add para la suma

El método especial __add__() se llama cuando usas el operador +:

python
class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __add__(self, other):
        """Sumar dos vectores"""
        return Vector(self.x + other.x, self.y + other.y)
    
    def __str__(self):
        return f"Vector({self.x}, {self.y})"
 
v1 = Vector(1, 2)
v2 = Vector(3, 4)
 
# El operador + llama a __add__()
v3 = v1 + v2
print(v3)  # Output: Vector(4, 6)

Cuando Python ve v1 + v2, llama a v1.__add__(v2). El método __add__() del operando izquierdo recibe el operando derecho como argumento.

31.7.2) Implementar eq para igualdad

El método especial __eq__() se llama cuando usas el operador ==:

python
class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)
    
    def __eq__(self, other):
        """Comprobar si dos vectores son iguales"""
        return self.x == other.x and self.y == other.y
    
    def __str__(self):
        return f"Vector({self.x}, {self.y})"
 
v1 = Vector(1, 2)
v2 = Vector(1, 2)
v3 = Vector(3, 4)
 
# El operador == llama a __eq__()
print(v1 == v2)  # Output: True
print(v1 == v3)  # Output: False

Sin __eq__(), Python compara identidad de objetos (si son el mismo objeto en memoria), no sus valores. Con __eq__(), definimos qué significa igualdad para nuestra clase.

31.7.3) Implementar operadores de comparación

Vamos a implementar operadores de comparación para una clase Money:

python
class Money:
    def __init__(self, amount):
        self.amount = amount
    
    def __eq__(self, other):
        """Comprobar si los importes son iguales"""
        return self.amount == other.amount
    
    def __lt__(self, other):
        """Comprobar si este importe es menor que el otro"""
        return self.amount < other.amount
    
    def __le__(self, other):
        """Comprobar si este importe es menor o igual que el otro"""
        return self.amount <= other.amount
    
    def __gt__(self, other):
        """Comprobar si este importe es mayor que el otro"""
        return self.amount > other.amount
    
    def __ge__(self, other):
        """Comprobar si este importe es mayor o igual que el otro"""
        return self.amount >= other.amount
    
    def __str__(self):
        return f"${self.amount:.2f}"
 
price1 = Money(10.50)
price2 = Money(15.75)
price3 = Money(10.50)
 
print(price1 == price3)  # Output: True
print(price1 < price2)  # Output: True
print(price1 <= price3)  # Output: True
print(price2 > price1)  # Output: True
print(price2 >= price1)  # Output: True

31.7.4) Manejar incompatibilidades de tipo en operadores

Al implementar operadores, deberías manejar casos donde el otro operando no es del tipo esperado:

python
class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __add__(self, other):
        """Sumar dos vectores o sumar un escalar a ambos componentes"""
        if isinstance(other, Vector):
            return Vector(self.x + other.x, self.y + other.y)
        elif isinstance(other, (int, float)):
            return Vector(self.x + other, self.y + other)
        else:
            return NotImplemented  # Permitir que Python pruebe other.__radd__(self)
    
    def __eq__(self, other):
        if not isinstance(other, Vector):
            return False
        return self.x == other.x and self.y == other.y
    
    def __str__(self):
        return f"Vector({self.x}, {self.y})"
 
v1 = Vector(1, 2)
v2 = Vector(3, 4)
 
print(v1 + v2)  # Output: Vector(4, 6) (vector addition)
print(v1 + 5)  # Output: Vector(6, 7) (scalar addition)
 
print(v1 == v2)  # Output: False
print(v1 == "not a vector")  # Output: False (no error)

Devolver NotImplemented (una constante especial integrada) le dice a Python que intente la operación reflejada en el otro operando. Esto es importante para que los operadores funcionen correctamente con distintos tipos.

Sobrecarga de operadores

Operadores aritméticos

Operadores de comparación

add para +

sub para -

mul para *

truediv para /

eq para ==

lt para <

le para <=

gt para >

ge para >=

31.8) Ejemplo 3: Acceso a secuencias (getitem, setitem)

Los métodos especiales __getitem__() y __setitem__() te permiten usar sintaxis de indexación (obj[key]) con tus clases personalizadas. Esto hace que tus objetos se comporten como listas, diccionarios u otras secuencias.

31.8.1) Implementar getitem para indexación

El método __getitem__() se llama cuando usas corchetes para acceder a un elemento:

python
class Playlist:
    def __init__(self, name):
        self.name = name
        self.songs = []
    
    def add_song(self, song):
        self.songs.append(song)
    
    def __getitem__(self, index):
        """Obtener una canción por índice"""
        return self.songs[index]
    
    def __len__(self):
        return len(self.songs)
 
playlist = Playlist("My Favorites")
playlist.add_song("Song A")
playlist.add_song("Song B")
playlist.add_song("Song C")
 
# La indexación llama a __getitem__()
print(playlist[0])  # Output: Song A
print(playlist[1])  # Output: Song B
print(playlist[-1])  # Output: Song C (negative indexing works!)

Como delegamos en self.songs[index], todas las funcionalidades de indexación de listas funcionan automáticamente: índices positivos, índices negativos e incluso lanzar IndexError para índices inválidos.

31.8.2) Soportar slicing con getitem

El mismo método __getitem__() también maneja el slicing:

python
class Playlist:
    def __init__(self, name):
        self.name = name
        self.songs = []
    
    def add_song(self, song):
        self.songs.append(song)
    
    def __getitem__(self, index):
        """Obtener una canción por índice o slice"""
        return self.songs[index]
    
    def __len__(self):
        return len(self.songs)
 
playlist = Playlist("My Favorites")
playlist.add_song("Song A")
playlist.add_song("Song B")
playlist.add_song("Song C")
playlist.add_song("Song D")
 
# El slicing también llama a __getitem__()
print(playlist[1:3])  # Output: ['Song B', 'Song C']
print(playlist[:2])  # Output: ['Song A', 'Song B']
print(playlist[::2])  # Output: ['Song A', 'Song C']

Cuando usas slicing, Python pasa un objeto slice a __getitem__(). Al delegar en self.songs[index], soportamos automáticamente toda la sintaxis de slices.

31.8.3) Implementar setitem para asignación

El método __setitem__() se llama cuando asignas a un índice:

python
class Playlist:
    def __init__(self, name):
        self.name = name
        self.songs = []
    
    def add_song(self, song):
        self.songs.append(song)
    
    def __getitem__(self, index):
        return self.songs[index]
    
    def __setitem__(self, index, value):
        """Reemplazar una canción en un índice específico"""
        self.songs[index] = value
    
    def __len__(self):
        return len(self.songs)
 
playlist = Playlist("My Favorites")
playlist.add_song("Song A")
playlist.add_song("Song B")
playlist.add_song("Song C")
 
print(playlist[1])  # Output: Song B
 
# La asignación llama a __setitem__()
playlist[1] = "New Song B"
print(playlist[1])  # Output: New Song B

31.8.4) Hacer que los objetos sean iterables con getitem

Un efecto secundario interesante: si implementas __getitem__() con índices enteros empezando en 0, tu objeto automáticamente se vuelve iterable:

python
class Playlist:
    def __init__(self, name):
        self.name = name
        self.songs = []
    
    def add_song(self, song):
        self.songs.append(song)
    
    def __getitem__(self, index):
        return self.songs[index]
    
    def __len__(self):
        return len(self.songs)
 
playlist = Playlist("My Favorites")
playlist.add_song("Song A")
playlist.add_song("Song B")
playlist.add_song("Song C")
 
# ¡Los bucles for funcionan automáticamente!
for song in playlist:
    print(song)
# Output:
# Song A
# Song B
# Song C

Python intenta iterar llamando a __getitem__(0), luego __getitem__(1), y así sucesivamente hasta que obtiene un IndexError. Este es un protocolo de iteración más antiguo: aprenderemos sobre el protocolo moderno de iteradores en el Capítulo 35.

31.8.5) Acceso tipo diccionario con claves de cadena

__getitem__() y __setitem__() funcionan con cualquier tipo de clave, no solo enteros:

python
class ScoreBoard:
    def __init__(self):
        self.scores = {}
    
    def __getitem__(self, player_name):
        """Obtener la puntuación de un jugador"""
        return self.scores.get(player_name, 0)
    
    def __setitem__(self, player_name, score):
        """Establecer la puntuación de un jugador"""
        self.scores[player_name] = score
    
    def __contains__(self, player_name):
        return player_name in self.scores
    
    def __len__(self):
        return len(self.scores)
 
scoreboard = ScoreBoard()
 
# Establecer puntuaciones usando claves de cadena
scoreboard["Alice"] = 100
scoreboard["Bob"] = 85
 
# Actualizar una puntuación
scoreboard["Alice"] = 120
 
# Obtener puntuaciones
print(scoreboard["Alice"])  # Output: 120
print(scoreboard["Bob"])    # Output: 85
print(scoreboard["Carol"])  # Output: 0
 
print("Alice" in scoreboard)  # Output: True
print(len(scoreboard))  # Output: 2

Acceso a secuencias

getitem

setitem

Llamado para obj[key]

Maneja indexación

Maneja slicing

Hace que el objeto sea iterable

Llamado para obj[key] = value

Habilita asignación

Puede validar valores


Este capítulo te ha mostrado cómo crear clases sofisticadas que se integran perfectamente con la sintaxis de Python. Al implementar variables de clase, properties, métodos de clase, métodos estáticos y métodos especiales, puedes hacer que tus clases personalizadas se comporten como tipos integrados. En el Capítulo 32, exploraremos la herencia y el polimorfismo, que te permiten construir jerarquías de clases relacionadas que comparten y amplían comportamiento.

© 2025. Primesoft Co., Ltd.
support@primesoft.ai