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

Worksheet 3 Solutions

MCS 275 Spring 2021 - David Dumas

Object-oriented programming

The exercises on this worksheet concern our discussion of object-oriented programming (from lectures 4, 5, and 6).

Instructions

Complete these coding exercises. Each one asks you to write one or more programs or modules. Even though these are not collected, you should get into the habit of following the coding standards that apply to all graded work in MCS 275.

1. Kelvin temperature classes

The Kelvin temperature scale is one where the temperature of 0K (0 Kelvins) represents absolute zero, the lowest possible temperature. One Kelvin is the same size as one degree celsius, 0K is about -273.15°C.

It doesn't really make sense to add two temperatures; doing so would be a bit like adding one student's score on a quiz to another student's score on that quiz. You could come up with a scenario where that's the right thing to do, but in most cases, the answer would not have any direct meaning.

On the other hand, the ratio of two temperatures is meaningful, and has no units. 100K divided by 50K is equal to 2, which represents the ratio of average kinetic energies of the particles of an object. Similarly, it does make sense to multiply temperatures by numbers; for example, multiplying a temperature by 2 answers the question "If I doubled the average kinetic energy of each particle in this object, what would its new temperature be?".

(One reason for using Kelvins here is that multiplication of Kelvins is meaningful, whereas multiplication of Celsius or Fahrenheit degrees is not.)

Use operator overloading to make two classes:

  • class Temperature to represent a temperature measured in Kelvins, given as a nonnegative float

    • The constructor should expect one argument, a float, that specifies the temperature in Kelvins.
    • Printing a Temperature object in the REPL or with print() should show something like 301.5K
    • Taking the difference of two Temperature objects gives a TemperatureDifference object
    • Multiplication of a Temperature by an int or float gives another Temperature
    • Dividing one Temperature object by another Temperature object gives a float
    • Equality testing is supported; is only equal to another instance of Temperature with the same number of Kelvins.
  • class TemperatureDifference to represent the difference between two Temperature objects

    • The constructor should expect one argument, a float, that specifies a temperature difference in Kelvins (which may be positive, negative, or zero)
    • Printing a TemperatureDifference object in the REPL or with print() should show something like 301.5K difference
    • Addition or subtraction of two TemperatureDifference objects gives another TemperatureDifference object
    • Addition ot a TemperatureDifference object and a Temperature object (in any order) gives another Temperature object.
    • Equality testing is supported; is only equal to another instance of TemperatureDifference with the same number of Kelvins.
    • Dividing one TemperatureDifference by another gives a float
    • Multiplication of a TemperatureDifference by an int or float gives another TemperatureDifference

The test code below should work when you're done, assuming you've imported these two classes into the global scope. The comments show the expected output.

In [1]:
# MCS 275 Worksheet 3 Problem 1
# Jennifer Vaccaro
# I wrote this code in accordance with the rules defined in the syllabus.

class Temperature():
    """Represents a temperature measured in Kelvins, given as a nonnegative float"""
    def __init__(self, temp):
        """Constructor, stores a nonnegative float as the temperature value"""
        # check that the temp value is nonnegative
        if temp<0:
            raise ValueError("Temperature parameter temp represents Kelvins and cannot be negative")
        self.temp = temp
        
    def __repr__(self):
        """String representation looks like 301.5K"""
        return str(self)

    def __str__(self):
        """String representation looks like 301.5K"""
        return "{}K".format(self.temp)

    def __sub__(self, other):
        """Subtracting Temperature objects gives a TemperatureDifference object"""
        if isinstance(other, Temperature):
            return TemperatureDifference(self.temp - other.temp)
        else:
            return NotImplemented

    def __mul__(self, scale):
        """Multiplication by an int or float gives another Temperature"""
        if isinstance(scale, int) or isinstance(scale, float):
            return Temperature(self.temp*scale) # Will create a ValueError if negative
        else:
            return NotImplemented

    def __rmul__(self, scale):
        """Reverse multiplication by an int or float gives another Temperature"""
        return self*scale

    def __truediv__(self, other):
        """Division of Temperatures gives a scalar ratio"""
        if isinstance(other, Temperature):
            return self.temp / other.temp
        else:
            return NotImplemented

    def __eq__(self, other):
        """Compares the float equality of two Temperatures"""
        if isinstance(other, Temperature):
            return abs(self.temp - other.temp) < 1e-6 # This helps avoid rounding issues when comparing floats
        else:
            return False # If they are different types, then they cannot be equal

