Object-oriented programming (OOP) is a computer programming paradigm that tries to model real-world problems by organizing them as classes and objects. Its simplicity and effectiveness make it highly applicable - it's hard to think of any software application where OOP is not used. Considering their importance, I have specifically decided to cover them today in the context of Python. This is a comprehensive review with examples that can be helpful for beginners to the intermediate.
If you would like to learn more about using Python to scrape websites, please check out our 2024 step-by-step Python web scraping tutorial.
What are classes in Python?
Before going into the programming terminologies, I will begin with a simple example. We have some books in a bookshop. Some attributes can differ (or be similar), but one thing is the same: they are books and can be classified as a specific category.
In OOP, we identify similar objects and model them as a class. A class can then have the attributes shared by all the respective objects, like author, genre, edition, etc., for a book class.
Why use classes?
Classes are helpful in several ways. First, it is always easier or more manageable to modify (or add/delete) attributes in a single class than change them across several objects.
There are plenty of other uses, too, but before we discuss them, we need to review the basic class structure.
Basic class structure
Let's create a class and see it in action. Defining the classes in Python is pretty straightforward - we specify the keyword class
followed by the class’s name. For example, a Car
class can be defined as:
class Car:
#Attributes
This “Car” is quite generic and needs some attributes.
class Car:
model = "Toyota"
make = "Corolla"
colour = "Red"
Creating objects (instances)
A class is just a blueprint. To use an object, we need to instantiate it from a class. An object can be instantiated from the just-defined Car
class as:
c = Car()
Since we didn’t specify any attribute value, c
(attributes) will take just the default value specified in the class above. We can verify it:
print(c.colour)
#Red
print(c.make)
#Corolla
Usually, some attributes will take non-default values, so we can override them too.
c.colour = 'Black'
c.colour
#Black
Class methods and attributes
We mentioned earlier that a class is much more than mere attributes. Consider a Car
and its functionalities: it may apply brakes, switch gears, turn, etc.
These functionalities can be covered using functions. Functions in a class can be defined similarly to those defined normally and specifically known as methods.
Using the same Car
class above, we expand it by adding a couple of methods.
class Car:
model = "Toyota"
make = "Corolla"
colour = "Red"
def brake():
print('Brakes are applied. Decelerating..')
def accelerate():
print('Car speed is increasing')
Similar to attributes, we can call methods from an object in a similar manner.
c = Car()
c.accelerate()
Unlike normal functions, this code will not work and will send an unfamiliar error. The reason is simple: whenever we call a method, Python automatically attaches the caller object (c in our case) as an argument to the method. This can be new for someone coming from a non-Python background.
To fix it, we specify the self
attribute in every method of a class. The updated class will be:
class Car:
model = "Toyota"
make = "Corolla"
colour = "Red"
def brake(self):
print('Brakes are applied. Decelerating..')
def accelerate(self):
print('Car speed is increasing')
Now, calling it won’t cause any issues and will have the expected outcome (i.e., printing that Car's speed is increasing).
Constructors
While instantiating an object, we saw a similar notation to calling a method (parentheses with the class’s name). Basically, that’s what happens: whenever we instantiate an object, a method is implicitly called. This method is known as a constructor.
The interpreter itself makes a constructor. If we want to define it explicitly, we can define it using the special keyword, __init__()
. Like other methods, it also requires the self
argument by default.
def __init__(self):
print('Constructor is called')
We can use constructors to perform a desired functionality at the object’s creation, like specifying the value of some attribute, triggering the call of some method, and so on.
Static methods
Let's add a method to check the model of a car.
def print_model(self):
print(self.model)
Now, I'll make a couple of objects with separate models and call this method for both.
c1 = Car()
c1.print_model()
c2 = Car()
c2.model = 'Camry'
c2.print_model()
It will print the respective models for both. This is in contrast with the (too general) brake()
and accelerate()
methods we defined above.
The methods which execute exclusively for each object are known as instance methods, while those which are independent of the calling object are known as static methods. Static methods are used less frequently than the default instance methods but have their own applications in some cases (like we saw for the general functions above).
If static methods are independent of the calling object, why should we call them using an object?! Instead, a better approach should be to address them using the class’s name. However, if we try to call brake()
using the class name, it won’t work. The error, which we encountered before, is demanding an object here.
The solution is quite simple: decorate the desired function using the @staticmethod
decorator. Now, we can call this method easily from either the class name or the object’s name.
class Car:
make = "Toyota"
model = "Corolla"
colour = "Red"
def __init__(self):
print('Constructor is called')
@staticmethod
def brake():
print('Brakes are applied. Decelerating..')
@staticmethod
def accelerate():
print('Car speed is increasing')
def print_model(self):
print(self.model)
And it can be called either way.
c2.brake()
Car.brake()
Inheritance
Now, our engineers are interested in making an electric car, so we will make another class.
class ElectricCar:
make = None
model = None
horse_power = None
registration_number = None
cell_power = None
def drive(self):
print('Car '+self.registration_number+' is driving in environment-friendly manner.')
We can confirm it by instantiating it and calling the Drive()
method.
ec1 = ElectricCar()
ec1.registration_number = 'AB-230'
ec1.drive()
While the class is created well and functioning in the desired manner, something is lacking. We can see that all the attributes in this class (except the fuel cell’s power) are attributes of any car. Similarly,drive()
was also defined in the original Car
class. We can see that both Car
andElectricCar
classes are related. But is there any way of relating them in OOP?
The answer, luckily, is yes. Inheritance is a concept that allows us to derive (or inherit) classes from existing classes. Although simple, inheritance is very powerful and is considered one of the pillars of OOP. We can inherit ElectricCar
from Car
as:
class Car:
make = None
model = None
horse_power = None
registration_number = None
def drive(self):
print('Car with registration number '+self.registration_number+' is driving.')
class ElectricCar(car):
cell_power = None
make
, model
, registration_number
, cell_power
are related to every car, these attributes have been taken into the Car
class.If we make an electric car object, it will display the same attributes as the original Car
objects, plus allow us to access the additional cell_power,
too.
ec1 = ElectricCar()
ec1.registration_number = 'AB-230'
ec1.cell_power = 12000
But calling the drive()
method leads to calling the parent class (Car
)’s method and needs to be updated.
Method overriding
To override the parent class’s driving behavior, we will override drive()
in the ElectricCar
class.
class ElectricCar(Car):
cell_power = None
def drive(self):
print('Car ' + self.registration_number + ' is driving in environment-friendly manner.')
While apparently, little has changed. We have defined the samedrive()
function again. But now there is something different: we can call the same drive()
for any car instance, and it will call the respective method.
Car
, ElectricCar
or some other derived class- is a Car
instance.super()
function
Most things have stayed the same. However, there is something different: we can call the same drive()
for any car instance, and it will call the respective method. What if we want to call the parent class’s drive()
method too?
We can call any base class method using the super()
. Check the change in ElectricCar
's drive()
method after calling it.
class ElectricCar(Car):
cell_power = None
def drive(self):
super().drive()
print('Car is driving in environment-friendly manner.')
ec2 = ElectricCar()
ec2.registration_number = 'LH-239'
ec2.drive()
As you can see, the output will be from both the parent and derived classes.
Polymorphism
Now, let's pause the technical details and discuss the real world. How would you try to recognize a soccer player - say Zidane? Some will remember him as a midfielder, some as a captain, some as a Real Madrid player, and so on, but one thing will remain the same: he is a soccer player, and this is common for every soccer player - irrespective of playing position, league or any other class.
Let's translate it back to OOP: every object of a child class can also be treated as a base class’s object. Returning to our example, every car - whether a normal Car
, ElectricCar
or some other derived class like SportsCar
- is a Car
instance too.
class SportsCar(Car):
def drive(self):
print('Sports car is driving.')
Let's make some objects of all three types to see the point above.
c1 = Car()
sc1 = SportsCar()
sc2 = SportsCar()
# ec2 is defined in earlier code examples
ec3 = ElectricCar()
ec3.registration_number = 'CV-782'
Now, I put them in a list and call the drive()
. By the way, ec2
was already defined (under the super()
function discussion).
cars = [c1, sc1, sc2, ec2, ec3]
for car in cars:
car.drive()
Car
definition has been updated to add a default ‘XX-000’ as registration_number
due to its requirement in the drive()
method.Here output shows that each object has been identified accordingly to the execution time, and the respective drive()
method is called:
# Car with registration number XX-000 is driving.
# Sports car is driving.
# Sports car is driving.
# Car with registration number LH-239 is driving.
# Car is driving in environment-friendly manner.
# Car with registration number CV-782 is driving.
# Car is driving in environment-friendly manner.
This phenomenon is known as polymorphism where we can call a (parent class) function without caring much about the caller’s exact type, as this is something the interpreter will take care of.
Encapsulation
Encapsulation is the third pillar of OOP, along with inheritance and polymorphism. It means we can make class members (usually attributes) inaccessible outside the class by declaring them private.
In Python, however, private members don’t exist per se. We have name mangling, where we enclose the respective class members within double underscores.
Advanced concepts
OOP is a big topic. No wonder there are dedicated semester courses on it. It’s hard to cover in a single post. But now, I will try to cover some of the advanced concepts supported by Python.
Multiple inheritance
We can also derive a class from multiple classes. For example, I have a class UnmannedVehicle
for automated vehicles - anything from a small Drone or robot to a self-driving bus.
class UnmannedVehicle:
navigation_algorithm = "A*"
camera_max_resolution = "1080p"
I want to make a class forSelfDrivingCar
. Obviously, there are some things common between an UnmannedVehicle
and SelfDrivingCar
, but it's a Car
too. So, in this case, we will inherit it from both classes:
class SelfDrivingCar(Car,UnmannedVehicle):
license_region = 'Nevada'
Multiple inheritance is perfectly allowed in Python (some languages like C# or Java don’t allow it), and we can confirm it by accessing the respective parent class members:
sdc1 = SelfDrivingCar()
sdc1.registration_number = "SD-102"
print(sdc1.navigation_algorithm)
print(sdc1.registration_number)
Abstract classes
Sometimes, we want a class to implement some methods from a base class but want to leave their implementation up to those classes. In such a case, there’s no need to have the method’s implementation in the base class. For example, we don’t want to care about how a car switches its gears - should it show it on the LED or not, etc. So we can simply make the switch_gear()
an abstract method (i,e., no implementation at all) and leave it up to the respective cars (derived classes) to implement it as per their requirements. An abstract method is specified by the @abstractmethod
decorator.
Any class with an abstract method is an abstract class (it may have non-abstract methods, too), and since it is more for inheritance, it cannot have an object (that’s perfectly intuitive). Abstract classes were introduced much later in Python, but rightly so. They are defined in the ABC (Abstract Base Classes) module. We can inherit from this class to declare abstract classes.
from abc import ABC, abstractmethod
class GearSystem(ABC):
@abstractmethod
def change_gear(self):
pass
abc
is the module, and ABC
is the class.Using this class, I define another class, AutomaticGearingCar
which inherits from both Car
and GearSystem
.
class AutomaticGearingCar(Car, GearSystem):
def change_gear(self):
print('Automatically switching the gears')
TL;DR: Abstract classes are sort of a contract that needs to be fulfilled. You can derive from as many abstract (applies true to non-abstract too) classes as you would like, but failing to override/implement any of their abstract methods will result in the derived class’s abstraction - we will not be able to instantiate it.
How to import classes in Python
In the above example, we saw the ABC class being imported. Can we do the same for any self-defined class? Yes! It is much easier than it appears.
To import a class, either place it in the current directory or refer to its path. For example, I create a Python filesample_module.py
and define some classes in it.
Now, any class within the module can easily accessed as:
from sample_module import class_a
Practical examples of using classes
Let's round this off with a practical example of a library system. To make a library system, inevitably we will begin with the Book
class.
class Book:
def __init__(self, title, author):
self.title = title
self.author = author
self.issued = False
Then, we will add the issue and return methods:
def issue_book(self):
if not self.issued:
self.issued= True
def return_book(self):
if self.issued:
self.issued=False
Now, we will create the library class. A library is meant to keep books, so we will create a list of books as its member attribute. This is an interesting case where a collection of other classes’ objects is a member attribute of another class (and something we will often see in other practical examples, like having a deck of cards for a card game, a collection of different objects in an arcade game, assets for a firm, and so on).
Further, it will have methods for adding, removing or finding a certain book.
class Library:
def __init__(self):
self.books = []
def add_book(self, book):
self.books.append(book)
def remove_book(self, title):
for book in self.books:
if book.title == title:
self.books.remove(book)
return f"{title} was succesfully removed from the library."
return "Book not found."
def find_book(self, keyword):
for book in self.books:
if keyword in book.title:
return book.title
return "Book not found."
def list_books(self):
if not self.books:
return "No books in the library."
for book in self.books:
print(book.title)
Our humble library is now ready, and we can add, delete, or find books. Here’s a sample testing.
library = Library()
book1 = Book("Great Expectations", "Charles Dickens")
book2 = Book("The Count of Monte Cristo", "Alexandar Dumas")
library.add_book(book1)
library.add_book(book2)
library.list_books()
library.find_book('Count')
library.find_book('Jane') #No books found
Putting OOP into practice
Object-oriented Programming (OOP) is a powerful yet simple paradigm for programming in almost all the major languages. While Python doesn’t support all the OOP features (like encapsulation or method overloading), it’s still powerful enough for a number of applications. This article could go on and on, but we have kept it concise while covering the majority of OOP in Python. Obviously, it doesn’t conclude here and needs to be complimented with practical examples at your end. To recap the blog, here are some questions:
- If MobilePhone is a class, will Samsung be its object or a child class?
- What will happen if two classes (
B
andC
) derive from the same classA
and then classD
derives fromB
andC
? - Why don’t we study the “virtual functions” specifically in Python?
Can a class or its object be a part of another class?