2025, Dec 13 21:02
Как заменить реестры и Enum на полиморфизм при отрисовке фигур в Python
Разбираем, почему диспетчеризация через Enum и реестры хрупка, и показываем 3 рабочих варианта рендера с полиморфизмом в Python: делегирование, владение, передача. Примеры кода.
Разделение бизнес‑логики и отрисовки в небольшом примере кажется простым, но на практике первая версия часто сводится к проверкам типов и таблицам диспетчеризации. Пример ниже показывает типичный подход, который работает, но плохо масштабируется. Цель этого материала — объяснить, почему такая архитектура хрупка, и как перестроить её на полиморфизм с распределением обязанностей между фигурами и рендерами.
Проблема: диспетчеризация через реестр типов
Следующая реализация перечисляет фигуры через Enum и направляет отрисовку через реестр. Формально всё работает, но расширять такую систему неудобно и легко что‑то сломать.
from enum import Enum
class FigKind(Enum):
BOX = 0,
ROUND = 1,
TRIG = 2
class Fig:
def __init__(self, f_kind: FigKind):
self.f_kind = f_kind
class Box(Fig):
def __init__(self, x, y):
super().__init__(FigKind.BOX)
self.x = x
self.y = y
class Round(Fig):
def __init__(self, r):
super().__init__(FigKind.ROUND)
self.r = r
class Trig(Fig):
def __init__(self, a, b, c):
super().__init__(FigKind.TRIG)
self.a = a
self.b = b
self.c = c
class FigPainter:
def __init__(self):
self._dispatch = {
FigKind.BOX: self.draw_box,
FigKind.ROUND: self.draw_round,
FigKind.TRIG: self.draw_trig
}
def draw_box(self, surface, shape: Box):
print(f"Rendering a rectangle ({shape.x}, {shape.y}) on the {surface}")
def draw_round(self, surface, shape: Round):
print(f"Rendering a circle ({shape.r}) on the {surface}")
def draw_trig(self, surface, shape: Trig):
print(f"Rendering a circle ({shape.a}, {shape.b}, {shape.c}) on the {surface}")
def render(self, surface, shape: Fig):
if handler := self._dispatch.get(shape.f_kind):
handler(surface, shape)
else:
raise Exception('Shape type not supported')
p = FigPainter()
p.render('surface1', Box(10, 5))
p.render('surface2', Round(7))
p.render('surface1', Trig(3, 4, 5))
Что здесь не так
Эта конструкция перечисляет подклассы через Enum и проводит поведение через реестр. При добавлении новой фигуры приходится менять Enum, реестр и писать обработчик. Логика выбора реализации находится вне полиморфных типов, которым она и должна принадлежать. Такая диспетчеризация через if/elif или реестры — антипаттерн в данной задаче; за это должен отвечать механизм наследования.
Решение: поручите отрисовку наследованию
В зависимости от того, как взаимодействуют фигуры и устройства вывода, есть несколько аккуратных способов разделить обязанности. Ниже — три альтернативных схемы, которые обходятся без ручной диспетчеризации. Первая особенно уместна, когда фигура рисуется на определённом устройстве вывода.
Вариант №1: фигура делегирует своему экземпляру рендера
Базовый рендер предоставляет единый метод render, а фигура хранит экземпляр рендера и делегирует ему работу. Проверки типов исчезают, а детали конкретного устройства концентрируются в классах рендеров.
from abc import ABC, abstractmethod
class GeoForm:
"""
Base type for all geometry. A particular instance is expected to be
rendered on a single output device, so a renderer instance is provided
at construction time and used by render().
"""
def __init__(self, painter):
self._painter = painter
def render(self):
self._painter.render(self)
class Quad(GeoForm):
def __init__(self, x, y, painter):
super().__init__(painter)
self.x = x
self.y = y
class DrawAgent(ABC):
"""Abstract base for all shape renderers."""
@abstractmethod
def render(self, shape):
...
class QuadPrinter(DrawAgent):
"""Renders a rectangle on a specific surface."""
def __init__(self, target):
self._target = target
def render(self, quad):
print(f"Rendering a rectangle ({quad.x}, {quad.y}) on the {self._target}")
rect = Quad(10, 5, QuadPrinter('surface1'))
rect.render()
Здесь фигура создаётся сразу с подходящим рендером для целевого устройства и по требованию просто просит его выполнить отрисовку.
Вариант №2: рендер хранит ссылку на конкретную фигуру
Другой вариант — рендер, который знает экземпляр фигуры, которую нужно рисовать. Зависимость разворачивается в другую сторону, но внешние проверки типов по‑прежнему не нужны.
from abc import ABC, abstractmethod
class RenderUnit(ABC):
@abstractmethod
def render(self, shape):
...
class GeoBase:
pass
class Quad(GeoBase):
def __init__(self, x, y):
self.x = x
self.y = y
class QuadPrinter(RenderUnit):
"""Renders a rectangle held by the renderer itself."""
def __init__(self, quad, target):
self._quad = quad
self._target = target
def render(self):
print(f"Rendering a rectangle ({self._quad.x}, {self._quad.y}) on the {self._target}")
q = Quad(10, 5)
q_renderer = QuadPrinter(q, 'surface1')
q_renderer.render()
Вариант №3: передавать фигуру в момент вызова
Если ни один из объектов не должен хранить ссылку на другой, метод рендера может принимать фигуру аргументом. Такой интерфейс так же избавляет от внешних проверок типов.
from abc import ABC, abstractmethod
class DrawUnit(ABC):
@abstractmethod
def render(self, shape):
...
class GeoBase:
pass
class Quad(GeoBase):
def __init__(self, x, y):
self.x = x
self.y = y
class QuadPrinter(DrawUnit):
"""Renders any rectangle passed in at call time."""
def __init__(self, target):
self._target = target
def render(self, quad):
print(f"Rendering a rectangle ({quad.x}, {quad.y}) on the {self._target}")
q = Quad(10, 5)
printer = QuadPrinter('surface1')
printer.render(q)
Почему это важно
В типичном приложении для рисования пользователи создают разные фигуры, которые затем нужно перерисовывать на одном конкретном устройстве вывода, например на экране. При единственном устройстве первый вариант — самый практичный: фигуры создаются сразу с нужным рендером и хранятся вместе, а повторная отрисовка сводится к простому проходу с вызовом render у каждой фигуры. Третий вариант заставляет во время прохода решать, какой рендер использовать, а значит возвращает нас к антипаттерну. Второй вариант тоже жизнеспособен, если приложение управляет списком экземпляров рендеров, а не «сырых» фигур.
Итоги
Ручная диспетчеризация через Enum и реестры привязывает код к фиксированному набору фигур и размазывает знания об отрисовке по несвязанным местам. Замена этого на полиморфизм выравнивает зоны ответственности: фигуры описывают данные, рендеры знают, как рисовать, а вызывающему коду больше не нужно угадывать, кто за что отвечает. Выбирайте вариант, исходя из того, как в приложении хранятся ссылки: Вариант №1 — когда каждая фигура привязана к одному устройству; Вариант №2 — когда основная управляемая сущность — рендер; Вариант №3 — когда рендер должен принимать фигуры на вызове и не владеть ими. В любом случае система получается проще расширять и безопаснее сопровождать.