class TemperatureDifference():
    """Represents the difference between two Temperature objects"""
    def __init__(self, dif):
        """Constructor, dif may be a float of any value"""
        self.dif = dif

    def __repr__(self):
        """String representation like '301.5K difference' """
        return str(self)

    def __str__(self):
        """String representation like '301.5K difference' """
        return "{}K difference".format(self.dif)

    def __add__(self, other):
        """Addition with either a TemperatureDifference or a Temperature"""
        if isinstance(other, TemperatureDifference):
            return TemperatureDifference(self.dif+other.dif)
        elif isinstance(other, Temperature):
            return Temperature(self.temp+other.dif)
        else:
            return NotImplemented

    def __radd__(self, other):
        """Addition between Temperature and TemperatureDifference gives a Temperature object"""
        return self + other

    def __sub__(self, other):
        """Subtraction between TemperatureDifferences"""
        if isinstance(other, TemperatureDifference):
            return TemperatureDifference(self.dif - other.dif)
        else:
            return NotImplemented
    
    def __mul__(self, scale):
        """Multiplication by a scalar gives another TemperatureDifference"""
        if isinstance(scale, (int,float)): # scalar types are int and float
            return TemperatureDifference(self.dif*scale)
        else:
            return NotImplemented

    def __rmul__(self, scale):
        """Reverse multiplication by an int or float gives another TemperatureDifference"""
        return self*scale

    def __truediv__(self, other):
        """Division of TemperatureDifferences gives a scalar ratio"""
        if isinstance(other, TemperatureDifference):
            return self.dif / other.dif
        else:
            return NotImplemented

    def __eq__(self, other):
        """Compares the float equality of two TemperatureDifferences"""
        if isInstance(other, TemperatureDifference):
            return abs(self.dif - other.dif) < 1e-6 # This helps avoid rounding issues when comparing floats
        else:
            return False # If they are different types, then they cannot be equal
In [2]:
# Test code assuming `Temperature` and `TemperatureDifference` exist in global scope
T0 = Temperature(273.15) # 0°C
T1 = Temperature(373.15) # 100°C = boiling point of water at 1 atmosphere pressure
print(T0)  # 273.15K
DeltaOneK = TemperatureDifference(1) # 1K difference
print(T1-T0)  # 100K difference
print((T1-T0)/DeltaOneK) # 100
print(T0==T1) # False
print(T0==Temperature(273.15)) # True
print(2*(T1-T0)) # 200K difference
print((T1-T0)*2) # 200K difference
print(T1/T0) # 1.3660992128866922
T2 = 2*T0
print(T2)  # 546.3K
T2 = T0*2  # try multiplying in other order
print(T2)  # 546.3K

print("""If a sample of helium starts at 0°C, you'd need to heat it to a temperature
of {}°C in order to double the average kinetic energy of the atoms.""".format(
  (T2-T0)/(DeltaOneK)
))  # Should report need to heat to 273.15°C
273.15K
100.0K difference
100.0
False
True
200.0K difference
200.0K difference
1.3660992128866922
546.3K
546.3K
If a sample of helium starts at 0°C, you'd need to heat it to a temperature
of 273.15°C in order to double the average kinetic energy of the atoms.

2. Additional bots

This is another exercise about the robot simulations; you should work on it eventually, but if you are tired of that specific example and want to start with subclassing applied to another topic, go on to question 3 and come back to this one.

Any time after 3pm on Monday January 25, download the files for the robot simulation developed in Lectures 5-6:

Now, take the MarchBot class you made in worksheet 2 and convert it to a subclass of Bot in bots.py, so that it uses Point and Vector classes instead of storing pairs of integers and takes advantage of the methods inherited from Bot.

Then, add two new robots to bots.py that are subclasses of Bot:

  • class DelayMarchBot()
    • A robot that waits for a specified number of time units, and thereafter marches in a direction (default is Vector(1,0), but any direction can be specified in the constructor)
  • class PauseMarchBot()
    • 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 fixed direction that was set in the constructor
      • Pause for a moment (do nothing) (I don't think these should be subclasses of MarchBot, because they don't exhibit the behavior of MarchBot, which steps in the same direction every time.)

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

