I'm trying to create a simple simulation with shapes drawn on the screen. I've heard that it's good practice to separate the logic from the rendering, but I'm still not sure how to handle this properly. If the object is not supposed to render itself, where should it be implemented? This thing works, but maybe someone has a better idea.
from enum import Enum
class ShapeType(Enum):
RECTANGLE = 0,
CIRCLE = 1,
TRIANGLE = 2
class Shape:
def __init__(self, s_type: ShapeType):
self.s_type = s_type
class Rectangle(Shape):
def __init__(self, x, y):
super().__init__(ShapeType.RECTANGLE)
self.x = x
self.y = y
class Circle(Shape):
def __init__(self, r):
super().__init__(ShapeType.CIRCLE)
self.r = r
class Triangle(Shape):
def __init__(self, a, b, c):
super().__init__(ShapeType.TRIANGLE)
self.a = a
self.b = b
self.c = c
class ShapePrinter:
def __init__(self):
self._type_registry = {
ShapeType.RECTANGLE: self.render_rectangle,
ShapeType.CIRCLE: self.render_circle,
ShapeType.TRIANGLE: self.render_triangle
}
def render_rectangle(self, surface, shape: Rectangle):
print(f"Rendering a rectangle ({shape.x}, {shape.y}) on the {surface}")
def render_circle(self, surface, shape: Circle):
print(f"Rendering a circle ({shape.r}) on the {surface}")
def render_triangle(self, surface, shape: Triangle):
print(f"Rendering a circle ({shape.a}, {shape.b}, {shape.c}) on the {surface}")
def render(self, surface, shape: Shape):
if handler := self._type_registry.get(shape.s_type):
handler(surface, shape)
else:
raise Exception('Shape type not supported')
x = ShapePrinter()
x.render('surface1', Rectangle(10, 5))
x.render('surface2', Circle(7))
x.render('surface1', Triangle(3, 4, 5))
You have an object-oriented design using an Enum
to enumerate the possible subclasses. This is not an ideal approach since it makes adding new subclasses a maintenance nightmare. Moreover, code that dispatches a method invocation to the correct implementation (i.e. ShapePrinter.render
) using either a bunch of if/elif
statements or a registry should instead be using inheritance.
Not knowing the use cases for your particular application different designs might be appropriate. I will present 3 entirely different possibilities (I am inclined to go with Design #1). Perhaps you will now come up with a 4th and 5th possibility:
Design #1
First, we should have the notion of an abstract base class ShapeRenderer
:
class ShapeRenderer(ABC):
@abstractmethod
def render(self, shape):
...
Its render
method is invoked to render the passed shape on a specific output device. So we might have multiple subclasses of ShapeRenderer
that render Rectangle
instances, one subclass for each output device we want to support. If we assume that a specific instance of shape such as a Rectangle
is only displayed on one output device, then we can instantiate the rectangle with a specific renderer instance. Otherwise, the Rectangle.render
method needs to be passed a renderer for the specific output device. This will be clearer after I define a few more classes:
from abc import ABC, abstractmethod
class Shape:
"""
Base class for all shapes. It is assumed that a given instance
will ony be asked to be rendered on a single, specific output device.
Thus, we can instantiate the shape with that specific renderer, which will
be used by the render method. If this were not the case, then the
render method would need to be passed the specific renderer to be used.
"""
def __init__(self, renderer):
self._renderer = renderer
def render(self):
# Delegate to the renderer:
self._renderer.render(self)
class Rectangle(Shape):
def __init__(self, x, y, renderer):
super().__init__(renderer)
self.x = x
self.y = y
class ShapeRenderer(ABC):
"""Abstract base class for all shape renders."""
@abstractmethod
def render(self, shape):
...
class RectanglePrinter(ShapeRenderer):
"""Class that renders a Rectangle on a printer."""
def __init__(self, surface):
self._surface = surface
def render(self, rectangle):
print(f"Rendering a rectangle ({rectangle.x}, {rectangle.y}) on the {self._surface}")
# Create and render a Rectangle instance on some printer:
rectangle = Rectangle(10, 5, RectanglePrinter('surface1'))
rectangle.render()
In the above design a Rectangle
instance is instantiated with a specific renderer and we can then call the rectangle's render
method. Is this the best possible design?
Design #2
Consider a different design where instead of a Rectangle
instance knowing about its renderer, we have a renderer that knows about a specific rectangle:
from abc import ABC, abstractmethod
class ShapeRenderer(ABC):
@abstractmethod
def render(self, shape):
...
class Shape:
pass
class Rectangle(Shape):
def __init__(self, x, y):
self.x = x
self.y = y
class ShapeRenderer(ABC):
"""Abstract base class for all shape renders."""
@abstractmethod
def render(self, shape):
...
class RectanglePrinter(ShapeRenderer):
"""Class that renders a Rectangle on a printer."""
def __init__(self, rectangle, surface):
self._rectangle = rectangle
self._surface = surface
def render(self):
print(f"Rendering a rectangle ({self._rectangle.x}, {self._rectangle.y}) on the {self._surface}")
rectangle = Rectangle(10, 5)
rectangle_renderer = RectanglePrinter(rectangle, 'surface1')
rectangle_renderer.render()
Design #3
Finally, we have shape and renderer instances neither of which is is instantiated with a reference to the other:
from abc import ABC, abstractmethod
class ShapeRenderer(ABC):
@abstractmethod
def render(self, shape):
...
class Shape:
pass
class Rectangle(Shape):
def __init__(self, x, y):
self.x = x
self.y = y
class ShapeRenderer(ABC):
"""Abstract base class for all shape renders."""
@abstractmethod
def render(self, shape):
...
class RectanglePrinter(ShapeRenderer):
"""Class that renders a Rectangle on a printer."""
def __init__(self, surface):
self._surface = surface
def render(self, rectangle):
print(f"Rendering a rectangle ({rectangle.x}, {rectangle.y}) on the {self._surface}")
rectangle = Rectangle(10, 5)
rectangle_renderer = RectanglePrinter('surface1')
rectangle_renderer.render(rectangle)
Discussion
A typical shape drawing application would allow the user to create instances of different types of shapes, which would be rendered on a specific type of output device, for example the screen. After various interactions from the user, it becomes necessary to re-render the shapes. Since we are dealing with one specific output device, i.e. the screen, then Design #1 seems appropriate. The shapes are instantiated with the required render type and maintained in a list. We can then enumerate each shape instance calling its render
method. Design #3 does not work since as we enumerate each shape, we need to somehow test the type of shape it is to determine what type of renderer we need to use. Again, doing these if/elif
tests in an anti-pattern. However, Design #2 is possible if instead of keeping a list of shapes, we keep now a list of shape renderers.