A document from MCS 275 Spring 2024, instructor Emily Dumas. You can also get the notebook file.

MCS 275 Spring 2024 Worksheet 3 Solutions

  • Course instructor: Emily Dumas
  • Contributers to this document: Emily Dumas, Johnny Joyce, Kylash Viswanathan, Patrick Ward

Topics

This worksheet corresponds to material from lectures 4-5, which focus on these aspects of object-oriented programming:

  • Operator overloading
  • Subclasses and inheritance

Resources

These things might be helpful while working on the problems. Remember that for worksheets, we don't strictly limit what resources you can consult, so these are only suggestions.

1. Boolean coercion

At the moment, if you use a Vector2 object in an if statement or any other place that a boolean is expected, it will always be considered True:

In [11]:
vzero = plane.Vector2(0,0)
vzerofloat = plane.Vector2(0.0,0.0)
v = plane.Vector2(1,1)
w = plane.Vector2(0,2)

if vzero:
    print("Vector of zeros (integer) is considered True")
if vzerofloat:
    print("Vector of zeros (float) is considered True")
if v:
    print(v,"is considered True")    
if w:
    print(w,"is considered True")
Vector of zeros (integer) is considered True
Vector of zeros (float) is considered True
Vector2(1,1) is considered True
Vector2(0,2) is considered True

That's not because of anything we put in the class definition, but because it's Python's default behavior. If a class doesn't specify how it should be coerced to a boolean, then it will always be considered True.

A more reasonable behavior for Vector2 is to consider it False if both components are equal to zero, and True otherwise. You can implement that behavior by adding a method __bool__ to Vector2 that takes no arguments and returns the boolean that represents the value of self.

Add this feature. If you are successful, the behavior of the same code shown above should change to:

In [13]:
vzero = plane.Vector2(0,0)          # now False
vzerofloat = plane.Vector2(0.0,0.0) # now False
v = plane.Vector2(1,1)              # now True
w = plane.Vector2(0,2)              # now True

if vzero:
    print("Vector of zeros (integer) is considered True")
if vzerofloat:
    print("Vector of zeros (float) is considered True")
if v:
    print(v,"is considered True")    
if w:
    print(w,"is considered True")
Vector2(1,1) is considered True
Vector2(0,2) is considered True

Solution

Adding this method inside the definition of class Vector2 suffices:

In [ ]:
    def __bool__(self):
            "considers vector false if both components are equal to zero and true otherwise"
            if self.verbose:
                print("called: {}.__bool__".format(self.__class__.__name__))
            return self.x != 0 or self.y != 0

2. Indexed coordinate access

This is another problem about Point2 and Vector2.

If instead of using these classes we stored coordinates in a list, e.g.

In [14]:
a = [5,3]

Then we couldn't use a.x and a.y to get the coordinates. Instead we'd access them by index:

In [15]:
a[0] # x coordinate is at index 0
Out[15]:
5
In [16]:
a[1] # y coordinate is at index 1
Out[16]:
3

Sometimes it is nicer to have named attributes for coordinates, as in Point2 and Vector2, but the ability to refer to coordinates using index can also be convenient (e.g. if a loop needs to do the same thing to each coordinate).

To add indexing to Point2 and Vector2, add a special method __getitem__ to each class so that if a is an object of type Point2 or Vector2, then a[0] will return the x coordinate and a[1] will return the y coordinate. The way indexing relates to the special method is that a[i] evaluates to a.__getitem__(i).

Attempting to get any other index on a Point2 or Vector2 object should raise IndexError.

Here's a demo of the expected behavior:

In [18]:
v = plane.Vector2(5,7)
print("v.x =",v.x)
print("v[0] =",v[0])  # asking for v[0] calls v.__getitem__(0)
print("v.y =",v.y)
print("v[1] =",v[1])  # asking for v[1] calls v.__getitem__(1)
v.x = 5
v[0] = 5
v.y = 7
v[1] = 7
In [19]:
v[-3]  # v.__getitem__(-3)
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
/tmp/ipykernel_4654/381668737.py in <module>
----> 1 v[-3]  # v.__getitem__(-3)

/tmp/ipykernel_4654/1846116892.py in g(self, k)
      2     if k in (0,1):
      3         return [self.x,self.y][k]
----> 4     raise IndexError("Index must be 0 or 1")
      5 plane.Vector2.__getitem__ = g

IndexError: Index must be 0 or 1
In [20]:
v["Irrational Exuberance"]   # v.__getitem__("Irrational Exuberance")
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
/tmp/ipykernel_4654/3241521498.py in <module>
----> 1 v["Irrational Exuberance"]   # v.__getitem__("Irrational Exuberance")

