Metaprogramación en Python

Como los metadatos son datos sobre datos, la metaprogramación es escribir programas que manipulan programas. La percepción habitual es que los metaprogramas son los programas que generan otros programas. Pero, el paradigma es incluso más amplio. Todos los programas diseñados para leer, analizar, transformar o modificarse son ejemplo de metaprogramación. Algunos ejemplos incluyen:

  • Lenguajes específicos para el dominio (DSLs)
  • Analizadores
  • Interpretadores
  • Compiladores
  • Probadores de teoremas
  • Reescritores de términos

Este tutorial explora la metaprogramación en Python. Refresca sus conocimientos de Python con una revisión de sus funciones para que sea posible entender mejor los conceptos en este tutorial. También explica cómo el tipo en Python tiene más importancia que simplemente devolver la clase de un objeto. Después, discute las formas de utilizar la metaprogramación en Python y cómo puede simplificar determinadas tareas.

Un poco de introspección

Si lleva un tiempo programando en Python, es posible que sepa que todo es un objeto y que las clases crean objetos. Pero, si todo es un objeto (y las clases también son objetos), ¿quién crea las clases? Esta es exactamente la pregunta que respondo.

Verifiquemos si las declaraciones anteriores son siquiera correctas:

>>> class SomeClass:
...     pass
>>> someobject = SomeClass()
>>> type(someobj)
<__main.SomeClass instance at 0x7f8de4432f80>

Así que, la función type llamó a un objeto para devolver la clase de ese objeto.

>>> import inspect
>>>inspect.isclass(SomeClass)
True
>>>inspect.isclass(some_object)
False
>>>inspect.isclass(type(some_object))
True
                

inspect.isclass devuelve True si se le pasa una clase y False en caso contrario. Ya que some_object no es una clase (es una instancia de una clase SomeClass), devuelve False. Y, porque type(some_object) devuelve la clase de some_object, inspect.isclass(type(some_object)) devuelve True:

>>> type(SomeClass)
<type 'classobj'>>>>
inspect.isclass(type(SomeClass))
True

classobj es la clase que todas las clases de Python 3 devuelven de forma predeterminada. Ahora todo tiene sentido. Pero, qué ocurre con classobj? Animemos un poco las cosas:

>>> type(type(SomeClass))
<type 'type'>
>>>inspect.isclass(type(type(SomeClass)))
True
>>>type(type(type(SomeClass)))
<type 'type'>
>>>isclass(type(type(type(SomeClass))))
True

Origen, ¿eh? Resulta que la primera declaración de todas (que todo es objeto) no era completamente cierta. Esta es una declaración mejor:

En Python todo es un objeto y una instancia de clase o una instancia de metaclase, excepto el tipo.

Para verificar esto:

>>> some_obj = SomeClass()
>>> isinstance(some_obj,SomeClass)
True
>>> isinstance(SomeClass, type)
True

Así que, sabemos que una instancia es una clase de instanciación y una clase es una instancia de una metaclase.

type no es lo que creemos que es

type es una clase y su propio tipo. Es una metaclase. Una metaclase instancia y define el comportamiento para una clase, igual que una clase instancia y define el comportamiento de una instancia.

type es la metaclase incorporada que Python utiliza. Para cambiar el comportamiento de las clases en Python (como el comportamiento de SomeClass), podemos definir una metaclase personalizada mediante la herencia de la metaclase type . Las metaclases son una forma de metaprogramar en Python.

¿Qué ocurre cuando se define una clase?

Primero, repasemos lo que ya sabemos. Los bloques de construcción básicos de un programa Python son:

  • Declaraciones
  • Funciones
  • Clases

Las declaraciones realizan el trabajo real de un programa. Las declaraciones se pueden ejecutar en el ámbito global (a nivel de módulo) o en el local (anexadas dentro de una función). Las funciones son unidades de tipo fundamental de código que están formadas por una o más declaraciones ordenadas de una manera para lograr realizar una tarea específica. Las funciones se pueden definir a nivel de módulo o como un método de clases. Las clases proporcionan a las funciones una forma de programar orientada a objetos. Definen cómo los objetos se van a instanciar y sus características (atributos y métodos).

