Notes from Lecture 3 – Design patterns I
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 prickling issue when you try to extend a software system along a dimension
that is not well-supported by your language.
For example consider the following simple program written in the functional
subset of Racket:
(struct circle (radius color)) |
(struct square (side color)) |
(define (color? c) (or (equal? c 'red) (equal? c 'blue) (equal? c 'green))) |
(define (surface a-shape) | (cond [(circle? a-shape) (* pi (sqr (circle-radius a-shape)))] | [(square? a-shape) (sqr (square-side a-shape))])) |
|
(define my-circle (circle 10 'green)) |
(define my-square (square 10 'red)) |
The program would look similar, give or take stylistic differences, in
pretty much any other functional language such as ML.
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 functionality.
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 our selves 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.
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 along the functionality dimension. For instance consider the above program re-phrased in the
class-based/object-oriented subset of Racket:
(define shape<%> | (interface () surface)) |
|
(define circle% | (class* object% (shape<%>) | (super-new) | (init-field radius) | (define/public (surface) | (* pi (sqr radius))))) |
|
(define square% | (class* object% (shape<%>) | (super-new) | (init-field side) | (define/public (surface) | (sqr side)))) |
|
(define my-circle (new circle% [radius 10])) |
(define my-square (new square% [side 10])) |
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 enforces all classes
that implement it, such as circle% and square%, to implement a 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 composite% | (class* object% (shape<%>) | (super-new) | (init-field shape-1 shape-2) | (define/public (surface) | (+ (send shape-1 surface) (send shape-2 surface))))) |
|
(define my-composite | (new composite% [shape-1 my-circle] [shape-2 my-square])) |
|
> (send my-composite surface) |
414.1592653589793 |
However adding more functionality 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. Here is how it works.
First we include to the shape<%> interface a single method, accept:
(define shape<%> | (interface () accept)) |
|
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 circle% | (class* object% (shape<%>) | (super-new) | (init-field radius color) | (define/public (accept v) | (send v visit-circle this)))) |
|
(define square% | (class* object% (shape<%>) | (super-new) | (init-field side color) | (define/public (accept v) | (send v visit-square this)))) |
|
(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:
(define surface-visitor% | (class object% (super-new) | (define/public (visit-circle c) (* pi (sqr (get-field radius c)))) | (define/public (visit-square s) (sqr (get-field side s))))) |
|
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:
(define get-color-visitor% | (class object% (super-new) | (define/public (visit-circle c) (get-field color c)) | (define/public (visit-square s) (get-field color s)))) |
|
> (send my-circle accept (new get-color-visitor%)) |
'green |
> (send my-square accept (new get-color-visitor%)) |
'red |
2 The state pattern
In Santorini at each point in a game a player can perform
only a specific action on the board of the game. In particular, at each turn the player has to
move a worker and only then maybe use the worker to build. The referee of the game that
interacts with the board on behalf of the player has to be careful to maintain that
invariant.
The state pattern helps us structure code to remove the problem of having
to rely on the referee maintaining the invariant. 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 on a board with a class that implements the action<%>
interface at a given point, objects of this class should enable a move or a build
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 move or the build 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:
(define action<%> | (interface () act)) |
|
Then we define separate classes for each different action, i.e, move%
and build%, that implement act:
(define move% | (class* object% (action<%>) | (super-new) | (define/public (act worker direction board container) | (begin0 | '(fake-board-with-a-worker-moved) | (send container flip (new build%)))))) |
|
(define build% | (class* object% (action<%>) | (super-new) | (define/public (act worker direction board container) | (begin0 | '(fake-board-with-a-new-building-level) | (send container flip (new move%)))))) |
|
The act method of these classes implements the functionality
for either move or build and in addition sends a message flip 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 flip and act from above. Specifically,
the latter invokes act on the next-action object:
(define action-container% | (class* object% (action<%>) | (super-new) | (define next-action (new move%)) | (define/public (flip an-action) | (set! next-action an-action)) | (define/public (act worker direction board) | (send next-action act worker direction board this)))) |
|
Now our Santorini 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 "blue1" "N" '(this-is-not-really-a-board)) |
'(fake-board-with-a-worker-moved) |
> (send my-action act "blue1" "N" '(this-is-not-really-a-board)) |
'(fake-board-with-a-new-building-level) |