Metaclases en acción

Beginner

This tutorial is from open-source community. Access the source code

Introducción

En este laboratorio, aprenderás sobre las metaclases, una de las características más poderosas y avanzadas de Python. Las metaclases te permiten personalizar la creación de clases, lo que te da control sobre cómo se definen e instancian las clases. Explorarás las metaclases a través de ejemplos prácticos.

Los objetivos de este laboratorio son comprender qué son las metaclases y cómo funcionan, implementar una metaclase para resolver problemas reales de programación y explorar las aplicaciones prácticas de las metaclases en Python. Los archivos modificados en este laboratorio son structure.py y validate.py.

Comprender el problema

Antes de comenzar a explorar las metaclases, es importante entender el problema que pretendemos resolver. En programación, a menudo necesitamos crear estructuras con tipos específicos para sus atributos. En nuestro trabajo previo, desarrollamos un sistema para estructuras con comprobación de tipos. Este sistema nos permite definir clases donde cada atributo tiene un tipo específico, y los valores asignados a estos atributos se validan de acuerdo con ese tipo.

A continuación, se muestra un ejemplo de cómo usamos este sistema para crear una clase Stock:

from validate import String, PositiveInteger, PositiveFloat
from structure import Structure

class Stock(Structure):
    name = String()
    shares = PositiveInteger()
    price = PositiveFloat()

    @property
    def cost(self):
        return self.shares * self.price

    def sell(self, nshares: PositiveInteger):
        self.shares -= nshares

En este código, primero importamos los tipos de validadores (String, PositiveInteger, PositiveFloat) del módulo validate y la clase Structure del módulo structure. Luego definimos la clase Stock, que hereda de Structure. Dentro de la clase Stock, definimos atributos con tipos de validadores específicos. Por ejemplo, el atributo name debe ser una cadena, shares debe ser un entero positivo y price debe ser un número de punto flotante positivo.

Sin embargo, hay un problema con este enfoque. Necesitamos importar todos los tipos de validadores en la parte superior de nuestro archivo. A medida que agregamos más y más tipos de validadores en un escenario del mundo real, estas importaciones pueden volverse muy largas y difíciles de manejar. Esto podría llevarnos a usar from validate import *, lo cual generalmente se considera una mala práctica porque puede causar conflictos de nombres y hacer que el código sea menos legible.

Para entender nuestro punto de partida, echemos un vistazo a la clase Structure. Debes abrir el archivo structure.py en el editor y examinar su contenido. Esto te ayudará a ver cómo se implementa el manejo básico de estructuras antes de agregar la funcionalidad de metaclase.

code structure.py

Cuando abras el archivo, verás una implementación básica de la clase Structure. Esta clase es responsable de manejar la inicialización de atributos, pero aún no tiene ninguna funcionalidad de metaclase.

A continuación, examinemos las clases de validadores. Estas clases se definen en el archivo validate.py. Ya tienen funcionalidad de descriptor, lo que significa que pueden controlar cómo se accede y se establecen los atributos. Pero necesitaremos mejorarlas para resolver el problema de importación que discutimos anteriormente.

code validate.py

Al observar estas clases de validadores, tendrás una mejor comprensión de cómo funciona el proceso de validación y qué cambios necesitamos hacer para mejorar nuestro código.

Recopilación de tipos de validadores

En Python, los validadores son clases que nos ayudan a garantizar que los datos cumplan con ciertos criterios. Nuestra primera tarea en este experimento es modificar la clase base Validator para que pueda recopilar todas sus subclases. ¿Por qué necesitamos hacer esto? Bueno, al recopilar todas las subclases de validadores, podemos crear un espacio de nombres que contenga todos los tipos de validadores. Más adelante, inyectaremos este espacio de nombres en la clase Structure, lo que nos permitirá manejar y utilizar diferentes validadores de manera más sencilla.

Ahora, comencemos a trabajar en el código. Abre el archivo validate.py. Puedes usar el siguiente comando en la terminal para abrirlo:

code validate.py

Una vez abierto el archivo, necesitamos agregar un diccionario a nivel de clase y un método __init_subclass__() a la clase Validator. El diccionario a nivel de clase se utilizará para almacenar todas las subclases de validadores, y el método __init_subclass__() es un método especial en Python que se llama cada vez que se define una subclase de la clase actual.

Agrega el siguiente código a la clase Validator, justo después de la definición de la clase:

## Add this to the Validator class in validate.py
validators = {}  ## Dictionary to collect all validator subclasses

@classmethod
def __init_subclass__(cls):
    """Register each validator subclass in the validators dictionary"""
    Validator.validators[cls.__name__] = cls

Después de agregar el código, tu clase Validator modificada debería verse así:

class Validator:
    validators = {}  ## Dictionary to collect all validator subclasses

    @classmethod
    def __init_subclass__(cls):
        """Register each validator subclass in the validators dictionary"""
        Validator.validators[cls.__name__] = cls

    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return instance.__dict__[self.name]

    def __set__(self, instance, value):
        self.validate(value)
        instance.__dict__[self.name] = value

    def validate(self, value):
        pass