Los espacios de nombres de las clases se estratifican como diccionarios. Por ejemplo:

 
>>> class SomeClass:
...     classvar = 1
...     def init(self):
...         self.somevar = 'Some value'

>>> SomeClass.dict
{'doc': None,
 'init': <function main.init>,
 'module': 'main',
 'class_var': 1}

>>> s = SomeClass()

>>> s.__dict
{'some_var': 'Algún valor'}

Esto es lo que ocurre cuando se encuentra la clase palabra clave:

  • Se aísla el cuerpo (declaraciones y funciones) de la clase.
  • Se crea el espacio de nombres del diccionario de la clase (pero no se rellena aún).
  • Se ejecuta el cuerpo de la clase, después el espacio de nombres del diccionario se rellena con todos los atributos, métodos definidos y algo de información útil adicional acerca de la clase.
  • La metaclase se identifica en las clases base de los ganchos de la metaclase (se explica posteriormente) de la clase que se va a crear.
  • Después, se llama a la metaclase con el nombre, las bases y los atributos de la clase para instanciarla.

Y, porque type es la metaclase predeterminada en Python. Es posible utilizar type para crear clases en Python.

El otro lado de type

type, cuando se llama con un argumento, produce la información de type de una clase existente. Cuando se llama a type con tres argumentos se crea un nuevo objeto de clase. Cuando se invoca a type, los argumentos son el nombre de la clase, una lista de clases base, y un diccionario que proporciona el espacio de nombres para la clase (todos los campos y métodos).

Así que:
>>> class SomeClass: pass

Equivale a:
>>> SomeClass = type('SomeClass', (), {})

y:

class ParentClass:
    pass

class SomeClass(ParentClass):
    some_var = 5
    def some_function(self):
        print("Hello!")

Equivale efectivamente a:

def some_function(self):
    print("Hello")

ParentClass = type('ParentClass', (), {})
SomeClass = type('SomeClass',
                 [ParentClass],
                 {'some_function': some_function,
                  'some_var':5})

Así que, al utilizar nuestra metaclase predeterminada en vez de type, podemos inyectar algún comportamiento en las clases en las que no podría haber sido posible. Pero, antes de que empecemos a implementar las metaclases para alterar el comportamiento, veamos algunas formas habituales de metaprogramar en Python.

Decoradores: un ejemplo habitual de metaprogramación en Python

Los decoradores son una forma de cambiar los comportamientos de una función o una clase. Los decoradores se utilizan de una forma parecida a esta:

@some_decorator
def some_func(∗args, ∗∗kwargs):
    pass

@some_decorator sólo es el azúcar sintáctico para representar que some_func está envuelta por otra función some_decorator. Sabemos que las funciones, y las clases (excepto las de tipo metaclase), son objetos en Python, lo que significa que pueden ser:

  • Asignadas a una variable
  • Copiadas
  • Pasadas como parámetros a otras funciones

La estructura sintáctica anterior equivale de forma efectiva a:
some_func = some_decorator(some_func)

Es posible que se esté preguntando cómo se define some_decorator :

def some_decorator(f):
    """
    El decorador recibe una función como parámetro.
    """
    def wrapper(∗args, ∗∗kwargs):
        #doing something before calling the function
        f(∗args, ∗∗kwargs)
        #doing something after the function is called
    return wrapper

Imaginemos que tenemos una función que extrae datos de una URL. El servidor del que extraemos los datos tiene un mecanismo de aceleración que activa si detecta que muchas solicitudes vienen de una dirección IP que está dentro del mismo intervalo. Así que, para hacer que nuestro extractor parezca humano, vamos a hacer que espere una cantidad de tiempo aleatoria antes de enviar las solicitudes para engañar al servidor. ¿Podemos crear un decorador que haga eso? Veamos:

from functools import wraps
import random
import time

