The purpose of this assignment is to give you practice with object-oriented programming and to introduce you to design patterns.
In assignment 3, we completed a mindreader program. The computer tried to predict whether the user would choose heads or tails, by relying on previous user patterns. We had several types of objects: a string for the context; a dictionary for the memory; a tuple, list, or other object of your choosing for the heads-and-tails information. Many of these objects were used implicitly, in the sense that a string is only a context because we apply only operations related to contexts on such a string. We can be much more explicit about the objects we use, and increase flexibility and modularity, by using object-oriented programming. In this assignment, we will refactor our approach from assignment 3 into an object-oriented style. Along the way, we will speculate on the benefits of object-oriented programming, sometimes imagining that the current program is orders of magnitude larger than it is. Whenever you're able to make a comparison between the approach in assignment 3 and the object-oriented solution developed here, make a note of it in a file called oop.txt
. In the text below, you will be asked to make other observations in this file as well. You will submit oop.txt
for part of your grade.
class
statements for the classes you will implement. Save your work in mindreader.py
.
HTCounter
We will use a class called HTCounter
, whose objects will represent a number of heads and tails. These objects will be used as values in our memory
dictionary. Create this class with the following methods.
__init__
: initialize the number of heads and number of tails of the new object to 0. You should do this using two instance variables called _heads
and _tails
. The underscores here are meant to deter you from trying to access these private variables from outside of the class. We want these counters to be modified only by methods of the class, described next.
get_heads
, get_tails
: these two methods return the number of heads and tails, respectively.
inc_heads
, inc_tails
: these two methods increment the number of heads and the number of tails, respectively.
__str__
: return a string of the form [h:x, t:y]
, where x
is the number of heads and y
is the number of tails.
How is using these objects as values in the memory
dictionary more advantageous than your approach from assignment 3? Are there drawbacks to this more object-oriented approach? (Answer in oop.txt
.)
HTCounter
In a file named test_a4.py
, write three Nose tests for your HTCounter
class. Each test should instantiate a new object of the HTCounter
class, (possibly) call some of its methods, and finally assert
that the expected result holds. For example, one of your test cases might be that a newly created HTCounter
object represents zero heads and zero tails. Be sure that each of your tests stresses a different ``category'' of situations. These are the only Nose tests we are asking you to write for this assignment.
ShiftBuffer
Our ShiftBuffer
class will encapsulate the idea of a context. Internally, the shift buffer will still be represented as a string, but now the only access to that underlying representation is through the ShiftBuffer
objects' methods. (In other words, now we are being explicit that a context is not just a string.)
Create this class with the following methods. (Again, use leading underscores in your private instance variables.)
__init__
: takes and stores a string representing the initial contents of the ShiftBuffer
.
shift
: take a character representing the user's next guess, and create a new ShiftBuffer
object whose initial string consists of all of the characters from the original buffer except the leftmost character, followed by the new guess. Return this new ShiftBuffer
.
num_heads
, num_tails
: return the number of heads and tails currently in the buffer string, respectively. For example, if the string for a ShiftBuffer
is hhht
, then num_heads
should return 3 and num_tails
should return 1.
__str__
: return the underlying string used to hold the shift buffer.
__eq__
: take parameter other
, and return True
exactly when the contents of this buffer are exactly the same as the contents of other
.
__hash__
: return the sum of the number of heads times three, plus the number of tails, in this buffer. For example, for the buffer holding hhtt
, __hash__
would return 8. (This hash method gives Python information required to store these objects as keys in dictionaries.)
Here, we have wrapped a string object inside another object and then restricted what can be done with the encapsulated string. Why is this more advantageous than using the string directly? (Answer in oop.txt
.)
MindReader
Our MindReader
class manages the components of a mindreader game. Its objects hold references to a shift buffer, a memory (still represented as a dictionary), and a predictor object. We will see that the predictor object is responsible for actually providing the prediction algorithm used by the computer opponent. Create this class with the following methods.
__init__
: take parameters size
and predictor
. Initialize self
as follows: create ShiftBuffer
initially with size
asterisks, create an empty dictionary for memory
, and store the supplied predictor object as an instance variable. (Therefore, you should be assigning three instance variables of self
here.)
store_guess
: take the user's guess, guess
, as a parameter. look up the current context (represented by the ShiftBuffer
object stored as an instance variable of self
) in memory to retrieve an HTCounter
object, or add a new HTCounter
to memory if the context is not found. In this HTCounter
object, increment the counter (heads or tails) corresponding to the user's guess. Finally, shift the context (the ShiftBuffer
) with the user's guess.
get_prediction
: make a call to predict
of the predictor object stored by the constructor.
Instead of embedding the prediction algorithm in the MindReader
class itself, we have split it off into predictor objects. When we create a MindReader
object, we will configure it with a given predictor object to dictate the prediction algorithm the computer should use. To create a predictor object, we instantiate any
class that has a predict
method. Any class with a predict
method is said to conform to the predictor interface. We will create two such classes now.
Create a class named TimeSeries
, with only a predict
method. This method will take a context (a ShiftBuffer
object) and a memory (a dictionary), in that order, and make a prediction based on the number of heads and tails that occur in memory under the given context. The algorithm should implement the strategy used by the computer opponent in assignment 3. That is, predict heads if heads is more common, predict tails if tails is more common, or otherwise predict randomly.
Now, create a class named MoreRecent
, also with only a predict
method. The predict
method should have the same signature as predict
in TimeSeries
. This class will encapsulate a different prediction technique, as follows. If the current context string contains more heads, guess heads; if it contains more tails, guess tails; otherwise, guess randomly. Notice that we are not using the memory at all, even though it is passed as a parameter to this method.
Object-oriented programming has benefited from catalogues of design patterns that capture its lessons learned and best practices. The patterns have proven useful in a wide variety of circumstances, and programmers refer to these patterns in order to take advantage of this shared collective wisdom.
Creating separate classes housing each prediction algorithm, and initializing MindReader
objects with a reference to a predictor object, is an example of the strategy design pattern. We call each prediction algorithm a strategy. For the following discussion, assume that we have a large number of strategies, such as twenty.
There are two alternative approaches we could take to supporting multiple strategies.
Mindreader
class. We would have methods predict_time_series
, predict_more_recent
, and so on, having one method for each prediction algorithm. The get_prediction
method would then use an if... elif... elif ... else
construct to decide, based on a parameter, which algorithm to call.
get_prediction
method. For example, we might have class TimeSeriesMindreader
, inheriting from Mindreader
and defining a get_prediction
method that implements the prediction strategy of assignment 3.
In oop.txt
, provide a small paragraph for each of these two alternatives, discussing their advantages and disadvantages and comparing them to the strategy pattern. For example:
Take a look at the code in play.py. When you have created all of the classes above, this code can be used to play a game of Mindreader. It is very similar to the play
function we gave you in assignment 3, except that the current version creates objects of user-defined classes instead of built-in Python types.
By changing one line in play.py
, we can change the prediction algorithm from the one represented by TimeSeries
to the one represented by MoreRecent
. In oop.txt
, explain how we would make this change.
These are the aspects of your work that we will focus on in the marking:
oop.txt
will be graded for discussion clarity, strength of arguments, correctness and completeness.
Hand in the following files:
mindreader.py
test_a4.py
oop.txt