Python
Tutorial Python: Métodos especiales (dunder methods)
Aprende a implementar métodos especiales (dunder) en Python para personalizar clases con str, repr, operadores aritméticos y contenedores.
Aprende Python y certifícatestr y repr
En Python, los métodos especiales (también conocidos como métodos dunder, por el doble guion bajo o "double underscore") permiten personalizar el comportamiento de nuestras clases para que interactúen de forma natural con las funciones y operadores integrados del lenguaje. Dos de los métodos especiales más fundamentales son __str__
y __repr__
, que controlan cómo se representan nuestros objetos como cadenas de texto.
Estos métodos son invocados automáticamente cuando usamos las funciones str()
y repr()
respectivamente, o cuando Python necesita una representación textual de nuestros objetos.
El método __str__
El método __str__
define la representación de cadena "informal" o "amigable para el usuario" de un objeto. Este método es llamado por la función str()
y por la función print()
. Su propósito es proporcionar una representación legible para humanos.
class Producto:
def __init__(self, nombre, precio):
self.nombre = nombre
self.precio = precio
# Sin __str__, obtenemos algo como <__main__.Producto object at 0x7f8a8c0b5e80>
Si creamos una instancia de esta clase e intentamos imprimirla:
producto = Producto("Laptop", 1200)
print(producto) # <__main__.Producto object at 0x7f8a8c0b5e80>
Obtenemos una representación poco útil. Implementemos el método __str__
:
class Producto:
def __init__(self, nombre, precio):
self.nombre = nombre
self.precio = precio
def __str__(self):
return f"{self.nombre} - ${self.precio}"
Ahora, al imprimir el objeto:
producto = Producto("Laptop", 1200)
print(producto) # Laptop - $1200
str(producto) # 'Laptop - $1200'
Obtenemos una representación mucho más útil y legible.
El método __repr__
El método __repr__
define la representación de cadena "oficial" o "técnica" de un objeto. Este método es llamado por la función repr()
y se utiliza en el intérprete interactivo cuando se evalúa una expresión. Su propósito es proporcionar una representación inequívoca que idealmente permita recrear el objeto.
class Producto:
def __init__(self, nombre, precio):
self.nombre = nombre
self.precio = precio
def __str__(self):
return f"{self.nombre} - ${self.precio}"
def __repr__(self):
return f"Producto('{self.nombre}', {self.precio})"
Ahora, al usar repr()
:
producto = Producto("Laptop", 1200)
repr(producto) # "Producto('Laptop', 1200)"
La representación repr
es especialmente útil para depuración y para entender el estado interno de un objeto. Idealmente, eval(repr(obj))
debería crear un objeto con el mismo estado.
Diferencias clave entre __str__
y __repr__
- Propósito:
__str__
: Legibilidad para usuarios finales__repr__
: Información técnica para desarrolladores
- Comportamiento por defecto:
- Si
__str__
no está definido, Python usa__repr__
- Si
__repr__
no está definido, se usa la representación predeterminada de la clase
- Contextos de uso:
__str__
se usa conprint()
ystr()
__repr__
se usa en el intérprete interactivo y conrepr()
Uso en colecciones
Cuando un objeto forma parte de una colección como una lista o un diccionario, Python utiliza __repr__
para mostrar los elementos:
productos = [
Producto("Laptop", 1200),
Producto("Mouse", 25)
]
print(productos) # [Producto('Laptop', 1200), Producto('Mouse', 25)]
Implementación práctica
Veamos un ejemplo más completo con una clase Punto
que representa coordenadas en un plano:
class Punto:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f"Punto en ({self.x}, {self.y})"
def __repr__(self):
return f"Punto({self.x}, {self.y})"
Ahora podemos ver cómo se comporta en diferentes contextos:
p = Punto(3, 4)
# Uso de str() - representación amigable
print(p) # Punto en (3, 4)
print(f"{p}") # Punto en (3, 4)
# Uso de repr() - representación técnica
print(repr(p)) # Punto(3, 4)
print(f"{p!r}") # Punto(3, 4) - usando el especificador !r en f-strings
Caso práctico: Clase para gestión de fechas
Veamos un ejemplo más elaborado con una clase que gestiona fechas:
class Fecha:
def __init__(self, dia, mes, año):
self.dia = dia
self.mes = mes
self.año = año
# Validación básica
if not (1 <= dia <= 31 and 1 <= mes <= 12):
raise ValueError("Fecha inválida")
def __str__(self):
# Formato amigable para usuarios
meses = ["enero", "febrero", "marzo", "abril", "mayo", "junio",
"julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"]
return f"{self.dia} de {meses[self.mes-1]} de {self.año}"
def __repr__(self):
# Formato técnico para desarrolladores
return f"Fecha({self.dia}, {self.mes}, {self.año})"
Uso de esta clase:
# Creamos una fecha
fecha_nacimiento = Fecha(15, 3, 1990)
# Representación para usuarios
print(fecha_nacimiento) # 15 de marzo de 1990
# Representación para desarrolladores
print(repr(fecha_nacimiento)) # Fecha(15, 3, 1990)
# En una lista
fechas_importantes = [Fecha(1, 1, 2023), Fecha(25, 12, 2023)]
print(fechas_importantes) # [Fecha(1, 1, 2023), Fecha(25, 12, 2023)]
Buenas prácticas
Siempre implementa
__repr__
: Es una buena práctica implementar al menos__repr__
para todas tus clases, ya que proporciona información útil para depuración.Haz que
__repr__
sea inequívoco: Idealmente,__repr__
debería proporcionar suficiente información para recrear el objeto.Implementa
__str__
cuando necesites una representación amigable: Si tus objetos serán mostrados a usuarios finales, implementa__str__
para proporcionar una representación más legible.Reutiliza código: Si
__str__
y__repr__
son similares, puedes hacer que uno llame al otro:
class Temperatura:
def __init__(self, celsius):
self.celsius = celsius
def __repr__(self):
return f"Temperatura({self.celsius})"
# Reutilizamos __repr__ si no necesitamos una representación diferente
__str__ = __repr__
Los métodos __str__
y __repr__
son fundamentales para crear clases que se integren bien con el resto del ecosistema Python y proporcionen información útil tanto para usuarios como para desarrolladores. Implementarlos correctamente mejora significativamente la usabilidad y depuración de nuestro código.
Operadores aritméticos
Los métodos dunder nos permiten personalizar cómo nuestras clases interactúan con los operadores aritméticos de Python. Esto nos da la capacidad de definir comportamientos personalizados cuando aplicamos operaciones como suma, resta o multiplicación a nuestros objetos.
Cuando utilizamos un operador aritmético entre objetos, Python busca un método dunder específico en la clase. Si este método está implementado, se ejecuta para realizar la operación. De lo contrario, obtendremos un error de tipo.
Métodos para operadores aritméticos básicos
Los principales métodos dunder para operaciones aritméticas son:
__add__
: Implementa el comportamiento del operador+
__sub__
: Implementa el comportamiento del operador-
__mul__
: Implementa el comportamiento del operador*
__truediv__
: Implementa el comportamiento del operador/
Veamos un ejemplo práctico con una clase Vector2D
que representa un vector bidimensional:
class Vector2D:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f"({self.x}, {self.y})"
def __add__(self, otro):
"""Suma de vectores: v1 + v2"""
if isinstance(otro, Vector2D):
return Vector2D(self.x + otro.x, self.y + otro.y)
return NotImplemented
def __sub__(self, otro):
"""Resta de vectores: v1 - v2"""
if isinstance(otro, Vector2D):
return Vector2D(self.x - otro.x, self.y - otro.y)
return NotImplemented
Ahora podemos sumar y restar vectores de forma intuitiva:
v1 = Vector2D(3, 4)
v2 = Vector2D(1, 2)
v3 = v1 + v2 # Vector2D(4, 6)
print(v3) # (4, 6)
v4 = v1 - v2 # Vector2D(2, 2)
print(v4) # (2, 2)
Operaciones con escalares
También podemos implementar operaciones entre nuestros objetos y tipos diferentes, como números:
class Vector2D:
# ... (código anterior) ...
def __mul__(self, escalar):
"""Multiplicación por escalar: v * n"""
if isinstance(escalar, (int, float)):
return Vector2D(self.x * escalar, self.y * escalar)
return NotImplemented
def __rmul__(self, escalar):
"""Multiplicación por escalar (orden inverso): n * v"""
# Delegamos a __mul__
return self.__mul__(escalar)
def __truediv__(self, escalar):
"""División por escalar: v / n"""
if isinstance(escalar, (int, float)):
if escalar == 0:
raise ZeroDivisionError("No se puede dividir por cero")
return Vector2D(self.x / escalar, self.y / escalar)
return NotImplemented
Con estas implementaciones, podemos realizar:
v = Vector2D(3, 4)
# Multiplicación por escalar
v_doble = v * 2 # Vector2D(6, 8)
print(v_doble) # (6, 8)
# Multiplicación en orden inverso
v_triple = 3 * v # Vector2D(9, 12)
print(v_triple) # (9, 12)
# División por escalar
v_mitad = v / 2 # Vector2D(1.5, 2.0)
print(v_mitad) # (1.5, 2.0)
El valor NotImplemented
Observa que devolvemos NotImplemented
(no confundir con la excepción NotImplementedError
) cuando la operación no tiene sentido para los tipos proporcionados. Esto permite a Python buscar un método alternativo o lanzar un error apropiado.
Por ejemplo, si intentamos sumar un vector y un número:
v = Vector2D(3, 4)
# v + 5 # Esto lanzará TypeError: unsupported operand type(s) for +: 'Vector2D' and 'int'
Operadores aritméticos con asignación
Python también proporciona métodos dunder para operadores de asignación combinada como +=
, -=
, etc.:
class Vector2D:
# ... (código anterior) ...
def __iadd__(self, otro):
"""Implementa v1 += v2"""
if isinstance(otro, Vector2D):
self.x += otro.x
self.y += otro.y
return self
return NotImplemented
def __isub__(self, otro):
"""Implementa v1 -= v2"""
if isinstance(otro, Vector2D):
self.x -= otro.x
self.y -= otro.y
return self
return NotImplemented
La diferencia clave es que estos métodos modifican el objeto existente en lugar de crear uno nuevo:
v1 = Vector2D(3, 4)
v2 = Vector2D(1, 2)
# Sin __iadd__, esto crearía un nuevo objeto
# Con __iadd__, modifica v1 directamente
v1 += v2
print(v1) # (4, 6)
Operadores aritméticos adicionales
Python ofrece métodos dunder para todos los operadores aritméticos:
__floordiv__
: Implementa la división entera//
__mod__
: Implementa el módulo%
__pow__
: Implementa la potencia**
__matmul__
: Implementa la multiplicación de matrices@
(Python 3.5+)
Veamos un ejemplo con potencia y módulo:
class Complejo:
def __init__(self, real, imag):
self.real = real
self.imag = imag
def __str__(self):
if self.imag >= 0:
return f"{self.real} + {self.imag}i"
return f"{self.real} - {abs(self.imag)}i"
def __pow__(self, exponente):
"""Implementa c ** n para números enteros positivos"""
if not isinstance(exponente, int) or exponente < 0:
return NotImplemented
if exponente == 0:
return Complejo(1, 0)
resultado = Complejo(self.real, self.imag)
for _ in range(exponente - 1):
# Multiplicación de números complejos
real = resultado.real * self.real - resultado.imag * self.imag
imag = resultado.real * self.imag + resultado.imag * self.real
resultado.real, resultado.imag = real, imag
return resultado
def __abs__(self):
"""Implementa abs(c) - módulo del número complejo"""
return (self.real ** 2 + self.imag ** 2) ** 0.5
Uso de estos métodos:
c = Complejo(3, 4)
print(c) # 3 + 4i
# Potencia
c_cuadrado = c ** 2
print(c_cuadrado) # -7 + 24i
# Módulo (valor absoluto)
modulo = abs(c)
print(modulo) # 5.0
Operadores aritméticos reflexivos
Para cada operador aritmético, existe una versión "reflexiva" que se invoca cuando el operando izquierdo no sabe cómo manejar la operación. Estos métodos tienen nombres como __radd__
, __rsub__
, etc.
class Dinero:
def __init__(self, cantidad):
self.cantidad = cantidad
def __str__(self):
return f"${self.cantidad:.2f}"
def __add__(self, otro):
if isinstance(otro, Dinero):
return Dinero(self.cantidad + otro.cantidad)
elif isinstance(otro, (int, float)):
return Dinero(self.cantidad + otro)
return NotImplemented
def __radd__(self, otro):
"""Maneja: número + Dinero"""
# Delegamos a __add__
return self.__add__(otro)
Esto permite operaciones como:
precio = Dinero(19.99)
propina = 3.00
# Ambas operaciones funcionan gracias a __radd__
total1 = precio + propina # Dinero(22.99)
total2 = propina + precio # Dinero(22.99)
print(total1) # $22.99
print(total2) # $22.99
Caso práctico: Clase Fracción
Veamos un ejemplo más completo implementando una clase Fraccion
con soporte para operaciones aritméticas:
def mcd(a, b):
"""Calcula el máximo común divisor de a y b"""
while b:
a, b = b, a % b
return a
class Fraccion:
def __init__(self, numerador, denominador=1):
if denominador == 0:
raise ZeroDivisionError("El denominador no puede ser cero")
# Simplificamos la fracción
divisor = mcd(abs(numerador), abs(denominador))
# Manejamos el signo
if denominador < 0:
numerador, denominador = -numerador, -denominador
self.num = numerador // divisor
self.den = denominador // divisor
def __str__(self):
if self.den == 1:
return str(self.num)
return f"{self.num}/{self.den}"
def __add__(self, otro):
if isinstance(otro, Fraccion):
nuevo_num = self.num * otro.den + otro.num * self.den
nuevo_den = self.den * otro.den
return Fraccion(nuevo_num, nuevo_den)
elif isinstance(otro, int):
return self + Fraccion(otro)
return NotImplemented
def __sub__(self, otro):
if isinstance(otro, Fraccion):
nuevo_num = self.num * otro.den - otro.num * self.den
nuevo_den = self.den * otro.den
return Fraccion(nuevo_num, nuevo_den)
elif isinstance(otro, int):
return self - Fraccion(otro)
return NotImplemented
def __mul__(self, otro):
if isinstance(otro, Fraccion):
return Fraccion(self.num * otro.num, self.den * otro.den)
elif isinstance(otro, int):
return Fraccion(self.num * otro, self.den)
return NotImplemented
def __truediv__(self, otro):
if isinstance(otro, Fraccion):
return Fraccion(self.num * otro.den, self.den * otro.num)
elif isinstance(otro, int):
if otro == 0:
raise ZeroDivisionError("División por cero")
return Fraccion(self.num, self.den * otro)
return NotImplemented
# Métodos reflexivos
__radd__ = __add__
__rmul__ = __mul__
Ahora podemos realizar operaciones aritméticas con fracciones:
f1 = Fraccion(1, 2) # 1/2
f2 = Fraccion(2, 3) # 2/3
print(f1 + f2) # 7/6
print(f1 - f2) # -1/6
print(f1 * f2) # 1/3
print(f1 / f2) # 3/4
# Operaciones con enteros
print(f1 + 1) # 3/2
print(2 * f1) # 1/1 (simplificado a 1)
Consideraciones de diseño
Al implementar operadores aritméticos en tus clases, considera estas buenas prácticas:
- Consistencia matemática: Asegúrate de que tus operaciones sigan las reglas matemáticas esperadas.
- Inmutabilidad: Para objetos que representan valores matemáticos, es mejor crear nuevas instancias en lugar de modificar las existentes.
- Validación de tipos: Verifica que los operandos sean compatibles y devuelve
NotImplemented
cuando no lo sean. - Simplificación: Cuando sea apropiado, simplifica los resultados (como hicimos con las fracciones).
- Documentación: Documenta claramente el comportamiento esperado de cada operador.
La implementación de métodos dunder para operadores aritméticos hace que nuestras clases se integren de manera natural con el lenguaje Python, permitiendo escribir código más intuitivo y expresivo.
Comparación
Los métodos dunder de comparación permiten definir cómo se comportan nuestros objetos cuando los comparamos con otros usando operadores como ==
, <
, >
, etc. Esto nos da control total sobre la lógica de comparación, permitiendo que nuestras clases personalizadas se comporten de manera intuitiva en expresiones condicionales, ordenamientos y otras operaciones de comparación.
Python utiliza seis métodos dunder principales para implementar las operaciones de comparación:
__eq__
: Define el comportamiento del operador de igualdad (==
)__ne__
: Define el comportamiento del operador de desigualdad (!=
)__lt__
: Define el comportamiento del operador "menor que" (<
)__le__
: Define el comportamiento del operador "menor o igual que" (<=
)__gt__
: Define el comportamiento del operador "mayor que" (>
)__ge__
: Define el comportamiento del operador "mayor o igual que" (>=
)
Implementación básica de comparaciones
Veamos un ejemplo con una clase Temperatura
que almacena valores en grados Celsius:
class Temperatura:
def __init__(self, celsius):
self.celsius = celsius
def __str__(self):
return f"{self.celsius}°C"
def __eq__(self, otro):
"""Implementa: self == otro"""
if isinstance(otro, Temperatura):
return self.celsius == otro.celsius
elif isinstance(otro, (int, float)):
return self.celsius == otro
return NotImplemented
def __lt__(self, otro):
"""Implementa: self < otro"""
if isinstance(otro, Temperatura):
return self.celsius < otro.celsius
elif isinstance(otro, (int, float)):
return self.celsius < otro
return NotImplemented
Con esta implementación, podemos comparar objetos Temperatura
entre sí o con valores numéricos:
t1 = Temperatura(25)
t2 = Temperatura(30)
t3 = Temperatura(25)
# Comparaciones entre objetos
print(t1 == t3) # True
print(t1 == t2) # False
print(t1 < t2) # True
# Comparaciones con números
print(t1 == 25) # True
print(t2 > 28) # True
Optimización con functools.total_ordering
Implementar los seis métodos de comparación puede ser repetitivo. Python proporciona el decorador total_ordering
del módulo functools
que nos permite definir solo __eq__
y uno de los métodos de ordenación (generalmente __lt__
), y automáticamente implementa los demás:
from functools import total_ordering
@total_ordering
class Temperatura:
def __init__(self, celsius):
self.celsius = celsius
def __str__(self):
return f"{self.celsius}°C"
def __eq__(self, otro):
if isinstance(otro, Temperatura):
return self.celsius == otro.celsius
elif isinstance(otro, (int, float)):
return self.celsius == otro
return NotImplemented
def __lt__(self, otro):
if isinstance(otro, Temperatura):
return self.celsius < otro.celsius
elif isinstance(otro, (int, float)):
return self.celsius < otro
return NotImplemented
Ahora podemos usar todos los operadores de comparación, aunque solo implementamos dos métodos:
t1 = Temperatura(25)
t2 = Temperatura(30)
print(t1 <= t2) # True
print(t1 >= t2) # False
print(t1 != t2) # True
Comparaciones reflexivas
Al igual que con los operadores aritméticos, Python intenta la operación inversa cuando el operando izquierdo devuelve NotImplemented
. Sin embargo, para las comparaciones, no hay métodos reflexivos específicos como __req__
o __rlt__
. En su lugar, Python invierte la comparación y utiliza el método correspondiente.
Por ejemplo, si a < b
devuelve NotImplemented
, Python intentará b > a
.
Caso práctico: Comparación de versiones de software
Veamos un ejemplo más elaborado con una clase Version
que permite comparar versiones de software siguiendo el formato semántico (major.minor.patch):
@total_ordering
class Version:
def __init__(self, version_str):
# Dividimos la cadena de versión y convertimos a enteros
parts = version_str.split('.')
# Aseguramos que tenemos al menos 3 componentes
while len(parts) < 3:
parts.append('0')
self.major = int(parts[0])
self.minor = int(parts[1])
self.patch = int(parts[2])
def __str__(self):
return f"{self.major}.{self.minor}.{self.patch}"
def __repr__(self):
return f"Version('{self}')"
def __eq__(self, otro):
if not isinstance(otro, Version):
return NotImplemented
return (self.major, self.minor, self.patch) == (otro.major, otro.minor, otro.patch)
def __lt__(self, otro):
if not isinstance(otro, Version):
return NotImplemented
return (self.major, self.minor, self.patch) < (otro.major, otro.minor, otro.patch)
Esta implementación aprovecha el hecho de que las tuplas en Python se comparan elemento por elemento, lo que es perfecto para versiones semánticas:
v1 = Version("1.2.3")
v2 = Version("1.3.0")
v3 = Version("2.0.0")
v4 = Version("1.2.3")
# Comparaciones básicas
print(v1 == v4) # True
print(v1 < v2) # True
print(v2 < v3) # True
# Ordenamiento de versiones
versiones = [v3, v1, v2, v4]
versiones_ordenadas = sorted(versiones)
print([str(v) for v in versiones_ordenadas]) # ['1.2.3', '1.2.3', '1.3.0', '2.0.0']
Comparaciones personalizadas y contextuales
A veces, queremos comparar objetos de manera diferente según el contexto. Por ejemplo, podríamos querer comparar estudiantes por su calificación o por su nombre:
class Estudiante:
def __init__(self, nombre, calificacion):
self.nombre = nombre
self.calificacion = calificacion
def __str__(self):
return f"{self.nombre}: {self.calificacion}"
def __eq__(self, otro):
if not isinstance(otro, Estudiante):
return NotImplemented
# Dos estudiantes son iguales si tienen el mismo nombre
return self.nombre == otro.nombre
def __lt__(self, otro):
if not isinstance(otro, Estudiante):
return NotImplemented
# Por defecto, comparamos por calificación
return self.calificacion < otro.calificacion
# Métodos alternativos de comparación
def comparar_por_nombre(self, otro):
if not isinstance(otro, Estudiante):
return NotImplemented
return self.nombre < otro.nombre
Podemos usar estos métodos así:
e1 = Estudiante("Ana", 85)
e2 = Estudiante("Carlos", 78)
e3 = Estudiante("Berta", 92)
# Comparación por defecto (calificación)
print(e1 < e2) # False (85 no es menor que 78)
# Para ordenar por nombre, usamos una función key
estudiantes = [e1, e2, e3]
por_nombre = sorted(estudiantes, key=lambda e: e.nombre)
print([e.nombre for e in por_nombre]) # ['Ana', 'Berta', 'Carlos']
# Para ordenar por calificación
por_calificacion = sorted(estudiantes, key=lambda e: e.calificacion)
print([e.nombre for e in por_calificacion]) # ['Carlos', 'Ana', 'Berta']
Comparaciones ricas y hash
Si implementamos __eq__
, generalmente también deberíamos implementar __hash__
para que nuestros objetos puedan usarse como claves en diccionarios o elementos en conjuntos:
class Coordenada:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, otro):
if not isinstance(otro, Coordenada):
return NotImplemented
return self.x == otro.x and self.y == otro.y
def __hash__(self):
# El hash debe ser consistente con __eq__
return hash((self.x, self.y))
Con esta implementación, podemos usar nuestros objetos como claves:
c1 = Coordenada(3, 4)
c2 = Coordenada(3, 4) # Igual a c1
c3 = Coordenada(5, 6)
# Usamos coordenadas como claves en un diccionario
mapa = {
c1: "Tesoro",
c3: "Enemigo"
}
# Podemos buscar usando una coordenada equivalente
print(mapa[c2]) # "Tesoro"
Comparaciones con tolerancia
En aplicaciones científicas o de ingeniería, a menudo necesitamos comparar valores numéricos con cierta tolerancia:
class ValorNumerico:
def __init__(self, valor, tolerancia=1e-9):
self.valor = valor
self.tolerancia = tolerancia
def __str__(self):
return str(self.valor)
def __eq__(self, otro):
if isinstance(otro, ValorNumerico):
# Comparación con tolerancia
return abs(self.valor - otro.valor) <= self.tolerancia
elif isinstance(otro, (int, float)):
return abs(self.valor - otro) <= self.tolerancia
return NotImplemented
def __lt__(self, otro):
if isinstance(otro, ValorNumerico):
# Para < necesitamos asegurar que la diferencia es mayor que la tolerancia
return self.valor < otro.valor and not self.__eq__(otro)
elif isinstance(otro, (int, float)):
return self.valor < otro and abs(self.valor - otro) > self.tolerancia
return NotImplemented
Esto permite comparaciones más robustas para valores de punto flotante:
v1 = ValorNumerico(1.0000001)
v2 = ValorNumerico(1.0000002)
print(v1 == v2) # True (dentro de la tolerancia)
print(v1 == 1.0) # True
v3 = ValorNumerico(1.1)
print(v1 == v3) # False (fuera de la tolerancia)
print(v1 < v3) # True
Buenas prácticas para implementar comparaciones
Consistencia: Asegúrate de que tus comparaciones sean consistentes entre sí. Si
a == b
entonces no debería ser cierto quea < b
oa > b
.Transitividad: Si
a < b
yb < c
, entonces debería ser cierto quea < c
.Reflexividad: Un objeto siempre debe ser igual a sí mismo (
a == a
).Simetría: Si
a == b
entoncesb == a
.Manejo de tipos: Devuelve
NotImplemented
cuando compares con tipos incompatibles, en lugar de lanzar una excepción.Documentación: Documenta claramente la lógica de comparación, especialmente si no es obvia.
Eficiencia: Las comparaciones deben ser operaciones rápidas, ya que pueden usarse frecuentemente en ordenamientos y búsquedas.
La implementación adecuada de los métodos de comparación hace que nuestras clases se integren perfectamente con las funciones de ordenamiento de Python como sorted()
, min()
, max()
y con estructuras de datos ordenadas como heapq
o bisect
.
Contenedores
Los métodos dunder para contenedores permiten que nuestras clases personalizadas se comporten como las estructuras de datos integradas de Python (listas, diccionarios, etc.). Estos métodos nos dan la capacidad de crear objetos que se pueden indexar, iterar, y consultar por su longitud, haciendo que nuestras clases se integren perfectamente con el resto del ecosistema Python.
Cuando implementamos estos métodos, nuestros objetos pueden utilizarse con la sintaxis familiar de Python para acceder a elementos, verificar pertenencia y determinar el tamaño de la colección.
Métodos dunder fundamentales para contenedores
Los métodos más importantes para implementar un contenedor son:
__len__
: Define el comportamiento de la funciónlen()
__getitem__
: Define el acceso mediante índice o claveobjeto[clave]
__setitem__
: Define la asignación mediante índice o claveobjeto[clave] = valor
__delitem__
: Define la eliminación mediante índice o clavedel objeto[clave]
__contains__
: Define el comportamiento del operadorin
__iter__
: Define el comportamiento para iteración
Veamos un ejemplo básico con una clase Cartera
que almacena acciones:
class Cartera:
def __init__(self):
self.acciones = {} # Diccionario con símbolo -> cantidad
def __len__(self):
"""Devuelve el número de símbolos diferentes en la cartera"""
return len(self.acciones)
def __getitem__(self, simbolo):
"""Permite acceder a la cantidad de acciones con cartera['AAPL']"""
return self.acciones.get(simbolo, 0)
def __setitem__(self, simbolo, cantidad):
"""Permite asignar cantidades con cartera['AAPL'] = 10"""
if cantidad <= 0:
# Si la cantidad es cero o negativa, eliminamos el símbolo
if simbolo in self.acciones:
del self.acciones[simbolo]
else:
self.acciones[simbolo] = cantidad
def __delitem__(self, simbolo):
"""Permite eliminar símbolos con del cartera['AAPL']"""
if simbolo in self.acciones:
del self.acciones[simbolo]
else:
raise KeyError(f"Símbolo no encontrado: {simbolo}")
def __contains__(self, simbolo):
"""Permite verificar si un símbolo existe con 'AAPL' in cartera"""
return simbolo in self.acciones
def __iter__(self):
"""Permite iterar sobre los símbolos de la cartera"""
return iter(self.acciones)
Ahora podemos usar esta clase como un contenedor nativo de Python:
# Creamos una cartera y añadimos acciones
cartera = Cartera()
cartera['AAPL'] = 10
cartera['MSFT'] = 15
cartera['GOOG'] = 8
# Verificamos la longitud
print(len(cartera)) # 3
# Accedemos a elementos
print(cartera['AAPL']) # 10
print(cartera['TSLA']) # 0 (no existe, pero devuelve 0 en lugar de error)
# Verificamos pertenencia
print('MSFT' in cartera) # True
print('TSLA' in cartera) # False
# Iteramos sobre los símbolos
for simbolo in cartera:
print(f"{simbolo}: {cartera[simbolo]} acciones")
# Salida:
# AAPL: 10 acciones
# MSFT: 15 acciones
# GOOG: 8 acciones
# Eliminamos un símbolo
del cartera['GOOG']
print('GOOG' in cartera) # False
Implementando un contenedor de secuencia
Si queremos crear un contenedor que se comporte como una lista o tupla, necesitamos implementar __getitem__
para manejar índices enteros y slices:
class HistorialPrecios:
def __init__(self, simbolo, precios):
self.simbolo = simbolo
self.precios = list(precios) # Hacemos una copia de la lista
def __len__(self):
"""Devuelve el número de precios en el historial"""
return len(self.precios)
def __getitem__(self, indice):
"""Maneja historial[i] y historial[i:j]"""
if isinstance(indice, slice):
# Si es un slice, devolvemos un nuevo HistorialPrecios
return HistorialPrecios(self.simbolo, self.precios[indice])
else:
# Si es un índice, devolvemos el precio directamente
return self.precios[indice]
def __iter__(self):
"""Permite iterar sobre los precios"""
return iter(self.precios)
def __contains__(self, precio):
"""Verifica si un precio está en el historial"""
return precio in self.precios
def __str__(self):
return f"Historial de {self.simbolo}: {self.precios}"
Ahora podemos usar esta clase como una secuencia:
# Creamos un historial de precios
historial = HistorialPrecios('AAPL', [150.25, 152.75, 148.90, 155.30, 153.80])
# Accedemos por índice
print(historial[0]) # 150.25
print(historial[-1]) # 153.80
# Usamos slices
primera_mitad = historial[:3]
print(primera_mitad) # Historial de AAPL: [150.25, 152.75, 148.9]
# Verificamos longitud
print(len(historial)) # 5
# Verificamos pertenencia
print(152.75 in historial) # True
print(160.00 in historial) # False
# Iteramos sobre los precios
for precio in historial:
print(f"${precio:.2f}")
# Salida:
# $150.25
# $152.75
# $148.90
# $155.30
# $153.80
Implementando un contenedor mutable
Para crear un contenedor que permita modificar sus elementos, necesitamos implementar __setitem__
y posiblemente __delitem__
:
class Matriz2D:
def __init__(self, filas, columnas, valor_inicial=0):
self.filas = filas
self.columnas = columnas
# Creamos una matriz con el valor inicial
self.datos = [[valor_inicial for _ in range(columnas)] for _ in range(filas)]
def __getitem__(self, indices):
"""Permite acceder con matriz[i, j]"""
if isinstance(indices, tuple) and len(indices) == 2:
fila, columna = indices
if 0 <= fila < self.filas and 0 <= columna < self.columnas:
return self.datos[fila][columna]
else:
raise IndexError("Índices fuera de rango")
else:
raise TypeError("Se requieren dos índices: matriz[fila, columna]")
def __setitem__(self, indices, valor):
"""Permite asignar con matriz[i, j] = valor"""
if isinstance(indices, tuple) and len(indices) == 2:
fila, columna = indices
if 0 <= fila < self.filas and 0 <= columna < self.columnas:
self.datos[fila][columna] = valor
else:
raise IndexError("Índices fuera de rango")
else:
raise TypeError("Se requieren dos índices: matriz[fila, columna] = valor")
def __len__(self):
"""Devuelve el número total de elementos en la matriz"""
return self.filas * self.columnas
def __str__(self):
"""Representación en forma de matriz"""
return '\n'.join([' '.join([str(self.datos[i][j]) for j in range(self.columnas)])
for i in range(self.filas)])
Uso de esta matriz:
# Creamos una matriz 3x3
matriz = Matriz2D(3, 3)
# Asignamos valores
matriz[0, 0] = 1
matriz[1, 1] = 5
matriz[2, 2] = 9
# Accedemos a valores
print(matriz[1, 1]) # 5
# Mostramos la matriz completa
print(matriz)
# Salida:
# 1 0 0
# 0 5 0
# 0 0 9
# Verificamos la longitud (número total de elementos)
print(len(matriz)) # 9
Implementando iteradores personalizados
El método __iter__
debe devolver un iterador, que es un objeto con un método __next__
que devuelve el siguiente elemento o lanza StopIteration
cuando no hay más elementos:
class RangoInverso:
"""Clase que itera desde n hasta 1"""
def __init__(self, n):
self.n = n
def __iter__(self):
"""Devuelve un iterador"""
return RangoInversoIterador(self.n)
def __len__(self):
return self.n
def __contains__(self, item):
return isinstance(item, int) and 1 <= item <= self.n
class RangoInversoIterador:
def __init__(self, n):
self.actual = n
def __next__(self):
if self.actual < 1:
raise StopIteration
valor = self.actual
self.actual -= 1
return valor
Uso de este iterador personalizado:
# Creamos un rango inverso de 5 a 1
rango = RangoInverso(5)
# Verificamos longitud y pertenencia
print(len(rango)) # 5
print(3 in rango) # True
print(6 in rango) # False
# Iteramos sobre el rango
for num in rango:
print(num)
# Salida:
# 5
# 4
# 3
# 2
# 1
# También podemos usar list() para convertirlo a lista
print(list(rango)) # [5, 4, 3, 2, 1]
Implementando un contenedor de solo lectura
A veces queremos crear contenedores que no permitan modificaciones:
class RegistroInmutable:
def __init__(self, **kwargs):
# Almacenamos los valores en un diccionario interno
self._datos = kwargs
def __getitem__(self, clave):
"""Permite acceso mediante registro['nombre']"""
if clave in self._datos:
return self._datos[clave]
raise KeyError(f"Clave no encontrada: {clave}")
def __contains__(self, clave):
"""Permite verificar si una clave existe"""
return clave in self._datos
def __iter__(self):
"""Permite iterar sobre las claves"""
return iter(self._datos)
def __len__(self):
"""Devuelve el número de campos"""
return len(self._datos)
def __str__(self):
campos = [f"{k}={v}" for k, v in self._datos.items()]
return f"Registro({', '.join(campos)})"
# No implementamos __setitem__ ni __delitem__, por lo que no se pueden modificar
Uso de este registro inmutable:
# Creamos un registro de usuario
usuario = RegistroInmutable(id=1001, nombre="Ana García", email="ana@ejemplo.com")
# Accedemos a los campos
print(usuario['nombre']) # Ana García
# Verificamos si existen campos
print('email' in usuario) # True
print('telefono' in usuario) # False
# Iteramos sobre las claves
for campo in usuario:
print(f"{campo}: {usuario[campo]}")
# Salida:
# id: 1001
# nombre: Ana García
# email: ana@ejemplo.com
# Intentar modificar generará un error
# usuario['email'] = "nuevo@ejemplo.com" # AttributeError: 'RegistroInmutable' object has no attribute '__setitem__'
Caso práctico: Implementación de una cola de prioridad
Veamos un ejemplo más completo implementando una cola de prioridad simple:
class ColaPrioridad:
def __init__(self):
self._elementos = []
def agregar(self, item, prioridad):
"""Agrega un elemento con su prioridad (menor número = mayor prioridad)"""
self._elementos.append((prioridad, item))
# Mantenemos la lista ordenada por prioridad
self._elementos.sort(key=lambda x: x[0])
def __len__(self):
"""Devuelve el número de elementos en la cola"""
return len(self._elementos)
def __getitem__(self, indice):
"""Permite acceder a los elementos por índice"""
if 0 <= indice < len(self._elementos):
# Devolvemos solo el item, no la prioridad
return self._elementos[indice][1]
raise IndexError("Índice fuera de rango")
def __iter__(self):
"""Permite iterar sobre los elementos (sin sus prioridades)"""
for _, item in self._elementos:
yield item
def __contains__(self, item):
"""Verifica si un elemento está en la cola"""
return any(elemento == item for _, elemento in self._elementos)
def extraer(self):
"""Extrae el elemento de mayor prioridad"""
if not self._elementos:
raise IndexError("La cola está vacía")
return self._elementos.pop(0)[1]
def __str__(self):
if not self._elementos:
return "Cola vacía"
resultado = ["Cola de prioridad:"]
for prioridad, item in self._elementos:
resultado.append(f" Prioridad {prioridad}: {item}")
return "\n".join(resultado)
Uso de esta cola de prioridad:
# Creamos una cola de prioridad
cola = ColaPrioridad()
# Agregamos elementos con diferentes prioridades
cola.agregar("Tarea crítica", 1)
cola.agregar("Tarea normal", 3)
cola.agregar("Tarea urgente", 2)
cola.agregar("Tarea de baja prioridad", 5)
# Verificamos la longitud
print(len(cola)) # 4
# Accedemos por índice (ya ordenados por prioridad)
print(cola[0]) # Tarea crítica
print(cola[2]) # Tarea normal
# Verificamos si un elemento está en la cola
print("Tarea urgente" in cola) # True
print("Tarea cancelada" in cola) # False
# Iteramos sobre los elementos
print("Elementos en la cola:")
for tarea in cola:
print(f"- {tarea}")
# Salida:
# Elementos en la cola:
# - Tarea crítica
# - Tarea urgente
# - Tarea normal
# - Tarea de baja prioridad
# Extraemos elementos por orden de prioridad
print(cola.extraer()) # Tarea crítica
print(cola.extraer()) # Tarea urgente
# Verificamos el estado actual
print(cola)
# Salida:
# Cola de prioridad:
# Prioridad 3: Tarea normal
# Prioridad 5: Tarea de baja prioridad
Implementando un contenedor con acceso múltiple
Podemos crear contenedores que permitan diferentes formas de acceso a sus elementos:
class Estudiantes:
def __init__(self):
self._por_id = {}
self._por_nombre = {}
def agregar(self, id_estudiante, nombre, calificacion):
"""Agrega un estudiante a la colección"""
estudiante = {"id": id_estudiante, "nombre": nombre, "calificacion": calificacion}
# Almacenamos referencias al mismo diccionario
self._por_id[id_estudiante] = estudiante
self._por_nombre[nombre] = estudiante
def __getitem__(self, clave):
"""Permite acceder por ID (int) o por nombre (str)"""
if isinstance(clave, int):
# Acceso por ID
if clave in self._por_id:
return self._por_id[clave]
raise KeyError(f"ID no encontrado: {clave}")
elif isinstance(clave, str):
# Acceso por nombre
if clave in self._por_nombre:
return self._por_nombre[clave]
raise KeyError(f"Nombre no encontrado: {clave}")
else:
raise TypeError("La clave debe ser un ID (int) o un nombre (str)")
def __contains__(self, clave):
"""Verifica si existe un estudiante con el ID o nombre dado"""
if isinstance(clave, int):
return clave in self._por_id
elif isinstance(clave, str):
return clave in self._por_nombre
return False
def __len__(self):
"""Devuelve el número de estudiantes"""
return len(self._por_id)
def __iter__(self):
"""Itera sobre los datos de los estudiantes"""
return iter(self._por_id.values())
Uso de esta clase:
# Creamos una colección de estudiantes
estudiantes = Estudiantes()
# Agregamos algunos estudiantes
estudiantes.agregar(101, "Ana López", 85)
estudiantes.agregar(102, "Carlos Ruiz", 92)
estudiantes.agregar(103, "Elena Martín", 78)
# Accedemos por ID
print(estudiantes[101]["calificacion"]) # 85
# Accedemos por nombre
print(estudiantes["Carlos Ruiz"]["id"]) # 102
# Verificamos si existen estudiantes
print(103 in estudiantes) # True
print("Elena Martín" in estudiantes) # True
print("Juan Pérez" in estudiantes) # False
# Iteramos sobre todos los estudiantes
print("Lista de estudiantes:")
for estudiante in estudiantes:
print(f"ID: {estudiante['id']}, Nombre: {estudiante['nombre']}, Calificación: {estudiante['calificacion']}")
Buenas prácticas para implementar contenedores
Consistencia: Asegúrate de que tus métodos de contenedor se comporten de manera consistente con los contenedores estándar de Python.
Validación: Valida los índices y claves para evitar comportamientos inesperados.
Eficiencia: Implementa los métodos de manera eficiente, especialmente
__contains__
y__getitem__
que se usan frecuentemente.Inmutabilidad: Decide si tu contenedor debe ser mutable o inmutable y sé consistente con esa decisión.
Documentación: Documenta claramente cómo se debe acceder a los elementos y qué excepciones pueden lanzarse.
Iteración: Proporciona una forma eficiente de iterar sobre los elementos con
__iter__
.Manejo de errores: Lanza excepciones apropiadas como
IndexError
,KeyError
oTypeError
cuando sea necesario.
La implementación adecuada de los métodos dunder para contenedores permite que nuestras clases se integren perfectamente con el resto del ecosistema Python, haciendo que nuestro código sea más intuitivo y fácil de usar.
Ejercicios de esta lección Métodos especiales (dunder methods)
Evalúa tus conocimientos de esta lección Métodos especiales (dunder methods) con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.
Módulo math
Reto herencia
Excepciones
Introducción a Python
Reto variables
Funciones Python
Reto funciones
Módulo datetime
Reto acumulación
Reto estructuras condicionales
Polimorfismo
Módulo os
Reto métodos dunder
Diccionarios
Reto clases y objetos
Reto operadores
Operadores
Estructuras de control
Funciones lambda
Reto diccionarios
Reto función lambda
Encapsulación
Reto coleciones
Reto funciones auxiliares
Crear módulos y paquetes
Módulo datetime
Excepciones
Operadores
Diccionarios
Reto map, filter
Reto tuplas
Proyecto gestor de tareas CRUD
Tuplas
Variables
Tipos de datos
Conjuntos
Reto mixins
Módulo csv
Módulo json
Herencia
Análisis de datos de ventas con Pandas
Reto fechas y tiempo
Reto estructuras de iteración
Funciones
Reto comprehensions
Variables
Reto serialización
Módulo csv
Reto polimorfismo
Polimorfismo
Clases y objetos
Reto encapsulación
Estructuras de control
Importar módulos y paquetes
Módulo math
Funciones lambda
Reto excepciones
Listas
Reto archivos
Encapsulación
Reto conjuntos
Clases y objetos
Instalación de Python y creación de proyecto
Reto listas
Tipos de datos
Crear módulos y paquetes
Tuplas
Herencia
Reto acceso a sistema
Proyecto sintaxis calculadora
Importar módulos y paquetes
Clases y objetos
Módulo os
Listas
Conjuntos
Reto tipos de datos
Reto matemáticas
Módulo json
Todas las lecciones de Python
Accede a todas las lecciones de Python y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.
Introducción A Python
Introducción
Instalación Y Creación De Proyecto
Introducción
Tema 2: Tipos De Datos, Variables Y Operadores
Introducción
Instalación De Python
Introducción
Tipos De Datos
Sintaxis
Variables
Sintaxis
Operadores
Sintaxis
Estructuras De Control
Sintaxis
Funciones
Sintaxis
Estructuras Control Iterativo
Sintaxis
Estructuras Control Condicional
Sintaxis
Testing Con Pytest
Sintaxis
Listas
Estructuras De Datos
Tuplas
Estructuras De Datos
Diccionarios
Estructuras De Datos
Conjuntos
Estructuras De Datos
Comprehensions
Estructuras De Datos
Clases Y Objetos
Programación Orientada A Objetos
Excepciones
Programación Orientada A Objetos
Encapsulación
Programación Orientada A Objetos
Herencia
Programación Orientada A Objetos
Polimorfismo
Programación Orientada A Objetos
Mixins Y Herencia Múltiple
Programación Orientada A Objetos
Métodos Especiales (Dunder Methods)
Programación Orientada A Objetos
Composición De Clases
Programación Orientada A Objetos
Funciones Lambda
Programación Funcional
Aplicación Parcial
Programación Funcional
Entrada Y Salida, Manejo De Archivos
Programación Funcional
Decoradores
Programación Funcional
Generadores
Programación Funcional
Paradigma Funcional
Programación Funcional
Composición De Funciones
Programación Funcional
Funciones Orden Superior Map Y Filter
Programación Funcional
Funciones Auxiliares
Programación Funcional
Reducción Y Acumulación
Programación Funcional
Archivos Comprimidos
Entrada Y Salida Io
Entrada Y Salida Avanzada
Entrada Y Salida Io
Archivos Temporales
Entrada Y Salida Io
Contexto With
Entrada Y Salida Io
Módulo Csv
Biblioteca Estándar
Módulo Json
Biblioteca Estándar
Módulo Datetime
Biblioteca Estándar
Módulo Math
Biblioteca Estándar
Módulo Os
Biblioteca Estándar
Módulo Re
Biblioteca Estándar
Módulo Random
Biblioteca Estándar
Módulo Time
Biblioteca Estándar
Módulo Collections
Biblioteca Estándar
Módulo Sys
Biblioteca Estándar
Módulo Statistics
Biblioteca Estándar
Módulo Pickle
Biblioteca Estándar
Módulo Pathlib
Biblioteca Estándar
Importar Módulos Y Paquetes
Paquetes Y Módulos
Crear Módulos Y Paquetes
Paquetes Y Módulos
Entornos Virtuales (Virtualenv, Venv)
Entorno Y Dependencias
Gestión De Dependencias (Pip, Requirements.txt)
Entorno Y Dependencias
Python-dotenv Y Variables De Entorno
Entorno Y Dependencias
Acceso A Datos Con Mysql, Pymongo Y Pandas
Acceso A Bases De Datos
Acceso A Mongodb Con Pymongo
Acceso A Bases De Datos
Acceso A Mysql Con Mysql Connector
Acceso A Bases De Datos
Novedades Python 3.13
Características Modernas
Operador Walrus
Características Modernas
Pattern Matching
Características Modernas
Instalación Beautiful Soup
Web Scraping
Sintaxis General De Beautiful Soup
Web Scraping
Tipos De Selectores
Web Scraping
Web Scraping De Html
Web Scraping
Web Scraping Para Ciencia De Datos
Web Scraping
Autenticación Y Acceso A Recursos Protegidos
Web Scraping
Combinación De Selenium Con Beautiful Soup
Web Scraping
En esta lección
Objetivos de aprendizaje de esta lección
- Comprender la diferencia y uso de los métodos especiales str y repr para representar objetos.
- Implementar operadores aritméticos personalizados mediante métodos dunder como add, sub, mul, etc.
- Definir comparaciones personalizadas con métodos como eq, lt, y utilizar functools.total_ordering para simplificar.
- Crear clases que se comporten como contenedores mediante métodos como len, getitem, setitem, iter, y otros.
- Aplicar buenas prácticas en la implementación de métodos especiales para mejorar la usabilidad y depuración del código.