Introduction

Object-oriented programming, or OOP, is one of the most dominant programming paradigms in modern programming. Its popularity has been steadily increasing, from initial attempts at implementation back in the 1960s to being in some of the most prevalent languages today. As a set of programming concepts and design methodologies, it essentially offers a way of thinking about program structure and organisation.

In the following section, we will first look at a comparison of object-oriented programming and two other programming paradigms, namely imperative and functional programming. We will then introduce object-oriented programming in detail, elaborating on the definition and practical implementation of classes, objects and methods in Python. The last section focuses on the four fundamental principles of object-oriented programming, with examples clarifying each principle.

Comparison of Programming Paradigms

Programming languages are often classified by the programming paradigm they mainly support. The features of each programming language often encourage, or even mandate, structuring the various parts of a program in a specific way. While Python is often referred to as an object-oriented language, it is probably best described as a multi-paradigm programming language as its features also support many other programming paradigms.

The main difference between the various paradigms is the way they handle the state of the program, i.e. the values of its variables. Controlling how and where state is transformed in a program has practical implications for how reliable and manageable the code becomes. Each programming pradigm offers a different way of managing state, in the hope that the program does not quickly fall into the trap of "spaghetti code", where the code is so badly structured that it becomes too complex to maintain.

As an example, three programming paradigms are:

  1. Imperative
  2. Functional
  3. Object-Oriented

The following subsections will illustrate the differences between these three programming paradigms by producing the same output using code organised in these three different ways.

Imperative

Using the analogy of programming as a problem-solving methodology, imperative programming defines a program as a series of steps, sequentially running each line of code one by one until a solution is found. While this is often the simplest and most intuitive way to structure small programs (like the one in the example below), larger imperative programs have a tendency to become unreliable and unmanageable.

So, why do large imperative programs end up this way? This mainly has to do with how, in imperative programs, all variables are global variables. Assigning a value to a variable, like so:

x = 12

changes the value of the variable "x" globally, i.e. it changes the "global state". In small programs with few variables, this is not a major issue, but in large programs with millions of lines and thousands of variables, it is easy to see how a programmer working on one part of the code may accidentally reuse and reassign a variable that another programmer had already used in a different part of the code. The vulnerability of code to changes in the global state means that imperative programming is prone to what is called "side effects" (unexpected output occuring due to unexpected input); this therefore makes imperative programming unsuitable for the creation of large programs.

In the following imperative program, we simulate the processes that turn food into solid waste in a human being.

In [1]:
# Digestive system of a human, imperative style

# Food is composed of solids and liquids.
food = ["Solids", "Liquids"]

# Line by Line, we follow the transformation of food into solid waste.
print("Mouth:")
print("Food is eaten.")

print("\nStomach:")
for (i, component) in enumerate(food):
    if food[i] == "Solids":
        print("{} are digested into nutrients.".format(food[i]))
        food[i] = "Nutrients"
    else:
        pass

print("\nSmall Intestine:")
for (i, component) in enumerate(food):
    if food[i] == "Nutrients":
        print("{} are absorbed, leaving solid waste.".format(food[i]))
        food[i] = "Solid waste"
    elif food[i] == "Liquids":
        print("{} are absorbed, leaving water.".format(food[i]))
        food[i] = "Water"

print("\nLarge Intestine:")
for (i, component) in enumerate(food):
    if food[i] == "Solid waste":
        pass
    elif food[i] == "Water":
        print("{} is absorbed.".format(food[i]))
        food.remove(food[i])

print("\nAnus:")
print("{} is defecated.".format(food[0]))
Mouth:
Food is eaten.

Stomach:
Solids are digested into nutrients.

Small Intestine:
Nutrients are absorbed, leaving solid waste.
Liquids are absorbed, leaving water.

Large Intestine:
Water is absorbed.

Anus:
Solid waste is defecated.

As can be seen, the program follows the path of the food as it passes through each organ, ending up as solid waste. This can be thought of as a series of transformations of state in the human body, where in this case, state is the "food" variable.

In short, imperative programming sees programs in terms of a sequential transformation of state.

Functional

Functional programming attempts to manage complexity by focusing on the use of "pure functions" to control the transformation of state. Pure functions are functions that rely only on the given arguments to produce output i.e. they do not rely on any global variables, and by extension, are not affected by unexpected changes in the global state. This makes the program much less vulnerable to side effects, and greatly increases the reliability of the code.

In the following functional program, we show how the results of the imperative program above can be achieved using code organised according to the functional programming paradigm.

In [2]:
# Digestive system of a human, functional style

