Clases: métodos mágicos y propiedades

Clases: métodos mágicos y propiedades

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__() para a < b.
  • __gt__() para a > b.
  • __le__() para a <= b.
  • __ge__() para a >= b.
  • __ne__() para a != b.
  • __eq__() para a == 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.

Deja una respuesta