Métodos Dunder: Las gemas ocultas de Python | de Federico Zabeo | noviembre de 2024

Ejemplos del mundo real sobre cómo el uso activo de métodos especiales puede simplificar la codificación y mejorar la legibilidad.

Los métodos Dunder, aunque posiblemente sean un tema básico en Python, son algo que a menudo he notado que solo se entienden superficialmente, incluso por personas que han estado codificando durante bastante tiempo.

Descargo de responsabilidad: Esta es una brecha perdonable, ya que en la mayoría de los casos, el uso activo de métodos Dunder “simplemente” acelera y estandariza tareas que se pueden realizar de manera diferente. Incluso cuando su uso es esencial, los programadores a menudo no son conscientes de que están escribiendo métodos especiales que pertenecen a la categoría más amplia de métodos Dunder.

De todos modos, si codificas en Python y no estás familiarizado con este tema, o si eres un experto en código intrigado por los aspectos más nativos de un lenguaje de programación como yo, este artículo podría ser lo que estás buscando.

Las apariencias pueden engañar… ¡incluso en Python!

Si hay algo que aprendí en mi vida es que no todo es lo que parece a primera vista, y Python no es una excepción.

Foto por Robert Katzki en desempaquetar

Consideremos un ejemplo aparentemente simple:

class EmptyClass:
pass

Esta es la clase personalizada “más vacía” que podemos definir en Python, ya que no definimos atributos ni métodos. Está tan vacío que uno pensaría que no se puede hacer nada con él.

Sin embargo, este no es el caso. Por ejemplo, Python no se quejará si intentas crear una instancia de esta clase o incluso comparar dos instancias para determinar la igualdad:

empty_instance = EmptyClass()
another_empty_instance = EmptyClass()

>>> empty_instance == another_empty_instance
False

Por supuesto, esto no es magia. Simplemente, aprovechando un estándar objeto interfaz, cualquier objeto en Python hereda algunos atributos y métodos predeterminados que permiten al usuario tener siempre un conjunto mínimo de posibles interacciones con él.

Si bien estos métodos pueden parecer ocultos, no son invisibles. Para acceder a los métodos disponibles, incluidos los asignados por el propio Python, basta con utilizar el directorio() función incorporada. Para nuestra clase vacía, obtenemos:

>>> dir(EmptyClass)
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__',
'__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__',
'__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__',
'__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__',
'__str__', '__subclasshook__', '__weakref__']

Son estos métodos los que pueden explicar el comportamiento que observamos anteriormente. Por ejemplo, dado que la clase en realidad tiene un __inicio__ método no debería sorprendernos que podamos crear una instancia de un objeto de la clase.

Conozca los métodos Dunder

Todos los métodos mostrados en el último resultado pertenecen al grupo especial de, adivina qué, métodos tontos. El término “dunder” es la abreviatura de guión bajo doble y se refiere a los guiones bajos dobles al principio y al final de los nombres de estos métodos.

Son especiales por varias razones:

  1. Están integrados en cada objeto.: cada objeto Python está equipado con un conjunto específico de métodos dunder determinados por su tipo.
  2. Se invocan implícitamente.: muchos métodos de Dunder se activan automáticamente mediante interacciones con los operadores nativos de Python o funciones integradas. Por ejemplo, comparar dos objetos con == equivale a llamar a su __eq__ método.
  3. son personalizables: puede anular los métodos dunder existentes o definir otros nuevos para sus clases para darles un comportamiento personalizado y al mismo tiempo preservar su invocación implícita.

Para la mayoría de los desarrolladores de Python, el primer error que encuentran es __inicio__el método constructor. Este método se llama automáticamente cuando crea una instancia de una clase, utilizando la sintaxis familiar MiClase(*args, **kwargs) como un atajo para llamar explícitamente MiClase.__init__(*args, **kwargs).

A pesar de ser el más utilizado, __inicio__ También es uno de los métodos Dunder más especializados. No muestra completamente la flexibilidad y el poder de los métodos dunder, que pueden permitirle redefinir cómo sus objetos interactúan con las características nativas de Python.

Hacer bonito un objeto

Definamos una clase que represente un artículo a la venta en una tienda y creemos una instancia del mismo especificando el nombre y el precio.

class Item:
def __init__(self, name: str, price: float) -> None:
self.name = name
self.price = price