# We first define the various transformations that food goes through
# as it passes through the digestive system of a human.
def eat(food):
    print("Food is eaten.")

def digest(food):
    for (i, component) in enumerate(food):
        if food[i] == "Solids":
            print("{} are digested into nutrients.".format(food[i]))
            food[i] = "Nutrients"
        else:
            pass

def absorb_nutrients(food):
    for (i, component) in enumerate(food):
        if food[i] == "Nutrients":
            print("{} are absorbed, leaving solid waste.".format(food[i]))
            food[i] = "Solid waste"
        elif food[i] == "Liquids":
            print("{} are absorbed, leaving water.".format(food[i]))
            food[i] = "Water"

def absorb_water(food):
    for (i, component) in enumerate(food):
        if food[i] == "Solid waste":
            pass
        elif food[i] == "Water":
            print("{} is absorbed.".format(food[i]))
            food.remove(food[i])

def defecation(food):
    print("{} is defecated.".format(food[0]))


# Food is composed of solids and liquids.
food = ["Solids", "Liquids"]


# Now, we apply an action to the food at each stage of the digestive process.
print("Mouth:")
eat(food)

print("\nStomach:")
digest(food)

print("\nSmall Intestine:")
absorb_nutrients(food)

print("\nLarge Intestine:")
absorb_water(food)

print("\nAnus:")
defecation(food)
Mouth:
Food is eaten.

Stomach:
Solids are digested into nutrients.

Small Intestine:
Nutrients are absorbed, leaving solid waste.
Liquids are absorbed, leaving water.

Large Intestine:
Water is absorbed.

Anus:
Solid waste is defecated.

As can be seen, functional programs focus on the strict transformation of state only within pure functions that do not rely on global variables other than the "food" parameter. The code is organised according to the function of each organ, such as the digestion of food, the absorption of nutrients and the absorption of water.

When compared to the imperative program shown earlier, the benefits of organising the code this way may not be readily apparent; however, with much larger programs, it is easy to see that organising the code this way has benefits with regards to the maintainability and readability of the program, as it is much easier to see which function does what.

In short, functional programming sees programs as a set of pure functions that transform state in controlled ways, strictly isolating state from function.

Object-Oriented Programming

The object-oriented programming paradigm, in contrast to the functional programming paradigm, believes that the better way to manage the complexity of code is to group state and function together in structures called "objects". After all, in real life, state and function are rarely separated the way it is in functional programming; instead, object-oriented programming tries to organise code in a way that reflects reality, where objects perform functions that transform some state associated with itself.

By thinking about "objects" instead of "functions", programmers can manage the challenges of global state in a different, but arguably just as effective, way.

In the following object-oriented program, we implement the same program as in the previous examples, this time following the object-oriented programming paradigm.

In [3]:
# Digestive system of a human, object-oriented style

# First, we define a class called "Human"
class Human:
    
    def __init__(self):
        pass

    # In our "Human" class, we initialize methods for each organ in the human body
    # Note how we organise this by organ, not the function of each organ
    def mouth(self, food):
        print("Mouth:")
        print("Food is eaten.")
    
    def stomach(self, food):
        print("\nStomach:")
        for (i, component) in enumerate(food):
            if food[i] == "Solids":
                print("{} are digested into nutrients.".format(food[i]))
                food[i] = "Nutrients"
            else:
                pass
    
    def small_intestine(self, food):
        print("\nSmall Intestine:")
        for (i, component) in enumerate(food):
            if food[i] == "Nutrients":
                print("{} are absorbed, leaving solid waste.".format(food[i]))
                food[i] = "Solid waste"
            elif food[i] == "Liquids":
                print("{} are absorbed, leaving water.".format(food[i]))
                food[i] = "Water"

    def large_intestine(self, food):    
        print("\nLarge Intestine:")
        for (i, component) in enumerate(food):
            if food[i] == "Solid waste":
                pass
            elif food[i] == "Water":
                print("{} is absorbed.".format(food[i]))
                food.remove(food[i])
    
    def anus(self, food):
        print("\nAnus:")
        print("{} is defecated.".format(food[0]))


# We instantiate an object named "David", under the "Human" class
David = Human()

# Food is composed of solids and liquids.
food = ["Solids", "Liquids"]

# Now, each organ transforms the state of food as it passes through David's body.
David.mouth(food)

David.stomach(food)

David.small_intestine(food)

David.large_intestine(food)

David.anus(food)
Mouth:
Food is eaten.

Stomach:
Solids are digested into nutrients.

Small Intestine:
Nutrients are absorbed, leaving solid waste.
Liquids are absorbed, leaving water.