Ahora, cada vez que se defina un nuevo tipo de validador, como String o PositiveInteger, Python llamará automáticamente al método __init_subclass__(). Este método agregará la nueva subclase de validador al diccionario validators, utilizando el nombre de la clase como clave.

Probemos si nuestro código funciona. Crearemos un script de Python simple para verificar el contenido del diccionario validators. Puedes ejecutar el siguiente comando en la terminal:

python3 -c "from validate import Validator; print(Validator.validators)"

Si todo funciona correctamente, deberías ver una salida similar a esta, que muestra todos los tipos de validadores y sus clases correspondientes:

{'Typed': <class 'validate.Typed'>, 'Positive': <class 'validate.Positive'>, 'NonEmpty': <class 'validate.NonEmpty'>, 'String': <class 'validate.String'>, 'Integer': <class 'validate.Integer'>, 'Float': <class 'validate.Float'>, 'PositiveInteger': <class 'validate.PositiveInteger'>, 'PositiveFloat': <class 'validate.PositiveFloat'>, 'NonEmptyString': <class 'validate.NonEmptyString'>}

Ahora que tenemos un diccionario que contiene todos nuestros tipos de validadores, podemos usarlo en el siguiente paso para crear nuestra metaclase.

Creación de la metaclase StructureMeta

Ahora, hablemos de lo que vamos a hacer a continuación. Hemos encontrado una forma de recopilar todos los tipos de validadores. Nuestro siguiente paso es crear una metaclase. Pero, ¿qué es exactamente una metaclase? En Python, una metaclase es un tipo especial de clase. Sus instancias son clases en sí mismas. Esto significa que una metaclase puede controlar cómo se crea una clase. Puede gestionar el espacio de nombres donde se definen los atributos de la clase.

En nuestra situación, queremos crear una metaclase que haga disponibles los tipos de validadores cuando definamos una subclase de Structure. No queremos tener que importar estos tipos de validadores explícitamente cada vez.

Comencemos abriendo nuevamente el archivo structure.py. Puedes usar el siguiente comando para abrirlo:

code structure.py

Una vez abierto el archivo, necesitamos agregar algún código en la parte superior, antes de la definición de la clase Structure. Este código definirá nuestra metaclase.

from validate import Validator
from collections import ChainMap

class StructureMeta(type):
    @classmethod
    def __prepare__(meta, clsname, bases):
        """Prepare the namespace for the class being defined"""
        return ChainMap({}, Validator.validators)

    @staticmethod
    def __new__(meta, name, bases, methods):
        """Create the new class using only the local namespace"""
        methods = methods.maps[0]  ## Extract the local namespace
        return super().__new__(meta, name, bases, methods)

Ahora que hemos definido la metaclase, necesitamos modificar la clase Structure para usarla. De esta manera, cualquier clase que herede de Structure se beneficiará de la funcionalidad de la metaclase.

class Structure(metaclass=StructureMeta):
    _fields = []

    def __init__(self, *args, **kwargs):
        if len(args) > len(self._fields):
            raise TypeError(f'Expected {len(self._fields)} arguments')

        ## Set all of the positional arguments
        for name, val in zip(self._fields, args):
            setattr(self, name, val)

        ## Set the remaining keyword arguments
        for name, val in kwargs.items():
            if name not in self._fields:
                raise TypeError(f'Invalid argument: {name}')
            setattr(self, name, val)

    def __repr__(self):
        values = [getattr(self, name) for name in self._fields]
        args_str = ','.join(repr(val) for val in values)
        return f'{type(self).__name__}({args_str})'

Analicemos lo que hace este código:

  1. El método __prepare__() es un método especial en Python. Se llama antes de que se cree la clase. Su función es preparar el espacio de nombres donde se definirán los atributos de la clase. Aquí usamos ChainMap. ChainMap es una herramienta útil que crea un diccionario en capas. En nuestro caso, incluye nuestros tipos de validadores, haciéndolos accesibles en el espacio de nombres de la clase.

  2. El método __new__() es responsable de crear la nueva clase. Extraemos solo el espacio de nombres local, que es el primer diccionario en el ChainMap. Descartamos el diccionario de validadores porque ya hemos hecho que los tipos de validadores estén disponibles en el espacio de nombres.

Con esta configuración, cualquier clase que herede de Structure tendrá acceso a todos los tipos de validadores sin necesidad de importarlos explícitamente.

Ahora, probemos nuestra implementación. Crearemos una clase Stock utilizando nuestra clase base Structure mejorada.

cat > stock.py << EOF
from structure import Structure

class Stock(Structure):
    name = String()
    shares = PositiveInteger()
    price = PositiveFloat()

    @property
    def cost(self):
        return self.shares * self.price

    def sell(self, nshares: PositiveInteger):
        self.shares -= nshares
EOF

Si nuestra metaclase está funcionando correctamente, deberíamos poder definir la clase Stock sin importar los tipos de validadores. Esto se debe a que la metaclase ya los ha hecho disponibles en el espacio de nombres.

