Паттерны проектирования в Python, о которых следует забыть
- вторник, 26 августа 2025 г. в 00:00:10
Попробуйте поискать в Интернете «Паттерны проектирования на Python» — и получите целую простыню туториалов, демонстрирующих, как в точности воспроизвести на Python паттерны проектирования из книги «Банды четырёх». Там же будут диаграммы классов, иерархии фабрик и столько шаблонного кода, что выхлопа хватит, чтобы отопить маленькую деревню. Так вам внушают, будто вы пишете «серьёзный» код. Умно. Профессионально. Готово для корпоративного использования.
Но вот в чём проблема: большинство из этих паттернов решают проблемы, которые в Python просто отсутствуют. Паттерны разрабатывались для таких языков как Java и C++, где для выполнения самых базовых вещей требуется настоящая эквилибристика — нет ни функций первого класса, ни динамической типизации, ни модулей в качестве пространств имён. Разумеется, вам потребуется Фабрика или Синглтон, если без них в вашем языке просто не с чем работать.
Слепо копировать эти паттерны в Python — не признак большого ума. Из-за них ваш код сложнее читать, тестировать, а также объяснять очередному бедняге, которому этот код придётся поддерживать. Возможно, через три месяца этим беднягой станете вы..
В этом посте мы разберём несколько классических паттернов «Банды четырёх» (GOF), которые при разработке на Python лучше забыть. Для каждого из этих паттернов мы рассмотрим:
Как он обычно (и при этом неудачно) реализуется в Python,
Почему такой код пробуждает воспоминания о том, как писали на Java в 2001 году
Как выглядит нормальная альтернатива на Python — поскольку, да, почти всегда можно сделать проще.
Начнём с самого большой головной боли: порождающих паттернов — то есть, с целой категории решений для тех проблем, которые в Python уже решены.
Ах, да, синглтон. Паттерн номер один для разработчиков, которые хотят глобальное состояние, но при этом пытаются создать видимость, будто пишут объектно-ориентированный код. В Python часто попадается такая «умная» реализация с использованием new и переменной класса:
class Singleton:
_instance: "Singleton" | None = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
Выглядит умно — пока не попытаешься этим пользоваться.
s1 = Singleton(name="Alice", age=30)
s2 = Singleton(name="Bob", age=25)
print(s1.name) # 'Алиса'
print(s2.name) # Тоже 'Алиса'!
Что случилось? Оказывается, вы всё время получаете один и тот же экземпляр, какие бы параметры вы ни передавали. При втором вызове к Singleton(name="Bob", age=25) не создаётся ничего нового — код просто тихо переиспользует оригинальный объект с его исходными атрибутами. Никаких предупреждений. Никакой ошибки. Просто бесшумно делается ерунда.
Но всё становится ещё хуже при попытке унаследовать от этого класса:
class DBConnection:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
class MySqlConnection(DBConnection): ...
class PostGresConnection(DBConnection): ...
conn1 = MySqlConnection()
conn2 = PostGresConnection()
Логично было бы ожидать, что здесь будет два отдельных объекта, по одному для каждого из подклассов. Но нет — и conn1, и conn2 — это один и тот же экземпляр. Всё дело в том, что _instance находится в базовом классе, а не в каждом подклассе отдельно. Поэтому поздравляю: вы написали идеальную коробку сюрпризов. PostGresConnection() может вернуть MySqlConnection, а MySqlConnection() может выдать вам PostGresConnection. Всё зависит от того, который из них вы инстанцировали первым.
Надеюсь, в вашем приложении нормально играть в рулетку.
# settings.py
from typing import Final
class Settings: ...
settings: Final[Settings] = Settings() # добавляем в настройки typing.Final, в таком случае механизм проверки типов пожалуется, если кто-то попытается переприсвоить объект настроек.
Будем честны: синглтон появился не на пустом месте. Он зародился на диких просторах C++ — языка без нормальной системы модулей, где только в самом приблизительном виде существовали пространства имён.
В C++ код живёт в заголовочных файлах и файлах исходников, и вся эта информация перемешивается в ходе компиляции. Невозможно чётко выразить: «это значение является приватным в рамках этого файла» или «этот глобальный объект существует только однажды», приходится импровизировать. Язык предоставляет вам глобальные переменные, которые быстро превращаются в путаницу, если тщательно не контролировать их инициализацию и время жизни.
В C++ не было модулей (до версии C++20) или полноценных систем пакетов, поэтому Синглтон был умным приёмом, гарантировавшим, что будет существовать ровно один экземпляр класса. Синглтон спасал от таких кошмаров как дублирование глобальных значений и множественные определения. Таким образом, в языке были вынуждены изобрести паттерн для обработки таких сущностей, которые в Python выражаются просто как объект на уровне модуля.
// logger.h
#ifndef LOGGER_H
#define LOGGER_H
class Logger {
public:
void log(const char* msg);
};
extern Logger globalLogger; // Declaration
#endif
// logger.cpp
#include "logger.h"
#include <iostream>
Logger globalLogger; // Definition
void Logger::log(const char* msg) {
std::cout << msg << std::endl;
}
// main.cpp
#include "logger.h"
int main() {
globalLogger.log("Starting the app");
return 0;
}
Здесь globalLogger определяется в одной единице трансляции (logger.cpp), но, если вы случайно определите его сразу в нескольких местах, то компоновщик будет ругаться на дублирование символов. Управлять таким глобальным состоянием непросто — и паттерн Синглтон заключает эту идею в класс, который сам управляет своим единственным экземпляром, поэтому не приходится беспокоиться о множественных определениях.
Таким образом, Синглтон можно считать пластырем для C++, понадобившимся в C++ из-за отсутствия модульности и из-за того, что не было налажено чистое управление глобальным состоянием — это совсем не святой Грааль проектирования программ.
Если в Python вам нужен глобальный единственный экземпляр, то не нужно заново изобретать велосипед, создавая сложные классы-синглтоны. Python уже предоставляет вам всё, что нужно — в форме модулей.
Просто создавайте объект на уровне модуля — и он гарантированно будет синглтоном, пока этот модуль импортируется:
# settings.py
from typing import Final
class Settings: ...
settings: Final[Settings] = Settings() # добавляем в настройки typing.Final, в таком случае механизм проверки типов пожалуется, если кто-то попытается переприсвоить объект настроек.
Ладно, допустим, вы хотите отложить создание объекта до тех пор, пока он действительно не понадобится — это называется «ленивая инициализация». Всё равно нет необходимости в паттерне синглтон.
Воспользуйтесь для сохранения экземпляра простой функцией с замыканием и внутренней переменной:
def _settings():
settings: Settings = Settings()
def get_settings() -> Settings:
return settings
def set_settings(value: Settings) -> None:
nonlocal settings
settings = value
return get_settings, set_settings
get_settings, set_settings = _settings()
Пример этого паттерна с github
Такой подход особенно полезен, когда ваш объект настроек зависит от значений, доступных только во время выполнения – например, от пути к файлу окружения (env_file: Path). При ленивой инициализации с использованием замыканий можно отложить создание экземпляра Settings до тех пор, пока у вас не будет всей нужной информации, а не делать это принудительно во время импорта.
Если вы увлекаетесь паттернами проектирования, то, вероятно, имели дело с паттерном Строитель, прославляемым как красивый инструмент для пошагового создания сложных объектов. В таких языках как Java или C++, где у конструкторов не может быть аргументов по умолчанию, и при этом всё держится на неизменяемости объектов, такой паттерн действительно в каком-то смысле оправдан. .
Но в Python? Ох, ребята. Зачастую попадаются строители, которые выглядят вот так:
class CarBuilder:
def __init__(self):
self._color = None
self._engine = None
def set_color(self, color: str) -> "CarBuilder":
self._color = color
return self
def set_engine(self, engine: str) -> "CarBuilder":
self._engine = engine
return self
def build(self) -> "Car":
return Car(color=self._color, engine=self._engine)
class Car:
def __init__(self, color: str, engine: str):
self.color = color
self.engine = engine
car = (
CarBuilder()
.set_color("red")
.set_engine("V8")
.build()
)
Подобный код создаёт у вас впечатление, будто вы знаете, что делаете… пока вы не поймёте, что просто переизобрели именованные аргументы со сцеплением методов и дополнительными классами. Весь этот шаблонный код нужен вам лишь для того, чтобы не использовать принятые в Python аргументы по умолчанию или аргументы ключевых слов?
Мои поздравления! Вам только что удалось при помощи строителя обойти проблему, которую Python решает прямо «из коробки».
Паттерн Строитель часто требуется в тех случаях, когда у параметров нет значений по умолчанию:
public class Car {
private final String color;
private final String engine;
private Car(Builder builder) {
this.color = builder.color;
this.engine = builder.engine;
}
public static class Builder {
private String color; // нет значения по умолчанию
private String engine; // нет значения по умолчанию
public Builder setColor(String color) {
this.color = color;
return this;
}
public Builder setEngine(String engine) {
this.engine = engine;
return this;
}
public Car build() {
// Возможно, вы захотите добавить здесь валидацию
return new Car(this);
}
}
public static void main(String[] args) {
Car car = new Car.Builder()
.setColor("Red")
.setEngine("V8")
.build();
}
}
В Java конструкторы не могут иметь значения по умолчанию для параметров, а когда опций много, перегрузка методов быстро становится неудобной. Паттерн Строитель решает эту проблему, обеспечивая пошаговое построение с опциональными параметрами.
Так как же создавать в Python сложные объекты без всех этих церемоний. Просто: всего лишь используем язык по назначению.
1. Пользуемся аргументами по умолчанию как человек разумный
В Python, когда требуется просто создать объект, не нужно сцеплять сеттеры. Можно задать для параметров значения по умолчанию прямо в конструкторе — и обойтись без всяких дополнительных классов:
class Car:
def __init__(self, color: str = "black", engine: str = "V4"):
self.color = color
self.engine = engine
car = Car(color="red", engine="V8")
Бум. Удобочитаемо, лаконично и бесконечно проще в тестировании. Хотите автомобиль по умолчанию? Просто назовите его Car(). Хотите автомобиль с тюнингом? Передайте ему аргументы. Готово.
2. Хотите что-то ещё более причудливое? Используйте фабричную функцию с перегрузками
Если хотите полнее контролировать код, или вам нужна более серьёзная поддержка при редактировании (например, разные комбинации аргументов), то фабричная функция с typing.overload обеспечит вам нужную гибкость и избавит от необходимости писать целый класс Builder:
from typing import overload
class Car:
def __init__(self, color: str, engine: str):
self.color = color
self.engine = engine
@overload
def make_car() -> Car: ...
@overload
def make_car(color: str) -> Car: ...
@overload
def make_car(color: str, engine: str) -> Car: ...
def make_car(color: str = "black", engine: str = "V4") -> Car:
return Car(color=color, engine=engine)
car1 = make_car()
car2 = make_car("red")
car3 = make_car("blue", "V8")
Получается чистая логика, удобное автозавершение прямо в IDE, а также ноль шаблонного кода. Представляете — мы справились без Строителя, решив его задачи при помощи одних лишь функций и умолчаний. Кто бы мог подумать?