Object-oriented Programming in Python: An Introduction
In this article, we’ll dig into object-oriented programming (OOP) in Python. We won’t go too deeply into the theoretical aspects of OOP. The main goal here is to demonstrate how we can use the object-oriented paradigm with Python.
According to Statista, Python is the fourth most used programming language among developers. Why is that? Well, some say that it’s because of Python’s simplified syntax; others say it’s because of Python’s versatility. Whatever the reason is, if we want to study a trending programming language, Python should be one of our choices.
Contents:
- The Fundamentals of OOP
- Classes and Objects
- Defining a New Method
- Access Modifiers: Public, Protected and Private
- Inheritance
- Polymorphism
- Method Overloading
- Method Overriding
The Fundamentals of OOP
Let’s start with a gentle summary of object-oriented programming. Object-oriented programming is a programming paradigm — a group of ideas that set a standard for how things must be done.
The idea behind OOP is to model a system using objects. An object is a component of our system of interest, and it usually has a specific purpose and behavior. Each object contains methods and data. Methods are procedures that perform actions on data. Methods might require some parameters as arguments.
Java, C++, C#, Go, and Swift are all examples of object-oriented programming languages. The implementation of the OOP principles in all of those languages is different, of course. Every language has its syntax, and in this article, we’ll see how Python implements the object-oriented paradigm.
To learn more about OOP in general, it’s worth reading this article from MDN, or this interesting discussion about why OOP is so widespread.
Classes and Objects
The first important concept of OOP is the definition of an object. Let’s say you have two dogs, called Max and Pax. What do they have in common? They are dogs and they represent the idea of a dog. Even if they are of a different breed or color, they still are dogs. In this example, we can model Max and Pax as objects or, in other words, as instances of a dog.
But wait, what is a dog? How can I model the idea of a dog? Using classes.
As we can see in the picture above, a class is a template that defines the data and the behavior. Then, starting from the template provided by the class, we create the objects. Objects are instances of the class.
Let’s have a look at this Python code:
class Dog():
def __init__(self, name, breed):
self.name = name
self.breed = breed
def __repr__(self):
return f"Dog(name={self.name}, breed={self.breed})"
max = Dog("Max", "Golden Retriever")
pax = Dog("Pax", "Labrador")
print(max)
print(pax)
On line 1, we declare a new class using the name Dog
. Then we bump into a method called __init__
. Every Python class has this, because it’s the default constructor. This method is used to initialize the object’s state, so it assigns values to the variables of the newly created object. As arguments of the constructor, we have the name
, the breed
, and a special keyword called self
. It’s not a coincidence that this is the first argument of the method.
Inside the class code, the self
keyword represents the current instance of the class. This means that each time we want to access a certain method or variable that belongs to an instance of the class (max
or pax
are two different instances), we must use the self
keyword. Don’t worry if it’s not completely clear now; it will be in the next sections.
Look at the first line of the __init__
method — self.name = name
. In words, this says to the Python interpreter: “Okay, this object that we’re creating will have a name (self.name
), and this name is inside the name
argument”. The same thing happens for the breed
argument. Okay, so we could have stopped here. This is the basic blueprint used to define a class. Before jumping to the execution of this snippet, let’s look at the method that was added after the __init__
.
The second method is called __repr__
. In Python, the __repr__
method represents the class object as a string. Usually, if we don’t explicitly define it, Python implements it in its own way, and we’ll now see the difference. By default, if we don’t explicitly define a __repr__
method, when calling the function print()
or str()
, Python will return the memory pointer of the object. Not quite human-readable. Instead, if we define a custom __repr__
method, we have a nice version of our object in a stringed fashion, which can also be used to construct the object again.
Let’s make a change to the code above:
class Dog:
def __init__(self, name, breed):
self.name = name
self.breed = breed
max = Dog("Max", "Golden Retriever")
pax = Dog("Max", "Golden Retriever")
# Default (internal) implementation of __repr__
print(max)
print(pax)
print(max == pax)
If we save and run this code, this is what we get:
<__main__.Dog object at 0x0000026BD792CF08>
<__main__.Dog object at 0x0000026BD792CFC8>
False
Wait, how can it be possible that they aren’t two equal dogs, if they have the same name and the same breed? Let’s visualize it using the diagram we made before.
First, when we execute print(max)
, Python will see that there’s no custom definition of a __repr__
method, and it will use the default implementation of the __repr__
method. The two objects, max
and pax
, are two different objects. Yes, they have the same name and the same breed, but they’re different instances of the class Dog
. In fact, they point to different memory locations, as we can see from the first two lines of the output. This fact is crucial for understanding the difference between an object and a class.
If we now execute the first code example, we can see the difference in the output when we implement a custom __repr__
method:
Dog(name=Max, breed=Golden Retriever)
Dog(name=Pax, breed=Labrador)
Defining a New Method
Let’s say we want to get the name of the max
object. Since in this case the name
attribute is public, we can simply get it by accessing the attribute using max.name
. But what if we want to return a nickname for the object?
Well, in that case, we create a method called get_nickname()
inside our class. Then, outside the definition of the class, we simply call the method with max.get_nickname()
:
class Dog:
def __init__(self, name, breed):
self.name = name
self.breed = breed
def get_nickname(self):
return f"{self.name}, the {self.breed}"
def __repr__(self):
return f"Dog(name={self.name}, breed={self.breed})"
max = Dog("Max", "Golden Retriever")
pax = Dog("Pax", "Labrador")
print(max.name)
print(max.get_nickname())
If we run this snippet, we get the following output:
> python snippet.py
Max
Max, the Golden Retriever
Access Modifiers: Public, Protected and Private
Let’s now consider access modifiers. In OOP languages, access modifiers are keywords used to set the accessibility of classes, methods or attributes. It’s a different situation in C++ and Java, where access modifiers are explicit keywords defined by the language. In Python, there’s no such thing. Access modifiers in Python are a convention rather than a guarantee over access control.
Let’s look at what this means with a code sample:
class BankAccount:
def __init__(self, number, openingDate):
# public access
self.number = number
# protected access
self._openingDate = openingDate
# private access
self.__deposit = 0
In this snippet, we create a class called BankAccount
. Any new BankAccount
object must have three attributes: a number, an opening date and an initial deposit set to 0. Notice the single underscore (_
) before openingDate
and the double underscore (__
) before deposit
.
Great! According to Python’s convention, the single underscore is used as a prefix for protected
members, while the double underscore is for private
members. What does this mean in practice? Let’s try to add the code below under the class definition:
account = BankAccount("ABXX", "01/01/2022")
print(account.number)
print(account._openingDate)
print(account.__deposit)
If we try to execute this code, we’ll get something like this:
> python snippet.py
ABXX
01/01/2022
Traceback (most recent call last):
File "snippet.py", line 14, in <module>
print(account.__deposit)
AttributeError: 'BankAccount' object has no attribute '__deposit'
We can print the account number
because it’s a public attribute. We can print the openingDate
, even if, according to the convention, it’s not advised. We can’t print the deposit
.
In the case of the deposit attribute, the proper way to read or modify its value should be through get()
and set()
methods. Let’s see an example of this:
class BankAccount:
def __init__(self, number, openingDate):
self.number = number
self._openingDate = openingDate
self.__deposit = 0
def getDeposit(self):
return self.__deposit
def setDeposit(self, deposit):
self.__deposit = deposit
return True
account = BankAccount("ABXX", "01/01/2022")
print(account.getDeposit())
print(account.setDeposit(100))
print(account.getDeposit())
In the code above, we define two new methods. The first one is called getDeposit
, and the second one is setDeposit
. As their names imply, they’re used to get or set the deposit. It’s a convention in OOP to create get and set methods for all of the attributes that need to be read or modified. So, instead of directly accessing them from outside the class, we implement methods to do that.
As we can easily guess, executing this code gives the following output:
> python snippet.py
0
True
100
Inheritance
DRY. Don’t repeat yourself. Object-oriented programming encourages the DRY principle, and inheritance is one of the strategies used to enforce the DRY principle. In this section, we’ll see how inheritance works in Python. Please note that we’ll use the terms parent class and child class. Other aliases might include base class for the parent and derived class for the children. Since inheritance defines a hierarchy of classes, it’s pretty convenient to differentiate between the parent and all the children.
Okay, so let’s start with an example. Let’s say we want to model a classroom. A classroom is made by a professor and a number of students. What do they all have in common? What relationship do they all share? Well, they’re certainly all humans. As such, they share a certain number of features. For simplicity here, we define a class Person
as having two private attributes, name and surname. This class also contains the get()
and set()
methods.
The image below shows a parent class and two children.
As we can see, in both Student
and Professor
classes we have all the methods and attributes defined for the Person
class, because they inherit them from Person
. Additionally, there are other attributes and methods highlighted in bold that are specific to the child class.
Here’s the code for this example:
class Person:
def __init__(self, name, surname):
self.__name = name
self.__surname = surname
def getName(self):
return self.__name
def getSurname(self):
return self.__surname
def setName(self, newName):
self.__name = newName
def setSurname(self, newSurname):
self.__surname = newSurname
Then, we have two entities to model, the Student
and the Professor
. There’s no need to define all the things we define above in the Person
class for Student
and Professor
also. Python allows us to make the Student
and the Professor
class inherit a bunch of features from the Person
class (parent).
Here’s how we can do that:
class Student(Person):
def __init__(self, name, surname, grade):
super().__init__(name, surname)
self.__grade = grade
def getGrade(self):
return self.__grade
def setGrade(self, newGrade):
self.__grade = newGrade
In the first line, we define a class using the usual class Student()
syntax, but inside the parentheses we put Person
. This tells the Python interpreter that this is a new class called Student
that inherits attributes and methods from a parent class called Person
. To differentiate this class a bit, there’s an additional attribute called grade
. This attribute represents the grade the student is attending.
The same thing happens for the Professor
class:
class Professor(Person):
def __init__(self, name, surname, teachings):
super().__init__(name,surname)
self.__teachings = teachings
def getTeachings(self):
return self.__teachings
def setTeachings(self, newTeachings):
self.__teachings = newTeachings
There’s a new element we haven’t seen before. On line 3 of the snippet above, there’s a strange function called super().__init__(name,surname)
.
The super()
function in Python is used to give the child access to members of a parent class. In this case, we’re calling the __init__
method of the class Person
.
Polymorphism
The example introduced above shows a powerful idea. Objects can inherit behaviors and data from other objects in their hierarchy. The Student
and Professor
classes were both subclasses of the Person
class. The idea of polymorphism, as the word says, is to allow objects to have many shapes. Polymorphism is a pattern used in OOP languages in which classes have different functionalities while sharing the same interface.
Speaking of the example above, if we say that a Person
object can have many shapes, we mean that it can be a Student
, a Professor
or whatever class we create as a subclass of Person
.
Let’s see some other interesting things about polymorphism:
class Vehicle:
def __init__(self, brand, color):
self.brand = brand
self.color = color
def __repr__(self):
return f"{self.__class__.__name__}(brand={self.brand}, color={self.color})"
class Car(Vehicle):
pass
tractor = Vehicle("John Deere", "green")
red_ferrari = Car("Ferrari", "red")
print(tractor)
print(red_ferrari)
So, let’s have a look. We define a class Vehicle
. Then, we create another class called Car
as a subclass of Vehicle
. Nothing new here. To test this code, we create two different objects and store them in two separate variables called tractor
and red_ferrari
. Note here that the class Car
doesn’t have anything inside. It’s just defined as a different class, but till now it has had no different behavior from its parent. Don’t bother about what’s inside the __repr__
method for now, as we’ll come back to it later.
Can you guess the output of this code snippet? Well, the output is the following:
Vehicle(brand=John Deere, color=green)
Car(brand=Ferrari, color=red)
Note the magic happening here. The __repr__
method is defined inside the Vehicle
class. Any instance of Car
will adopt it, since Car
is a subclass of Vehicle
. But Car
doesn’t define a custom implementation of __repr__
. It’s the same as its parent.
So the question here is why the behavior is different. Why does the print show two different things?
The reason is that, at runtime, the Python interpreter recognizes that the class of red_ferrari
is Car
. self.__class__.__name__
will give the name of the class of an object, which in this case is the self
object. But remember, we have two different objects here, created from two different classes.
If we want to check whether an object is an instance of a certain class, we could use the following functions:
print(isinstance(tractor, Vehicle)) # Yes, tractor is a Vehicle object!
print(isinstance(tractor, Car)) # No, tractor is only a Vehicle object. Not a Car object.
On the first line, we’re asking the following question: is tractor
an instance of the class Vehicle
?
On the second line, we’re instead asking: is tractor
an instance of the class Car
?
Method Overloading
In Python, like in any other OOP language, we can call the same method in different ways — for example, with a different number of parameters. That might be useful when we want to design a default behavior but don’t want to prevent the user from customizing it.
Let’s see an example:
class Overloading:
def sayHello(self, i=1):
for times in range(i):
print("Nice to meet you!")
a = Overloading()
print("Running a.sayHello():")
a.sayHello()
print("Running a.sayHello(5):")
a.sayHello(5)
Here, we define a method called sayHello
. This method has only one argument, which is i
. By default, i
has a value of 1. In the code above, when we call a.sayHello
for the first time without passing any argument, i
will assume its default value. The second time, we instead pass 5 as a parameter. This means i=5
.
What is the expected behavior then? This is the expected output:
> python snippet.py
Running a.sayHello():
Nice to meet you!
Running a.sayHello(5):
Nice to meet you!
Nice to meet you!
Nice to meet you!
Nice to meet you!
Nice to meet you!
The first call to a.sayHello()
will print the message "Nice to meet you!"
only once. The second call to a.sayHello()
will print "Nice to meet you!"
five times.
Method Overriding
Method overriding happens when we have a method with the same name defined both in the parent and in the child class. In this case, we say that the child is doing method overriding.
Basically, it can be demonstrated as shown below. The following diagram shows a child class overriding a method.
The sayHello()
method in Student
is overriding the sayHello()
method of the parent class.
To show this idea in practice, we can modify a bit the snippet we introduced at the beginning of this article:
class Person:
def __init__(self, name, surname):
self.name = name
self.surname = surname
def sayHello(self):
return ("Hello, my name is {} and I am a person".format(self.name))
class Student(Person):
def __init__(self, name, surname, grade):
super().__init__(name,surname)
self.grade = grade
def sayHello(self):
return ("Hello, my name is {} and I am a student".format(self.name))
a = Person("john", "doe")
b = Student("joseph", "doe", "8th")
print(a.sayHello())
print(b.sayHello())
In this example, we have the method sayHello()
, which is defined in both classes. The Student
implementation of sayHello()
is different, though, because the student says hello in another way. This approach is flexible, because the parent is exposing not only an interface but also a form of the default behavior of sayHello
, while still allowing the children to modify it according to their needs.
If we run the code above, this is the output we get:
> python snippet.py
Hello, my name is john and I am a person
Hello, my name is joseph and I am a student
Conclusion
By now, the basics of OOP in Python should be pretty clear. In this article, we saw how to create classes and how to instantiate them. We addressed how to create attributes and methods with different visibility criteria. We also discovered fundamental properties of OOP languages like inheritance and polymorphism, and most importantly how to use them in Python.