Large Intestine:
Water is absorbed.

Anus:
Solid waste is defecated.

As can be seen, this object-oriented program first creates a class (more about classes later) called "Human", with methods (...more about methods later) akin to class-specific functions that govern the behaviour of the object. We then create an instance of the "Human" class called "David", and the rest of the code follows the behaviour of each of "David"'s organs.

In this example, unlike the previous functional example, the "Human" class contains methods in the form of organs like the "stomach", "small_intestine" and "large_intestine". This reflects the difference in the way that the object-oriented programmer thinks about programming as compared to the functional programmer; while the functional programmer sees the digestion process as a series of actions, like digestion or nutrient absorption, the object-oriented programmer instead thinks of objects that each perform an action in the context of a larger whole.

In short, object-oriented programming sees programs as a set of objects where state is coupled with function, with strict designations as to which states are to be modified by these functions.

More about Object-Oriented Programming

Object-oriented programming (OOP) is based on the idea that we are no longer primarily focused on the logic or actions of our program but rather on the data itself, in particular the objects of our program. While object-oriented programming serves a similar purpose as functional programming i.e. eliminating the global state, it does this differently, by allowing us to store variables in objects rather than functions. Our programming is therefore centered around the objects themselves.

To create and use your own types and create objects of that type, all you need is three simple steps:

  1. Define a Class by initializing attributes
  2. Initialize Methods
  3. Instantiate Objects

To illustrate this process more specifically and represent what an object may be, imagine a troop of gorillas. The gorilla as an animalistic species has certain characteristics- a name, height, 2 arms and 2 legs. However exploring every individual gorilla, we find that some of those characteristics vary- for example one animal will be called George and weigh 150 kg, whereas another may be called Harry and weigh 190 kg. The species is the general case of our beast, the class. The individual beast is an instance of our class. This generalization as a class of objects is similar to Plato's concept of the ideal chair that stands for all chairs, or in our case the "ideal" gorilla.

Step 1: Define a Class & Attributes

A class is a blueprint for our generalized object. In our case the gorilla as a species is the class. Let's define our class of the gorilla. Classes are mere descriptions of how our to-be objects should look like.

In [4]:
class Gorilla: 
    # Class attribute 
    species = "Gorilla"
    
    # Instance attribute
    def __init__(self, name, age):
        self.name = name 
        self.age = age

It's important to highlight that we have defined both class-wide attributes (the species) and instance-specific attributes such as the name and age.

Step 2: Initialize Methods

Methods are functions used inside classes that form the behaviour of an object. When we interact with lists, for example, we might use its type's methods such as list.append(), list.count() or list.index(). Similarily, we can define our own methods in our new class.

In [5]:
class Gorilla: 
    
    # Class attribute 
    species = "Gorilla"
    
    # Instance attribute
    def __init__(self, name, age):
        self.name = name 
        self.age = age
    
    # Instance method
    def eat(self):
        return '{} is eating a banana.'.format(self.name)

Step 3: Instantiating an Object

What is an object? Objects are specific instances of classes. In fact, everything in Python is an object in disguise. More specifically, when we instantiate any variable, Python hiobjects are instances of the different types. For example, 'Hello world' is an instance of a string, 123456789 of an integer and [a, b, c] of a string. Objects are specific instances of classes. To instantiate our class, we call our class and enter the respective parameters.

In [6]:
kingkong = Gorilla("King Kong", 15)
donkeykong = Gorilla("Donkey Kong", 11)

# Class attributes
print("King Kong is a {}.".format(kingkong.__class__.species))
print("Donkey Kong is a {}.".format(donkeykong.__class__.species))
print("")

# Instance attributes
print("{} is {} years old.". format(kingkong.name, kingkong.age))
print("{} is {} years old.". format(donkeykong.name, donkeykong.age))
print("")

# Instance method
print(kingkong.eat())
print(donkeykong.eat())
King Kong is a Gorilla.
Donkey Kong is a Gorilla.

King Kong is 15 years old.
Donkey Kong is 11 years old.

King Kong is eating a banana.
Donkey Kong is eating a banana.

We have thus defined an object kingkong, which is a gorilla of name "King Kong", and 15 years of age. We have also defined donkeykong, a gorilla of name "Donkey Kong", 11 years of age.

Both King Kong and Donkey Kong are eating a banana. Note that this is because the only argument is "self" here. The "self" argument refers to the object or the bound variable itself. The output is the same for King Kong and Donkey Kong. To get instance-specific output we can use their attributes.

