Lecture 8: Design of programs, MVC architecture
Discussing design patterns for HW6
8.1 From functional correctness to good design
A correctly-working program is undoubtedly useful, but is not enough. It is a fact of software development that systems are repaired, enhanced, debugged and otherwise grown. Each one of these activities require changes to the code, and any change necessitates review and testing which costs time and money. Therefore the objective of writing software should not only be a program that (somehow) spits out the correct answer, but also one that can stand inevitable changes in the future. Thus whatever we design needs protection from variations, and this is inherently a design problem.
8.1.1 Characteristics of a well-designed system
A well-designed system not only works correctly, but supports making changes to it with less effort. It is not possible to quantify this in an absolute manner, and the problem becomes more difficult when we realize that we don’t know what changes will be proposed in the future at the time of designing it. A broad characteristic is for the design to allow changes to be made in an isolated manner, i.e. a new requirement shouldn’t require extensive changes to the current design or a significant re-implementation. But how do we actually (try to) ensure this?
A basic rule of designing complicated systems is to decompose them into simpler components that are easier to design. Therefore a good design would be one that picks a suitable decomposition, so that future changes happen to some (isolated) components and not others. Some object-oriented ideas give us broad guidelines:
Cohesion: Cohesion is a property of each component. Each component should be cohesive, i.e. it should serve exactly one primary purpose. Ensuring high cohesion means that all our components are well-defined, which means that it is possible to identify (a small number of) components that should change when a new requirement has to be met.
In OO, this means designing classes and groups of classes well, with well-defined boundaries.
Coupling: Coupling is some way in which two or more components are dependent on each other. Coupling cannot be avoided completely since the components must work with each other. However ensuring low coupling means that a component can be replaced with another version of itself with minimal disturbance to other components. This creates the isolation that we desire.
Coupling in OO comes in many flavors. Examples include a class extending another, a class using another object, a class’s method using another specific class, etc.
Information hiding: This makes every component seem simpler than it is, because its details are hidden from another component that is using it. Good information hiding makes a component more replaceable (another component cannot depend on some detail that it doesn’t even know about). Thus good information hiding facilitates lower coupling.
In OO, access modifiers (public, private, etc.) are examples of information hiding.
We have seen several simple examples of good design facilitating these above goals:
Design/program by interface: Use interface names instead of implementation names. Thus the coupling is to “any class that implements this interface” rather than “this specific implementation”. This makes it easier to swap implementations of the same interface.
Use private as much as possible, and public judiciously: An object cannot use any private data or methods of another object, so it cannot depend on they being present.
Distribute methods well: If a method is using data only from a particular class, the method likely belongs to that class. This makes the class more cohesive.
But how does one create a design that has all these characteristics? How would one know that the design is good, without knowing specifically what must be changed? There is no foolproof answer to this, but several design practices and principles have been shown to work well in practice. The realistic objective of good design is not eliminate the possibility of major re-design and re-implementation (this is not possible) but to delay the inevitable.
8.2 The SOLID principles
Many good practices in design and coding have been distilled into the SOLID principles. They are:
Single Responsibility: Each component (class) should have a single purpose.
Effect: a class should have only one reason to change, and that reason is easy to find given the new requirement.
Open for extension, closed for modification: Each component (class) should be open to extending its functionality, but without modifying its source code.
Effect: adding functionality to a tested class by modifying it is a recipe for disaster. Design that follows this principle allows such addition without modification.
Liskov’s substitution principle: If S is a subtype of T, then objects of T can be substituted with objects of S without altering any expected functionality.
Effect: a newer version of a class is backward-compatible with its older self. For example, if you extend a class and override its methods, they shouldn’t be inconsistent with their original versions.
Interface segregation: No client should be forced to depends on methods it does not use.
Effect: a client is only offered functionality that is useful. If a client needs to use only part of an existing interface, this interface should be decomposed into smaller ones.
Dependency inversion: Details should depend on abstractions, not the other way around.
Effect: a high-level class does not depend on specific low-level classes. Rather they depend on their abstractions (i.e. interfaces)
While it is more obvious why the SOLID principles lead to good design, it may not be as obvious as to how to follow them. We will understand this further when we see different design recipes, patterns and practices.
8.3 Model-view-controller
Usually the desire of a software program is expressed in terms of what it is expected to do. This list of requirements is likely both long and vague. While we can envision what the overall program will look like, how do we proceed to decompose it? A popular way to start the decomposition is to break it into three parts: the model, the view and the controller.
The model-view-controller (MVC) is a composition of an entire program into three broad categories by what part of the program they implement (hence it is also referred to as the MVC “architecture”) . The model implements the actual functionalities offered by the program. The view is the part of the program that shows results to the user. The controller takes inputs from the user and tells the model what to do and the view what to show.
Take the IDE you are using to write programs as an example (IntelliJ). The view shows the source code, the project structure and the console output. The controller is the part that decides what to do when you select “Run” or “File->Open” and tells others parts of the program to actually carry out the operations. The model is the part that you cannot see, but one that actually compiles your program, runs it and keeps track of all the data needed for the program to function.
The MVC architecture allows you to isolate the entire behavior of your program into categories: actual operations, user display and user interaction and delegation. Practically each class you will design for the program should fall in exactly one of the model, view or controller. A badly implemented MVC architecture would be if a class mixes operations (e.g. a class that implements a functionality and prints the result, a class that shows a menu and implements some of its offered operations, etc.). This is illustrated in the figure above. Thus the MVC architecture, when used correctly, promotes cohesion.
The MVC architecture also mandates which components can directly use other components. Since the model, view and controller have separate functions, access to each other is also restricted. Typically the model and the view cannot directly access each other, and the controller communicates with both. In many programs the view cannot ask the controller for data: the controller decides when to provide data to it. This is illustrated in the figure above. In doing so, the MVC architecture promotes low coupling between groups of classes.
Do Now!
Think about a program you have written in the past that included user interaction. How can you design it better using the MVC architecture?
Another way to think about the merits of MVC is what each part does not do:
Model:
What it does: implements all the functionality
Does not do: does not care about which functionality is used when, how results are shown to the user
Controller:
What it does: takes user inputs, tells model what to do and view what to display
Does not do: does not care how model implements functionality, screen layout to display results
View:
What it does: display results to user
Does not do: does not care how the results were produced, when to respond to user action
8.4 The Model
The model is the part of your software that actually does all the work. It is the workhorse of the program.
To design the model one must specify what functionality has to be offered by the program, and then distill that information into a set of operations which can be divided into one or more classes.
8.4.1 Tic-tac-toe
To create a program that allows two players to play tic-tac-toe with each other, we must think about what operations are necessary to play such a game.
Do Now!
Play a few games of tic-tac-toe with a friend. Think about which functionality will need to be supported by the program to administer such a game.
Example 1: X wins
│ │ X│ │ X│ │O X│ │O X│ │O X│ │O X│ │O X│ │O ─┼─┼─ ─┼─┼─ ─┼─┼─ ─┼─┼─ ─┼─┼─ ─┼─┼─ ─┼─┼─ ─┼─┼─ │ │ │ │ │ │ │ │ │O│ │O│ │O│ X│O│ ─┼─┼─ ─┼─┼─ ─┼─┼─ ─┼─┼─ ─┼─┼─ ─┼─┼─ ─┼─┼─ ─┼─┼─ │ │ │ │ │ │ │ │X │ │X X│ │X X│O│X X│O│X
Example two: stalemate
│ │ X│ │ X│ │ X│ │ X│ │ ─┼─┼─ ─┼─┼─ ─┼─┼─ ─┼─┼─ ─┼─┼─ │ │ │ │ │O│ │O│ │O│O ─┼─┼─ ─┼─┼─ ─┼─┼─ ─┼─┼─ ─┼─┼─ │ │ │ │ │ │ │ │X │ │X X│ │ X│ │ X│ │X X│O│X X│O│X ─┼─┼─ ─┼─┼─ ─┼─┼─ ─┼─┼─ ─┼─┼─ X│O│O X│O│O X│O│O X│O│O X│O│O ─┼─┼─ ─┼─┼─ ─┼─┼─ ─┼─┼─ ─┼─┼─ │ │X O│ │X O│ │X O│ │X O│X│X
8.4.1.1 Analysis
The following operations summarize the expected functionality.
Play a move as X.
Play a move as O.
Find out whose turn it is.
Find out the contents of the grid, perhaps in order to display it.
Find out whether the game is over, and if so, who the winner is, if any.
Because we want the model to enforce the rules of the game, we should also consider ways in which clients should not be able to manipulate the model. Rather than allow the model’s state to be corrupted, it should signal an exception in these cases:
Attempting to play out of turn.
Attempting to play in a cell that already has a mark.
Attempting to play after the game has ended.
Attempting to play in a cell that doesn’t exist (if the interface allows that to be expressed).
8.4.1.2 Designing an interface
We now formalize these operations as methods in an interface.
How to move
In one possible design, we could provide separate methods for playing as X and playing as O:
/** * Places an X mark in the specified cell. * * @param column the column of the cell * @param row the row of the cell * @throws IllegalStateException if it's Y's turn, if the game is * over, or if there is already a mark in the cell. * @throws IndexOutOfBoundsException if the cell is out of bounds. */ void moveAsX(int column, int row); void moveAsY(int column, int row);
With the above design, the model must throw an exception if its client calls moveAsX when it is Y’s turn or moveAsY when it is X’s turn.
We can expect the model to keep track of whose turn it is. Therefore a better way would be to offer a single method:
/** * Places an X or O mark in the specified cell. Whether it places an X * or O depends on which player's turn it is. * * @param column the column of the cell * @param row the row of the cell * @throws IllegalStateException if the game is over, or if there is * already a mark in the cell. * @throws IndexOutOfBoundsException if the cell is out of bounds. */ void move(int column, int row);
There are several assumptions that we need to make: do the row and column numbers start with 0 or 1, how do the numbers map to the actual display, etc. Whatever we decide, we need to document them so that when we design the view we are consistent.
Whose turn is it?
In order to allow the client to find out whose turn it is, we also have several choices. One simple way is a boolean method or methods to ask if it is a particular player’s turn:
/** * Determines whether it is player X's turn to move. * * @return whether X can play now */ boolean isXsTurn(); /** [mutatis mutandis] */ boolean isYsTurn();
Even within the above approach, we have a design choice: What should the methods do if called once the game is over? They could return false, or they could throw an exception; either way, we ought to document this choice. Which do you prefer, and why?
Another way would be to have the method return some representation of the player whose turn it is, like so:
/** * Returns the player whose turn is next. * * @return the next player * @throws IllegalStateException if the game is over */ ? nextPlayer();
There are a variety of choices we could use for the return type ?. Which of these do you prefer, and why?:
type char, with 'X' for X and 'Y' for Y
class String, with "X" for X and "Y" for Y
type boolean, with true for X and false for Y
type boolean, with true for Y and false for X
an enumeration defined as enum Player { X, Y }
Do Now!
Think about the advantages and pitfalls of each choice.
Getting the grid
We need to offer a way for a client of our model to get the current state of the game. We can write a method that simply returns the board. However we must be careful not to return the actual reference, because the client can then mutate it to directly change the model! (Imagine someone else wrote the controller and the view, and their controller got the board from your model and then directly manipulated it to win the game!)
/** * Returns a two-dimensional array representing the state of the * grid. The first index is the column and the second the row. * The resulting array is newly allocated and unconnected to the model; * thus, mutating it will have no effect on the model or subsequent * calls to {@code getGrid}. * * @return a copy of the grid */ Player[][] getGrid();
Alternatively, rather than return some representation of the grid, we could provide some means of querying it:
/** * Returns the {@link Player} whose mark is in the cell at the given * coordinates, or {@code null} if that cell is empty. * * @param column the column of the cell * @param row the row of the cell * @return a {@code Player} or {@code null} * @throws IndexOutOfBoundsException if the cell is out of bounds. */ Player getMarkAt(int column, int row);
Finding out the results
Finally, we need some way to find out when the game is over and who, if anyone, won. Unlike the previous operations, there isn’t much room in this design space (though there are a few sensible alternatives). We will have one method to ask whether the game is over and another to ask who has won:
/** * Determines whether the game is over. * * @return whether the game is over */ boolean isGameOver(); /** * Returns the winner of the game, or {@code null} if the game is a * tie. * * @return the winner or {@code null} * @throws IllegalStateException if the game isn't over */ Player getWinner();
In a game that does not admit ties, could these methods be combined into one? Is that a good idea?
8.4.1.3 Implementation
Tic-tac-toe is a very simple game, yet designing the interface to the model can still be an involved process. Some decisions are consequential, some not, and we might not be able to tell which is which until we start attempting to implement or even use the interface. However, whatever interface design we might choose, much of the hard work is now done, and implementing the interface is a simple matter of programming.
8.4.1.4 Testing
Testing a model is usually straightforward, as there is no interaction. Most of the classes you have tested thus far can be treated as models.