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 Buttons and stores these Buttons 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 the args 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 saying args 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() and str() on the buttons to use when solving this problem. For example, repr(b1) or str(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?
_____

© 2023 Brigham Young University, All Rights Reserved