In [ ]:
"""bots.py Classes representing robots in a simulation"""
# MCS 275 Worksheet 3 Problem 2
# Jennifer Vaccaro
# I adapted this code (originally by David Dumas) in accordance with the 
# rules defined in the syllabus.


from plane import Point,Vector
import random

class Bot:
    """Base class for all robots.  Sits in one place forever."""
    def __init__(self,position):
        """Setup with initial position `position`"""
        self.alive = True
        self.position = position # intended to be a plane.Point instance
    
    def __str__(self):
        """Human-readable string representation"""
        return "{}(position={},alive={})".format(
            self.__class__.__name__,
            self.position,
            self.alive
        )

        # self.__class__ is Bot
        # self.__class__.__name__ is "Bot"

    def __repr__(self):
        """Unambiguous string representation"""
        return str(self)

    def update(self):
        """Advance one time step (by doing nothing)"""

class WanderBot(Bot):
    """Robot that wanders randomly"""
    steps = [ Vector(1,0),
              Vector(0,1),
              Vector(-1,0),
              Vector(0,-1) ]  # Class attribute
    def __init__(self,position):
        """Setup wandering robot with initial position `position`"""
        # Call the constructor of Bot
        super().__init__(position)
        # WanderBot-specific initialization
    
    def update(self):
        """Take one random step"""
        self.position += random.choice(self.steps)
        #             ^ ends up calling Point.__add__(Vector)

class DestructBot(Bot):
    """Robot that sits in one place for a while, then self-destructs"""
    def __init__(self,position,lifetime):
        """Setup a bot at position `position` that sits for
        `lifetime` steps before self-destructing."""
        super().__init__(position)
        self.lifetime = lifetime # number of steps remaining

    def update(self):
        """Decrease lifetime by one unit, and self-destruct
        if it reaches zero."""
        # We only decrease the lifetime if it is
        # nonzero.  That way, once it reaches zero,
        # it stays there.
        if self.lifetime:
            self.lifetime -= 1

        # alive should be True if lifetime>0,
        # or False if lifetime==0.
        self.alive = bool(self.lifetime)

class PatrolBot(Bot):
    """Robot that walks back and forth along a fixed route"""
    def __init__(self,position,direction,nstep):
        """Setup a bot at position `position` that takes steps
        along the vector `direction` for `nstep` units of time
        before turning around and going back.  This cycle
        repeats forever."""
        super().__init__(position)
        self.direction = direction
        self.nstep = nstep # total length of patrol route (number of steps)
        self.count = self.nstep # steps left before the next turn

    def update(self):
        """Take one step, turn around if appropriate"""
        self.position += self.direction # take one step
        self.count -= 1
        if self.count == 0:
            self.direction = -1*self.direction # turn around
            self.count = self.nstep
        
class DelayMarchBot(Bot):
    """Robot that waits, and then proceeds at a constant pace in a consistent direction"""
    def __init__(self, position, direction=Vector(1,0), wait_time=0):
        """Constructor sets up the bot with a position, direction, and wait_time"""
        super().__init__(position)
        self.direction = direction
        self.wait_time = wait_time
    def update(self):
        """If wait_time>0, waits. Otherwise, march in a consistent direction"""
        if self.wait_time>0:
            self.wait_time -= 1 # wait for an update cycle
        else:
            self.position += self.direction # after the waiting is done, march!

class PauseMarchBot(Bot):
    """Robot that either waits or marches, based on a random choice"""
    def __init__(self, position, direction=Vector(1,0)):
        """Constructor sets up the bot with a position and direction"""
        super().__init__(position)
        self.direction = direction
    def update(self):
        """Decides using random.choice whether to pause or march"""
        choice = random.choice(["pause", "march"])
        if choice == "march":
              self.position += self.direction
        # otherwise, if choice == "pause", do nothing
In [ ]:
"""botsimulation.py Classes representing robots in a simulation"""
# MCS 275 Worksheet 3 Problem 2
# Jennifer Vaccaro
# I adapted this code (originally by David Dumas) in accordance with the 
# rules defined in the syllabus.

from plane import Vector,Point
import bots
import random
import time

width=60
height=30

current_bots = []

