DEV Community

Cover image for Python: gamberradas con __getattr__ y amigos

Python: gamberradas con __getattr__ y amigos

Supongamos el siguiente (inocente) código:

class Personaje:
    def __init__(self, nombre):
        self.__nombre = nombre
    ...

    @property
    def nombre(self):
        return self.__nombre
    ...
...
Enter fullscreen mode Exit fullscreen mode

Tenemos entonces definido un personaje con una propiedad nombre. Poca cosa, vamos.

Podríamos crear un método Personaje.di(msg: str), que devolviera un mensaje pasado por parámetro formateado adecuadamente. Evidentemente, podríamos crear directamente dicho método, pero eso... es aburrido.

Tenemos la posibilidad de utilizar __getattr__(item: str), que se ejecuta cuando se intenta invocar un miembro cualquiera de la clase (independientemente de que sea un método o atributo), y este miembro no existe. Se podría decir que es un último recurso antes de que el runtime del lenguaje lance la excepción AttributeError (recordemos que en notación Python, todos los miembros son atributos, los métodos solo se distinguen por devolver True a la función callable()).

La idea es que, cuando se invoque algo como p1 = Personaje("Homer"); p1.di("¡Bart! ¿Qué has hecho?"), nuestro método __getattr__() entrará en acción y devolverá la respuesta adecuada.

class Personaje:
    def __init__(self, nombre):
        self.__nombre = nombre
    ...

    @property
    def nombre(self):
        return self.__nombre
    ...

    def __getattr__(self, nombre_miembro):
        if nombre_miembro == "di":
            return lambda msg: f"{self.nombre}: {msg}"
        ...

        raise AttributeError(f"{self.__class__.__name__}.{nombre_miembro}??")
    ...
...


if __name__ == "__main__":
    homer = Personaje("Homer Simpson")
    print(homer.di("¡Bart! ¿Qué has hecho?"))
...
Enter fullscreen mode Exit fullscreen mode

Hay que tener en cuenta un par de cuestiones. Cuando se ejecuta __getattr__(), si no reconocemos el miembro que se intenta invocar, se espera que se invoque AttributeError con algo parecido a "el atributo x no existe".

La segunda cuestión es: ¿por qué se devuelve una lambda? Para responder a esta pregunta tenemos que analizar cómo se realizan las llamadas a los métodos en Python. Así, con la instrucción homer.di("¡Bart!..."), en realidad __getattr__ solo se involucra hasta homer.di, es decir, la búsqueda de di en el objeto homer, y nada más. Cuando se retorna el método, es cuando se le aplica el argumento (el mensaje), es decir, ("¡Bart!..."). Como ilustración, si Python obtiene una función de homer.di, vamos a suponer que se llama f_di. Bien, pues el siguiente paso será f_di("¡Bart!...").

Así, tenemos que simular este comportamiento, y por eso devolvemos una lambda que va a comportarse, al fin y al cabo, como si el método di() existiera realmente, y por tanto esta lambda tomará el papel de ese f_di explicado arriba, teniendo que comportarse de la misma forma.

Si el método especial __getattr__ se ejecuta cuando un miembro no es encontrado, __getattribute__ se ejecuta siempre que se invoca a un miembro, independientemente de si es encontrado. Si deseamos interferir en la invocación, podemos hacerlo con este método. En otro caso, la "dejamos pasar", invocando a __getattribute__ en object.

Supongamos que queremos crear un personaje como Maggie Simpson (sí, el bebé). Realmente, no habla, así que queramos lo que queramos que diga, siempre va a hacer el ruido del chupete: "Chup, chup".

class Personaje:
    def __init__(self, nombre):
        self.__nombre = nombre
    ...

    @property
    def nombre(self):
        return self.__nombre
    ...

    def __getattr__(self, nombre_miembro):
        if nombre_miembro == "di":
            return lambda msg: f"{self.nombre}: {msg}"
        ...

        raise AttributeError(f"{self.__class__.__name__}.{nombre_miembro}??")
    ...

    def __getattribute__(self, nombre_miembro):
        if nombre_miembro == "di":
            nombre = self.nombre
            if nombre.lower().startswith("maggie"):
                return lambda msg: f"{self.nombre}: Chup, chup"
            ...
        ...

        return super().__getattribute__(nombre_miembro)
    ...