Prueba de nuestra implementación

Ahora que hemos implementado nuestra metaclase y modificado la clase Structure, es hora de probar nuestra implementación. Las pruebas son cruciales porque nos ayudan a asegurarnos de que todo funciona correctamente. Al ejecutar pruebas, podemos detectar cualquier problema potencial desde el principio y asegurarnos de que nuestro código se comporte como se espera.

Primero, ejecutemos las pruebas unitarias para ver si nuestra clase Stock funciona como se espera. Las pruebas unitarias son pruebas pequeñas y aisladas que verifican partes individuales de nuestro código. En este caso, queremos asegurarnos de que la clase Stock funcione correctamente. Para ejecutar las pruebas unitarias, usaremos el siguiente comando en la terminal:

python3 teststock.py

Si todo funciona correctamente, todas las pruebas deberían pasar sin errores. Cuando las pruebas se ejecutan con éxito, la salida debería verse algo así:

........
----------------------------------------------------------------------
Ran 6 tests in 0.001s

OK

Los puntos representan cada prueba que pasó, y el OK final indica que todas las pruebas fueron exitosas.

Ahora, probemos nuestra clase Stock con algunos datos reales y la funcionalidad de formato de tablas. Esto nos dará un escenario más real para ver cómo interactúa nuestra clase Stock con los datos y cómo funciona el formato de tablas. Usaremos el siguiente comando en la terminal:

python3 -c "
from stock import Stock
from reader import read_csv_as_instances
from tableformat import create_formatter, print_table

## Read portfolio data into Stock instances
portfolio = read_csv_as_instances('portfolio.csv', Stock)
print('Portfolio:')
print(portfolio)

## Format and print the portfolio data
print('\nFormatted table:')
formatter = create_formatter('text')
print_table(portfolio, ['name', 'shares', 'price'], formatter)
"

En este código, primero importamos las clases y funciones necesarias. Luego leemos datos de un archivo CSV en instancias de Stock. Después, imprimimos los datos del portafolio y luego los formateamos en una tabla y la imprimimos.

Deberías ver una salida similar a esta:

Portfolio:
[Stock('AA',100,32.2), Stock('IBM',50,91.1), Stock('CAT',150,83.44), Stock('MSFT',200,51.23), Stock('GE',95,40.37), Stock('MSFT',50,65.1), Stock('IBM',100,70.44)]

Formatted table:
      name     shares      price
---------- ---------- ----------
        AA        100       32.2
       IBM         50       91.1
       CAT        150      83.44
      MSFT        200      51.23
        GE         95      40.37
      MSFT         50       65.1
       IBM        100      70.44

Tómese un momento para apreciar lo que hemos logrado:

  1. Hemos creado un mecanismo para recopilar automáticamente todos los tipos de validadores. Esto significa que no tenemos que realizar un seguimiento manual de todos los validadores, lo que nos ahorra tiempo y reduce la posibilidad de errores.
  2. Hemos implementado una metaclase que inyecta estos tipos en el espacio de nombres de las subclases de Structure. Esto permite que las subclases utilicen estos validadores sin tener que importarlos explícitamente.
  3. Hemos eliminado la necesidad de importaciones explícitas de tipos de validadores. Esto hace que nuestro código sea más limpio y fácil de leer.
  4. Todo esto sucede en segundo plano, lo que hace que el código para definir nuevas estructuras sea limpio y simple.

El archivo final stock.py es notablemente limpio en comparación con lo que habría sido sin nuestra metaclase:

from structure import Structure

class Stock(Structure):
    name = String()
    shares = PositiveInteger()
    price = PositiveFloat()

    @property
    def cost(self):
        return self.shares * self.price

    def sell(self, nshares: PositiveInteger):
        self.shares -= nshares

Sin necesidad de importar directamente los tipos de validadores, el código es más conciso y fácil de mantener. Este es un gran ejemplo de cómo las metaclases pueden mejorar la calidad de nuestro código.

Resumen

En este laboratorio, has aprendido cómo aprovechar el poder de las metaclases en Python. Primero, comprendiste el desafío de gestionar las importaciones de tipos de validadores. Luego, modificaste la clase Validator para recopilar automáticamente sus subclases y creaste una metaclase StructureMeta para inyectar tipos de validadores en los espacios de nombres de las clases. Finalmente, probaste la implementación con una clase Stock, eliminando la necesidad de importaciones explícitas.

Las metaclases, una característica avanzada de Python, permiten personalizar el proceso de creación de clases. Aunque deben usarse con moderación, ofrecen soluciones elegantes a problemas específicos, como se muestra en este laboratorio. Al utilizar una metaclase, simplificaste el código para definir estructuras con atributos validados, eliminaste la necesidad de importaciones explícitas de tipos de validadores y creaste una API más mantenible y elegante. Este patrón de inyección de espacio de nombres basado en metaclases se puede aplicar a otros escenarios para una API de usuario más sencilla.