While their class attributes are the same meaning that both objects are of the species Gorilla, it is important to highlight that their instance attributes differ, as these are specific to each object.

Extras: Special Methods

Class functions that begin with double underscore _ are called special functions in Python. The \_init\() function as seen above, is one of them. Using special functions, we can override built-in functions such as +, -, ==, len(), print and many others.

In [7]:
print("kingkong")
print(kingkong)
kingkong
<__main__.Gorilla object at 0x0000012A8CCB5DA0>

Our first code line simply prints its string input.

However the second code line refers to our object, kingkong, our Gorilla named "King Kong" and 15 years of age. But what is our function print actually doing? What is this weird output doing? It tells us about our object kingkong, which is of type Gorilla as __main\.Gorilla indicates, and it also shows us our object's memory location: 0x000001ACC8201828. This is the built-in function __str\() of python. We can change the printing behaviour of the object by overwriting __str\() in our class.

In [8]:
class Gorilla1: 
    species = "Gorilla"
    # Instance attribute
    def __init__(self, name, age):
        self.name = name 
        self.age = age
    # Instance method
    def eat(self):
        return '{} is eating a banana.'.format(self.name)
    # Special method
    def __str__(self):
         return '<{}>,<{}>'.format(self.name, self.age)

kingkong = Gorilla1("King Kong", 15)
print(kingkong)
<King Kong>,<15>

That's much better! Our newly defined method returns a little summary of the object. This is a lot more user-friendly.

Here are some other special methods that correspond to each built-in function:

