Object Oriented Programming
Video
Object-Oriented Programming
- There are different paradigms of programming. As you learn other languages, you will start recognising 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
Notice that this program follows a procedural, step-by-step paradigm: Much like you have seen in prior parts of this course.code student.py
in the terminal window and code as follows: -
Drawing on our work from previous weeks, we can create functions to abstract away parts of this program.
Notice howdef 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()
get_name
andget_house
abstract away some of the needs of our main function. Further, notice how the final lines of the code above tell the compiler to run the main function. -
We can further simplify our program by storing the student as a
tuple
. Atuple
is a sequences of values. Unlike alist
, atuple
can’t be modified. In spirit, we are returning two values.Notice howdef 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()
get_student
returnsname, house
. -
Packing that
tuple
, such that we are able to return both items to a variable called student, we can modify our code as follows.Notice that (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()
name, house
) explicitly tells anyone reading our code that we are returning two values within one. Further, notice how we can index intotuple
s usingstudent[0]
orstudent[1]
. -
tuples
are immutable, meaning we cannot change those values. Immutability is a way by which we can program defensively.Notice that this code produces an error. Sincedef 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()
tuple
s are immutable, we’re not able to reassign the value ofstudent[1]
. -
If we wanted to provide our fellow programmers flexibility, we could utilise a
list
as follows.Note thedef 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()
lists
are mutable. That is, the order ofhouse
andname
can 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 utilised in this implementation. Recall that dictionaries provide a key-value pair.
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.Notice we can utilisedef 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()
{}
braces in thereturn
statement to create the dictionary and return it all in the same line. -
We can provide our special case with
Notice how, similar in spirit to our previous iterations of this code, we can utilise the key names to index into our student dictionary.Padm
a in our dictionary version of our code.
Classes
- Classes are a way to encapsulate (contain) data and functionality together. They allow us to create objects that have attributes (data) and methods (functions).
- We can create a class for our
Student
object and use it to encapsulate the data and functionality of our program. - We can also add methods to our class to encapsulate the functionality of our program.
- The class is like a blueprint or template for creating objects.
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()
Student
is capitalised. Further, notice the ...
simply means that we will later return to finish that portion of our code. Further, notice that in get_student
, we can create a student
of class Student
using the syntax student = Student()
. Further, notice that we utilise “dot notation” to access attributes of this variable student
of class Student
.
- Any time you create a class and you utilise that blueprint to create something, you create what is called an “object” or an “instance”. In the case of our code,
student
is 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:Notice that withinclass 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()
Student
, we standardise the attributes of this class. We can create a function within classStudent
, called a “method”, that determines the behaviour of an object of classStudent
. Within this function, it takes thename
andhouse
passed to it and assigns these variables to this object. Further, notice how theconstructor student = Student(name, house)
calls this function within theStudent
class and creates astudent
.self
refers to the current object that was just created. -
We can simplify our code as follows:
Notice howclass 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()
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 program 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:
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 calledclass 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()
raise
. In the case above, we raiseValueError
with 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:
Notice howclass 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()
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:Notice how we define our own methodclass 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()
charm
. Unlike dictionaries, classes can have built-in functions called methods. In this case, we define ourcharm
method where specific cases have specific results. Further, notice that Python has the ability to utilise emojis directly in our code. -
Before moving forward, let us remove our patronus code. Modify your code as follows:
Notice how we have only two methods: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()
__init__
and__str__
.
types
- 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 of int. - Strings too are also a class. If you have used
str.lower()
, you were using a method that came within thestr
class. You can learn more in Python’s documentation of str.list
is 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 of list.dict
is also a class within Python. You can learn more in Python’s documentation of dict. -
To see how you have been using classes all along, go to your console and type
Notice how by executing this code, it will display that the class ofcode type.py
and then code as follows:50
isint
. -
We can also apply this to
Notice how executing this code will indicate this is of the classstr
as follows:str
. -
We can also apply this to
Notice how executing this code will indicate this is of the classlist
as follows:list
. -
We can also apply this to a
Notice how executing this code will indicate this is of the classlist
using the name of Python’s built-inlist
class as follows:list
. -
We can also apply this to
Notice how executing this code will indicate this is of the classdict
as follows:dict
. -
We can also apply this to a dict using the name of Python’s built in
Notice how executing this code will indicate this is of the classdict
class as follows:dict
.
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:Notice that there is a class above calledclass 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") ...
Wizard
and 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, bothStudent
andProfessor
inherit the characteristics ofWizard
. Within the “child” classStudent
,Student
can inherit from the “parent” or “super” classWizard
as the linesuper().__init__(name)
runs theinit
method 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:
You can learn more in Python’s documentation of exceptions.
BaseException +-- KeyboardInterrupt +-- Exception +-- ArithmeticError | +-- ZeroDivisionError +-- AssertionError +-- AttributeError +-- EOFError +-- ImportError | +-- ModuleNotFoundError +-- LookupError | +-- KeyError +-- NameError +-- SyntaxError | +-- IndentationError +-- ValueError ...
Generalisation
- Generalisation is the process of creating a more general class that can be used to create specific classes. This is done by defining common attributes and methods in a base class and then defining specific attributes and methods in derived classes.
-
For example, consider the following code where we may be using digital advertising for images.
-
After some time, our clients ask us to create an ad for videos. We can create a new class
Video
that inherits from theImage
class and adds specific attributes and methods for videos. This way, we can reuse the code for images and only add the specific code for videos. -
Later on, our clients ask us to create an ad for audio. We could create another class
Audio
that inherits from theImage
class and adds specific attributes and methods for audio. This way, we can reuse the code for images and only add the specific code for audio. But that sounds a bit odd, as an audio file doesn't have dimensions or something to display. - We can use abstraction to pull out the common attributes and methods into an abstract base class
Media
and then create concrete classes for images, videos, and audio that inherit from this abstract base class.Media
would be the generalisation ofImage
,Video
, andAudio
.You can use a class diagram to represent the relationships between these classes. Here is an example of what the class diagram might look like:from abc import ABC, abstractmethod class Media(ABC): def __init__(self, filename): self._filename = filename @abstractmethod def format(self): pass @property def filename(self): return self._filename class Image(Media): def __init__(self, filename, width, height): super().__init__(filename) self.width = width self.height = height def format(self): return f"{self.filename} is an image with dimensions {self.width}x{height}" class Video(Image): def __init__(self, filename, width, height, duration): super().__init__(filename, width, height) self.duration = duration def format(self): return f"{self.filename} is a video with dimensions {self.width}x{height} and duration {self.duration}" class Audio(Media): def __init__(self, filename, duration): super().__init__(filename) self.duration = duration def format(self): return f"{self.filename} is an audio file with a duration of {self.duration}"
classDiagram Media <|-- Image Image <|-- Video Media <|-- Audio class Media { filename: str format(): str } class Image { width: int height: int format(): str } class Video { duration: int format(): str } class Audio { duration: int format(): str }
Polymorphism
- There are 2 main types of polymorphism:
-
Compile-time polymorphism (method overloading)
- Python does not support method overloading. However, you can achieve similar functionality using default arguments.
- If you have a method with the same name and same number of parameters but different types, Python will use the last method defined with that name and number of parameters. For example: Notice when you run this code that the last method defined with the name "add" and 2 parameters is used.
-
Runtime polymorphism (method overriding)
- also known as dynamic polymorphism or virtual functions.
- This is when a method in a subclass has the same name as a method in its superclass.
- The method in the subclass overrides the method in the superclass.
Notice that the
class Animal: def speak(self): pass class Dog(Animal): def speak(self): return "Woof!" class Cat(Animal): def speak(self): return "Meow!" def animal_sound(animal: Animal): print(animal.speak()) dog = Dog() cat = Cat() animal_sound(dog) # Output: Woof! animal_sound(cat) # Output: Meow!
speak
method in theDog
andCat
classes override thespeak
method in theAnimal
class. When we call theanimal_sound
function with aDog
orCat
object, it calls the overriddenspeak
method.
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:Notice how theclass 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)
__str__
method returns a formatted string. Further, notice how the__add__
method allows for the addition of the values of two vaults.self
is what is on the left of the+
operand.other
is what is right of the+
. -
You can learn more in Python’s documentation of operator overloading.
Decorators
-
Properties can be utilised to harden our code. In Python, we define properties using function “decorators”, which begin with
@
. Modify your code as follows:Notice how we’ve writtenclass 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()
@property
above a function calledhouse
. Doing so defineshouse
as a property of our class. Withhouse
as 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 ofhouse
for us. Notice how we raise aValueError
if the value ofhouse
is not any of the Harry Potter houses, otherwise, we’ll usehouse
to update the value of_house
. Why_house
and nothouse
?house
is a property of our class, with functions via which a user attempts to set our class attribute._house
is that class attribute itself. The leading underscore,_
, indicates to users they need not (and indeed, shouldn’t!) modify this value directly._house
should only be set through the house setter. Notice how thehouse
property simply returns that value of_house
, our class attribute that has presumably been validated using our house setter. When a user callsstudent.house
, they’re getting the value of_house
through our house “getter”. -
In addition to the name of the house, we can protect the name of our student as well. Modify your code as follows:
Notice how, much like the previous code, we provide a getter and setter for the name.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()
-
You can learn more in Python’s documentation of methods.
Class Methods
- Sometimes, we want to add functionality to a class itself, not to instances of that class.
@classmethod
is 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.py
and code as follows: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 thatimport 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")
hat = Hat()
instantiates ahat
. Thesort
functionality is always handled by the instance of the classHat
. By executinghat.sort("Harry")
, we pass the name of the student to thesort
method of the particular instance ofHat
, which we’ve calledhat
. -
We may want, though, to run the
sort
function without creating a particular instance of the sorting hat (there’s only one, after all!). We can modify our code as follows:Notice how theimport random class Hat: houses = ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"] @classmethod def sort(cls, name): print(name, "is in", random.choice(cls.houses)) Hat.sort("Harry")
__init__
method is removed because we don’t need to instantiate ahat
anywhere in our code.self
, therefore, is no longer relevant and is removed. We specify this sort as a@classmethod
, replacingself
withcls
. Finally, notice howHat
is capitalised by convention near the end of this code, because this is the name of our class. -
Returning back to
students.py
we can modify our code as follows, addressing some missed opportunities related to@classmethods:
Notice thatclass 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()
get_student
is removed and a@classmethod
calledget
is 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
@staticmethod
may 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.
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
- Polymorphism
- Abstraction
- Encapsulation
- Operator Overloading