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: Library

We’d like to create a Library class that takes in an arbitrary number of Books and stores these Books in a dictionary. This dictionary will be called books. The keys in the dictionary will be integers that represent the book's id number in the Library, and the 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 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 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)'
>>> l.read_author("J.R.R. Tolkien")
'The Hobbit has been read 2 time(s)'
'The Fellowship of the Ring has been read 1 time(s)'
>>> b1.times_read
1
>>> b2.times_read
2
"""


def __init__(self, *args):
___________
for _____ in ______:
_______________

def read_book(self, id):
"""Takes in an id of the book read, and
returns that book's title and the number
of times it has been read."""

if ___ in ___________:
__________________
__________________
__________________________________

def read_author(self, author):
"""Takes in the name of an author, and
returns the total output of reading every
book written by that author in a single string.
Hint: Each book output should be on a different line."""

______________
for _______________:
if __________________:
_______________________________
______________

def add_book(self, book):
if ______________:
______
else:
_____________

Test your code:

python3 -m pytest test_lab07.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 class. For 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 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_lab07.py::test_book_str_and_repr

For 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() and str() on the books to use when solving this problem. For example, repr(b1) or str(b2).

Test your code:

python3 -m pytest test_lab07.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 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