def wait_random(min_wait=1, max_wait=30):
    def inner_function(func):
        @wraps(func)
        def wrapper(∗args, ∗∗kwargs):
            time.sleep(random.randint(min_wait, max_wait))
            return func(∗args, ∗∗kwargs)

        return wrapper

    return inner_function

@wait_random(10, 15)
def function_to_scrape():
    #hacer algo de extracción

Es posible que la inner_function y el decorador @wraps sean nuevos para usted. Si mira atentamente, inner_function es análoga al some_decorator que definimos anteriormente. La otra capa envolvente de wait_random nos permite pasar parámetros al decorador, además de (min_wait y max_wait). @wraps es un buen decorador que copia los metadatos de func (como el nombre, la cadena de caracteres del documento y los atributos de la función). Sino los usamos, no podremos obtener resultados útiles de las llamadas a la función como help(func) porque, en ese caso, devuelve la cadena de caracteres del documento y la información de wrapper en vez de func.

Pero, y si tenemos una clase scraper con varias funciones como:

class Scraper:
    def func_to_scrape_1(self):
        #some scraping stuff
        pass
    def func_to_scrape_2(self):
        #some scraping stuff
        pass
    def func_to_scrape_3(self):
        #some scraping stuff
        pass

Una opción es envolver individualmente todas las funciones con @wait_random . Pero lo podemos hacer mejor: podemos crear un decorador de clase. La idea es recorrer el espacio de nombres de la clase, identificar las funciones y envolverlas con nuestro decorador.

def classwrapper(cls):
    for name, val in vars(cls).items():
        #callable return True if the argument is callable
        #i.e. implements the __call
        if callable(val):
            #instead of val, wrap it with our decorator.
            setattr(cls, name, wait_random()(val))
    return cls

Ahora es posible envolver toda la clase con @classwrapper. Pero ¿y si hay varias clases extractoras o varias subclases del scraper?? Puede utilizar @classwrapper en ellas individualmente o, en este escenario, puede es posible crear una metaclase.

Metaclases

Para crear metaclases personalizadas hacen falta dos pasos:

  1. Escribir una subclase del tipo metaclase.
  2. Insertar la nueva metaclase en el proceso de creación de clases utilizando el gancho metaclase.

Hacemos que la clase de tipo sea subclase y modificamos los métodos mágicos como __init__, __new__, __prepare__y desde __call__ para modificar el comportamiento de las clases mientras las creamos. Estos métodos tienen información como la clase base, el nombre de la clase, los atributos y sus valores. En Python 2, el gancho de la metaclase es un campo estático de la clase llamado __metaclass__. En Python 3, es posible especificar la metaclase como un argumento metaclass de la lista de clases base de una clase.

>>> class CustomMetaClass(type):
...     def init(cls, name, bases, attrs):    
...         for name, value in attrs.items():
                #do some stuff
...             print('{} :{}'.format(name, value))
>>> class SomeClass:
...          #the Python 2.x way
...         metaclass = CustomMetaClass
...         classattribute = "Some string"
module :main
metaclass :<class '_main.CustomMetaClass'>
class_attribute :Some string

Los atributos son impresos automáticamente debido a la declaración para imprimir del método __init__ de nuestra metaclase CustomMetaClass. Imaginemos que en su proyecto de Python usted tiene un colaborador irritante que prefiere utilizar camelCase para colocar los nombres de los atributos y métodos de las clases. Usted sabe que no es lo correcto, y que el colaborador debería utilizar snake_case (al fin y al cabo, ¡es Python!). ¿Podemos escribir una metaclase para cambiar todos esos atributos de camelCase a snake_case?

def camelto_snake(name):
    """
    Una función que convierte camelCase a snake_case.
    Referencia: https://stackoverflow.com/questions/1175208/elegant‑python‑function‑to‑convert‑camelcase‑to‑snake‑case
    """
    import re
    s1 = re.sub('(.)([A‑Z][a‑z]+)', r'\1\2', name)
    return re.sub('([a‑z0‑9])([A‑Z])', r'\1\2', s1).lower()

