Seminar 15 Snake game: object-oriented programming
We will not be programming a new game today. Rather, you will learn about object-oriented approach in Python and will use it to simplify your code of the Snake game that you already programmed. Yes, it would be more fun to program a new game but this way you get to concentrate on the concepts rather than on the game logic.
15.1 Object-oriented programming
The core idea is in the name: Instead of having variables/data and functions separately, you combine them in an object that has attrbutes/properties (its own variables) and methods (functions). This approach uses our natural tendency to perceive the world as a collection of interacting objects and has several advantages that I will discuss below.
15.1.1 Classes and objects (instances of classes)
Before we continue, I need to make an important distinction between classes and objects. A class is a “blue print” that describes properties and behavior (methods) of objects of that class. This “blue print” is used to create an instance of that class, which is called an object. For example, Homo sapiens is a class that describes species that have certain properties, such as height, and can do certain things, such as running. However, Homo sapiens as a class only has a concept of height but no specific height itself. E.g., you cannot ask “What is height of Homo sapiens?” only what is an average (mean, median, etc.) height of individuals of that class. Similarly, you cannot say “Run, Homo sapiens! Run!” as abstract concepts have trouble with real actions like that. Instead, it is Alexander Pastukhov who is an instance of Homo sapiens class with a specific height and a specific (not particularly good) ability to run. Other instances of Homo sapiens (other people) will have different height and a different (typically better) ability to run. Thus, class describes what kind of properties and methods objects have. This means that whenever you meet a Homo sapien, you could be sure that they have height. However, individual objects have different values for this properties and so calling their methods may result in different outcomes.
Another, a more applied, example would be your use of ImageStim
class to create multiple instances of front side of a card in “Memory” game. Again, the class defines properties (image
, pos
, size
, etc.) and methods (e.g., method draw()
) that individual objects will have. You created these objects to serve as front side of cards. You set different values for same properties (image
, pos
) and that ensured that when you call their method draw()
, each card was drawn at its own location and with it own image.
15.1.2 Encapsulation
Putting all the data (properties) and behavior (methods) inside the class simplifies programming by ensuring that all relevant information can be found in its definition. Thus, you have a single place that should hold everything that defines object’s behavior. Contrast this with our approach in previous two seminars where snake data (e.g., variable snake
) was defined at one place and functions that used and altered it (e.g., snake_bit_itself()
or grow_snake()
) were defined elsewhere. Moreover, we had to resort to using snake or apple in function names just to remind ourselves that they belong to the snake or an apple. This also necessitated creating functions with multiple arguments that ensured that all information is available within each function. And, we modified snake
inside the function creating further uncertainty about when and where it can be changed. Today, you will see how encapsulating everything into classes turns this mess into a simpler and easier-to-understand code.
15.2 Inheritance / Generalization
In object-oriented programming, a class can be derived from some other ancestor class and thus inherit its properties and methods. Moreover, several classes can be derived from a single ancestor producing a mix of unique and shared functionality. This means that instead of rewriting the same code for each class, you can define a common code in an ancestor class and focus on differences or additional methods and properties in descendants.
Using the Homo sapiens example from above. Humans, chimpanzees and gorillas are all different species but we share a common ancestor. Hence, we are different in many respect, yet, you could think about all of us as “apes” that have common properties such as binocular trichromatic vision. On other words, if you are interested in color vision, you do not care what specific species you are looking it, as all apes are the same in that respect. Or, you can move further down the evolution tree and think about us as “mammals” that, again, have common properties and behavior, such as thermoregulation and lactation. Again, if you are interested only in whether an animal has thermoregulation, knowing that it is a mammal is enough.
Similarly, in PsychoPy various visual stimuli that we used (ImageStim
, TextStim
, Rect
) have same properties (e.g., pos
, size
, etc.) and methods (most notably, draw()
). This is because they are all descendants from a common ancestor BaseVisualStim
that defines their common properties and methods27. This means that you can assume that any visual stimulus (as long as it descends from BaseVisualStim
) will have size
, pos
, ori
and can be drawn. This, in turn, means that you can have a list of various PsychoPy visual stimuli and move or draw all of them in a single loop without thinking which specific visual stimulus you are moving or drawing. Also note that you cannot assume these same proporties of sound stimuli because they are not descendants of BaseVisualStim
but of _SoundBase
class.
Where is another way of achieving common behavior (generalization) in Python without inheritance. It is called “duck typing”28 and it will be the topic of a different seminar.
15.3 Polymorphism
As you’ve learned in the previous section, inheritance allows different descendants to share common properties and behavior, so that in certain cases you can view them as being equivalent to an ancestor. E.g., any visual stimulus (a descendant of BaseVisualStim
class) can be drawn, so you just call its draw()
method. However, it is clear that these different stimuli implement drawing differently, as the Rect
stimulus looks different from the ImageStim
or TextStim
. This is called “polymorphism” and the idea is to keep the common interface (same draw()
call) while abstracting away the actual implementation. This allows you to think about what you want an object to do (or what to do with an object), instead of thinking how exactly it is implemented.
15.4 A minimal class example
Enough of the theory, let us see how classes are implemented in Python. Here is a very simple class that has nothing but the constructor __init__()
method, which is called whenever a new object (class instance) is created, and a single attribute / property total
.
class Accumulator:
"""
Simple class that accumulates (sums up) values.
Properties
----------
total : float
Total accumulated value
"""
def __init__(self):
"""
Constructor, initializes the total value to zero.
"""
self.total = 0
# here we create an object number_sum, which is an instance of class Accumulator.
= Accumulator() number_sum
Let’s go through it line by line. First line class Accumulator:
shows that this is a declaration of a class
whose name is Accumulator
. Note that the first letter is capitalized. This is not required per se, so Python police won’t be knocking on your door if you write it all in lower or upper case. However, the general recommendation is that class names are written using UpperCaseCamelCase
whereas object (instances of the class) names are written using lower_case_snake_case
. This makes distinguishing between classes and objects easier, so you should follow this convention.
The definition of the class are the remaining indented lines. As with functions or loops, it is the indentation that defines what is inside and what is outside of the class. The only method we defined is def __init__(self):
. This is a special method29 that is called when an object (instance of the class) is created. This allows you to initialize the object based on parameters that were passed to this function (if any). You do not call this function directly, rather it is called whenever an object is created, e.g. number_sum = Accumulator()
(last line). Also, it does not return any value explicitly via return
. Instead, self
(the very first parameter, more on it below) is returned automatically.
All class methods (apart from special cases we currently do not concern ourselves with) must have one special first parameter that is the object itself. By convention it is called self
30. It is passed to the method automatically, so whenever you write square.draw()
(no explicit parameters written in the function call), the actual method still receives one parameter that is the reference to the square
variable whose method you called. Inside a method, you use this variable to refer to the object itself.
Let us go back to the constructor __init()__
to see how you can use self
. Here, we add a new persistent attribute/property to the object and assign a value to it: self.total = 0
. It is persistent, because even though we created it inside the method, the mutable object is passed by reference and, therefore, we assigned it to the object itself. Now you can use this property either from inside self.total
or from outside number_sum.total
. You can think of properties as being similar to field/value pairs in the dictionary we used during previous seminar but for syntax: object.property
versus dictonary["field"]
31. Technically, you can create new properties in any method or even from outside (e.g., nothing prevents you from writing number_sum.color = "red"
). However, this makes understanding the code much harder, so the general recommendation is to create all properties inside the constructor __init__()
method, even if this means assigning None
to them32.
15.5 add
method
Let us add a method that adds 1 to the total
property.
class Accumulator:
# I am skipping all previous code here
...
def add(self):
"""
Add 1 to total
"""
self.total += 1
It has first special argument self
that is the object itself and we simply add 1 to its total
property. Again, remember that self
is passed automatically whenever you call the method, meaning that an actual call looks like number_sum.add()
.
Create a Jupyter notebook (you will need to submit it as part of the assignment) and copy-paste the code for Accumulator
class, including the .add()
method. Create two objects, call them counter1
and counter2
. Call .add()
method twice for counter2
and thrice for counter1
(bonus: do it using for
loop). What is the value of the .total
property of each object? Check it by printing it out.
Copy-paste and test Accumulator
class code in a Jupiter notebook.
15.6 Flexible accumulator with a subtract
method
Now lets us create a new class that is a descendant of the Accumulator
. We will call it FlexibleAccumulator
as it will allow you to also subtract from the total count. You specify ancestors (could be more than one!) in round brackets after the class name
class FlexibleAccumulator(Accumulator):
pass # You must have at least one non-empty line, and pass means "do nothing"
Now you have a new class that is a descendant of Accumulator
but, so far, is a perfect copy of it. Add subtract
method to the class. It should subtract 1
from the .total
property (don’t forget to document it!). Check that it works. Create one instance of Accumulator
and another one of FlexibleAccumulator
class and check that you can call add()
on both of them but subtract()
only for the latter.
Add subtract
method to the FlexibleAccumulator
class in a Jupiter notebook. Add testing.
15.7 Method arguments
Now, create a new class SuperFlexibleAccumulator
that will be able to both add()
and subtract()
arbitrary value! Think about which class it should inherit from. Redefine both .add()
and .subtract()
method in that new class by adding value
argument to both method and add/subtract this value rather than 1
. Note that now you have two arguments in each method (self, value)
but when you call you only need to pass the latter (again, self
is passed automatically). Don’t forget to document value
argument (but you do not need to document self
as its meaning is fixed).
Create SuperFlexibleAccumulator
class and define super flexible add
and subtract
methods that have value
parameter ( in a Jupiter notebook). Test them!
15.8 Constructor arguments
Although constructor __init(...)__
is special, it is still a method. Thus, you can pass arguments to it just like you did it for other methods. You pass these arguments when you create an object, so in our case, you put it inside the bracket for counter = SuperFlexibleAccumulator(...)
.
Modify the code so that you pass the initial value that total is set to, instead of zero.
::: {.infobox .program}
Add initial_value
parameter to the constructor of the SuperFlexibleAccumulator
class in a Jupiter notebook. Test it!
:::
15.9 Calling methods from other methods
You can call a function or object’s method at any point of time, so, logically, you can use methods inside methods. Let’s modify our code, realizing that subtracting a value is like adding a negative value. Modify your code, so that .subtract()
only negates the value before passing is to .add()
for actual processing. Thus, total
is modified only inside the add()
method.
Modify subtract()
method of SuperFlexibleAccumulator
to utilize add()
in a Jupiter notebook. Test it!
15.10 Local variables
Just like normal functions, you methods can have local variables. They are local (visible and accessible only from within the method) and are not persistent (their values do not survive between the calls). Conceptually, you separate variables that need to be persistent (retain their value the whole time object exists) as attributes/properties and temporary variables that are need only for the computation itself as local method variables. What would be value of property .total
in this example:
class Accumulator:
def __init__(self, initial):
= initial * 2
temp self.total = inital
= Accumulator(1) counter
What about in this case?
class Accumulator:
def __init__(self, initial):
= initial * 2
temp self.total = temp
= Accumulator(1) counter
15.11 Refactoring the Snake game
Got the basic idea about using classes? Let us practice by partially refactoring the snake game. In principle, we can factor it into three classes / objects:
- a window class that will incorporate grid information and mapping
- an apple class
- a snake class
Here, we will cover the first two but I encourage you to think about how to convert snake-bits into a class as well. Before you begin, create a new folder (snake2, snake-oop, etc.) and copy your old code where.
15.12 GridWindow class
One of the most annoying things about our current code was the necessity to pass grid information (GRID_SIZE
, SQUARE_SIZE
, etc.) as well as window variable every time we needed to create a new segment, or an apple, or grow snake, etc. It would be much simpler, if our window object would also include information on the grid (grid and square sizes) as well as the function that maps grid-to-window coordinates (map_grid_to_win()
). In this case, we would only need to pass the window variable that has all information that snake or apple function and classes will need.
We still want the window to behave like PsychoPy window, so our GridWindow
will inherit from it. Create a new file gridwindow.py and create a new class as follows. We will discuss the specific bits below.
# 1. import
class GridWindow(Window):
"""
Grid window, inherits from PsychoPy window and adds grid attributes and functions.
"""
def __init__(self, grid_size, square_size_pix):
"""
Parameters
----------
grid_size : tuple
Size of the grid
square_size_pix : integer
size of a single square in pixels
"""
# 2. store grid_size as an attribute, so that later on you
# could use it as self.grid_size (from inside) and
# win.grid_size (from outside)
# 3. compute square_size attribute
# 4. call the constructor of the ancestor
super().__init__(units="norm", size=(however-you-computed-it))
def map_grid_to_win(self, ipos):
"""
Converts grid coordinates to window coordinates in height units.
Parameters
-----------
ipos : tuple
(x, y) coordiantes on the grid
Returns
-----------
tuple
(norm_x, norm_y) coordinates in the window
"""
# 5. code that computed the transformation but that uses
# class attribute for square size
Let us go through the code!
- You do need to import some modules, which ones?
- We pass the
grid_size
as a parameter to the constructor but we need to retain in for future computation. Hence, we should store it as a persistent attributed of the class. You can (should) use the same name but remember this is an attribute not a constant, so you should usesnake_lower_case
for the name. - In the original code, you store the computed square size as a constant. Here, you store it as a persistent attribute but, otherwise, the idea is the same.
- New and somewhat complicated bit! In the original code, we created a window and specified its size in pixels (computing it from the grid size and size of the square in pixels) and
"norm"
units. We still need to do this, so that the window is initialized properly. In the constructor we wrote the code that takes care of new bits but we need to ensure that the ancestor class does the old bits. For this, we need to call its constructor (__init__()
method). But there is a catch, we overwrote it with a brand new__init__()
method of our own, so we cannot just call it viaself.__init__()
33. Instead, Python hassuper()
function that refers to the ancestor of the class. Hence,super().__init__(your-arguments-inside)
initializes the window. Note that you needsuper()
only if you override the original method, as we did with__init__()
. E.g., you do not need it to callflip()
method defined in the original class. - The code is essentially the same but you can use square size attribute, so no need to pass it as a separate argument.
And in the main code, you now create the window as
= GridWindow(GRID_SIZE, SQUARE_SIZE_PIX) win
15.13 Apple class
Create it in a separate apples.py file. The apple class has no ancestors and we just need to put all methods and variables together. Here are lists of attributes and methods.
Attributes:
win
: you should pass window variable to the constructor of anApple
class and store it as an attribute for later use when you (re)create the apple at random position.pos
: same as the field in the original dictionary but now as an attribute.visuals
: same as the field in the original dictionary but now as an attribute.
Methods:
__init__(self, win)
should store window as attribute and create empty attributespos
andvisuals
.reset(self, snake)
: modified version ofcreate_apple()
function but you use grid size and square size attributes of theself.win
instead of passing them as arguments. It should overwrite any old values ofself.pos
andself.visuals
instead of returning the dictionary.draw()
: should draw the apple!
Now, you need to create an apple
object after you create the window and reset()
it whenever you need a new apple (at the beginning of each round and once the snake ate the apple).
15.14 Refactoring the code
Start by changing with window class to GridWindow
without doing anything else. The code should “just work” as our GridWindow
has all the functionality of PsychoPy Window
that we relied upon. Next, go through the snake code (the apple has its own class, so we will deal with it later) to use window attributes instead of constants and simplify functions. E.g., create_snake_segment(win, ipos, square_size)
does not need the third argument anymore, as you can get it from win
itself. Similarly, you do not need it for grow_snake()
function. Alter one function at a time and test your code.
Next, use Apple
class for the apple and adjust the code accordingly using attributes and draw()
method. Here, you have to do all alterations in one go, of course, as changes we introduced are breaking.
Remember, one step at a time, check your code before continuing. Put breakpoints and use debug console, if you are unsure about the changes.
BaseVisualStim
does not actually definedraw()
method, only that it must be present.↩︎Yes, it is really called “duck typing”.↩︎
There are more special methods that you will learn about later, they all follow
__methodname__()
convention.↩︎Again, you can use any name but that will surely confuse everyone.↩︎
This is actually how all properties and methods are stored, in a
__dict__
attribute, so you can writenumber_sum.__dict__["total"]
to get it.↩︎If you use a linter, it will complain whenever it sees a property not defined in the constructor↩︎
Well, we can write that but we will be calling out brand new
__init__()
again.↩︎