Lecture 8
- Object-Oriented Programming
- Classes
raise- Decorators
- Connecting to Previous Work in this Course
- Class Methods
- Static Methods
- Inheritance
- Inheritance and Exceptions
- Operator Overloading
- Summing Up
Object-Oriented Programming
- There are different paradigms of programming. As you learn other languages, you will start recognizing patterns like these.
- Up until this point, you have worked procedurally step-by-step.
- Object-oriented programming (OOP) is a compelling solution to programming-related problems.
- To begin, type
code student.pyin the terminal window and code as follows:name = input("Name: ") house = input("House: ") print(f"{name} from {house}")Notice that this program follows a procedural, step-by-step paradigm: Much like you have seen in prior parts of this course.
- Drawing on our work from previous weeks, we can create functions to abstract away parts of this program.
def main(): name = get_name() house = get_house() print(f"{name} from {house}") def get_name(): return input("Name: ") def get_house(): return input("House: ") if __name__ == "__main__": main()Notice how
get_nameandget_houseabstract away some of the needs of ourmainfunction. Further, notice how the final lines of the code above tell the interpreter to run themainfunction. -
We can further simplify our program by storing the student as a
tuple. Atupleis a sequence of values. Unlike alist, atuplecan’t be modified. In spirit, we are returning two values.def main(): name, house = get_student() print(f"{name} from {house}") def get_student(): name = input("Name: ") house = input("House: ") return name, house if __name__ == "__main__": main()Notice how
get_studentreturnsname, house. - Packing that
tuple, such that we are able to return both items to a variable calledstudent, we can modify our code as follows.def main(): student = get_student() print(f"{student[0]} from {student[1]}") def get_student(): name = input("Name: ") house = input("House: ") return (name, house) if __name__ == "__main__": main()Notice that
(name, house)explicitly tells anyone reading our code that we are returning two values within one. Further, notice how we can index intotuples usingstudent[0]orstudent[1]. tuples are immutable, meaning we cannot change those values. Immutability is a way by which we can program defensively.def main(): student = get_student() if student[0] == "Padma": student[1] = "Ravenclaw" print(f"{student[0]} from {student[1]}") def get_student(): name = input("Name: ") house = input("House: ") return name, house if __name__ == "__main__": main()Notice that this code produces an error. Since
tuples are immutable, we’re not able to reassign the value ofstudent[1].- If we wanted to provide our fellow programmers flexibility, we could utilize a
listas follows.def main(): student = get_student() if student[0] == "Padma": student[1] = "Ravenclaw" print(f"{student[0]} from {student[1]}") def get_student(): name = input("Name: ") house = input("House: ") return [name, house] if __name__ == "__main__": main()Note that lists are mutable. That is, the order of
houseandnamecan be switched by a programmer. You might decide to utilize this in some cases where you want to provide more flexibility at the cost of the security of your code. After all, if the order of those values is changeable, programmers that work with you could make mistakes down the road. - A dictionary could also be utilized in this implementation. Recall that dictionaries provide a key-value pair.
def main(): student = get_student() print(f"{student['name']} from {student['house']}") def get_student(): student = {} student["name"] = input("Name: ") student["house"] = input("House: ") return student if __name__ == "__main__": main()Notice in this case, two key-value pairs are returned. An advantage of this approach is that we can index into this dictionary using the keys.
- Still, our code can be further improved. Notice that there is an unneeded variable. We can remove
student = {}because we don’t need to create an empty dictionary.def main(): student = get_student() print(f"{student['name']} from {student['house']}") def get_student(): name = input("Name: ") house = input("House: ") return {"name": name, "house": house} if __name__ == "__main__": main()Notice we can utilize
{}braces in thereturnstatement to create the dictionary and return it all in the same line. - We can provide our special case with Padma in our dictionary version of our code.
def main(): student = get_student() if student["name"] == "Padma": student["house"] = "Ravenclaw" print(f"{student['name']} from {student['house']}") def get_student(): name = input("Name: ") house = input("House: ") return {"name": name, "house": house} if __name__ == "__main__": main()Notice how, similar in spirit to our previous iterations of this code, we can utilize the key names to index into our student dictionary.
Classes
- Classes are a way by which, in object-oriented programming, we can create our own type of data and give them names.
- A class is like a mold for a type of data – where we can invent our own data type and give them a name.
- We can modify our code as follows to implement our own class called
Student:class Student: ... def main(): student = get_student() print(f"{student.name} from {student.house}") def get_student(): student = Student() student.name = input("Name: ") student.house = input("House: ") return student if __name__ == "__main__": main()Notice by convention that
Studentis capitalized. Further, notice the...simply means that we will later return to finish that portion of our code. Further, notice that inget_student, we can create astudentof classStudentusing the syntaxstudent = Student(). Further, notice that we utilize “dot notation” to access attributes of this variablestudentof classStudent. - Any time you create a class and you utilize that blueprint to create something, you create what is called an “object” or an “instance”. In the case of our code,
studentis an object. - Further, we can lay some groundwork for the attributes that are expected inside an object whose class is
Student. We can modify our code as follows:class Student: def __init__(self, name, house): self.name = name self.house = house def main(): student = get_student() print(f"{student.name} from {student.house}") def get_student(): name = input("Name: ") house = input("House: ") student = Student(name, house) return student if __name__ == "__main__": main()Notice that within
Student, we standardize the attributes of this class. We can create a function withinclass Student, called a “method”, that determines the behavior of an object of classStudent. Within this function, it takes thenameandhousepassed to it and assigns these variables to this object. Further, notice how the constructorstudent = Student(name, house)calls this function within theStudentclass and creates astudent.selfrefers to the current object that was just created. - We can simplify our code as follows:
class Student: def __init__(self, name, house): self.name = name self.house = house def main(): student = get_student() print(f"{student.name} from {student.house}") def get_student(): name = input("Name: ") house = input("House: ") return Student(name, house) if __name__ == "__main__": main()Notice how
return Student(name, house)simplifies the previous iteration of our code where the constructor statement was run on its own line. - You can learn more in Python’s documentation of classes.
raise
- Object-oriented programming encourages you to encapsulate all the functionality of a class within the class definition. What if something goes wrong? What if someone tries to type in something random? What if someone tries to create a student without a name? Modify your code as follows:
class Student: def __init__(self, name, house): if not name: raise ValueError("Missing name") if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]: raise ValueError("Invalid house") self.name = name self.house = house def main(): student = get_student() print(f"{student.name} from {student.house}") def get_student(): name = input("Name: ") house = input("House: ") return Student(name, house) if __name__ == "__main__": main()Notice how we check now that a name is provided and a proper house is designated. It turns out we can create our own exceptions that alerts the programmer to a potential error created by the user called
raise. In the case above, we raiseValueErrorwith a specific error message. - It just so happens that Python allows you to create a specific function by which you can print the attributes of an object. Modify your code as follows:
class Student: def __init__(self, name, house, patronus): if not name: raise ValueError("Missing name") if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]: raise ValueError("Invalid house") self.name = name self.house = house self.patronus = patronus def __str__(self): return f"{self.name} from {self.house}" def main(): student = get_student() print(student) def get_student(): name = input("Name: ") house = input("House: ") patronus = input("Patronus: ") return Student(name, house, patronus) if __name__ == "__main__": main()Notice how
def __str__(self)provides a means by which a student is returned when called. Therefore, you can now, as the programmer, print an object, its attributes, or almost anything you desire related to that object. __str__is a built-in method that comes with Python classes. It just so happens that we can create our own methods for a class as well! Modify your code as follows:class Student: def __init__(self, name, house, patronus=None): if not name: raise ValueError("Missing name") if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]: raise ValueError("Invalid house") if patronus and patronus not in ["Stag", "Otter", "Jack Russell terrier"]: raise ValueError("Invalid patronus") self.name = name self.house = house self.patronus = patronus def __str__(self): return f"{self.name} from {self.house}" def charm(self): match self.patronus: case "Stag": return "🐴" case "Otter": return "🦦" case "Jack Russell terrier": return "🐶" case _: return "🪄" def main(): student = get_student() print("Expecto Patronum!") print(student.charm()) def get_student(): name = input("Name: ") house = input("House: ") patronus = input("Patronus: ") or None return Student(name, house, patronus) if __name__ == "__main__": main()Notice how we define our own method
charm. Unlike dictionaries, classes can have built-in functions called methods. In this case, we define ourcharmmethod where specific cases have specific results. Further, notice that Python has the ability to utilize emojis directly in our code.- Before moving forward, let us remove our patronus code. Modify your code as follows:
class Student: def __init__(self, name, house): if not name: raise ValueError("Invalid name") if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]: raise ValueError("Invalid house") self.name = name self.house = house def __str__(self): return f"{self.name} from {self.house}" def main(): student = get_student() student.house = "Number Four, Privet Drive" print(student) def get_student(): name = input("Name: ") house = input("House: ") return Student(name, house) if __name__ == "__main__": main()Notice how we have only two methods:
__init__and__str__.
Decorators
- Properties can be utilized to harden our code. In Python, we define properties using function “decorators”, which begin with
@. Modify your code as follows:class Student: def __init__(self, name, house): if not name: raise ValueError("Invalid name") self.name = name self.house = house def __str__(self): return f"{self.name} from {self.house}" # Getter for house @property def house(self): return self._house # Setter for house @house.setter def house(self, house): if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]: raise ValueError("Invalid house") self._house = house def main(): student = get_student() print(student) def get_student(): name = input("Name: ") house = input("House: ") return Student(name, house) if __name__ == "__main__": main()Notice how we’ve written
@propertyabove a function calledhouse. Doing so defineshouseas a property of our class. Withhouseas a property, we gain the ability to define how some attribute of our class,_house, should be set and retrieved. Indeed, we can now define a function called a “setter”, via@house.setter, which will be called whenever the house property is set—for example, withstudent.house = "Gryffindor". Here, we’ve made our setter validate values ofhousefor us. Notice how we raise aValueErrorif the value ofhouseis not any of the Harry Potter houses, otherwise, we’ll usehouseto update the value of_house. Why_houseand nothouse?houseis a property of our class, with functions via which a user attempts to set our class attribute._houseis that class attribute itself. The leading underscore,_, indicates to users they need not (and indeed, shouldn’t!) modify this value directly._houseshould only be set through thehousesetter. Notice how thehouseproperty simply returns that value of_house, our class attribute that has presumably been validated using ourhousesetter. When a user callsstudent.house, they’re getting the value of_housethrough ourhouse“getter”. - In addition to the name of the house, we can protect the name of our student as well. Modify your code as follows:
class Student: def __init__(self, name, house): self.name = name self.house = house def __str__(self): return f"{self.name} from {self.house}" # Getter for name @property def name(self): return self._name # Setter for name @name.setter def name(self, name): if not name: raise ValueError("Invalid name") self._name = name @property def house(self): return self._house @house.setter def house(self, house): if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]: raise ValueError("Invalid house") self._house = house def main(): student = get_student() print(student) def get_student(): name = input("Name: ") house = input("House: ") return Student(name, house) if __name__ == "__main__": main()Notice how, much like the previous code, we provide a getter and setter for the name.
- You can learn more in Python’s documentation of methods.
Connecting to Previous Work in this Course
- While not explicitly stated in past portions of this course, you have been using classes and objects the whole way through.
- If you dig into the documentation of
int, you’ll see that it is a class with a constructor. It’s a blueprint for creating objects of typeint. You can learn more in Python’s documentation ofint. - Strings too are also a class. If you have used
str.lower(), you were using a method that came within thestrclass. You can learn more in Python’s documentation ofstr. listis also a class. Looking at that documentation forlist, you can see the methods that are contained therein, likelist.append(). You can learn more in Python’s documentation oflist.dictis also a class within Python. You can learn more in Python’s documentation ofdict.- To see how you have been using classes all along, go to your console and type
code type.pyand then code as follows:print(type(50))Notice how by executing this code, it will display that the class of
50isint. - We can also apply this to
stras follows:print(type("hello, world"))Notice how executing this code will indicate this is of the class
str. - We can also apply this to
listas follows:print(type([]))Notice how executing this code will indicate this is of the class
list. - We can also apply this to a
listusing the name of Python’s built-inlistclass as follows:print(type(list()))Notice how executing this code will indicate this is of the class
list. - We can also apply this to
dictas follows:print(type({}))Notice how executing this code will indicate this is of the class
dict. - We can also apply this to a
dictusing the name of Python’s built indictclass as follows:print(type(dict()))Notice how executing this code will indicate this is of the class
dict.
Class Methods
- Sometimes, we want to add functionality to a class itself, not to instances of that class.
@classmethodis a function that we can use to add functionality to a class as a whole.- Here’s an example of not using a class method. In your terminal window, type
code hat.pyand code as follows:import random class Hat: def __init__(self): self.houses = ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"] def sort(self, name): print(name, "is in", random.choice(self.houses)) hat = Hat() hat.sort("Harry")Notice how when we pass the name of the student to the sorting hat, it will tell us what house is assigned to the student. Notice that
hat = Hat()instantiates ahat. Thesortfunctionality is always handled by the instance of the classHat. By executinghat.sort("Harry"), we pass the name of the student to thesortmethod of the particular instance ofHat, which we’ve calledhat. - We may want, though, to run the
sortfunction without creating a particular instance of the sorting hat (there’s only one, after all!). We can modify our code as follows:import random class Hat: houses = ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"] @classmethod def sort(cls, name): print(name, "is in", random.choice(cls.houses)) Hat.sort("Harry")Notice how the
__init__method is removed because we don’t need to instantiate a hat anywhere in our code.self, therefore, is no longer relevant and is removed. We specify thissortas a@classmethod, replacingselfwithcls. Finally, notice howHatis capitalized by convention near the end of this code, because this is the name of our class. - Returning back to
students.pywe can modify our code as follows, addressing some missed opportunities related to@classmethods:class Student: def __init__(self, name, house): self.name = name self.house = house def __str__(self): return f"{self.name} from {self.house}" @classmethod def get(cls): name = input("Name: ") house = input("House: ") return cls(name, house) def main(): student = Student.get() print(student) if __name__ == "__main__": main()Notice that
get_studentis removed and a@classmethodcalledgetis created. This method can now be called without having to create a student first.
Static Methods
- It turns out that besides
@classmethods, which are distinct from instance methods, there are other types of methods as well. - Using
@staticmethodmay be something you might wish to explore. While not covered explicitly in this course, you are welcome to go and learn more about static methods and their distinction from class methods.
Inheritance
- Inheritance is, perhaps, the most powerful feature of object-oriented programming.
- It just so happens that you can create a class that “inherits” methods, variables, and attributes from another class.
- In the terminal, execute
code wizard.py. Code as follows:class Wizard: def __init__(self, name): if not name: raise ValueError("Missing name") self.name = name ... class Student(Wizard): def __init__(self, name, house): super().__init__(name) self.house = house ... class Professor(Wizard): def __init__(self, name, subject): super().__init__(name) self.subject = subject ... wizard = Wizard("Albus") student = Student("Harry", "Gryffindor") professor = Professor("Severus", "Defense Against the Dark Arts") ...Notice that there is a class above called
Wizardand a class calledStudent. Further, notice that there is a class calledProfessor. Both students and professors have names. Also, both students and professors are wizards. Therefore, bothStudentandProfessorinherit the characteristics ofWizard. Within the “child” classStudent,Studentcan inherit from the “parent” or “super” classWizardas the linesuper().__init__(name)runs theinitmethod ofWizard. Finally, notice that the last lines of this code create a wizard called Albus, a student called Harry, and so on.
Inheritance and Exceptions
- While we have just introduced inheritance, we have been using this all along during our use of exceptions.
- It just so happens that exceptions come in a hierarchy, where there are children, parent, and grandparent classes. These are illustrated below:
BaseException +-- KeyboardInterrupt +-- Exception +-- ArithmeticError | +-- ZeroDivisionError +-- AssertionError +-- AttributeError +-- EOFError +-- ImportError | +-- ModuleNotFoundError +-- LookupError | +-- KeyError +-- NameError +-- SyntaxError | +-- IndentationError +-- ValueError ... - You can learn more in Python’s documentation of exceptions.
Operator Overloading
- Some operators such as
+and-can be “overloaded” such that they can have more abilities beyond simple arithmetic. - In your terminal window, type
code vault.py. Then, code as follows:class Vault: def __init__(self, galleons=0, sickles=0, knuts=0): self.galleons = galleons self.sickles = sickles self.knuts = knuts def __str__(self): return f"{self.galleons} Galleons, {self.sickles} Sickles, {self.knuts} Knuts" def __add__(self, other): galleons = self.galleons + other.galleons sickles = self.sickles + other.sickles knuts = self.knuts + other.knuts return Vault(galleons, sickles, knuts) potter = Vault(100, 50, 25) print(potter) weasley = Vault(25, 50, 100) print(weasley) total = potter + weasley print(total)Notice how the
__str__method returns a formatted string. Further, notice how the__add__method allows for the addition of the values of two vaults.selfis what is on the left of the+operand.otheris what is right of the+. - You can learn more in Python’s documentation of operator overloading.
Summing Up
Now, you’ve learned a whole new level of capability through object-oriented programming.
- Object-oriented programming
- Classes
raise- Class Methods
- Static Methods
- Inheritance
- Operator Overloading