Data model: metaclases de Python

written by Javier Cordero Martínez on 2016-11-02

En el primer articulo de este blog, intentaré explicar las notas y distintos artículos o documentación de referencia que he ido necesitando para comprender el funcionamiento de las metaclases y como se construyen las clases en Python

Posiblemente las metaclases sean un concepto algo confuso a la hora de intentar comprender como funciona el modelo de datos propio de Python, resumiendo, una metaclase es una clase encargada de crear otra clase en memoria, podemos simplificar esto, considerando a una metaclase como una factoría que crea nuevas clases que posteriormente serán instanciadas en objetos, una clase cuyas instancias son a su vez clases, será una metaclase:

Image

Vistazo rápido al modelo de datos

Para comprender la utilidad de las metaclases, creo que es necesario tener claro como se representan los datos en Python (más información en la documentación de referencia del lenguaje1.

Un concepto inicial a tener en cuenta es que todo en Python son objetos, los objetos son la abstracción de datos empleada en el lenguaje, en resumen, todos los datos de nuestro programa estarán representados por objetos y las relaciones entre ellos, por tanto, las clases son objetos.

Cada objeto está formado por identity, type y value:

>>> class Foobar:
...     pass
  • identity, es inmutable una vez un objeto ha sido creado, podemos acceder a este valor por medio de la función id() y el operador is lo que hace es comparar la identidad de dos objetos. El valor que puede tomar la identidad dependerá de la implementación de Python que estemos usando, en el caso de CPython, corresponde a la dirección de memoria donde está el objeto.
    >>> obj = Foobar()
    >>> id(obj)
    139809539258464
    
  • type, define que operaciones y que posibles valores puede tener el objeto, para conocerlo podemos usar la función type() que devuelve el tipo del objeto (siendo este otro objeto), conociendo las operaciones que soporta una clase, el interprete evita hacer operaciones que deriven en un comportamiento desconocido (por lo que podemos considerar que Python es type safe), en este caso, type es mutable, aunque es una practica no recomendada y deberíamos evitarlo.
    >>> type(obj)
    <class '__main__.FooClass'>
    
  • value, el valor de la instancia creada, podemos considerar dos tipos:
    • mutable, en caso de que pueda cambiar.
    • inmutable, en caso de que el valor no pueda cambiar una vez fuera creada la instancia. Es importante conocer que un contenedor inmutable que contiene objetos mutables, puede cambiarse el valor de los objetos que contiene, pero el contenedor seguirá considerándose inmutable porque la colección de objetos sigue igual.

Siempre se usan metaclases

En el ejemplo anterior hemos creado una clase y una instancia de la misma, como ya dijimos, las metaclases son las encargadas de crear las nuevas clases, entonces, ¿qué metaclase ha creado Foobar?, las clases son objetos, por lo tanto podemos ver la clase de la clase con isinstance y con el atributo __class__

>>> isinstance(obj, Foobar) 
True
>>> isinstance(Foobar, type)
True
>>> Foobar.__class__
<class 'type'>
>>> list.__class__
<class 'type'>
>>> object.__class__
<class 'type'>

Como podemos intuir y parece lógico al ser también objetos, todas las clases de Python tienen como clase a type, por tanto, todos las instancias que creemos de estas clases, también son objetos de la clase type, que sera la metaclase por defecto.

Image

Como nota, type tiene como constructores type(object) y type(name, bases, dict), ambos funciones built-in2 que podemos usar para obtener el tipo de un objeto y para crear clases en tiempo de ejecución:

>>> type(list)
<class 'type'>
>>> Foo2 = type('Foo2', (object,), dict())
>>> type(Foo2)
<class 'type'>

Preparando una clase

Una metaclase es usada para construir otra clase, ya hemos visto como crear nuevas clases usando type(), pero también sabemos que podemos usar class para definir nuestras clases, el funcionamiento de ambas es similar, aunque class ademas da un valor a __qualname__3, __doc__ y llama a __prepare__4 para configurar el namespace .

A la hora de crear una clase, podemos considerar los siguientes pasos 5:

  1. Se construye un diccionario con todos los atributos y métodos de la clase.
  2. Se determina la metaclase a usar (por defecto type), llamemosla Metaclass.
  3. Se ejecuta Metaclass(classname, bases, classdict), que devolverá la clase ya preparada:
    • classname es el nombre de la clase que estábamos definiendo.
    • bases es una tupla con la jerarquia de clases.
    • classdict es un diccionario con los atributos de clase que hemos definido.

Nuestra primera metaclase

Para crear una metaclase propia debemos crear una nueva clase y heredar de type

class CustomMeta(type):
    pass

Para indicar a una nueva clase que metaclase debe usar, encontramos diferencias entre Python 3.x y 2.x

Python 3

Pasaremos como parametro metaclass en la definición de la clase

class Foobar(metaclass=CustomMeta):
    pass
Python 2.7

Debemos definirla en el atributo __metaclass__

class Foobar:
    __metaclass__ = CustomMeta
Compatibilidad Python 2 - 3

En caso de que necesitemos que nuestro código funcione en ambas versiones, deberemos usar el paquete six6 y usaremos la funcion `six.with_metaclass()' :

import six

class Foobar(six.with_metaclass(CustomMeta):
    pass

Herencia con metaclases

Una clase heredada de otra, también hereda sus metaclases, siendo posible añadir otras metaclases, pero aquí tenemos una restricción, la herencia entre metaclases debe ser lineal, es decir, si Footwo hereda de Foobar (que usa CustomMeta) y Footwo debe usar otra metaclase, está debe ser hija de CustomMeta.

class CustomMeta(type):
    pass

class CustomTwo(type):
    pass

class Foobar(metaclass=CustomMeta):
    pass

class Footwo(Foobar, metaclass=CustomMeta):
    pass
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all its bases

Como vemos el fallo salta directamente al terminar de definir la clase, ya que al usar class, empieza a construir la clase en memoria. Manteniendo la linealidad en la herencia de metaclases, no tendremos problema

class CustomThree(CustomMeta):
    pass

class Foothree(Foobar, metaclass=CustomThree):
    pass

Dando funcionalidad a nuestra metaclase

Los llamados métodos mágicos7 son una característica propia de Python que permiten modificar facilmente el comportamiento de la clase ante determinadas operaciones, como pueden ser en comparaciones los metodos __lt__, __le__, puedes ver el listado completo en la referencia8.

En este articulo nos centraremos en los métodos implicados en la creación de clases, __call__, __prepare__, __new__ e __init__

__call__(cls, *args, **kwargs)

Para evitar confusiones, trataremos este método primero, es importante tener claro que no es llamado en la creación de la clase, es llamado cuando se quiere instanciar un nuevo objeto de la clase, su función es devolver un objeto ya listo de nuestra clase final (Foobar en el ejemplo):

class CustomMeta(type):
    def __call__(cls, *args, **kwargs):
        print('{0}.__call__ with args {1} kwargs {2}'.format(str(cls), str(args), str(kwargs)))
        return super().__call__(*args, **kwargs)

class Foobar(metaclass=CustomMeta):
    def __init__(self, a):
        self.a = a

foo = Foobar(1)
<class 'Foobar'>.__call__ with args (1,) kwargs {}

Existen dos momentos en los que se llama a este metodo:

  1. Creando la clase:

    • Cuando definimos una clase y Python procede a crearla en memoria, se llamará al metodo __call__ de la metaclase de la metaclase (es decir, type en el ejemplo de CustomMeta ), para posteriormente llamar a __prepare__, __new__ e __init__ de la metaclase (en el ejemplo, CustomMeta).
  2. Creando una instancia de la clase:

    • En este caso se llama a la metaclase de la clase (es decir, CustomMeta, ya que la clase es Foobar), para posteriormente llamar a __new__ e __init__ de la clase (en el ejemplo, Foobar).
__prepare__(metacls, name, bases)

Este método fue añadido en Python 39, su única función es crear un diccionario que será usado para contener el namespace de la clase, sus atributos, etc, por tanto debe devolver un dict (o un objeto que implemente __getitem__ y __setitem__), posteriormente este diccionario se usará para llamar a __new__

class CustomMeta(type):
    @classmethod
    def __prepare__(metacls, name, bases):
        print('{0}.__prepare__ with name {1} bases {2}'.format(str(metacls), str(name), str(bases)))
        return {}

class Foobar(metaclass=CustomMeta):
    def __init__(self, a):
        self.a = a

foo = Foobar(1)
<class '__main__.CustomMeta'>.__prepare__ with name Foobar bases ()
__new__(metacls, name, bases, attrs)

Quizás este método sea el que mas confusión crea al confundirlo con __init__ (inicializador), __new__ es el constructor, es decir, sera llamado al momento de crear una nueva instancia de la clase, metacls, en función de lo que devuelva habrá dos posibilidades:

  • si devuelve una instancia de la clase metacls, se llamará al método __init__ para proceder a inicializar el objeto.
  • si no se devuelve una instancia de la clase metacls, no se llamará al método __init__
class CustomMeta(type):
    def __new__(metacls, name, bases, attrs):
        print('{0}.__new__ with name {1} bases {2} attrs {3}'.format(str(metacls), str(name), str(bases), str(attrs)))
        new_klass = super().__new__(metacls, name, bases, attrs)  # now the class object is created
        print('{0} with methods {1}'.format(str(type(new_klass)), str(dir(new_klass)))) 
        return new_klass

class Foobar(metaclass=CustomMeta):
    def __init__(self, a):
        self.a = a

foo = Foobar(1)
<class '__main__.CustomMeta'>.__new__ with name Foobar bases () attrs {'__module__': '__main__', '__qualname__': 'Foobar', '__init__': <function Foobar.__init__ at 0x7f78661c18c8>}
<class '__main__.CustomMeta'> with methods ['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__']

attrs contiene toda la información para el namespace de la clase creado en __prepare__

__init__(self[, ...])

Llamado después de __new__, es decir, cuando el objeto ya fue creado, pero antes de ser devuelto por __call__, los parámetros que recibe son los usados en la llamada del constructor y su función es inicializar los atributos que tendra el objeto (recordemos que una clase es también un objeto), como se ve en los ejemplos anteriores.

Notas finales

He intentado resumir la creación de clases en Python, para tener una visión mas detallada recomiendo ver:

Tengo un pequeño ejemplo que hace uso de una metaclase muy simple para crear automáticamente un modelo que sirve de historico de cambios de otro modelo en Django, integrándose con el sistema de migraciones, tiene ya algún tiempo y no esta terminado, fue para una prueba técnica de un contrato probado con Django 1.8, disponible en https://github.com/jneight/django-changelog-models