item = Item(name="Milk (1L)", price=0.99)

¿Qué sucede si intentamos mostrar el contenido del artículo ¿variable? En este momento, lo mejor que puede hacer Python es decirnos qué tipo de objeto es y dónde está asignado en la memoria:

>>> item
<__main__.Item at 0x00000226C614E870>

¡Intentemos obtener un resultado más informativo y bonito!

Foto por Estudios Shamblen en desempaquetar

Para hacer eso, podemos anular el __repr__ dunder, cuyo resultado será exactamente lo que se imprime al escribir una instancia de clase en la consola interactiva de Python, pero también, tan pronto como el otro método dunder __cadena__ no se anula: al intentar una imprimir() llamar.

Nota: es una práctica común tener __repr__ proporcione la sintaxis necesaria para recrear la instancia impresa. Entonces, en ese último caso esperamos que la salida sea Artículo(nombre=”Leche (1L)”, precio=0,99).

class Item:
def __init__(self, name: str, price: float) -> None:
self.name = name
self.price = price

def __repr__(self) -> str:
return f"{self.__class__.__name__}('{self.name}', {self.price})"

item = Item(name="Milk (1L)", price=0.99)

>>> item # In this example it is equivalent also to the command: print(item)
Item('Milk (1L)', 0.99)

Nada especial, ¿verdad? Y tendrías razón: podríamos haber implementado el mismo método y llamarlo mi_repr_personalizado sin entrar en métodos indo dunder. Sin embargo, si bien cualquiera entiende inmediatamente lo que queremos decir con imprimir (artículo) o simplemente artículo¿podemos decir lo mismo de algo como item.my_custom_repr()?

Definir la interacción entre un objeto y los operadores nativos de Python.

Imaginemos que queremos crear una nueva clase.Tienda de comestiblesque nos permite construir una colección de Artículo junto con sus cantidades.

En este caso, podemos utilizar métodos dunder para permitir algunas operaciones estándar como:

  1. Agregar una cantidad específica de Artículo hacia Tienda de comestibles usando el + operador
  2. Iterando directamente sobre el Tienda de comestibles clase usando un para bucle
  3. Acceder a un artículo específico de la clase Comestibles usando el soporte [] notación

Para lograrlo, haremos definir (eso ya lo vemos una clase genérica no tiene estos métodos por defecto) los métodos dunder __agregar__, __iter__ y __obtenerelemento__ respectivamente.

from typing import Optional, Iterator
from typing_extensions import Self

class Grocery:

def __init__(self, items: Optional[dict[Item, int]] = None):
self.items = items or dict()

def __add__(self, new_items: dict[Item, int]) -> Self:

new_grocery = Grocery(items=self.items)

for new_item, quantity in new_items.items():

if new_item in new_grocery.items:
new_grocery.items[new_item] += quantity
else:
new_grocery.items[new_item] = quantity

return new_grocery

def __iter__(self) -> Iterator[Item]:
return iter(self.items)

def __getitem__(self, item: Item) -> int:

if self.items.get(item):
return self.items.get(item)
else:
raise KeyError(f"Item {item} not in the grocery")

Inicialicemos un Tienda de comestibles instancia e imprimir el contenido de su atributo principal, elementos.

item = Item(name="Milk (1L)", price=0.99)
grocery = Grocery(items={item: 3})

>>> print(grocery.items)
{Item('Milk (1L)', 0.99): 3}

Luego, utilizamos el + operador para agregar un nuevo artículo y verificar que los cambios hayan tenido efecto.

new_item = Item(name="Soy Sauce (0.375L)", price=1.99)
grocery = grocery + {new_item: 1} + {item: 2}

>>> print(grocery.items)
{Item('Milk (1L)', 0.99): 5, Item('Soy Sauce (0.375L)', 1.99): 1}

Amable y explícito, ¿verdad?

El __iter__ El método nos permite recorrer un Tienda de comestibles objeto siguiendo la lógica implementada en el método (es decir, implícitamente el bucle iterará sobre los elementos contenidos en el atributo iterable elementos).

>>> print([item for item in grocery])
[Item('Milk (1L)', 0.99), Item('Soy Sauce (0.375L)', 1.99)]

De manera similar, el acceso a los elementos se maneja definiendo el __obtenerelemento__ tonto:

>>> grocery[new_item]
1

