Notes from lecture 4 – Design
patterns
As you must have noticed already, the language you use shapes the way you write code.
In particular, it makes it easy to write in one way and harder in another pushing you to
adapt your programming. This
becomes a prickly issue when you try to extend a software system along a dimension
that is not well-supported by the constructs you used in your language.
For example consider the following simple program written in the functional
subset of Racket:
The program would look similar, give or take stylistic differences, in
pretty much any other functional language, such as Haskell.
The program starts with the data definition for a shape that is either a
circle or a square, and proceeds with the definition of a function
surface followed by a couple of examples of shapes.
We can pass the examples to surface to get the surface of either shape:
> (surface my-circle) |
314.1592653589793 |
> (surface my-square) |
100 |
It is pretty easy to extend the above program with additional operations.
All it takes is adding another function definition such as get-color:
(define (get-color a-shape) | (cond [(circle? a-shape) (circle-color a-shape)] | [(square? a-shape) (square-color a-shape)])) |
|
> (get-color my-circle) |
'green |
> (get-color my-square) |
'red |
The need for such non-local cases is evidence of the
expressiveness limitations of a programming language.
However, if we restrict ourselves to a functional style of programming,
adding new kind of shapes to the program (a different dimension of extensibility)
is more involved than simply adding another function. It requires adding a new
struct
definition and, more annoyingly, adding cases for the new shape in the data definition
for shapes and
all functions that consume shapes. In other words such an extension requires
non-local changes to our code and changes to existing code.
In contrast to that design of shapes, other programming languages and programming styles may allow easy extensions along
the data-variance dimension (such as adding a new kind of shape) while making it harder
to extend by adding new operations. For instance, consider the above program re-phrased in the
class-based/object-oriented subset of Racket:
The program would look similar, give or take stylistic differences, in
pretty much any other class-based language such as Java.
Here the program starts with the definition of the shape<%> interface that ensures
the presence of the method surface. Given the definitions of these two classes we can instantiate objects
for each one and invoke surface on them to get the surface of the corresponding
shape:
> (send my-circle surface) |
314.1592653589793 |
> (send my-square surface) |
100 |
Adding more data variants in this setting is very easy. All it takes is adding a new class
that implements the shape interface without touching the rest of the code:
|
(define my-composite | (new composite% [shape-1 my-circle] [shape-2 my-square])) |
|
> (send my-composite surface) |
414.1592653589793 |
However adding new operations in this setting requires changes that
touch multiple pieces of the code;
it requires adding a new method in the interface and every class that implements
it.
Software engineers observed early these expressiveness limitations of the programming
languages they used and came up with systematic ways to engineer/organize their code
to provide the extensibility points they need. Such engineering solutions are known in the
context of software engineering as design patterns and the
Gang of Four book provided
their first systematic collection and categorization, akin to a technical handbook in other
engineering disciplines.
Design patterns are not specific to a language but rather language-agnostic “idioms”
for successfully arranging code elements such as classes and objects to solve specific
facets of the extensibility problem. Their success claim is based on empirical “archaeological”
evidence from long-living evolving projects.
Over the years they have become an integral part of the vocabulary of every
software engineer.
1 The visitor pattern
Let’s see how design patters can help us solve the functionality extensibility
issue of the class-based code above. The high-level idea is that we are looking for
a way to reorganize the code so that we can add functionality to classes without
having to modify our class hierarchy.
As the Gang of Four book explains, the visitor pattern offers a solution to
exactly this problem. Roughly, the way the visitor works is that we factor the
traversal of some data structure (like shapes) into two parts: the part that
knows what we want to do and the part that knows where to go.
Here is how it works.
First we add the accept method to the shape<%> interface:
This method will play the generic entry point for invoking functionality on all objects
that are instances of classes that implement the shape<%> interface.
The specific functionality that accept invokes is determined by its argument,
an object of the so-called visitor% class that comes with a method for every
shape class. Concretely, the definitions of circle% and
square% contain the definition of their accept method that
simply invokes the corresponding method, visit-circle and visit-square,
of its visitor argument v:
|
|
(define my-circle (new circle% [radius 10] [color 'green])) |
(define my-square (new square% [side 10] [color 'red])) |
The methods visit-circle and visit-square are now the hooks where
we can add the functionality that previously was part of each shape class’s method.
For instance, if we want to equip our shapes with a surface computation, we can define a
surface-visitor% whose methods compute the surface for each shape:
In reality the visitor object is nothing but a substitute for
a λ-function...
To obtain the surface of a shape object all we need to do is invoke
its accept method and pass it an instance of the surface-visitor%:
> (send my-circle accept (new surface-visitor%)) |
314.1592653589793 |
> (send my-square accept (new surface-visitor%)) |
100 |
Now if we want to add functionality to obtain the color of a shape all we have to
do is define a new visitor and leave all the shape classes unchanged:
> (send my-circle accept (new get-color-visitor%)) |
'green |
> (send my-square accept (new get-color-visitor%)) |
'red |
While the visitor pattern provides some nice improvements,
consider what happens if you want to add a new kind of shape
and then a new operation on shapes. Is one step
easier than the other? What does this reveal about the
limitations of the visitor pattern?
2 The state pattern
In a board game, at each point in a game the game admin can ask a player to perform
only a specific action. For instance, the game admin has to first ask the
player to register, and after that to receive its token and, only then to
make a move. The game admin has to be careful to follow this protocol.
The state pattern helps us structure code to remove the problem of having
to rely on the game admin doing things in the right order. This is an instance of
a general common issue that is not straight-forward to
achieve with classes and objects. In many cases, an object has to behave as if it
is an instance of a class that offers some functionality and some others an instance of a class that offers some other
functionality. Concretely, if we represent the
actions of a player with a class that implements the action<%>
interface at a given point, objects of this class should enable to
register or to receive
a token or to make a move
depending on the order of actions so far – but not both at the same time.
In other words, the action<%> interface behaves as the interface of an
automaton that is either in the register or the
receive-token or the make-a-move state and switches
between the two with each action of the player.
Using the state pattern, to implement such an interface we include in it a generic method act:
Then we define separate classes for each different action, i.e,
register%,
receive-token%,
and make-a-move%, that implement act:
The act method of these classes implements the functionality
for either register or
receive-stones or make-a-move and in addition sends a
message next to
an object that keeps track of the next allowed action. That latter object
is an instance of the action-container% that has a field storing the
next action, and the methods next and act from above. Specifically,
the latter invokes act on the next-action object:
Now our referee can instantiate action-container%
and invoke act on the resulting object without having to worry whether it
is time to make a move or to build. The pattern will take care of that by preserving the
automaton-like invariant by construction:
> (define my-action (new action-container%)) |
> (send my-action act "a-player" '()) |
'(fake-retrieving-the-player-name) |
> (send my-action act "a-player" '(this-is-not-really-a-token)) |
'(fake-telling-the-player-a-game-has-started) |
> (send my-action act "a-player" '(this-is-not-really-a-board)) |
'(fake-asking-the-player-to-choose-a-next-move) |