Подходы к реализации паттерна Фабрика в Python

—

от автора

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

Аннотация

В статье я не буду объяснять пользу использования паттернов проектирования и фабрики в частности. Здесь будут рассмотрены подходы к реализации, начиная от простых, банальных и заканчивая более интересными, а также сделан некоторый вывод.

Подходы к реализации

if-else / match-case

Код реализации фабрики с использованием операторов условия
class ClassNotFoundError(ValueError):     ...   class SubjectOne(object):     ...   class SubjectTwo(object):     ...   class Factory(object):     @staticmethod     def get(class_name: str) -> object:         if type(class_name) != str:             raise ValueError("class_name must be a string!")          if class_name == "SubjectOne":             return SubjectOne          if class_name == "SubjectTwo":             return SubjectTwo          raise ClassNotFoundError   # Usage class_ = Factory.get("SubjectOne") 

Внимание! Так делать не нужно!

Суть реализации проста — на вход метода получаем название класса или какой‑то ключ, по которому можно идентифицировать класс, и возвращаем объект класса.

Словарь

Код реализации фабрики с использованием словаря
from typing import Hashable, Callable   class ClassNotFoundError(ValueError):     ...   class SubjectOne(object):     ...   class SubjectTwo(object):     ...   class Factory(object):     @staticmethod     def get(class_name: Hashable) -> object:         if not isinstance(class_name, Hashable):             raise ValueError("class_name must be a Hashable type!")                  classes: dict[Hashable, Callable[..., object]] = {             "SubjectOne": SubjectOne,             "SubjectTwo": SubjectTwo         }          class_ = classes.get(class_name, None)         if class_ is not None:             return class_          raise ClassNotFoundError   # Usage class_ = Factory.get("SubjectOne") 

Понимая, что раз мы используем какой-то ключ, нетрудно дойти до идеи использования словаря, вместо кучи условий. Важно лишь помнить, что ключ словаря должен быть Hashable — поддерживать метод __hash__().

eval

Код реализации фабрики с использованием метода eval
class ClassNotFoundError(ValueError):     ...   class SubjectOne(object):     ...   class SubjectTwo(object):     ...   class Factory(object):     @staticmethod     def get(class_name: str) -> object:         if type(class_name) != str:             raise ValueError("class_name must be a string!")          try:             instance_ = eval(f"{class_name}()")             return instance_         except Exception as e:             from warnings import warn             warn(str(e))              raise ClassNotFoundError   # Usage instance_ = Factory.get("SubjectTwo") 

Внимание! Так делать не нужно!

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

Примечание: В eval можно определить контекст выполнения кода, однако мы посмотрим более интересное использование контекста в следующей главе!

Глобальный контекст

Код реализации фабрики с использованием глобального контекста
class ClassNotFoundError(ValueError):     ...   class SubjectOne(object):     ...   class SubjectTwo(object):     ...   class Factory(object):     @staticmethod     def get(class_name: str) -> object:         if type(class_name) != str:             raise ValueError("class_name must be a string!")          class_ = globals().get(class_name, None)         if class_ is not None:             return class_          raise ClassNotFoundError   # Usage class_ = Factory.get("SubjectTwo") 

Внимание! Так делать не нужно!

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

Подклассы

Код реализации фабрики с использованием подклассов
from typing import Callable   class ClassNotFoundError(ValueError):     ...   class FactorySubject(object):     ...   class SubjectOne(FactorySubject):     ...   class SubjectTwo(FactorySubject):     ...   class Factory(object):     @staticmethod     def get(class_name: str) -> object:         if type(class_name) != str:             raise ValueError("class_name must be a string!")          raw_subclasses_ = FactorySubject.__subclasses__()         classes: dict[str, Callable[..., object]] = {c.__name__:c for c in raw_subclasses_}         class_ = classes.get(class_name, None)         if class_ is not None:             return class_          raise ClassNotFoundError   # Usage class_ = Factory.get("SubjectTwo") 

Этот метод использует возможность языка получить все подклассы определённого класса, чтобы создать словарь со всеми доступными FactorySubject‘s.

Файлы модуля*

Исходники вместе с примером использования можно посмотреть в репозитории.

Код реализации фабрики с использованием автогенерации словаря на основе файлов модуля

Абстрактный класс фабрики

