Метаклассы в действии

Beginner

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

Введение

В этом практическом занятии (лабораторной работе) вы узнаете о метаклассах, одной из самых мощных и продвинутых возможностей Python. Метаклассы позволяют вам настраивать создание классов, давая вам контроль над тем, как классы определяются и создаются в экземпляры. Вы изучите метаклассы на практических примерах.

Цели этого практического занятия (лабораторной работы) — понять, что такое метаклассы и как они работают, реализовать метакласс для решения реальных задач программирования и исследовать практические применения метаклассов в Python. Файлы, которые будут изменены в этом практическом занятии (лабораторной работе), это structure.py и validate.py.

Понимание проблемы

Прежде чем мы приступим к изучению метаклассов, важно понять проблему, которую мы стремимся решить. В программировании мы часто должны создавать структуры с определенными типами для своих атрибутов. В нашей предыдущей работе мы разработали систему для структур с проверкой типов. Эта система позволяет нам определять классы, в которых каждый атрибут имеет определенный тип, и значения, присваиваемые этим атрибутам, проверяются в соответствии с этим типом.

Вот пример того, как мы использовали эту систему для создания класса 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

В этом коде мы сначала импортируем типы валидаторов (String, PositiveInteger, PositiveFloat) из модуля validate и класс Structure из модуля structure. Затем мы определяем класс Stock, который наследуется от Structure. Внутри класса Stock мы определяем атрибуты с определенными типами валидаторов. Например, атрибут name должен быть строкой, shares должен быть положительным целым числом, а price должен быть положительным числом с плавающей точкой.

Однако у этого подхода есть проблема. Мы должны импортировать все типы валидаторов в начале нашего файла. В реальной ситуации, по мере добавления все большего количества типов валидаторов, эти импорты могут стать очень длинными и сложными для управления. Это может привести нас к использованию from validate import *, что обычно считается плохой практикой, так как может вызвать конфликты имен и сделать код менее читаемым.

Для того чтобы понять, с чего мы начинаем, давайте посмотрим на класс Structure. Вам нужно открыть файл structure.py в редакторе и изучить его содержимое. Это поможет вам увидеть, как реализована базовая обработка структур, прежде чем мы добавим функциональность метакласса.

code structure.py

Когда вы откроете файл, вы увидите базовую реализацию класса Structure. Этот класс отвечает за обработку инициализации атрибутов, но пока не имеет какой - либо функциональности метакласса.

Далее, давайте рассмотрим классы валидаторов. Эти классы определены в файле validate.py. Они уже имеют функциональность дескрипторов, что означает, что они могут контролировать, как к атрибутам обращаются и как их устанавливают. Но нам нужно будет улучшить их, чтобы решить проблему импорта, о которой мы говорили ранее.

code validate.py

Изучив эти классы валидаторов, вы лучше поймете, как работает процесс валидации и какие изменения нам нужно внести, чтобы улучшить наш код.

Сбор типов валидаторов

В Python валидаторы - это классы, которые помогают нам убедиться, что данные соответствуют определенным критериям. Наша первая задача в этом эксперименте - модифицировать базовый класс Validator так, чтобы он мог собрать все его подклассы. Почему нам это нужно? Ну, собирая все подклассы валидаторов, мы можем создать пространство имен, которое содержит все типы валидаторов. Позже мы внедрим это пространство имен в класс Structure, что облегчит нам управление и использование различных валидаторов.

Теперь давайте начнем работать с кодом. Откройте файл validate.py. Вы можете использовать следующую команду в терминале, чтобы открыть его:

code validate.py

После того, как файл открыт, нам нужно добавить словарь на уровне класса и метод __init_subclass__() в класс Validator. Словарь на уровне класса будет использоваться для хранения всех подклассов валидаторов, а метод __init_subclass__() - это специальный метод в Python, который вызывается каждый раз, когда определяется подкласс текущего класса.

Добавьте следующий код в класс Validator сразу после определения класса:

## 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

После добавления кода ваш модифицированный класс Validator должен выглядеть следующим образом:

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

Теперь каждый раз, когда определяется новый тип валидатора, например String или PositiveInteger, Python автоматически вызовет метод __init_subclass__(). Этот метод затем добавит новый подкласс валидатора в словарь validators, используя имя класса в качестве ключа.

Давайте проверим, работает ли наш код. Мы создадим простой скрипт Python, чтобы проверить содержимое словаря validators. Вы можете выполнить следующую команду в терминале:

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

Если все работает правильно, вы должны увидеть вывод, похожий на следующий, показывающий все типы валидаторов и соответствующие им классы:

{'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'>}

Теперь, когда у нас есть словарь, содержащий все наши типы валидаторов, мы можем использовать его на следующем шаге для создания нашего метакласса.

Создание метакласса StructureMeta

Теперь давайте поговорим о том, что мы будем делать дальше. Мы нашли способ собрать все типы валидаторов. Нашим следующим шагом является создание метакласса. Но что же такое метакласс? В Python метакласс - это особый тип класса. Его экземпляры сами являются классами. Это означает, что метакласс может контролировать, как класс создается. Он может управлять пространством имен, где определяются атрибуты класса.

