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

Аннотация

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

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

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: Добавил предупреждение о том, что некоторые методы, указанные в статье, лучше не реализовывать.

Комментарии (10)


  1. Demos7787
    00.00.0000 00:00
    +1

    Зачем приводить пример, который нарушает SOLID? (Пример с if else).


    1. eijawa Автор
      00.00.0000 00:00

      Поскольку код в статье писался для примера, не учитывая принципы SOLID.


    1. pintor
      00.00.0000 00:00

      А могли бы вы рассказать как пример с if-else нарушает SOLID и какой именно принцип?


      1. Demos7787
        00.00.0000 00:00
        +1

        Open-Closed, можете почитать в интернете.


  1. outlingo
    00.00.0000 00:00
    +1

    Пример неудачного "примера". Глобальные переменные, статики, смешивание ответственности (определение класса создаваемого объекта и собственно создание объекта). И сами примеры не вычитывались, в частности

    if isinstance(class_name, Hashable):
    raise ValueError("class_name must be a Hashable type!")

    Тут явно пропущен NOT например


    1. eijawa Автор
      00.00.0000 00:00

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


  1. ValeryIvanov
    00.00.0000 00:00

    А толку от фабрики, которая возвращает экземпляры классов не реализующие единый интерфейс? Имхо, фабрики приведённые в статье больше похожи на ServiceLocator'ы.


    1. eijawa Автор
      00.00.0000 00:00
      -1

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


      1. fenrir1121
        00.00.0000 00:00

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

        class Factory(object):
            def get(class_name):
                classes = {
                    "SubjectOne": SubjectOne,
                    "SubjectTwo": SubjectTwo
                }
                return classes[class_name]()

        Ключи словаря вообще всегда Hachable. Что вы пытались показать поставив везде str и только в этом примере Hachable?


  1. DrrRos
    00.00.0000 00:00

    А для чего нужно явное наследование от object? Пора бы уже от обратной совместимости с небезопасными версиями отказываться в новом коде хотя бы.