Комментарии в коде написаны на английском языке, а все блоки кода спрятаны под спойлеры, чтобы не мешать чтению.
Аннотация
В статье я не буду объяснять пользу использования паттернов проектирования и фабрики в частности. Здесь будут рассмотрены подходы к реализации, начиная от простых, банальных и заканчивая более интересными, а также сделан некоторый вывод.
Подходы к реализации
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: Добавил предупреждение о том, что некоторые методы, указанные в статье, лучше не реализовывать.
Комментарии (10)
outlingo
00.00.0000 00:00+1Пример неудачного "примера". Глобальные переменные, статики, смешивание ответственности (определение класса создаваемого объекта и собственно создание объекта). И сами примеры не вычитывались, в частности
if isinstance(class_name, Hashable):
raise ValueError("class_name must be a Hashable type!")Тут явно пропущен NOT например
eijawa Автор
00.00.0000 00:00Да, вы правы, проглядел, поправил. Также изменил возвращаемый результат методов - с инстансов на сами классы.
ValeryIvanov
00.00.0000 00:00А толку от фабрики, которая возвращает экземпляры классов не реализующие единый интерфейс? Имхо, фабрики приведённые в статье больше похожи на ServiceLocator'ы.
eijawa Автор
00.00.0000 00:00-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?
DrrRos
00.00.0000 00:00А для чего нужно явное наследование от object? Пора бы уже от обратной совместимости с небезопасными версиями отказываться в новом коде хотя бы.
Demos7787
Зачем приводить пример, который нарушает SOLID? (Пример с if else).
eijawa Автор
Поскольку код в статье писался для примера, не учитывая принципы SOLID.
pintor
А могли бы вы рассказать как пример с if-else нарушает SOLID и какой именно принцип?
Demos7787
Open-Closed, можете почитать в интернете.