Todo programador de Python que trabaje creando (y, en menor medida, también utilizando) clases debe estar al tanto de los «métodos mágicos». Son aquellos que comienzan y terminan con doble guión bajo; ya estarás al tanto de algunos, como __init__()
, que no están pensados para ser invocados manualmente sino que son llamados por Python en situaciones particulares (por ejemplo, cuando se realiza una comparación entre dos instancias vía los operadores >
, <
, ==
, etc. o bien cuando se ejecuta alguna operación aritmética). Estaremos trabajando con varios de ellos y de paso daremos un vistazo a las propiedades, que sirven para encapsular atributos de una clase.
Un conocimiento más o menos básico sobre las clases en general y cómo funcionan en Python en particular sería una buena base para seguir este artículo. Si no estás muy familiarizado con estos conceptos, te invito a chequear el artículo sobre Clases y orientación a objetos y la sección de Clases en nuestro tutorial del lenguaje.
Primeros pasos
Iremos aprendiendo por ejemplos. Así que trabajaremos con una clase que llamaré Time
, que contendrá tres atributos: horas, minutos y segundos. Nuestro objetivo no es representar una hora del día en particular, sino una unidad de tiempo en general, de modo que permitiremos valores mayores a 24 horas y menores a 0.
class Time: def __init__(self, h=0, m=0, s=0): self.h = h self.m = m self.s = s
Hasta aquí tenemos una primera definición de nuestra clase, que toma los argumentos que mencionamos recién y los guarda en la instancia para que puedan ser accedidos como atributos. Probémosla: intentemos representar las 14 horas con 23 minutos y 10 segundos.
a = Time(14, 23, 10) print(a.h, a.m, a.s) # 14 23 10
Los argumentos tienen el valor cero por defecto; así, también es válido:
# Media hora. b = Time(m=30) print(b.h, b.m, b.s) # 0 30 0
Ahora bien, sería interesante poder imprimir directamente cualquier instancia de Time
y que nos muestre sus tres atributos. Si hacemos eso ahora, veremos que el resultado se ve más o menos así.
print(a) # <__main__.Time object at 0x027C6430>
Lo cual no nos dice mucho del objeto más que su dirección de memoria, lo cual no es muy relevante. Modifiquemos eso definiendo el método __repr__()
debajo del inicializador.
def __repr__(self): return f"<Time {self.h:02}:{self.m:02}:{self.s:02}>"
(Si tienes dudas sobre la sintaxis empleada aquí para constituir la cadena véase Formando cadenas de caracteres).
El resultado de este método será retornado por la función str()
al intentar convertir una instancia de nuestra clase a una cadena, que es justamente lo que hace print()
. Por ello, si ahora imprimimos nuestro objeto, veremos una representación más clara.
print(a) # <Time 14:23:10>
¡Excelente! Ya implementamos nuestro primer método mágico. Volveremos sobre ellos más adelante.
Propiedades
Las propiedades nos permiten encapsular atributos dentro de una clase. ¿Cuál es su propósito? Bien, consideremos lo siguiente.
a = Time(14, 23, 10) a.m = "Hello, world!" print(a) # ValueError!
Aquí básicamente el problema es que no hay nada que evite que se asigne una cadena a un atributo que se espera sea un número entero. Para solucionarlo vamos a emplear el decorador incorporado property()
, que nos permite controlar de qué forma se obtiene y se altere el valor de un atributo. Lo haremos para los tres atributos que nos competen.
Empecemos por las horas. Lo primero que debemos hacer es definir una función que será llamada por Python cuando se intente acceder al valor de nuestro atributo.
@property def h(self): return self._h
De modo que print(a.h)
imprimirá el valor retornado por el método h()
. Pero aún no hemos definido el atributo _h
, esto es, el valor que queremos encapsular para evitar que le sea asignado otro tipo de dato que no sea un entero. Entonces, lo siguiente es definir otra función que será llamada cuando se asigne un nuevo valor al atributo h
.
@h.setter def h(self, value): self._h = value
Así, hacer a.h = 50
será equivalente a invocar a la función anterior. Esto nos permite establecer restricciones en los valores asignados, por ejemplo, chequear su tipo de dato.
@h.setter def h(self, value): if not isinstance(value, int): raise TypeError("An integer is required.") self._h = value
Comprobemos, entonces, que ahora el siguiente código lanza un error.
a = Time(14, 23, 10) a.h = "Hello, world!" # TypeError
Y lo mismo ocurrirá si los argumentos no son del tipo correcto, por cuanto el método __init__()
se ocupa de asignar los valores pasados a sus respectivos atributos.
a = Time("Hello, world!", 23, 10) # TypeError
Hagamos, ahora, esto mismo para los otros dos atributos, y creemos un decorador adicional que se ocupe de chequear que siempre el argumento value
sea un entero.
from functools import wraps def _int_required(f): @wraps(f) def wrapper(self, value): if not isinstance(value, int): raise TypeError("An integer is required.") return f(self, value) return wrapper class Time: # [...] @property def h(self): return self._h @h.setter @_int_required def h(self, value): self._h = value @property def m(self): return self._m @m.setter @_int_required def m(self, value): self._m = value @property def s(self): return self._s @s.setter @_int_required def s(self, value): self._s = value
¡Genial! Ya tenemos el problema del tipo de dato solucionado.
Ahora ocupémonos de este otro tema. Sabemos que 60 segundos equivalen a un minuto y que 60 minutos equivalen a una hora. Debemos asegurar que nuestra clase se encargue de hacer el balance de los datos de forma automática: por ejemplo, convertir 80 segundos en 1 minuto y 20 segundos, asignando los atributos correspondientes. Para ello añadamos un balance en los métodos invocados cuando se asigna a los atributos m
y s
(setters).
@m.setter @_int_required def m(self, value): self._m = value self._h, self._m = _balance(self._h, self._m) # [...] @s.setter @_int_required def s(self, value): self._s = value self._m, self._s = _balance(self._m, self._s)
La función _balance()
la crearemos antes de la definición de la clase.
def _balance(a, b): if b >= 0: while b >= 60: a += 1 b -= 60 elif b < 0: while b < 0: a -= 1 b += 60 return a, b
Evito la explicación sobre cómo opera la función pues no es el tema central del artículo. Sí comprobemos que ahora el balance ocurre en forma automática.
a = Time(2, 80, 95) print(a) # <Time 03:21:35>
Operaciones aritméticas
Sería útil que podamos sumar y restar instancias de Time
usando los operadores +
y -
. Python nos permite implementarlo de una forma muy sencilla: definiendo los métodos __add__()
y __sub__()
, respectivamente. De igual forma ─aunque no lo haremos aquí pues no nos sirve a nuestro propósito─ están disponibles __mul__()
y __truediv__()
para la multiplicación (*
) y la división (/
).
Comencemos por la adición.
def __add__(self, other): h = self.h + other.h m = self.m + other.m s = self.s + other.s return Time(h, m, s)
La lógica es bastante fácil: si a
y b
son dos instancias de Time
, al hacer a + b
Python devolverá el resultado de a.__add__(b)
.
a = Time(2, 16, 48) b = Time(3, 51, 22) print(a + b) # <Time 06:08:10>
Para la resta el procedimiento es similar.
def __sub__(self, other): h = self.h - other.h m = self.m - other.m s = self.s - other.s return Time(h, m, s)
También podemos expresarlo de una forma más funcional del siguiente modo.
import operator # [...] def _operation(self, other, method): h = method(self.h, other.h) m = method(self.m, other.m) s = method(self.s, other.s) return Time(h, m, s) def __add__(self, other): return self._operation(other, operator.add) def __sub__(self, other): return self._operation(other, operator.sub)
(El módulo estándar operator
define funciones que actúan del mismo modo que los operadores de Python, por ejemplo, operator.add(a, b)
es igual a a + b
).
Ahora bien, para evitar que se intente sumar o restar una instancia de Time
con un objeto de cualquier otro tipo, debemos chequear el tipo de dato del argumento. Creemos un decorador para ello.
def _time_required(f): @wraps(f) def wrapper(self, other): if not isinstance(other, Time): raise TypeError("Can only operate on Time objects.") return f(self, other) return wrapper
Y apliquémoslo a los dos métodos que acabamos de crear.
@_time_required def __add__(self, other): return self._operation(other, operator.add) @_time_required def __sub__(self, other): return self._operation(other, operator.sub)
Ejemplo:
a = Time(2, 16, 48) print(a + 10) # TypeError
¡Perfecto! Veamos a continuación cómo permitir comparaciones entre dos instancias de nuestra clase.
Comparaciones
Los métodos mágicos para cada una de las operaciones de comparación son los siguientes.
__lt__()
paraa < b
.__gt__()
paraa > b
.__le__()
paraa <= b
.__ge__()
paraa >= b
.__ne__()
paraa != b
.__eq__()
paraa == b
.
Implementemos primero el último, que es más sencillo. Sabemos que una instancia de Time
será igual a otra cuando coincidan las horas, los minutos y los segundos.
@_time_required def __eq__(self, other): return (self.h == other.h and self.m == other.m and self.s == other.s)
Algunas pruebas:
print(Time(2, 16, 48) == Time(2, 16, 48)) # True print(Time(2, 16, 48) == Time(8, 16, 21)) # False
¡Sencillo! Ahora sigamos con la operación a < b
. En este caso, a
será menor a b
si a.h
< b.h
. Si las horas son iguales, debemos chequear los minutos. Y si estos son iguales, los segundos.
@_time_required def __lt__(self, other): if self.h < other.h: return True if self.h > other.h: return False if self.m < other.m: return True if self.m > other.m: return False return self.s < other.s
Otras pruebas:
print(Time(2, 16, 48) < Time(2, 16, 48)) # False print(Time(2, 16, 48) < Time(8, 16, 21)) # True
Bien. Podríamos definir el resto de los métodos de comparación tal como hemos hecho con estos. Pero dado que las otras operaciones pueden inferirse a partir de estas dos que definimos (por ejemplo, a != b
es igual a not a == b
), no es necesario que lo hagamos manualmente. El decorador functools.total_ordering()
se encargará de ello.
from functools import total_ordering, wraps # [...] @total_ordering class Time: # [...]
¡Listo! Ya con eso tenemos la definición de todos los operadores de comparación.
a = Time(2, 16, 48) b = Time(8, 16, 21) print(a == b) # False print(a != b) # True print(a > b) # False print(a < b) # True print(a >= b) # False print(a <= b) # True
Código completo
¡Hemos llegado al final! Muchos otros son los métodos mágicos disponibles en Python; la lista completa está en la documentación oficial. A continuación el código al que hemos arribado a lo largo del artículo.
#!/usr/bin/env python # -*- coding: utf-8 -*- import operator from functools import total_ordering, wraps def _time_required(f): @wraps(f) def wrapper(self, other): if not isinstance(other, Time): raise TypeError("Can only operate on Time objects.") return f(self, other) return wrapper def _int_required(f): @wraps(f) def wrapper(self, value): if not isinstance(value, int): raise TypeError("An integer is required.") return f(self, value) return wrapper def _balance(a, b): if b >= 0: while b >= 60: a += 1 b -= 60 elif b < 0: while b < 0: a -= 1 b += 60 return a, b @total_ordering class Time: def __init__(self, h=0, m=0, s=0): self.h = h self.m = m self.s = s @property def h(self): return self._h @h.setter @_int_required def h(self, value): self._h = value @property def m(self): return self._m @m.setter @_int_required def m(self, value): self._m = value self._h, self._m = _balance(self._h, self._m) @property def s(self): return self._s @s.setter @_int_required def s(self, value): self._s = value self._m, self._s = _balance(self._m, self._s) def _operation(self, other, method): h = method(self.h, other.h) m = method(self.m, other.m) s = method(self.s, other.s) return Time(h, m, s) @_time_required def __add__(self, other): return self._operation(other, operator.add) @_time_required def __sub__(self, other): return self._operation(other, operator.sub) @_time_required def __eq__(self, other): return (self.h == other.h and self.m == other.m and self.s == other.s) @_time_required def __lt__(self, other): if self.h < other.h: return True if self.h > other.h: return False if self.m < other.m: return True if self.m > other.m: return False return self.s < other.s def __repr__(self): return f"<Time {self.h:02}:{self.m:02}:{self.s:02}>"
Curso online 👨💻
¡Ya lanzamos el curso oficial de Recursos Python en Udemy!
Un curso moderno para aprender Python desde cero con programación orientada a objetos, SQL y tkinter
en 2024.
Consultoría 💡
Ofrecemos servicios profesionales de desarrollo y capacitación en Python a personas y empresas. Consultanos por tu proyecto.