fake_item = Item("Creamy Cheese (500g)", 2.99)
>>> grocery[fake_item]
KeyError: "Item Item('Creamy Cheese (500g)', 2.99) not in the grocery"

En esencia, asignamos algunos comportamientos estándar similares a los de un diccionario a nuestra clase Grocery y al mismo tiempo permitimos algunas operaciones que no estarían disponibles de forma nativa para este tipo de datos.

Mejore la funcionalidad: haga que las clases sean invocables por simplicidad y potencia.

Concluyamos esta inmersión profunda en los métodos Dunder con un ejemplo final que muestra cómo pueden ser una herramienta poderosa en nuestro arsenal.

Foto por Marek Studzinski en desempaquetar

Imaginemos que hemos implementado una función que realiza cálculos lentos y deterministas basados ​​en una determinada entrada. Para simplificar las cosas, como ejemplo usaremos una función de identidad con un tiempo.dormir de algunos segundos.

import time 

def expensive_function(input):
time.sleep(5)
return input

¿Qué sucede si ejecutamos la función dos veces en la misma entrada? Bueno, en este momento el cálculo se ejecutaría dos veces, lo que significa que obtendremos dos veces el mismo resultado esperando dos veces durante todo el tiempo de ejecución (es decir, un total de 10 segundos).

start_time = time.time()

>>> print(expensive_function(2))
>>> print(expensive_function(2))
>>> print(f"Time for computation: {round(time.time()-start_time, 1)} seconds")
2
2
Time for computation: 10.0 seconds

¿Tiene esto sentido? ¿Por qué deberíamos hacer el mismo cálculo (que conduce al mismo resultado) para la misma entrada, especialmente si es un proceso lento?

Una posible solución es “envolver” la ejecución de esta función dentro del __llamar__ método dunder de una clase.

Esto hace que las instancias de la clase sean invocables como funciones, lo que significa que podemos usar la sintaxis sencilla. mi_instancia_clase(*args, **kwargs) – al mismo tiempo que nos permite usar atributos como caché para reducir el tiempo de cálculo.

Con este enfoque también tenemos la flexibilidad de crear múltiples procesos (es decir, instancias de clase), cada uno con su propia caché local.

class CachedExpensiveFunction:

def __init__(self) -> None:
self.cache = dict()

def __call__(self, input):
if input not in self.cache:
output = expensive_function(input=input)
self.cache[input] = output
return output
else:
return self.cache.get(input)

start_time = time.time()
cached_exp_func = CachedExpensiveFunction()

>>> print(cached_exp_func(2))
>>> print(cached_exp_func(2))
>>> print(f"Time for computation: {round(time.time()-start_time, 1)} seconds")
2
2
Time for computation: 5.0 seconds

Como era de esperar, la función se almacena en caché después de la primera ejecución, lo que elimina la necesidad del segundo cálculo y, por lo tanto, reduce el tiempo total a la mitad.

Como se mencionó anteriormente, incluso podemos crear instancias separadas de la clase, cada una con su propio caché, si es necesario.

start_time = time.time()
another_cached_exp_func = CachedExpensiveFunction()

>>> print(cached_exp_func(3))
>>> print(another_cached_exp_func (3))
>>> print(f"Time for computation: {round(time.time()-start_time, 1)} seconds")
3
3
Time for computation: 10.0 seconds

¡Aquí estamos! Un truco de optimización simple pero poderoso posible gracias a los métodos dunder que no solo reduce los cálculos redundantes sino que también ofrece flexibilidad al permitir el almacenamiento en caché local específico de la instancia.

Mis consideraciones finales

Los métodos Dunder son un tema amplio y en constante evolución, y este escrito no pretende ser un recurso exhaustivo sobre el tema (para este propósito, puede consultar el 3. Modelo de datos: documentación de Python 3.12.3).

Mi objetivo aquí era más bien explicar claramente qué son y cómo pueden usarse de manera efectiva para manejar algunos casos de uso comunes.

Si bien es posible que no sean obligatorios para todos los programadores todo el tiempo, una vez que entendí bien cómo funcionan, marcaron una gran diferencia para mí y espero que también funcionen para usted.

De hecho, los métodos de Dunder son una forma de evitar reinventar la rueda. También se alinean estrechamente con la filosofía de Python, lo que genera un código más conciso, legible y compatible con las convenciones. Y eso nunca está de más, ¿verdad?