import importlib import logging import os from abc import ABC, abstractmethod from functools import cache from pathlib import Path from types import ModuleType from typing import Callable, Hashable, TypeVar, Generic  debug_logger = logging.getLogger("debug")  T = TypeVar("T")  CallableClass = Callable[..., T]   class AbstractFactory(Generic[T], ABC):     """     AbstractFactory     ===============      Class properties     ----------------      modules_dir: Path         A path to dir with desired modules,         which we want to get from our Factory.      parent_package: str         Full parent package path.         Example: src.high_package.low_package      filter_files: list[str]         List of file names to filter from results.       Required implemented methods     ----------------------------     @classmethod     def get_class_name(cls, module: ModuleType) -> str:         Prefer to use with dir(module)       Available additional methods to overload     ----------------------------------------     @classmethod     def get_class_key(cls, class_: object) -> Hashable:         This value used to define key in our dictionary         Default: class_.__class__.__name__     """      modules_dir: Path      parent_package: str      filter_files: list[str]      @classmethod     def get(cls, name: str) -> T:         """         Get class instance by class_name|key          Arguments         name: str             A class name or a key, if defined by get_class_key-method          Return         Instance of class          Exceptions         ModuleNotFoundError             Raises if module with given name was not found...         """          debug_logger.debug(f"Initialisation for: {name}")          # Get class-object by it's name         class_: CallableClass | None = cls.__get_classes_dict().get(name, None)          if not class_:             e = ModuleNotFoundError(f"Module with given name - {name} - not found!")             debug_logger.exception(e)             raise e          return class_      @classmethod     def exists(cls, name: str) -> bool:         """         Check if class with class_name|key exists in Factory.         It's handy method if you do not want to catch exception of get-method.         """          class_: CallableClass | None = cls.__get_classes_dict().get(name, None)          return class_ is not None      @classmethod     @cache     def __get_classes_dict(cls) -> dict[Hashable, CallableClass]:         """Get cached dictionary of all available classes by Factory"""          classes_dict: dict[Hashable, CallableClass] = {}          # Get all modules path in modules_dir folder         for module_path in os.listdir(cls.modules_dir):             # Filter unused, standard and all other modules,             # which contains in our filter_files             if module_path.endswith(".py") and module_path not in cls.filter_files:                 module: ModuleType = importlib.import_module(                     ".%s" % module_path.removesuffix(".py"),                     package=cls.parent_package,                 )                  # Get class_name by filtering all dir(module) data                 class_name = cls.get_class_name(module)                  class_ = getattr(module, class_name)                 classes_dict[cls.get_class_key(class_)] = class_          return classes_dict      @classmethod     @abstractmethod     def get_class_name(cls, module: ModuleType) -> str:         """         *In implementation of this method, I offer to use dir(module)*         This method must return only name of the desired callable class.          The reason behind this approach:         All data got from dir(module) will have link to all of class Parents,         methods, variables and etc., which contains in module,         because of that, we need to carefully filter result.         """          ...      @classmethod     def get_class_key(cls, class_: object) -> Hashable:         """This value used to define key in our dictionary"""          return class_.__class__.__name__ 

Пример класса-наследника

class ParsersFactory(AbstractFactory[BaseParser]):     modules_dir = Path(__file__).parent / "modules"     parent_package = "src.parsers.modules"      filter_files = ["__init__.py", "base.py"]      @classmethod     def get_class_name(cls, module: ModuleType):         return next(             filter(                 lambda x: x.endswith("Parser") and x != "BaseParser",                 dir(module),             )         )          @classmethod     def get_class_key(cls, class_: object) -> str:         return getattr(class_, "domain") 

Основная идея этого подхода строится на формировании словаря доступных классов на основе файлов из определённого модуля\папки.

*возможно это не самое оптимальное решение, но оно имело место быть и отлично работает до сих пор.

Вывод

Мы рассмотрели несколько подходов к реализации паттерна Фабрика в языке Python. Возможно, есть и другие подходы, но уже сейчас можно сделать вывод, что большая часть подходов использует словарь. Разница между ними лишь в методе создания словаря.

Источники

  1. Здесь я узнал о использовании глобального контекста — Python Design Patterns — Factory

  2. А здесь о получении подклассов класса — Factory: Encapsulating Object Creation

UPD: Добавил предупреждение о том, что некоторые методы, указанные в статье, лучше не реализовывать.


ссылка на оригинал статьи https://habr.com/ru/articles/725340/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *