Комментарии в коде написаны на английском языке, а все блоки кода спрятаны под спойлеры, чтобы не мешать чтению.
Аннотация
В статье я не буду объяснять пользу использования паттернов проектирования и фабрики в частности. Здесь будут рассмотрены подходы к реализации, начиная от простых, банальных и заканчивая более интересными, а также сделан некоторый вывод.
Подходы к реализации
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. Возможно, есть и другие подходы, но уже сейчас можно сделать вывод, что большая часть подходов использует словарь. Разница между ними лишь в методе создания словаря.
Источники
Здесь я узнал о использовании глобального контекста — Python Design Patterns - Factory
А здесь о получении подклассов класса — Factory: Encapsulating Object Creation
UPD: Добавил предупреждение о том, что некоторые методы, указанные в статье, лучше не реализовывать.