# Make some wander bots
for i in range(5):
    P = Point(random.randint(0,width-1),random.randint(0,height-1))
    current_bots.append(bots.WanderBot(position=P))

# Make some patrol bots
patrol_directions = [ 
    Vector(1,0),
    Vector(0,1),
    Vector(1,1)
]
for i in range(10):
    P = Point(random.randint(0,width-1),random.randint(0,height-1))
    D = random.choice(patrol_directions)
    current_bots.append(bots.PatrolBot(position=P,direction=D,nstep=8))

# Make two destruct bots
current_bots.append(bots.DestructBot(position=Point(4,4),lifetime=5))
current_bots.append(bots.DestructBot(position=Point(4,10),lifetime=15))

# Make two DelayMarchBots
current_bots.append(bots.DelayMarchBot(position=Point(2, 5), direction=Vector(1,1), wait_time = 3))
current_bots.append(bots.DelayMarchBot(position=Point(2, 2), direction=Vector(1,1), wait_time = 6))

# Make two PauseMarchBots
current_bots.append(bots.PauseMarchBot(position=Point(0, 5), direction=Vector(0,1)))
current_bots.append(bots.PauseMarchBot(position=Point(0, 2), direction=Vector(0,1)))

# Symbols for the different kinds of bots
botsymbols = {
    bots.PatrolBot: "P",
    bots.DestructBot: "D",
    bots.WanderBot: "W",
    bots.Bot: "*",
    bots.DelayMarchBot: "X", # Add symbols for new bot types
    bots.PauseMarchBot: "#"
}

print("Press ENTER to begin the simulation")
input()

n=0
while True:
    print("\n"*2*height)
    board = [ [" "]*width for _ in range(height) ]
    for b in current_bots:
        if not b.alive:
            continue
        elif b.position.x < 0 or b.position.x >= width:
            continue
        elif b.position.y < 0 or b.position.y >= height:
            continue
        # Mark the spot with a symbol depending on bot type
        board[b.position.y][b.position.x] = botsymbols[b.__class__]
    
    # To print the board, we'll print a lot of newlines, then
    # the board itself, and then the time indicator.  We'll put those
    # into a single string to reduce the chance that part of the display
    # is updated before the whole thing is shown.  This makes the
    # "graphics" a little more fluid.
    boardstr = "\n"*3*height
    for row in board:
        boardstr+="".join(row) + "\n"
    boardstr += "time={}".format(n)
    print(boardstr,flush=True)
    time.sleep(0.2)

    for b in current_bots:
        b.update()
    n += 1

3. Cipher class hierarchy

Build a module encoders (in encoders.py) containing classes for simple ciphers (or codes; ways of obscuring the contents of a string that can be undone later by the intended recipient).

There should be a base class BaseEncoder that has two methods:

  • encode(self,text) : Returns the string text unchanged. Subclasses will alter this behavior.
  • decode(self,text) : Returns the string text unchanged. Subclasses will alter this behavior.

It should be the case that obj.decode(obj.encode(s)) == s is true for any string s, and for any object obj that is an instance of BaseEncoder or subclass thereof.

Then, build subclasses of BaseEncoder that implement encoding and decoding by different ciphers, including:

  • RotateEncoder : Encoding rotates letters in the alphabet forward by a certain number of steps, e.g. so rotation by 5 turns "a" into "f" and "z" into "e" (because we wrap around when we reach the end of the alphabet). No transformation is applied to characters other than capital and lower case letters. Constructor accepts an integer, specifying the number of steps to rotate.

  • Rot13Encoder : A subclass of RotateEncoder that fixes the steps at 13, so that encoding and decoding are the same operation.

  • SubstitutionEncoder : The constructor accepts two arguments, pre and post. The string pre is a list of characters to be replaced when encoding, and string post indicates the things to replace them with. For example, using pre="abcd" and post="1j4e" would mean that "a" is supposed to be replaced by "1", "b" by "j", "c" by "4", and so on.

    • Be careful writing the encoder so that you don't replace things twice. For example pre="abc" and post="bca" should encode "banana" to "cbnbnb", and not "ccncnc".
    • You can assume that pre and post contain the same characters but in a different order. If that's not the case, then it would be impossible to ensure that decoding after encoding always gives the original text back again.