В нашей ситуации мы хотим создать метакласс, который сделает типы валидаторов доступными при определении подкласса Structure. Мы не хотим каждый раз явно импортировать эти типы валидаторов.

Давайте начнем с того, что откроем снова файл structure.py. Вы можете использовать следующую команду для его открытия:

code structure.py

После того, как файл открыт, нам нужно добавить некоторый код в начале, перед определением класса Structure. Этот код определит наш метакласс.

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)

Теперь, когда мы определили метакласс, нам нужно модифицировать класс Structure, чтобы использовать его. Таким образом, любой класс, который наследуется от Structure, будет использовать функциональность метакласса.

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})'

Давайте разберем, что делает этот код:

  1. Метод __prepare__() - это специальный метод в Python. Он вызывается перед созданием класса. Его задача - подготовить пространство имен, где будут определены атрибуты класса. Здесь мы используем ChainMap. ChainMap - это полезный инструмент, который создает слоистый словарь. В нашем случае он включает наши типы валидаторов, делая их доступными в пространстве имен класса.

  2. Метод __new__() отвечает за создание нового класса. Мы извлекаем только локальное пространство имен, которое является первым словарем в ChainMap. Мы отбрасываем словарь валидаторов, потому что мы уже сделали типы валидаторов доступными в пространстве имен.

При такой настройке любой класс, который наследуется от Structure, будет иметь доступ ко всем типам валидаторов без необходимости явного импорта.

Теперь давайте протестируем нашу реализацию. Мы создадим класс Stock, используя наш расширенный базовый класс Structure.

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

Если наш метакласс работает правильно, мы должны быть able to определить класс Stock без импорта типов валидаторов. Это потому, что метакласс уже сделал их доступными в пространстве имен.

Тестирование нашей реализации

Теперь, когда мы реализовали наш метакласс и модифицировали класс Structure, пришло время протестировать нашу реализацию. Тестирование является важным, так как оно помогает нам убедиться, что все работает правильно. Запуская тесты, мы можем выявить любые потенциальные проблемы на ранней стадии и убедиться, что наш код ведет себя как ожидается.

Сначала давайте запустим модульные тесты (unit tests), чтобы проверить, работает ли наш класс Stock как ожидается. Модульные тесты - это небольшие, изолированные тесты, которые проверяют отдельные части нашего кода. В данном случае мы хотим убедиться, что класс Stock функционирует корректно. Чтобы запустить модульные тесты, мы используем следующую команду в терминале:

python3 teststock.py

Если все работает правильно, все тесты должны пройти без ошибок. Когда тесты успешно выполнятся, вывод должен выглядеть примерно так:

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

OK

Точки представляют каждый пройденный тест, а конечный OK указывает, что все тесты прошли успешно.

Теперь давайте протестируем наш класс Stock с некоторыми реальными данными и функциональностью форматирования таблиц. Это даст нам более реальный сценарий, чтобы увидеть, как наш класс Stock взаимодействует с данными и как работает форматирование таблиц. Мы используем следующую команду в терминале:

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)
"

В этом коде мы сначала импортируем необходимые классы и функции. Затем мы считываем данные из CSV - файла в экземпляры класса Stock. После этого мы выводим данные о портфеле, а затем форматируем их в таблицу и выводим отформатированную таблицу.

Вы должны увидеть вывод, похожий на следующий:

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

Отдохните на минуту и оцените, что мы достигли:

  1. Мы создали механизм для автоматического сбора всех типов валидаторов. Это означает, что нам не нужно вручную отслеживать все валидаторы, что экономит наше время и снижает вероятность ошибок.
  2. Мы реализовали метакласс, который внедряет эти типы в пространство имен подклассов Structure. Это позволяет подклассам использовать эти валидаторы без необходимости явного импорта.
  3. Мы избавились от необходимости явного импорта типов валидаторов. Это делает наш код чище и проще для чтения.
  4. Все это происходит в фоновом режиме, делая код для определения новых структур чистым и простым.

Финальный файл stock.py удивительно чистый по сравнению с тем, каким он был бы без нашего метакласса:

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

Без необходимости напрямую импортировать типы валидаторов код становится более компактным и легким в поддержке. Это отличный пример того, как метаклассы могут повысить качество нашего кода.

Резюме

В этом практическом занятии (lab) вы узнали, как использовать мощь метаклассов в Python. Сначала вы поняли проблему управления импортами типов валидаторов. Затем вы модифицировали класс Validator для автоматического сбора его подклассов и создали метакласс StructureMeta для внедрения типов валидаторов в пространства имен классов. Наконец, вы протестировали реализацию с использованием класса Stock, избавившись от необходимости явного импорта.

Метаклассы, являющиеся продвинутой возможностью Python, позволяют настраивать процесс создания классов. Хотя их следует использовать с осторожностью, они предлагают элегантные решения для конкретных проблем, как показано в этом практическом занятии. Используя метакласс, вы упростили код для определения структур с валидируемыми атрибутами, избавились от необходимости явного импорта типов валидаторов и создали более поддерживаемый и элегантный API. Этот шаблон внедрения пространства имен на основе метаклассов может быть применен в других сценариях для упрощения пользовательского API.