Lab 07 - Classes
Due by 11:59pm on 2024-28-01.
Starter Files
Download lab07.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 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.
Implementing a __str__
dunder method
allows our class to output some human readable text when called by the str()
or print()
functions. This dunder
method should return a string containing the cotent to be outputed.
For example, if we add the following code to our Student
class:
def __str__(self):
return f"{self.name} is a {self.major} major."
We could now call str()
on a student object and it would 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.
__repr__
similarly outputs text, but text that is more machine readable. It will most often be a string containing
how the object was created.
def __repr__(self):
return f"Student('{self.name}', '{self.major}')"
>>> elle = Student("Elle", "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: Keyboard
We’d like to create a Keyboard
class that takes in an arbitrary number of Button
s and stores these Button
s
in a dictionary. This dictionary will be called buttons
. The keys in the dictionary will be integers that represent
the button's postition on the Keyboard
, and the values will be the respective Button
. Fill out the methods in the
Keyboard
class according to each description, using the doctests as a reference for the behavior of a Keyboard
.
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 Button:
def __init__(self, pos, key):
self.pos = pos
self.key = key
self.times_pressed = 0
class Keyboard:
"""A Keyboard takes in an arbitrary amount of buttons, and has a
dictionary of positions as keys, and values as Buttons.
>>> b1 = Button(0, "H")
>>> b2 = Button(1, "I")
>>> k = Keyboard(b1, b2)
>>> k.buttons[0].key
'H'
>>> k.press(1)
'I'
>>> k.press(2) # No button at this position
''
>>> k.typing([0, 1])
'HI'
>>> k.typing([1, 0])
'IH'
>>> b1.times_pressed
2
>>> b2.times_pressed
3
"""
def __init__(self, *args):
________________
for _________ in ________________:
________________
def press(self, info):
"""Takes in a position of the button pressed, and
returns that button's output."""
if ____________________:
________________
________________
________________
________________
def typing(self, typing_input):
"""Takes in a list of positions of buttons pressed, and
returns the total output."""
________________
for ________ in ____________________:
________________
________________
def add_button(self, button):
"""Adds a button to the keyboard if the position is not taken"""
if ____________________:
________________
________________
________________
Test your code:
python3 -m pytest test_lab07.py::test_keyboard_press_and_typing
Q3: __str__
and __repr__
Add from scratch the __str__
and __repr__
dunder methods to the Button
and Keyboard
class.
For the Button
class, __repr__
should return a string on how a button was constructed and
__str__
should return a string describing the button's information.
More specifically, your code should output the following:
>>> b1 = Button(0, "H")
>>> repr(b1)
"Button(0, 'H')"
>>> str(b1)
"Key: 'H', Pos: 0"
>>> print(b1)
Key: 'H', Pos: 0 # recall that print() calls str()
Test your code:
python3 -m pytest test_lab07.py::test_button_str_and_repr
For the Keyboard
class, __repr__
should return a string on how the keyboard was constructed and __str__
should return a string containing all the keyboard's button information.
Your code should output the following:
>>> b1 = Button(0, "H")
>>> b2 = Button(1, "I")
>>> k = Keyboard(b1, b2)
>>> repr(k)
"Keyboard(Button(0, 'H'), Button(1, 'I'))"
>>> str(k)
"Key: 'H', Pos: 0 | Key: 'I', Pos: 1"
Hint: You can call
repr()
andstr()
on the buttons to use when solving this problem. For example,repr(b1)
orstr(b2)
.
Test your code:
python3 -m pytest test_lab07.py::test_keyboard_str_and_repr
For Practice you can also add the following doctest to your code to test your add
function.
>>> b1 = Button(0, "H")
>>> b2 = Button(1, "I")
>>> k = Keyboard(b1, b2)
>>> str(k)
"Key: 'H', Pos: 0 | Key: 'I', Pos: 1"
>>> k.add_button(Button(2, "!"))
>>> k.add_button(Button(2, "?"))
>>> str(k)
"Key: 'H', Pos: 0 | Key: 'I', Pos: 1 | Key: '!', Pos: 2"
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
If you attend the lab, you don't have to submit anything.
If you don't attend the lab, you will have to submit working code.
Submit the lab07.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 slightly more clear that study_group()
is a function that is supposed to be used for
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 preprended 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
Whenever classes share this many similarities, there is a lot of extra duplicate code. Generally
we want to avoid duplicate code as this will make our code simpler, easier to understand, and maintainable.
We cannot make one class that shares both the functionality and state of Student
and Professor
as they have some differences like the study_with()
or give_help_to()
methods that, for this example, we
want to keep unique to its respective class. What we can do is make a super class that contains Student
's
and Professor
's similarities and have both of these classes inherit 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?
_____