You can find some test code below. The test code assumes all of the classes are in the global scope.

In [7]:
# MCS 275 Worksheet 3 Problem 3
# Jennifer Vaccaro
# I wrote this code in accordance with the rules defined in the syllabus.

"""Module encoders contains classes for simple ciphers"""

class BaseEncoder():
    """Base class for simple encoders"""
    def encode(self, text):
        """Base class returns string unchanged"""
        return text
    def decode(self, text):
        """Base class returns string unchanged"""
        return text

class SubstitutionEncoder():
    """Substitutes one string of text for another"""
    def __init__(self, pre, post):
        """Store attributes of characters to be replaced"""
        self.pre = pre
        self.post = post

    def encode(self, text):
        """Encodes the test using the rotate encoder"""
        text_encoded = ""
        for c in text:
            if c in self.pre:
                text_encoded += self.post[self.pre.index(c)] # substitute with the corresponding letter from post
            else:
                text_encoded += c # or add c, unchanged
        return text_encoded

    def decode(self, text):
        """Decodes the text using the rotate encoder"""
        text_decoded = ""
        for c in text:
            if c in self.post:
                text_decoded += self.pre[self.post.index(c)] # substitute with the corresponding letter from pre
            else:
                text_decoded += c # or add c, unchanged
        return text_decoded

class RotateEncoder(SubstitutionEncoder):
    """Encoding rotates alphabet letters forward by a certain number of steps"""
    def __init__(self, num_steps):
        """Constructor sets the integer number of steps to rotate by"""

        # Create the alphabet substitution blocks
        abc_upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
        abc_lower = abc_upper.lower()

        # pre is the alphabet, with all upper/lowercase letters in order
        pre = abc_upper + abc_lower
        # post is a "shuffled" version of the alphabets, offset by n
        post = abc_upper[num_steps:] + abc_upper[:num_steps] + abc_lower[num_steps:] + abc_lower[:num_steps]

        # Use pre and post as inputs in the superclass SubstitutionEncoder constructor
        # Then you don't need to write custom encode/decode functions
        super().__init__(pre, post)

class Rot13Encoder(RotateEncoder):
    """Encoding rotates alphabet letter by 13 steps"""
    def __init__(self):
        """Constructor calls super class RotateEncoder with num_steps 13"""

        # Use 13 as input in the superclass RotateEncoder constructor
        # Then you don't need to write custom encode/decode functions
        super().__init__(13)
In [8]:
E = RotateEncoder(5)
s = E.encode("Hello world!") # Mjqqt btwqi!
print(s) # Mjqqt btwqi!
print(E.decode(s)) # Hello world!

F = SubstitutionEncoder("lmno","nolm")
s = F.encode("Hello everyone!")
print(s) # Hennm everymle!
print(F.decode(s)) # Hello everyone!
Mjqqt btwqi!
Hello world!
Hennm everymle!
Hello everyone!

Bonus round

Work on these open-ended problems if you finish the exercises above. We don't plan to include solutions to these in the worksheet solutions, but we may do so if most people end up working on any of these.

Sending a key

The encoders in problem 3 don't handle the problem of communicating to your message recipient the information about what code you will use for future messages.

Add __str__ and __repr__ methods to the ciphers that give enough information so that a message recipient who is given encoded text and the return value of str(encoder_object) would be able to instantiate an encoder and decode a message.

More interesting cipher

Design and implement another cipher as a subclass of BaseEncoder which isn't as simple as substituting letters with specified replacements. For example, you might make it so that the way a letter is handled depends on both the letter and the text that's been encoded so far. Confirm that your cipher

DelayedActionBot

Make a robot class (a subclass of DestructBot) that stands still for a specified number of steps and then self-destructs. But before it does so, this class calls a user-specified function. The function is given as an argument to the constructor. So, for example:

def bye():
    """Robot says goodbye"""
    print("Thanks for including me in this simulation.  I was glad to be written in Python.  Goodbye.")

R = bots.DelayedActionBot(position=Point(3,3),lifetime=10,action=bye)

# ... code to run the simulation ...

should make a robot that sits at position (3,3) for 10 steps, prints a message, and then self-destructs.

The action argument of the constructor should default to None, and the class should know to not do anything if action==None. That way, any code that works with DestructBot will also work with DelayedActionBot.