Week 11: Notes

inheritance versus composition

In the last lecture we learned about object-oriented inheritance, and learned that a class can be a subclass of another class.

In designing an object-oriented program, sometimes we must decide whether a class A should be a subclass of a class B. In this situation, it's sometimes useful to ask whether the entities A and B have an is-a or a has-a relationship. If every instance of A is an instance of B, then inheritance makes sense. On the other hand, if every instance of A has an instance of B, then probably it is better to use composition, in which A has an attribute that points to a B.

For example, suppose that we are designing software for an auto repair shop. We might have a class Engine, with attributes such as capacity, horsepower, maker, and so on. We might also have a class Car, with its own set of attributes. Should Car inherit from Engine? In theory you could say that a car is like an engine, but has many additional features. However, this inheritance relationship would be questionable at best. It's more accurate to say that a car has an engine, so really the Car class should have an attribute that points to an Engine object.

As another example, suppose that we have a class ChessGame that represents a game of chess. It might have methods such as turn(), which returns the player (1 or 2) whose turn it is to move, move(), which makes a move, and winner(), which tells us which player (if any) has won the game. And suppose that we write an AI player for the game. Should we now have a class AIChessGame which is a subclass of ChessGame in which one player's moves are controlled by the AI? I also think this would be questionable. After all, I could write a second AI player, and then I'd like to be able to have the AI players play each other. But if each is in its own subclass, this won't work well. It's more natural to think of an AI player as an agent that uses a ChessGame object. So it would probably make more sense to have an AIPlayer class, which could take a ChessGame e.g. as an initializer argument.

Beginning programmers sometimes use inheritance in situations where composition would be more appropriate, so it's best to be a bit cautious. If you are unsure about whether to use inheritance or composition in a given situation, composition may be a better choice, especially since in general it leads to more flexibility in your program.

testing an object's type

We've already seen that Python includes a type() function that will return the type of any object:

>>> type(4)
<class 'int'>
>>> type('hello')
<class 'str'>

Suppose that I implement a hierarchy of classes, e.g.

class Event:
    def __init__(self, time):
        self.time = time

    ...

class Concert(Event):
    def __init__(self, time, artist):
        super().__init__(time)
        self.artist = artist

    ...

class SportsGame(Event):
    def __init__(self, time, team1, team2):
        super().__init__(time)
        self.team1 = team1
        self.team2 = team2

    ...

Now if I create an instance of any class, the type() function will map the instance to its class:

>>> from datetime import datetime
>>> c = Concert(datetime(2023, 12, 11, 17, 0), 'mig 21')
>>> type(c)
<class '__main__.Concert'>

Python also includes a built-in function isinstance() that tests whether an object has a certain type:

>>> isinstance(3, int)
True
>>> isinstance('hello', str)
True

isinstance() also works with user-defined classes:

>>> isinstance(c, Concert)
True
>>> isinstance(c, Event)
True

Notice that this last query returned True because a Concert is an Event! To put it differently, isinstance(o, C) returns true if C is the class of o, or is any ancestor of that class. This is often useful. For example, if we had many subclasses of Event, we could use isinstance() to tell whether an object represents any kind of event.

the 'match' statement

Python contains a statement 'match' that can test a value against a series of patterns. If the value matches any pattern, then a block of code associated with the pattern will run. In the simplest case, each pattern is just a constant value. For example:

match x:
    case 2:
        x += 10
    case 4:
        x += 20
        print(x)
    case 6:
        print(x - 10)
    case _:
        x += 30

This is exactly equivalent to

if x == 2:
    x += 10
elif x == 4:
    x += 20
    print(x)
elif x == 6:
    print(x - 10)
else:
    x += 30

Notice that the pattern '_' matches any value at all.

We see that a 'match' statement can be a convenient alternative to a series of 'if' statements that test the same value. (This is like a 'case' statement in languages such as C, C++ or Java.)

Actually the 'match' statement is much more powerful than this. For example, a pattern may be a list that contains both constants and variables. When this sort of pattern matches, Python will set the variables to the corresponding values. For example, suppose that we're writing an adventure game in which the player can type commands such as "look", "go north" or "take book". We might parse the player's commands as follows:

command = input()
match command.split() with
    case ['look']:
        look()
    case ['go', direction]:
        go(direction)
    case ['take', thing]:
        take(thing)
    ...

Suppose the player types "go north". The split() method will build the list ['go', 'north']. That will match the second pattern above, and Python will set the variable direction to the string 'north'. Then it will perform the function call go('north').

