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.
number_sum = Accumulator()

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 self30. 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):
        temp = initial * 2
        self.total = inital
        
counter = Accumulator(1)

What about in this case?

class Accumulator:
    def __init__(self, initial):
        temp = initial * 2
        self.total = temp
        
counter = Accumulator(1)

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!

  1. You do need to import some modules, which ones?
  2. 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 use snake_lower_case for the name.
  3. 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.
  4. 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 via self.__init__()33. Instead, Python has super() function that refers to the ancestor of the class. Hence, super().__init__(your-arguments-inside) initializes the window. Note that you need super() only if you override the original method, as we did with __init__(). E.g., you do not need it to call flip() method defined in the original class.
  5. 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

win = GridWindow(GRID_SIZE, SQUARE_SIZE_PIX)

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 an Apple 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 attributes pos and visuals.
  • reset(self, snake) : modified version of create_apple() function but you use grid size and square size attributes of the self.win instead of passing them as arguments. It should overwrite any old values of self.pos and self.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.


  1. BaseVisualStim does not actually define draw() method, only that it must be present.↩︎

  2. Yes, it is really called “duck typing”.↩︎

  3. There are more special methods that you will learn about later, they all follow __methodname__() convention.↩︎

  4. Again, you can use any name but that will surely confuse everyone.↩︎

  5. This is actually how all properties and methods are stored, in a __dict__ attribute, so you can write number_sum.__dict__["total"] to get it.↩︎

  6. If you use a linter, it will complain whenever it sees a property not defined in the constructor↩︎

  7. Well, we can write that but we will be calling out brand new __init__() again.↩︎