...

if __name__ == "__main__":
    homer = Personaje("Homer Simpson")
    maggie = Personaje("Maggie Simpson")
    print(homer.di("¡Bart! ¿Qué has hecho?"))
    print(maggie.di("¡Tengo hambre!"))
...
Enter fullscreen mode Exit fullscreen mode

La ejecución muestra:

Maggie Simpson: Chup, chup
Homer Simpson: ¡Bart! ¿Qué has hecho?
Enter fullscreen mode Exit fullscreen mode

Devolvemos una lambda en __getattribute__ que acepta un parámetro como mensaje, pero que realmente ignora, devolviendo la descripción del ruido del chupete.

Por cierto, en ambas funciones hay que tener mucho cuidado al llamar a métodos de la clase, puesto que podemos caer en una llamada recursiva infinita (hasta que el número de llamadas recursivas reviente el stack).

Como resumen, podemos asumir por tanto que mientras __getattribute__ permite interferir en el proceso normal de una invocación a un miembro de la clase, __getattr__ permite añadirle funcionalidad a la clase.

Ahora algo más serio. ¿Para qué podemos emplear estos dos métodos especiales? Un ejemplo podría ser el inyectar en una clase comprobaciones de precondiciones, postcondiciones e invariantes. En el siguiente ejemplo, emplearemos __getattribute__ para comprobar una invariante de clase en un método específico.

Supongamos la siguiente clase Conjunto.

class Conjunto:
    """Representa una lista de elementos que no se repiten."""
    def __init__(self):
        self.__conj = []
    ...

    def __add(self, val):
        self.__conj.append(val)
    ...

    def __str__(self):
        return str.join(", ", [str(x) for x in self.__conj])
    ...

    def __chk_invariante(self):
        for i, x in enumerate(self.__conj):
            cdr = self.__conj[i + 1:]
            if x in cdr:
                raise ValueError(f"{x} repetido en: {cdr.index(x) + i + 1}")
            ...
        ...
    ...
...
Enter fullscreen mode Exit fullscreen mode

Podemos crear un método __getattribute__ que "obligue" a llamar a la invariante de clase __chk_invariante(), tras invocar add(x) para añadir x al conjunto.

class Conjunto:
    # más cosas...

    def __getattr__(self, str_m):
        if str_m == "add":
            return lambda val: (self.__add(val), self.__chk_invariante())[0]
        ...

        raise AttributeError(f"{self.__class__.__name__}.{str_m}??")
    ...
Enter fullscreen mode Exit fullscreen mode

Lo cierto es que aquí recurrimos a un truco para poder ejecutar ambas funciones sin tener que modificar sus parámetros y retornos: se crea una tupla con los valores de retorno de ambas funciones (que terminarán siendo None, y que en realidad no nos importan en absoluto), para después devolver la primera de ellas, la que realmente añade el elemento. Pero al ser una tupla, también se ejecutará las siguiente a continuación, obteniendo el resultado deseado.

Así, si se ejecuta el siguiente código:

c1 = Conjunto()
c1.add(1)
c1.add(2)
c1.add(3)
print(c1)
Enter fullscreen mode Exit fullscreen mode

Se obtiene el resultado esperado de 1, 2, 3. Pero si en cambio se trata de ejecutar este otro código:

c1 = Conjunto()
c1.add(1)
c1.add(2)
c1.add(2)
print(c1)
Enter fullscreen mode Exit fullscreen mode

Lo que obtendremos será:

File conjunto.py line 69,
    c1.add(2)
File conjunto.py line 57,
    return lambda val: [self.__add(val), self.__chk_invariante()][0]
ValueError: 2 repetido en: 2
Enter fullscreen mode Exit fullscreen mode

¡Python mola! ¿A que sí?

Top comments (0)