Lab 10 - Classes
Due by 11:59pm on February 20, 2025
Starter Files
Download lab10.zip. Inside the archive, you will find starter files for the questions in this lab.
Topics
Object-Oriented Programming (OOP)
Object-oriented programming (OOP) is a programming paradigm that allows us to treat data as objects, like we do in real life.
For example, consider the class Student
. Each of you are a student, so that means you would be an instance of this class or an object.
Details that all CS 111 students have, such as name
, are called instance variables. Every student has these variables, but their values differ from student to student.
A variable, whose value is shared among all instances of Student is known as a class variable. For example, the max_slip_days
attribute is a class variable because it is a property of all students. Every student gets the same maximum number of slip days.
All students are able to do homework, attend lecture, and go to office hours. When functions belong to a specific object, they are called methods. In this case, these actions would be methods of Student objects.
Example implementation:
class Student:
max_slip_days = 3 # This is a class variable. Every student has the same max slip days
def __init__(self, name, major): # This is called a constructor
self.name = name # This is an instance variable. Students may have unique names
self.major = major
self.energy = 10
self.understanding = 0
print("Added", self.name)
def do_homework(self): # This is a method
if self.energy <= 0:
print("Too tired!")
self.understanding -= 1
else:
print("Wow, that was interesting!")
self.understanding += 1
self.energy -= 1
Notice, if you want to ever access an attribute or call a method from an object you have to use dot notation. For example, from outside of the class:
>>> elle = Student("Elle", "Law")
>>> elle.name
"Elle"
>>> elle.energy
10
>>> elle.do_homework()
Wow, that was interesting!
>>> elle.energy
9
If you want to reference an object's attribute or method from within the class, you have to use something like
self.understanding
where self
refers to an object's own self. (Refer back to the code above if needed.)
Try doing Q1: WWPD: Classes.
__str__
and __repr__
Dunder Methods
Dunder methods (named for their double underscores) are special methods that allow your class and objects to interact with existing builtin Python functions.
For this lab, we will focus on the __str__
and __repr__
dunder methods although others do exist such as the
__add__
, __mul__
, and __len__
dunder methods.
A __str__
dunder method return a string version of the object to be outputted. In this lab we will specify what we want this string to contain, but in practice it can just be a helpful string representation of an object. This method allows our class to output some human readable text when called by the str()
or print()
functions.
For example, if we add the following code to our Student
class:
def __str__(self):
return f"{self.name} is a {self.major} major."
Now when we call str()
on a student object, it will retrieve the string provided by the __str__
dunder
method. Additionally, print()
, when dealing with objects, tries to find a __str__
dunder method. If print()
finds one, it prints out the string provided by the __str__
dunder method.
>>> elle = Student("Elle", "Law")
>>> str(elle)
'Elle is a Law major.'
>>> print(elle)
Elle is a Law major.
The __repr__
method is similar to the __str__
method in that it similarly outputs text, but text that is more machine readable. It will most often be a string representing
how the object was created (i.e. what you would have typed in to create the object -- see below).
def __repr__(self):
return f"Student('{self.name}', '{self.major}')"
>>> elle = Student("Elle", "Law") # create a Student object named elle, with self.name = 'Elle', and self.major = 'Law'
>>> repr(elle)
"Student('Elle', 'Law')"
Required Questions
Q1: WWPD: Classes
Try the WWPD questions for the following code:
class Student:
max_slip_days = 3 # This is a class variable. Every student has the same max slip days
def __init__(self, name, staff):
self.name = name # This is an instance variable. Students may have unique names
self.understanding = 0
staff.add_student(self)
print("Added", self.name)
def visit_office_hours(self, staff):
staff.assist(self)
print("Thanks, " + staff.name)
class Professor:
def __init__(self, name):
self.name = name
self.students = {}
def add_student(self, student):
self.students[student.name] = student
def assist(self, student):
student.understanding += 1
def grant_more_slip_days(self, student, days):
student.max_slip_days = days
>>> callahan = Professor("Callahan")
>>> elle = Student("Elle", callahan)
_____
>>> elle.visit_office_hours(callahan)
_____
>>> elle.visit_office_hours(Professor("Paulette"))
_____
>>> elle.understanding
_____
>>> [name for name in callahan.students]
_____
>>> x = Student("Vivian", Professor("Stromwell")).name
_____
>>> x
_____
>>> [name for name in callahan.students]
_____
>>> elle.max_slip_days
_____
>>> callahan.grant_more_slip_days(elle, 7)
>>> elle.max_slip_days
_____
>>> Student.max_slip_days
_____
Q2: Library
We’d like to create a Library
class that takes in an arbitrary number of Book
s and stores these Book
s
in a dictionary. This dictionary should be called books
. The keys in the dictionary will be integers that represent
the book's id number in the Library
, and the dictionary's values will be the respective Book
object itself. Fill out the methods in the
Library
class according to each description, using the doctests as a reference for the behavior of a Library
.
Remember to read the doctests.
Note: To allow our constructor to take an arbitrary amount of arguments, we need to learn some new Python syntax. A
*args
parameter allows a function or method to take in any amount of arguments (although theargs
part is not required. What is required is the*
in front of the parameter name). Python effectively grabs all of those arguments and puts them into a tuple (a container like lists, but with different properties).*args
can be converted to a tuple collection by sayingargs
by itself without an asterisk*
symbol.
def func(*args):
return args
a_tuple = func("a", 4, "q") # aTuple will now equal -> ("a", 4, "q")
class Book:
def __init__(self, id, title, author):
self.id = id
self.title = title
self.author = author
self.times_read = 0
class Library:
"""A Library takes in an arbitrary amount of books, and has a
dictionary of id numbers as keys, and Books as values.
>>> b1 = Book(0, "A Tale of Two Cities", "Charles Dickens")
>>> b2 = Book(1, "The Hobbit", "J.R.R. Tolkien")
>>> b3 = Book(2, "The Fellowship of the Ring", "J.R.R. Tolkien")
>>> l = Library(b1, b2, b3)
>>> l.books[0].title
'A Tale of Two Cities'
>>> l.books[0].author
'Charles Dickens'
>>> l.read_book(1)
'The Hobbit has been read 1 time(s)'
>>> l.read_book(3) # No book with this id
>>> l.read_author("Charles Dickens")
'A Tale of Two Cities has been read 1 time(s)\\n'
>>> l.read_author("J.R.R. Tolkien")
'The Hobbit has been read 2 time(s)\\nThe Fellowship of the Ring has been read 1 time(s)\\n'
>>> b1.times_read
1
>>> b2.times_read
2
"""
def __init__(self, *args):
"""Takes in an arbitrary number of book objects and
puts them in a books dictionary which takes the book
id as the key and the book object as the value"""
___________
for _____ in ______:
_______________
def read_book(self, id):
"""Takes in an id of the book read,
increments the number of times that book was read,
and returns a string describing that book's
title and the number of times it has been read.
(See doctests above for required formatting) """
if ___ in ___________:
__________________
__________________
__________________________________
def read_author(self, author):
"""Takes in the name of an author, and
returns a single string containing every
book written by that author (and how many times it was read).
The book descriptions should be formatted in
the same way as the as in the read_book method.
Hint: Each book output should be on a different line."""
______________
for _______________:
if __________________:
_______________________________
______________
def add_book(self, book):
"""Takes in a book object and adds it to the books
dictionary if the book id is not already taken."""
if ______________:
______
else:
_____________
Test your code:
python3 -m pytest test_lab10.py::test_library_read_and_read_author
Q3: __str__
and __repr__
Add from scratch the __str__
and __repr__
dunder methods to the Book
and Library
classes.
The Book
class __repr__
should return a string on how a book was constructed and
__str__
should return a string describing the book's information.
More specifically, your code should output exactly the following:
>>> b1 = Book(0, "A Tale of Two Cities", "Charles Dickens")
>>> repr(b1)
"Book(0, 'A Tale of Two Cities', 'Charles Dickens')"
>>> str(b1)
'A Tale of Two Cities by Charles Dickens'
>>> print(b1)
'A Tale of Two Cities by Charles Dickens' # recall that print() calls str()
Hint: try using the built-in .join() function. This is how you use it:
<string>.join(<list_of_strings>)
Example:
>>> names = [James, Mary, Robert, Patricia]
>>> ' | '.join(names)
'James | Mary | Robert | Patricia'
Test your code:
python3 -m pytest test_lab10.py::test_book_str_and_repr
The Library
class __repr__
should return a string on how the library was constructed and __str__
should return a string containing all the library's book information.
Your code should output the following:
>>> b1 = Book(0, "A Tale of Two Cities", "Charles Dickens")
>>> b2 = Book(1, "The Hobbit", "J.R.R. Tolkien")
>>> l = Library(b1, b2)
>>> repr(l)
"Library(Book(0, 'A Tale of Two Cities', 'Charles Dickens'), Book(1, 'The Hobbit', 'J.R.R. Tolkien'))"
>>> str(l)
'A Tale of Two Cities by Charles Dickens | The Hobbit by J.R.R. Tolkien'
Hint: You can call
repr()
andstr()
on the books to use when solving this problem. For example,repr(b1)
orstr(b2)
.
Test your code:
python3 -m pytest test_lab10.py::test_library_str_and_repr
For Practice you can also add the following doctest to your code to test your add
function.
>>> b1 = Book(0, "A Tale of Two Cities", "Charles Dickens")
>>> b2 = Book(1, "The Hobbit", "J.R.R. Tolkien")
>>> l = Library(b1, b2)
>>> str(l)
'A Tale of Two Cities by Charles Dickens | The Hobbit by J.R.R. Tolkien'
>>> l.add_book(Book(2, "The Fellowship of the Ring", "J.R.R. Tolkien"))
>>> l.add_book(Book(2, "The Sorcerer's Stone", "J.K. Rowling"))
>>> str(k)
'A Tale of Two Cities by Charles Dickens | The Hobbit by J.R.R. Tolkien | The Fellowship of the Ring by J.R.R. Tolkien'
Potentially Useful Reminders
It may be helpful to know that when dealing with strings, some characters have special meaning such as quotation marks " "
.
If you want to tell python to interpret quotation marks literally rather than as the start and end of a string,
you can use back slash \'
. For example
>>> print('I\'m cool')
I'm cool
>>> s = 'I\'m cool'
>>> s
"I'm cool"
When iterating through a dictionary, a for
loop iterates through the keys:
d = {'a': 0, 'b': 1, 'c': 2}
for key in d:
print(key) # 'a', 'b', 'c'
print(d[key]) # 0, 1, 2
Submit
Submit the lab10.py
file on Canvas to Gradescope in the window on the assignment page.
Additional Info
Static Methods
Let's say we wanted to add a study_group()
function that takes in a list of students and increasing the understanding of each student by 2
.
def study_group(student_list):
for student in student_list:
student.understanding += 2
Based on our current understanding, since this function does not rely on a student object to use, we should not add it as a method to
in the Student
class. We should keep it outside of the class definition.
class Student:
max_slip_days = 3 # This is a class variable. Every student has the same max slip days
def __init__(self, name, major): # This is called a constructor
self.name = name # This is an instance variable. Students may have unique names
self.major = major
self.energy = 10
self.understanding = 0
print("Added", self.name)
def do_homework(self): # This is a method
if self.energy <= 0:
print("Too tired!")
self.understanding -= 1
else:
print("Wow, that was interesting!")
self.understanding += 1
self.energy -= 1
def study_group(student_list):
for student in student_list:
student.understanding += 2
However, since this method does deal with the Student
class, it would be nice for organizational purposes to have it somehow
belong to the Student
class so it is clearer that study_group()
is a function that is supposed to be used on
Student
objects.
Fortunately, Python supports a feature where you can call a method from a class without needing to have an object to call it. This is called a
static method. We can place study_group()
within the class definition and add the decorator @staticmethod
on top of the method definition.
class Student:
max_slip_days = 3 # This is a class variable. Every student has the same max slip days
def __init__(self, name, major): # This is called a constructor
self.name = name # This is an instance variable. Students may have unique names
self.major = major
self.energy = 10
self.understanding = 0
print("Added", self.name)
def do_homework(self): # This is a method
if self.energy <= 0:
print("Too tired!")
self.understanding -= 1
else:
print("Wow, that was interesting!")
self.understanding += 1
self.energy -= 1
@staticmethod
def study_group(student_list):
for student in student_list:
student.understanding += 2
Before making study_group()
a static method, we would have to call it like this:
>>> elle = Student("Elle", "Law")
>>> isaih = Student("Isaih", "Computer Science")
>>> max = Student("Max", "Math")
>>> study_group([elle, isaih, max])
Now as a static method, we would have to call study_group()
with the class name prepended to it:
>>> elle = Student("Elle", "Law")
>>> isaih = Student("Isaih", "Computer Science")
>>> max = Student("Max", "Math")
>>> Student.study_group([elle, isaih, max])
Inheritance
In some cases, objects may share several similarities. Down below are two classes, Student
and Professor
,
that share name
and energy
instance variables and a read()
and sleep()
method.
class Student:
def __init__(self, name, major):
self.name = name
self.major = major
self.understanding = 10
self.energy = 10
def read(self, time):
self.understanding += 2 * time
self.energy -= 2 * time
def sleep(self, time):
self.energy += 2 * time
def study_with(self, other_student):
if other_student.major == self.major:
self.understanding += 2
other_student.understanding += 2
class Professor:
def __init__(self, name, field):
self.name = name
self.field = field
self.understanding = 50
self.energy = 10
def read(self, time):
self.understanding += 2 * time
self.energy -= 2 * time
def sleep(self, time):
self.energy += 2 * time
def give_help_to(self, other):
other.understanding += 5
self.energy -= 1
When classes share many fields (i.e name, understanding, energy), this can turn into a lot of duplicate code. Generally,
if we avoid code duplication, our code will be simpler, easier to understand, and maintainable.
We can't make one class that shares both the functionality and state of Student
and Professor
because they have some important differences like the study_with()
or give_help_to()
methods that, for this example, we
want to keep unique to their respective classes. What we can do is make a super class that contains Student
's
and Professor
's similarities. Then we can have both the Student and Professor class inherit the things they need from the new super class.
To have a class inherit its attributes and methods from a super class, use the following syntax when first declaring a class:
class <class_name>(<super_class>):
...
This will make the methods in the <super_class>
accessible to any classes that inherit from
it. However, in the case we want to make the instance variables in the super class accessible
to the classes inheriting from it, in the constructor of our class we need to call the super class's
constructor. It follows this syntax:
def __init__(self, attribute1, attribute2):
super().__init__(attribute1) # <-------
self.attribute2 = attribute2
Of course, the attributes to call to the super class's constructor will vary; just pay close attention to the super class's constructor parameters. (Note: the example below may help you understand this a lot better as it brings it altogether.)
Down below is a more concrete example done by creating a Person
class
that the Student
and Professor
class inherits from.
class Person:
def __init__(self, name):
self.name = name
self.energy = 10
self.understanding = 10
def read(self, time):
self.understanding += 2 * time
self.energy -= 2 * time
def sleep(self, time):
self.energy += 2 * time
class Student(Person): # <--------------
def __init__(self, name, major):
super().__init__(name) # <--------------
self.major = major
def study_with(self, other_student):
if other_student.major == self.major:
self.understanding += 2
other_student.understanding += 2
class Professor(Person): # <--------------
def __init__(self, name, field):
super().__init__(name) # <--------------
self.field = field
self.understanding = 50
def give_help_to(self, other):
other.understanding += 5
self.energy -= 1
WWPD: Inheritance
Using the above code, figure out what Python would do. If you get stuck, run the code.
(It is recommended that you copy the above code into a python file and run the file in
interactive mode by typing python3 -i <your_program>.py
. This way you test your thinking
more easily.)
>>> isaih = Student("Isaih", "Computer Science")
>>> isaih.name
_____
>>> isaih.energy
_____
>>> isaih.understanding
_____
>>> isaih.sleep(2)
>>> isaih.energy
_____
>>> stephens = Professor("Stephens", "Astronomy")
>>> stephens.name
_____
>>> stephens.energy
_____
>>> stephens.understanding
_____
>>> stephens.read(2)
>>> stephens.understanding
_____
>>> stephens.energy
_____
>>> # You can also create a Person object
>>> elle = Person("Elle")
>>> elle.understanding
_____
>>> elle.study_with(Student("Emily", "Humanities")) # Is this valid?
_____