class SnakeCaseMetaclass(type):
    def _new(snakecase_metaclass, future_class_name,
                future_class_parents, future_class_attr):
        snakecase_attrs = {}
        for name, val in future_class_attr.items():
            snakecase_attrs[camel_to_snake(name)] = val
        return type(future_class_name, future_class_parents,
                    snakecase_attrs)

Es posible que se pregunte por qué aquí estamos utilizando __new__ en vez de __init__. __new__ de hecho, es el primer paso para crear una instancia. Es responsable de devolver una nueva instancia de su clase. __init__, por otra parte, no devuelve nada. Solo es responsable de inicializar la instancia después de haberla creado. Una regla general fácil de recordar: utilice new cuando tenga que controlar la creación de una instancia nueva; use init cuando tenga que controlar la inicialización de una instancia nueva.

Usted no verá a menudo que __init__ se implementa en una metaclase porque no es muy potente — De hecho, se llama a la clase que se crea antes que __init__. Es posible verlo como un decorador de clase con la diferencia que __init__ se ejecutaría cuando se están creando subclases, mientras que los decoradores de clase no se llaman para las subclases.

Debido a que nuestra tarea implicaba la creación de una instancia nueva (lo que evitaba que esos atributos camelCase entrasen sigilosamente en la clase), hay que sobrescribir el método __new__ en nuestra SnakeCaseMetaClass personalizada. Vamos a confirmar que funciona:


>>> class SomeClass(metaclass=SnakeCaseMetaclass):
...     camelCaseVar = 5
>>> SomeClass.camelCaseVar
AttributeError: type object 'SomeClass' has no attribute 'camelCaseVar'
>>> SomeClass.camel_case_var
5

¡Funciona! Ahora sabe cómo escribir y utilizar una metaclase en Python. Descubramos lo que es posible hacer con esto.

Cómo utilizar las metaclases en Python

Es posible utilizar las metaclases para hacer cumplir diferentes directrices en los atributos, métodos y en sus valores. Algunos ejemplos similares en línea con el ejemplo anterior (utilizando snake_case) son:

  • Restringiéndola restricción de dominio de los valores
  • Conversión implícita de valores a clases personalizadas (es posible que usted quiera ocultar todas estas complejidades de los usuarios que están escribiendo la clase)
  • Hacer cumplir diferentes convenciones de nomenclatura y directrices de estilo (como «todos los métodos deben tener una cadena de caracteres de documento»)
  • Añadir nuevos atributos a una clase

La principal razón para utilizar metaclases en vez de definir toda esta lógica en las propias definiciones de la clase es evitar la repetición de código a lo largo de toda la base de código.

Utilización de las metaclases en el mundo real

Debido a que las metaclases se heredan entre las subclases, solucionan un problema práctico de redundancia de código (No te repitas — DRY, por sus siglas en inglés). Las metaclases también ayudan a abstraer la lógica compleja de la creación de clases, normalmente realizando acciones extra o añadiendo código adicional mientras se producen los objetos de la clase. Algunos casos de uso de la utilización de las metaclases en el mundo real son:

  • Clases base abstractas
  • Registro de clases
  • Crear APIs en bibliotecas e infraestructuras

Veamos algunos ejemplos de cada uno de ellos.

Clases base abstractas

Las clases base abstractas son clases cuyo propósito es ser heredadas y no instanciadas. Python tiene las siguientes:

from abc import ABCMeta, abstractmethod

class Vehicle(metaclass=ABCMeta):

    @abstractmethod
    def refill_tank(self, litres):
        pass

    @abstractmethod
    def move_ahead(self):
        pass

Vamos a crear una clase Truck que herede de la clase Vehículo:

class Truck(Vehicle):
    def init(self, company, color, wheels):
        self.company = company
        self.color = color
        self.wheels = wheels

    def refill_tank(self, litres):
        pass

    def move_ahead(self):
        pass

Observe que no hemos implementado los métodos abstractos. Veamos lo que ocurre si intentamos instanciar un objeto de nuestra clase Truck:

