Reading 6: Software Architecture Design #
In the previous reading, we discussed classes and how you can use them to define your own types. In this reading, we will take a step back and consider the larger picture: how you can use a set of classes to create a larger, cohesive piece of software. Up to this point, the design of classes and functions together for a specific purpose has been largely given to you. Here, you will learn how to use composition and inheritance to describe how multiple classes relate to one another. We will then describe the model-view-controller (MVC) design pattern, a commonly used paradigm in interactive software that highlights some ways of using composition and inheritance in a larger context.
In a nutshell, composition is combining multiple types or classes within a larger class. Composition allows us to conceptually represent multiple objects or types being used together for a particular purpose.
Complex numbers illustrate simple composition #
A complex number is the sum of a real and imaginary number, such as
\(2 + 3i\)
is the square root of -1). Thus, a complex number has a real part (in this case, 2) and an imaginary part (in this case, 3). If we were to write a class
ComplexNumber to represent a complex number, we could think of it as being composed of its real part and its imaginary part, which in a class could be
Stated another way,
ComplexNumber is made up of two
floats, which represent the complex number’s real and imaginary parts. These
floats are used in operations involving
ComplexNumber instances, such as adding or multiplying two complex numbers. In this way, the composition of two
floats allows us to define more complex mathematical objects (pun intended).
Composition is a “has-a” relationship #
Composition is sometimes referred to as a “has-a” relationship because of what it conceptually represents. In our complex number example above, the
ComplexNumber class has a
float representing its real part, and also has a
float representing its imaginary part. In a Python class, this relationship would be expressed by making the
floats instance attributes of the
ComplexNumber class, like this:
class ComplexNumber: """ Representation of a complex number. Attributes: real: A float representing the real part of the number. imag: A float representing the imaginary part of the number. """ def __init__(self, real, imag): self.real = real self.imag = imag
The has-a relationship of composition also implies some degree of ownership: because
ComplexNumber has these
floats, it is also responsible for managing their values. This is especially true for private instance attributes, as the owning class’s methods are the only thing that can access them.
Composition can be nested #
A class can be composed of objects that are themselves compositions. For example, consider a game of Connect Four, in which two players take turns dropping disks into a board to try and form a row, column, or diagonal of four disks of their respective colors.
If we were to write a program to play a game of Connect Four, we might define an overarching
ConnectFourGame class, which contains a
ConnectFourBoard class and two instances of the
ConnectFourPlayer class. The classes contained in the
ConnectFourGame class can in turn be composed of other data types, and so on. For example, the
ConnectFourBoard class might have instance attributes to keep track of which disks are in which spaces of the board and whose turn it is.
We could instead choose to place all of the individual instance attributes in the
ConnectFourGame class, so that we would have just one large class that contained the board, each player’s information, etc. However, this is generally not considered good design because it does not make the components of the game very clear. In particular, player information is generally only handled by functions related to each player, and similarly for the game board. Logically, it makes more sense to make those classes of their own and compose the larger game using those classes.
Whether to compose a class using many small data types or a few larger types or classes comes down to choosing the right level of abstraction. Learning to find the right level of abstraction partially comes down to building up intuition through practice, but stepping back to think about what each component is doing in a larger context can be helpful as well.
In our Connect Four example, you can consider that from the game’s perspective, it really does not matter how the board stores its information (e.g., whether the pieces on the board are represented as a string, list, or a custom class) - all the game cares about is being able to tell the board to drop a piece in a specific slot or to get a sense of what pieces are where in the board. Because of this, it makes sense to have the game be composed of a board class, rather than the smaller data types that make up the board.
In a nutshell, inheritance is the practice of using part of a class’s definition in another class. Inheritance allows us to more cleanly define similar classes, placing their similar behavior into one class while making smaller classes that handle their unique functionality.
Game players illustrate simple inheritance #
Returning to our Connect Four example from above, notice that a game has a
ConnectFourPlayer class. From the game’s perspective, it does not matter how each player makes their move - all the game needs is their move itself. Because of this, whether the player is a human and makes their move by entering their choice into a prompt, or whether the player is an AI that considers the current state of the board and a host of other information to make their move, the result given to the game is just the column in which the next disk will be dropped.
We can define a class that contains the bare minimum functionality needed for the game to get the appropriate information from the player (i.e., the color of the player’s disk and a method to get the player’s moves).
class ConnectFourPlayer: """ Representation of a Connect Four player. Attributes: color: An integer representing the color of the player's disks (0 for red, 1 for yellow). """ def __init__(self, color): self.color = color def move(self): """ Make a move on the Connect Four board. Returns: An integer between 0 and 6 representing the column in which to make the next move. """ pass
We can then define a subclass of
ConnectFourPlayer that inherits all of the attributes and methods of the class, like this:
class HumanC4Player(ConnectFourPlayer): """ Representation of a human-controlled Connect Four player. """ def move(self): choice = -1 while choice < 0 or choice > 6: # Use input to read a choice from the user. choice = input("Enter an integer from 0 to 6: ") return int(choice)
Defining the class as
HumanC4Player(ConnectFourPlayer) means that all attributes and methods defined in
ConnectFourPlayer are available to
HumanC4Player - in other words,
HumanC4Player inherits the attributes and methods of
HumanC4Player can override any of the methods of
ConnectFourPlayer by defining a method with the same name (as it does with
move). An AI class could similarly override
move to define its own way of picking its next move.
The advantage of this is that the
ConnectFourGame class knows that it can call the
move method of the player, and no matter what kind of player it is, the method will produce a move that the game can process.
Inheritance is an “is-a” relationship #
Inheritance is sometimes referred to as an “is-a” relationship. Because a subclass inherits its attributes and methods from its parent class, an instance of the subclass is for all practical purposes an instance of the parent class and can be used in the ways that an instance of the parent class would. In our example above, a
HumanC4Player is a
As with composition, inheritance can be nested. We could define a class called
AIC4Player representing an AI player, and have it store the board used for the game. From there, we could define classes called
HardAIPlayer that all inherit from
AIC4Player. Because these classes all inherit from a class that stores the game board, they all have access to the game board in their methods. However, each class can define its own
move method and use the game board in different ways.
Be careful when overriding methods #
Note that overriding a method makes the relevant method of the parent class not execute. For example, suppose that the
move method of
ConnectFourPlayer was instead this:
def move(self): print("Calling move() in ConnectFourPlayer") return 0
If you created an instance of
HumanC4Player and called its
move method, you would not see the message
Calling move() in ConnectFourPlayer printed. This is because overriding a method is a bit like defining an instance attribute with the same name as a class attribute: the most specific one takes precedence. So if the parent class and subclass both define methods with the same name, the subclass’s one will be used.
This can be a particular problem when defining
__init__ methods, since we often want to use the attributes defined in the
__init__ methods of the parent class. To address this problem, we can use the
super function, as describe below.
super to access the parent class
The built-in function
super allows you to access methods in the parent class. This is particularly helpful when you want to add to a parent class’s method instead of replace it entirely.
ConnectFourPlayer class, which contains this
class ConnectFourPlayer: # Only the relevant portions of this class are shown here. def __init__(self, color): self.color = color
Suppose that we want to define the
AIC4Player class, which inherits from
ConnectFourPlayer and stores the game board as part of its attributes. Overriding the method, like this, will work, but is repetitive:
class AIC4Player(ConnectFourPlayer): def __init__(self, color, board): # There is a better way of implementing this function, shown below. self.color = color self._board = board
Using super, we could instead simply call the
__init__ method of the parent class like this:
class AIC4Player(ConnectFourPlayer): def __init__(self, color, board): super().__init__(color) self._board = board
super().__init__() does the following:
super() gets the parent class of
AIC4Player, which is
ConnectFourPlayer, and then we call the
__init__ method of that class. This can be useful when creating subclasses of complex classes, or when creating nested subclasses that are several levels deep.
The Model-View-Controller Pattern #
To illustrate how composition and inheritance might be used in a larger software project, we will use the example of designing a game of chess. In this example, we will also be highlighting the use of the model-view-controller (MVC) paradigm, which is commonly used as the software architecture for interactive programs. MVC is an example of a design pattern, which is a specific software architecture that is commonly used for a specific application.
In short, the MVC architecture consists of three main components: the model, the view, and the controller. Each of these is responsible for a core feature of the system. MVC is designed for modularity: each of the model, view, and controller components offer an interface that allows it to be easily switched out with other classes. This design makes it straightforward to extend or improve components as time goes on.
In this example, we will provide a fairly minimalistic example that highlights the design of the system. If you want to see a more detailed (but not implemented) example for this game, you can view more annotated code at https://github.com/syclops/chess-mvc-example.
The model represents the state of the system #
The model represents the state of the system (i.e., the information maintained and managed by the system) at a given point in time. In our chess example, this would include the following at a minimum:
- The pieces currently on the board (type and position)
- Whose turn it is
There are other pieces of information that the board could store for convenience if we wanted it to, such as the history of moves or whether or not one side is in check. Additionally, for the purposes of certain special moves such as castling or en passant captures, a board should store the necessary information to determine whether these moves are allowed on a given turn.
Given that in a chess game, we often access squares by rank and file, it would make sense to store the board as a two-dimensional list. Because there are 12 types of pieces in the game, we can represent each piece with a different integer (with an additional integer to indicate an empty square). This means that the board itself can be represented as a list of lists of ints.
When creating the board, we can set the next player’s move to be White, since that is the first player to move in chess. Each time a move is made, we can switch the next player to move from White to Black or vice versa.
This means that the beginning of our class might look something like this:
class ChessBoard: white = 0 black = 1 empty_square = -1 white_pawn = 0 black_pawn = 1 # Some pieces omitted here for conciseness. white_king = 10 black_king = 11 def __init__(self): self._next_move = self.white self._board = [[self.empty_square for _ in range(8)] for _ in range(8)]
This is a prime example of composition: the
ChessBoard class is composed of some integers that define players and pieces, as well as a two-dimensional list of integer representing the positions of these pieces on the board.
Now we can define a series of methods that define actions used in the gameplay of chess. This includes things like:
- Listing which squares a given piece can move to.
- Returning whether a given side’s king is in check.
- Determining whether moving a piece to a given square is a legal move.
- Moving a piece from one square to another (if it is a legal move), and capturing a piece if one is there.
Each of these actions can be defined as methods of the
ChessBoard class. During gameplay, these methods can be called to represent moves in the game.
The view represents the human-readable representation of the system #
The view is what translates the information stored by the model into a format that can be processed by users. In our chess example, the view takes a
ChessBoard instance and “draws” a representation of it in some way to the screen.
There are many ways to represent a chess board - we could draw it in a text format, like this:
a b c d e f g h +-+-+-+-+-+-+-+-+ 8 |R|N|B|Q|K|B|N|R| 8 +-+-+-+-+-+-+-+-+ 7 |P|P|P|P|P|P|P|P| 7 +-+-+-+-+-+-+-+-+ 6 | | | | | | | | | 6 +-+-+-+-+-+-+-+-+ 5 | | | | | | | | | 5 +-+-+-+-+-+-+-+-+ 4 | | | | | | | | | 4 +-+-+-+-+-+-+-+-+ 3 | | | | | | | | | 3 +-+-+-+-+-+-+-+-+ 2 |p|p|p|p|p|p|p|p| 2 +-+-+-+-+-+-+-+-+ 1 |r|n|b|q|k|b|n|r| 1 +-+-+-+-+-+-+-+-+ a b c d e f g h White (lowercase) to move next.
Unfortunately, this is not the most visually appealing, but it works for the purposes of showing the state of the board.
We could also show the board graphically, by for example creating a class to illustrate the moves over time, like this:
To allow for a modular design in which we can easily swap out these two views for each other, we can use inheritance to define a generic view class. This class simply stores the chess board and defines a
draw method to visually represent the board:
class ChessView: """ A visual representation of a chess board. Attributes: board: a ChessBoard instance representing the board to view. """ def __init__(self, board): self._board = board def draw(self): pass
We can then define a
TextChessView class that inherits from
ChessView and defines its own
draw method to print the text representation of the board to the screen. We can also define a
GraphicalChessView class that inherits from
ChessView and implements its
draw method to create a window on which it can draw the board.
The controller represents the input interface of the system #
The controller translates inputs from the players into calls to the appropriate methods in the board. For example, if the player selects a specific move by typing text into a prompt or by clicking specific squares in a graphical interface, the controller interprets these actions to determine the specific move to attempt to make on the board.
As with the view, using inheritance here allows us to select different ways of providing input to the board. We could simply define a
ChessController class that stores defines an empty method called
get_move that gets some input from the user (such as text) and translates that into a starting and ending square on the board. These coordinates can then be used in the
ChessBoard class to attempt to move a piece. We can then have subclasses
GraphicalController that translate different types of input into these coordinates.
Tie everything together with an overarching game class #
Once we have classes implemented for each of these components, we can use an overarching
ChessGame class to tie everything together. This simply manages the ways in which these components interact. Here is a brief sketch of what this class would look like:
class ChessGame: def __init__(self, board, view, controller): self._board = board self._view = view self._controller = controller def run_game(self): while not self._board.checkmate(): self._view.draw() start_rank, start_file, end_rank, end_file = \ self._controller.get_move() if self._board.is_legal_move(start_rank, start_file, end_rank, end_file): self._board.move(start_rank, start_file, end_rank, end_file) else: # Display that the attempted move is invalid. self._view.invalid_move()
With this class, we have another example of composition: the game is composed of the three major components in the MVC architecture, along with the methods required to make them interact and run a game of chess.