Classes and Iterators

Warm-up Exercise: Write a class Journal to keep a record of journal entries. We want an .add() method to add an entry (that should be a string) and we want the str() function to return each entry on a new line, prefixed by the date it was added. The date.today() function from the datetime module will help here.

In [1]:
import datetime as dt

class Journal:
def __init__(self):
self.entries = []

self.entries.append((dt.date.today(), entry))

def __str__(self):
return '\n'.join([f"{str(e[0])}: {e[1]}" for e in self.entries])

j = Journal()
j.add("Today was a good day :)")
j.add("The day is still good!")

print(j)

2020-10-19: Today was a good day :)
2020-10-19: The day is still good!


Exercise 1: Write a class Student that allows us to store important information about a student. Specifically we want to be able to create a student with a name and id number. We want to be able to change the name but not the id number, but we want to be able to retrieve both. When str() is called on a student it should return the name and id number.

(solution below)

.

.

.

.

.

.

.

.

In [2]:
class Student:
def __init__(self, name, id_num):
self.__name = name
self.__id = id_num

def set_name(self, name):
self.__name = name

def get_name(self):
return self.__name

def get_id(self):
return self.__id

def __str__(self):
return f"{self.__name} (id #{self.__id})"

s1 = Student('Robert', 14)
print(s1)
s1.set_name('Bob')
print(s1)

Robert (id #14)
Bob (id #14)


Exercise 2: Modify the previous Student class. We want to implement the magic method __lt__ so that we can sort a list of students by name and then by ID number. (That is, Student A < Student B is A's name is alphabetically before Student B's or if they have the same name but Student A has a lower ID number.)

Note: .__lt__(self, other) corresponds to self < other and returns True whenever this is true. This is the only method you need to be able to sort a list of your class's objects.

(solution below)

.

.

.

.

.

.

.

.

In [3]:
class Student:
def __init__(self, name, id_num):
self.__name = name
self.__id = id_num

def change_name(self, name):
self.__name = name

def set_name(self):
return self.__name

def get_id(self):
return self.__id

def __str__(self):
return f"{self.__name} (id #{self.__id})"

def __lt__(self, other):
if self.__name < other.__name:
return True
elif self.__name == other.__name:
return self.__id < other.__id
else:
return False

# for testing

list = [Student('Alice', 12), Student('Charlie', 6), Student('Bob', 3), Student('Alice', 4)]
list.sort()
print([str(s) for s in list])

['Alice (id #4)', 'Alice (id #12)', 'Bob (id #3)', 'Charlie (id #6)']


There are also __gt__, __le__, __ge__, __eq__, and __ne__ which correspond to >, <=, >=, ==, and !=, respectively. If you implement __eq__ then you don't need to implement __ne__ (but not vice-versa). All of the rest must be implemented separately (or you can use a decorator, which we haven't learned about yet). You only need to implement __gt__ or __lt__ to be able to sort a list of your class's objects.

Exercise 3: Write a class CheatingCoin which allows you to create a coin that flips normally except every n times it always comes up heads.

That is, you can let coin = CheatingCoin(n) and call coin.flip(). Every time you call coin.flip() you have a 50-50 chance of True or False, except that every n-th time it is guaranteed to be True.

(solution below)

.

.

.

.

.

.

.

.

In [4]:
import random

class CheatingCoin:
def __init__(self, n):
self.__flips = 0
self.__cheat_flip = n

def flip(self):
self.__flips += 1
if self.__flips == self.__cheat_flip:
self.__flips = 0
return True
else:
return random.randint(0,1) == 1

coin = CheatingCoin(3)
print(coin.flip())
print(coin.flip())
print(coin.flip())

False
True
True


We can modify the above class to allow us to flip and check the result of a CheatingCoin like this:

In [5]:
if coin:
# executes if the flip was heads/True
else:
# executes if the flip was tails/False
print("Tails!")

Heads!


by implementing the __bool__ method. Because CheatingCoin was already defined we can do a neat trick where we just set this identifier to a lambda expression (in Python functions are just a type of value, so we can do this!). This isn't great practice, but it's kind of cool and worth seeing. This does the same as just writing the method with the header def __bool__(self): to begin with, which is what you should normally do in this type of situation.

In [6]:
CheatingCoin.__bool__  = lambda self : self.flip()  # normally just define __bool__ with the class

if coin:
# executes if the flip was heads/True
else:
# executes if the flip was tails/False
print("Tails!")

Tails!


Exercise 4: Write a function SortedList which allows us to have a list that we always keep sorted. You should implement the following methods:

add(self, e) - a normal method to add a new element e to the list
__add__(self, other) - a magic method for +, will create a new SortedList with all the elements in self and other
__len__(self) - a magic method for len(), returns the length of the list
__contains__(self, e) - a magic method for in, returns if the element e is in the list or not getList(self) - returns a (sorted) list containing all of the elements we have

Make sure that no one can modify the values the SortedList contains without calling the add() method.

(solution below)

.

.

.

.

.

.

.

.

In [7]:
class SortedList:
def __init__(self):
self.__list = []

self.__list.append(e)
self.__list.sort() # there are more efficient ways but this works

def getList(self):
return self.__list.copy() # if we don't copy, they could modify the list itself

new_l = SortedList();
for e in self.__list:
for e in other.__list:
return new_l

def __len__(self):
return len(self.__list)

def __contains__(self, e):
return e in self.__list

sl1 = SortedList()

sl2 = SortedList()

print(len(sl1))
print(1 in sl1)
print(3 in sl1)

sl = sl1 + sl2

print(sl.getList())

3
True
False
[1, 2, 2, 4, 5, 6]