Various other kinds of match patterns exist, but we will not explore them in this course. Most often we will use 'match' in its simplest form, for matching against a series of constants.

However, be warned: you cannot use 'match' to match against a constant that's held in a variable. For example, this will fail!

RED = 1
BLUE = 2
GREEN = 3

c = int(input())
match c:
    case RED:
        print('rose')
    case BLUE:
        print('ocean')
    case GREEN:
        print('leaf')

The 'match' statement above will always print 'rose'. That's because the pattern RED looks like a variable name, which it will match any value at all! To see this, let's modify the code so that it prints out the value of RED:

RED = 1
BLUE = 2
GREEN = 3

c = int(input())
match c:
    case RED:
        print(RED)
        print('rose')
    case BLUE:
        print('ocean')
    case GREEN:
        print('leaf')

Now if we run this program and enter the number 2, it will print

2
rose

To avoid this problem, you can simply fall back on a series of 'if' clauses. There is nothing wrong with that:

if c == RED:
    print('rose')
elif c == BLUE:
    print('ocean')
elif c == GREEN:
    print('leaf')

Or, if you want to use 'match', you could make RED, BLUE and GREEN be class attributes:

class Color:
    RED = 1
    BLUE = 2
    GREEN = 3

c = int(input())
match c:
    case Color.RED:
        print('rose')
    case Color.BLUE:
        print('ocean')
    case Color.GREEN:
        print('leaf')

This will behave as expected. Python interprets any pattern containing a "." as a constant, not a variable.

magic methods for equality

In this course we have already seen several of Python's magic methods: __init__, __repr__, plus operator overloading methods such as __add__ and __sub__.

Let's revisit the Vec class for representing vectors, which we saw in an earlier lecture:

class Vec:
    def __init__(self, *a):
        self.a = a
    
    def __add__(self, w):
        assert len(self.a) == len(w.a)
        b = []
        for i in range(len(self.a)):
            b.append(self.a[i] + w.a[i])
        return Vec(*b)

    # Generate a string representation such as [3 5 10].
    def __repr__(self):
        w = []
        for x in self.a:
            w.append(str(x))
        return '[' + ' '.join(w) + ']'

As a reminder, the class works like this:

>>> v = Vec(2, 4, 6)
>>> w = Vec(10, 20, 30)
>>> v + w
[12 24 36]

Now suppose that we create two Vec objects with the same coordinates. Are they equal?

>>> v = Vec(2, 4, 6)
>>> w = Vec(2, 4, 6)
>>> v == w
False

Python does not consider them to be equal. By default, two instances of a user-defined class are equal only if they are the same object, i.e. the 'is' operator returns True when applied to the objects.

Now, we may wish to change this. Two vectors are mathematically equal if they have the same coordinates, so in that case it would make sense for them to be equal according to Python's == operator. Python includes a magic method __eq__ that we may use to define equality on any class we like. Let's add an implementation of __eq__ to the Vec class:

# in class Vec
def __eq__(self, w):
    return self.a == w.a

With this method in place, v and w will be equal:

>>> v = Vec(2, 4, 6)
>>> w = Vec(2, 4, 6)
>>> v == w
True

Vec is intended to be an immutable class, so we might like to use it as a dictionary key. Let's attempt to create a dictionary that maps vectors to integers:

>>> v = Vec(2, 4, 6)
>>> x = Vec(10, 20, 30)
>>> d = {v: 100, x: 200}
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'Vec'

Python won't let us.

Python implements a dictionary as a hash table, a data structure that we recently studied in Introduction to Algorithms. The problem here is that we have redefined equality on the Vec class, but now Python doesn't know how to compute a hash function for Vec objects. Suppose that v == w. Then d[v] should be the same as d[w], since v and w are mathematically equal. In other for that to work, v and w must have the same hash vaue. More generally speaking, if two objects are equal using ==, then they must have the same hash value.

And so if we implement the __eq__ magic method on a class, then we must also implement another magic method called __hash__ if we wish to use instances of our class as hash table keys. __hash__ returns a hash code for an object; it is automatically invoked by Python's hash() function, which Python also uses in its dictionary implementation.

Let's add an implementation of __hash__ to the Vec class:

# in class Vec
def __hash__(self):
    return hash(self.a)

Now we can use Vec objects as dictionary keys:

>>> v = Vec(2, 4, 6)
>>> w = Vec(2, 4, 6)
>>> x = Vec(10, 20, 30)
>>> d = {v: 100, x: 200}
>>> d[v]
100
>>> d[w]
100
>>> d[x]
200