2025, Nov 29 15:00

Polymorphic Rendering in Python: Replacing Enum Registries with Shape/Renderer Inheritance

Learn how to separate business logic from rendering in Python by replacing type checks and Enum registries with inheritance-based polymorphism. See examples.

Separating business logic from rendering in a small simulation sounds straightforward, yet the first attempt often ends up with type checks and dispatch tables. The example below illustrates a common approach that works but scales poorly. The goal of this guide is to show why this design is fragile and how to restructure it using inheritance-based dispatch with clear responsibilities between shapes and renderers.

Problem: dispatch by type registry

The following implementation enumerates shapes via an Enum and routes rendering through a registry. It behaves correctly, but extending it is cumbersome and easy to break.

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))

What goes wrong with this approach

This design enumerates subclasses via an Enum and funnels behavior through a registry. Every time a new shape appears, you need to modify the Enum, the registry, and add a handler. The code that decides which implementation to call lives outside the polymorphic types that should own that behavior. Such dispatch through if/elif chains or registries is an anti-pattern here; inheritance should do the work.

Solution: let inheritance drive rendering

Depending on how shapes and output devices interact, there are multiple clean ways to separate behavior. Below are three alternative designs that avoid manual dispatch. The first one is a natural fit when a shape is rendered to a specific output device.

Design #1: a shape delegates to its renderer instance

A base renderer exposes a uniform render operation, while a shape holds a renderer instance and delegates. This eliminates type checks and centralizes device-specific details in renderer classes.

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()

Here, the shape is constructed with the right renderer for the target device and simply asks it to render when needed.

Design #2: the renderer holds a reference to a specific shape

Another option is to have a renderer that knows the shape instance it should draw. This flips the dependency direction but still avoids any external type testing.

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()

Design #3: pass both shape and renderer at call time

If neither object should hold a reference to the other, the render call can take the shape as an argument. The interface still removes the need for external type checks.

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)

Why this matters

In a typical shape drawing application, users create various shapes that must be re-rendered on a specific output device, such as the screen. With a single output device, the first design is a pragmatic fit: shapes are created with the required renderer and stored together, and re-rendering becomes a simple iteration that calls render on each shape. The third design forces type checks during iteration to decide which renderer to use, recreating the anti-pattern. The second design is also viable if the application manages a list of renderer instances instead of raw shapes.

Conclusion

Manual dispatch through an Enum and registries couples the code to a fixed set of shapes and spreads knowledge about rendering across unrelated places. Replacing that with polymorphism keeps responsibilities aligned: shapes model data, renderers know how to draw, and the calling code no longer needs to guess who should handle what. Choose the variant that matches how your application holds references—Design #1 when a shape targets one device, Design #2 when renderers are the primary units you manage, or Design #3 when a renderer should accept shapes at call time without owning them. In all cases, the result is simpler to extend and safer to maintain.