/tmp/ipykernel_4654/1846116892.py in g(self, k)
      2     if k in (0,1):
      3         return [self.x,self.y][k]
----> 4     raise IndexError("Index must be 0 or 1")
      5 plane.Vector2.__getitem__ = g

IndexError: Index must be 0 or 1

Solution

Note the solution is identical for the point class and vector class. Just add this method to the class definition:

In [ ]:
    def __getitem__(self,i):
            "obtain vector (or point) coordinates in list access format"
            if i == 0:
                return self.x
            if i == 1:
                return self.y
            # If we get to this point, it's an invalid index
            raise IndexError('Index must be 0 or 1')

3. Additional bots

Download these files related to the robot simulation from the course sample code repository and put them in a directory where you'll do your work for this problem.

Note: I recommend you make a copy of the plane.py you edited in the previous problems (e.g. rename it plane2.py) and download a fresh copy of the original file. That way, if you accidentally introduced bugs into plane.py in your previous work, they won't affect this problem.

Now, open bots.py and add these new subclasses of Bot:

  • class DelayMarchBot

    • The constructor accepts a position, a number of time units (delay), a direction.
    • The robot that waits in its initial position for delay time units.
    • After the initial wait, the robot marches forever, taking steps in the given direction.
  • class LazyMarchBot

    • The constructor accepts a position and a direction.
    • At each time step, the robot chooses one of these two things to do based on a coin flip (random choice):
      • Take a step in a direction that was set in the constructor
      • Pause for a moment (do nothing)
  • class SpiralBot

    • In addition to its starting position, the constructor takes an argument steps.
    • Initially marching in the direction Vector2(1,0), after steps time units it makes a right hand 90-degree turn.
    • It continues marching and turning regularly so that it ultimately follows a spiral path like the one shown below.
    • When it gets to the end of the spiral, the robot stops moving but remains active.

spiral path

Add these robots to the simulation and confirm they exhibit the expected behavior.

Solution

New additions to the bot class

In [8]:
class DelayMarchBot(Bot):
    "Robot that waits for a given time then marches in a given direction"
    symbol = "d"
    def __init__(self, position, direction, delay_time):
        "Constructor sets up bot with position, wait time and direction"
        super().__init__(position)
        #DelayMarchBot-specific initialization
        self.delay_time = delay_time
        self.direction = direction
        
    def update(self):
        "Stay put or take one step"
        if self.delay_time > 0:
            self.delay_time -= 1 #pause for one cycle
        else: 
            self.move_by(self.direction) #use move from Bot class
    
class LazyBotMarch(WanderBot):
    "Robot that randomly stays in places or walks in a given direction"
    symbol = "L"
    def __init__(self, position, direction):
        "Sets up bot with position and direction"
        super().__init__(position)
        self.direction = direction

    def update(self):
        "Take one step or pause, randomly"
        possible_steps = [
            plane.Vector2(0, 0),
            self.direction
        ]
        step = random.choice(possible_steps) #chooses randomly a step in the given direction or a null step
        self.move_by(step)

class SpiralBot(Bot):
    "Robot that walks in a spiral of a given size"   
    symbol = "@"
    spiral_directions = [
        plane.Vector2(1, 0),
        plane.Vector2(0, 1),
        plane.Vector2(-1, 0),
        plane.Vector2(0, -1),
    ]    
    def __init__(self, position, steps):
        "Initialize a SpiralBot that walks in a spiral"
        #Call the Bot constructor
        super().__init__(position)
        #SpiralBot specific initialization
        self.steps = steps #tracks the length of the current line
        self.current = steps #tracks how far along the current line bot is
        self.dirtick = 0 #tracks what directions bot is moving

    def update(self):
        "Move robot in a spiral"
        #note down direction in the terminal is increasing y coordinate
        if self.steps > 1:
            if self.current == 0:
                self.dirtick += 1 #when bot reaches the end of the current line change direction
                if self.dirtick % 2 == 0: 
                    self.steps -= 1 #decrease the length of each line every two turns
                self.current = self.steps
            i = self.dirtick % 4 #direction rotates through possible_steps
            self.move_by(self.spiral_directions[i]) 
            self.current -= 1

Note that LazyMarchBot could almost be made a subclass of WanderBot, except that WanderBot has its list of possible directions as a class attribute, while LazyMarchBot must consider taking a step specified in its constructor (hence different for each instance).

Revision history

  • 2024-01-26 Initial release