Built In Functions Special Method Functionality
len(x) __len__(self) length of x
float(x) __float__(self) float equivalent of x
int(x) __int__(self) integer equivalent of x
str(x __str__(self) string representation of x
abs(x) __abs__(self) absolute value of x
hash(x) __hash__(self) integer hash code for x
iter(x) __iter__(self) iterator for x

\begin{align*} \href{https://docs.python.org/3/reference/datamodel.html#basic-customization}{Table Source} \end{align*}

Four Fundamental Principles of Object-oriented Programming

Encapsulation

Encapsulation refers to the principle of keeping the state of each object private, inside a class. By limiting direct access to this state and only allowing the object's own public setter methods to modify state in the class, this prevents the unintentional spread of changes made in one part of a program to other parts of the program. Encapsulation is especially important in large and complex projects worked on by teams of programmers, where communication between different parts of the program must be carefully managed.

In [9]:
class Gorilla2:
    def __init__(self, name, awakeness, colour):
        self.name = name
        self.awakeness = awakeness
        self.__colour = colour # Note the double underscore denoting a private attribute
        
    def currentstate(self):
        print(f"{self.name} is currently {self.awakeness}.")
        
    def currentcolour(self):
        print(f"{self.name} is {self.__colour} in colour.")
        
    def spraypaint(self, paint): # Public method that changes the private attribute "self.__colour"
        self.paint = paint
        self.__colour = paint
        
kingkong = Gorilla2("King Kong", "asleep", "black")

kingkong.currentstate()
kingkong.awakeness = "awake" # Usually, attributes in a class can be directly modified outside the class
kingkong.currentstate()
print("")

kingkong.currentcolour()
kingkong.__colour = "brown" # However, private attributes cannot be modified outside the class
kingkong.currentcolour()    # Therefore, this attribute will not be changed to "brown"
print("")

kingkong.currentcolour()
kingkong.spraypaint("red") # Private attributes can only be modified by public setter methods of the class
kingkong.currentcolour()
print("")
King Kong is currently asleep.
King Kong is currently awake.

King Kong is black in colour.
King Kong is black in colour.

King Kong is black in colour.
King Kong is red in colour.

Abstraction

Abstraction refers to the principle of displaying essential information by hiding unnecessary information. This is done by creating sub-classes for this unnecessary information. This isolation of the information is similar to inheritance in the aspect of how it is achieved (creating specific sub-classes), but it has its own purpose: simplicity.

This principle may not seem advantageous at first glance. After all, why would we want to hide information about how the tasks of our program are achieved? How exactly does this reduce complexity? Consider a TV remote. Do we know exactly how each of the buttons on our remote function in everyday usage? Should we be reminded at every press of a button on our remote, that we are making the underlying chip's sensor turn on, producing an electrical signal which is amplified with a transistor, then sent through a LED which finally prompts an infrared light to communicate with our TV?

What matters to us is that the power button correctly prompts the television to turn on, that the volume button changes the volume, and that the channel changes when we use the button to change channels. The inner-workings of these buttons do not need to be apparent in the daily usage of our remote. This is the exact point of abstraction: we want to reduce complexity. Through abstraction we are also able to isolate parts of our code, making its maintenance more efficient by ensuring changes are to be made locally.

Inheritance

Inheritance refers to how an object-oriented programming language allows the creation of (child) subclasses using the characteristics of an existing parent or superclass. In other words, the child class can inherit attributes from the parent class. By simply inheriting from the parent class, we have inherited all its functionality. Python will travel up the inheritance chain (e.g. Gorilla to Monkey) until it finds the called method (init) to be executed. Now, let's customize our subclass a little bit. Adding methods for the child class is no different than adding methods for the parent class. It is not possible to remove inherited attributes, variables and methods from the subclass, as they do not exist in the subclass. However, it is possible to overwrite them, as shown in the example below.

In [10]:
# Remember this is our parent class
class Monkey: 
    family = "Monkey"
    # Instance attribute
    def __init__(self, name, age):
        self.name = name 
        self.age = age
    # Instance method
    def eat(self):
        return '{} is eating a banana.'.format(self.name)   
    
# Creating our first child class   
class Gorilla(Monkey): 
    species = 'Gorilla'
    # Adding a new attribute
    def __init__(self, name, age, strength):
        # Calling the parents init methods  
        super().__init__(name, age)  
        # Call the new subclass specific attribute
        self.strength = strength 
        # Strength is quantified by how many kilograms the gorilla benches
        
# Creating our second child class   
class Chimpanzee(Monkey): 
    species = 'Chimpanzee'
    # Adding a new attribute
    def __init__(self, name, age, IQ):
        # Calling the parents init methods  
        super().__init__(name, age)  
        # Call the new subclass specific attribute
        self.IQ = IQ  
        # Replace method
    def eat(self):
        return '{} is eating caviar.'.format(self.name) 
    
brutus = Gorilla('Brutus', 11, 130)
george = Chimpanzee("George", 7, 170) 

print(brutus.__class__.family)
print(brutus.__class__.species)
print("")
print("Brutus benches {} kg.".format(brutus.strength))
print("George has an IQ of {}.".format(george.IQ))
print("")
print(brutus.eat())
print(george.eat())
Monkey
Gorilla

Brutus benches 130 kg.
George has an IQ of 170.

Brutus is eating a banana.
George is eating caviar.

Brutus, our object of sub-class Gorilla has effectively inherited the family Monkey from its parent attribute. However he also has specific attributes, such as his species Gorilla.

Both our monkeys Brutus and George have been given a new attribute, unique to their sub-class. Brutus has been given a strength one, whereas George an IQ one. While they both have the same parent class Monkey, they also have their own specific-sub classes which allow them to have different characteristics, gorillas being known for their strength and chimpanzees for their intelligence.

Lastly, while Brutus has inherited the parent-class eat function, and thus eats bananas, George shows that it is possible to replace parent class methods. Chimpanzees are special and eat caviar.

Polymorphism

Polymorphism is one of the 4 pillars of OOP. It allows our program to process information differently based on their data type. This is achieved through the usage of a generic interface. Let's illustrate this with an example.

In [11]:
# Creation first class
class Dolphin:
    
    def sing(self): 
        print("Dolphins can't sing, silly.")
        

# Creation second class
class Gorilla:

    def sing(self):
        print ("*Sings Despacito*")

# Creation of generic interface
def singing(animal):
    animal.sing()
    
# Now let's test what this does
# First, we create 2 animals, a dolphin and a gorilla 
dolphin1 = Dolphin()
gorilla1 = Gorilla()

# Pass our objects through the generic interface
singing(dolphin1)
singing(gorilla1)
Dolphins can't sing, silly.
*Sings Despacito*

We have defined two different classes, Dolphin() and Gorilla(), each with their own sing() method. We then defined a generic interface singing(), for the input of any object. Then, passing our two objects, dolphin1 and gorilla1 through this interface, we get differing output, depending on the class of our input. In the case of the Gorilla, it is able to sing. Because gorillas can sing. Duh. Well only Despacito. In the case of our Dolphin, it is sadly unable to sing.

The generic interface serves a similar purpose as a switchboard. We are now able to enter the sing() function of both Gorillas and Dolphins into this interface, which then directs our program to the correct class, allowing it to differentiate the singing() function for our data type, depending on whether it is a Gorilla and Dolphin. Differentiation of our objects is the main strength of Polymorphism.

The principles of Inheritance and Polymorphism go hand in hand. While Inheritance allows our sub-classes to inherit the same attributes and methods as a parent or super-class, Polymorphism allows for these to differ depending on which sub-class they originate from.