>>> mini_truck = Truck("Tesla Roadster", "Black", 4)

TypeError: Can't instantiate abstract class Truck with abstract methods move_ahead, refill_tank

Esto se puede corregir definiendo los dos métodos abstractos en nuestra clase Truck :

class Truck(Vehicle):
    def init(self, company, color, wheels):
        self.company = company
        self.color = color
        self.wheels = wheels

    def refilltank(self, litres):
        pass

    def moveahead(self):
        pass
>>> mini_truck = Truck("Tesla Roadster", "Black", 4)
>>> mini_truck
<__main.Truck at 0x7f881ca1d828>

Registro de clases

Para entender esto, vamos a tomar un ejemplo de varios manejadores de archivos en algún servidor. La idea es poder encontrar la clase de manejador adecuada rápidamente basándonos en el formato del archivo. Crearemos un diccionario de manejadores y dejaremos que nuestra CustomMetaclass registre los diferentes manejadores que se encuentren en el código:

handlers = {}

class CustomMetaclass(type):
    def new(meta, name, bases, attrs):
        cls = type.new(meta, name, bases, attrs)
        for ext in attrs["files"]:
            handlers[ext] = cls
        return cls

class Handler(metaclass=CustomMetaclass):
    formats =     #código habitual para todo tipo de manejadores de formatos de archivos


class ImageHandler(Handler):
    formats = "jpeg", "png"
class AudioHandler(Handler):
    formats = "mp3", "wav">>> handlers
{'mp3': main.AudioHandler,
 'jpeg': main.ImageHandler,
 'png': main.ImageHandler,
 'wav': main.AudioHandler}

Ahora podemos saber fácilmente qué clase de manejador debemos usar basándonos en el formato del archivo. Hablando de forma genérica, cuando tenga que mantener algún tipo de estructura de datos que almacene las características de las clases, es posible utilizar las metaclases.

Crear APIs

Debido a su capacidad de prevenir la redundancia de la lógica entre las subclases y a su capacidad de esconder la lógica de la creación de las clases personalizadas que los usuarios no necesitan saber, las metaclases se utilizan extensamente en las infraestructuras y bibliotecas. Esto presenta oportunidades interesantes para reducir el texto repetitivo y tener una API mejor. Por ejemplo, analice la utilización de este fragmento de código del ORM de Django:


>>> from from django.db import models
>>> class Vehicle(models.Model):
...    color = models.CharField(max_length=10)
...    wheels = models.IntegerField()

Aquí, creamos una clase Vehicle heredada de la clase models.Model en un paquete de Django. Dentro de la clase definimos un par de campos (color y wheels) para representar las características de un vehículo. Ahora, vamos a intentar instanciar un objeto de la clase que acabamos de crear.


>>> four_wheeler = Vehicle(color="Blue", wheels="Four")
#Raises an error
>>> four_wheeler = Vehicle(color="Blue", wheels=4)
>>> four_wheeler.wheels
4

Como somos un usuario que está creando un modelo para un Vehicle, sólo teníamos que heredar de la clase models.Model y escribir algunas declaraciones de alto nivel. El resto del trabajo (como crear un gancho en la base de datos, lanzar un error para los valores no válidos, devolver un tipo int en vez de models.IntegerField) se realizó de forma oculta por la clase model.Models y por la metaclase que esta utilizó.

Resumiendo

En este tutorial ha visto la relación entre las instancias, clases y metaclases en Python. Aprendió metaprogramación, que es una forma de manipular código. Hablamos sobre los decoradores de funciones y los decoradores de clases como una forma de inyectar comportamiento personalizado en las clases y métodos. Después, continuamos implementado nuestras metaclases personalizadas por medio de la subclasificación de la metaclase de tipo predeterminado de Python. Finalmente, vimos algunos casos de uso de metaclases en el mundo real. La pregunta de si se deben utilizar las metaclases es un asunto muy discutido online, pero ahora será más fácil analizar y responder si algún problema se resuelve mejor por medio de la metaprogramación.