Introduction

Just like many other programming languages out there, Python's functionality builds on so-called programming paradigms. While some languages may require you to use a certain paradigm, others allow for more than one paradigm in your code.

Python is mostly referred to as an object-oriented programming language, implying that only Object-Oriented Programming (OOP) - one of many paradigms - is allowed in it. However, the language supports and encourages the use of other paradigms too. Users can mix and match the paradigms in a way that suits the problem at hand and matches their programming style. This feature grants the user a lot of flexibility and is probably one reason why Python has become so popular. It would, therefore, be more accurate to speak of Python as a multi-paradigm programming language instead of a purely object-oriented one.

In the following sections, we will first provide you with a brief overview of the main paradigms in Python. Then, by building Tesla's Model X step by step, we will be illustrating the differences between the paradigms and show that each of them would do the trick.

Overview of Programming Paradigms

Now, what exactly are programming paradigms? Roughly speaking, a paradigm is a way of solving a problem. Each paradigm offers a different set of ideas to perform the task at hand. They follow a paradigm-specific approach, structure your code and control its execution.

The three main paradigms that are supported by Python are:

  1. Procedural
  2. Functional
  3. Object-Oriented

They differ mainly in when and how the program assigns values to variables. Controlling this is important because it directly affects the manageability and reliability of the code. Chaotic, repetitive or copy-paste codes are more prone to mistakes, difficult to read and hard to maintain. Programming paradigms assist you in writing well-structured code so that it remains comprehensible and concise.

Procedural

The procedural paradigm might be the most intuitive of the three. It uses a top-down approach, where it breaks the problem down into small pieces and instructs the machine step by step how to solve a task. The state of a variable is changed directly along the way, which makes the code very easy to follow and understand. For the machine it is like following a recipe.

However, the longer and more complex a code, the more difficult it gets to stay on top of things. For example, a programmer would always have to know which variable names he already used because accidentally assigning a value to an existing variable may cause problems down the line. Finally, reusing a program that follows the procedural paradigm is often not that easy and requires you to code up every step again.

Functional

While the procedural paradigm follows a step by step approach, the functional paradigm solves a task by executing a series of functions. In this case, the state of a variable is changed by applying a function to it. The definition of the function happens strictly seperate from its use and is done in a general way, such that we can use it again with different inputs. This procedure might be a little more time consuming at first, but it has the advantage that later on, we can use the function for similar tasks without having to code every instruction again.

This reusability makes the code a lot shorter. However, one will have to look up the function definition to understand what it does and how it changes the state of the variable. Additionally, when working with imported functions there is always the possibility of unwanted changes to their functionality. If the package creators decide to change said function, this will affect your program too.

Object Oriented

The object-oriented programming paradigm is built on the notion of classes and objects. These objects are similar to real life objects and all objects of the same class share certain attributes (states) or methods (functionalities). However, each object can also have personal attributes, just like one jacket might be red and the other one might be green, they both have the ability to keep you warm though.

As you see, with OOP state and function are no longer separated but happen both within the object. The problem at hand is then solved through the interaction between the different objects. For example, if you, as a human object, are cold you could put on the jacket object to solve that problem. The fact, that OOP basically mimicks the real world, makes it easier to understand and since these objects are whole in themselves, they can easily be reused in different applications.

Example Case: Building a Car

In the following sub-chapters, the three different paradigms - procedural, functional and object oriented - will all be illustrated by the same example: the construction of the Model X car by the manufacturer Tesla. The aim of this chapter is to convey to the reader that the three paradigms are not completely different 'things', but rather allow a different perspective or approach to tackle one and the same problem. The example with the car, a simple everyday object, is used to convey the concepts of the three paradigms more easily. To get a better idea of the car, here is a picture of the original Model X by Tesla:

Markdown

The steps in the construction of the car are as follows:

  1. Specifying the Model
  2. Defining the Attributes
  3. Defining the Functionality

Note, if a string is printed in the following subchapters (e.g. "Model X is driving"), the implied action is considered to take place immediately.

1. Specifying the Model

In this chapter, the exact model to be built is specified. In principle, it would also be possible to build another model instead of the Model X (for example, the Model S).

Procedural

In the procedural way, the car model is specified simply through assigning the name of the model to a variable of choice:

In [1]:
# specifying the model
model_x = "Model X"

# show the specified model
print("The model under construction is: {}".format(model_x))
The model under construction is: Model X

Functional

In the case of the functional paradigm, we create a function called specify_model and pass the desired name of the model to the function through the parameter called model_name. In the suite of the function the model is specified locally by assigning the passed name of the model to the local variable called model. In this context, local means the variable is only usable inside the suite of the function (the indented lines below the signature/name of the function). In the last step of the function, the value of the variable model is returned and assigned to the global variable called model. This last step has to be done in order to make the specified model globally available.

In [2]:
# function to specify the model
def specify_model(model_name):
    
    # specifying the model locally
    model = model_name
    
    # show the specified model
    print("The model under construction is: {}".format(model))
    
    # return the specified model
    return model
    
# specifying the model globally
model_x = specify_model("Model X")
The model under construction is: Model X

Object Oriented

And lastly, turning our viewpoint to the object oriented paradigm:

A class is like a blueprint for an object. Later in this tutorial you will see that a class specifies all the details of an object derived from that class. In the code below, the class keyword is used to define an empty class Car. Any indented code that follows the colon after the class definition represents the body of the class. For now, the pass statement is used as a placeholder to indicate where code in later steps will eventually go. It allows you to run the code without any errors.

In [3]:
# class definition
class Car:
    # body of the class
    pass

Creating a new object from a class is called "instantiating an object". You can instantiate a new object of the class Car by assigning the name of the class, followed by opening and closing parentheses, to a variable of choice: In this case the new object of the class Car is instantiated and assigned to the variable model_x. You can create as many objects of a class as you wish and assign each of them to a different variable. Altough they will all be instances of the same class, they represent independent objects, which may have different details.

In [4]:
# instantiating the object
model_x = Car()

# show the specified model
print("The model under construction is: {}".format(model_x))
The model under construction is: <__main__.Car object at 0x7fc166f8e2b0>

If you execute the code above, you may wonder why strange characters are displayed instead of the name of the model like in the paradigms shown before. This is due to the fact that we have not yet specified any attributes in the class Car. Therefore, the instantiated objects of this class also have no attributes. This will be rectified in the next chapter of this example. The character string displayed corresponds to the address of the instantiated object in the main memory.

So far, we have only specified which model we want to build. It's not really a car yet, because it doesn't have any physical attributes like colour or basic functionality like driving.

2. Defining the Attributes

This chapter focuses on the individual characteristics that differentiate a car (Model X) from another by determining its appearance, condition or other qualities.

Procedural

In the same way as in specifying the model, the attributes are specified simply by assigning the individual characteristics to a variable of choice:

In [5]:
# specifying the model
model_x_name = "Model X"

# specifying the attributes
model_x_colors = ["white", "black", "silver", "blue", "red"]
model_x_hp = 443
model_x_length = 5.04
model_x_width = 2.27

# printing the model and the attributes
print("The {} has the following specifications:".format(model_x_name))
print("Available colours: {}".format(model_x_colors))
print("HP: {}".format(model_x_hp))
print("Length: {}".format(model_x_length))
print("Width: {}".format(model_x_width))
The Model X has the following specifications:
Available colours: ['white', 'black', 'silver', 'blue', 'red']
HP: 443
Length: 5.04
Width: 2.27

Functional

In functional paradigm, the individual characteristics of our Model X are specified by the function car_attr. Firstly, we define the parameters of the newly created function car_attr. The parameters form the different car's attributes, in our case: name, colors, hp, length, width.

In [6]:
# function to specify the model
def specify_model(model_name):
    
    model = model_name
    
    print("The model under construction is: {}".format(model))
    
    return model

# function to specify the attributes
def car_attr(name, colors, hp, length, width):
    """
    Prints the specifications.
    Returns a list containing the car's specifications. 

    """
    car_attr = [name, colors, hp, length, width]
    
    print("The {} has the following specifications:".format(name))
    print("Available colours: {}".format(colors))
    print("HP: {}".format(hp))
    print("Length: {}".format(length))
    print("Width: {}".format(width))

    return car_attr

Secondly, once the function is created, we can use it by assigning the specific characteristics to our Model X.

In [7]:
# specifying the model
model_x = specify_model("Model X")

# specifying the attributes
model_x_attr = car_attr(name = model_x, 
                        colors = ["white", "black", "silver", "blue", "red"], 
                        hp = 443, 
                        length = 5.04, 
                        width = 2.27)
The model under construction is: Model X
The Model X has the following specifications:
Available colours: ['white', 'black', 'silver', 'blue', 'red']
HP: 443
Length: 5.04
Width: 2.27

Object Oriented

A class does not actually contain any data. A method called __init__() specifies that different parameters are necessary to define a certain car, but it does not contain the actual values for these parameters for any specific car. Every time a new car object is created, __init()__ sets the initial state of the object by assigning the values of the object’s properties. That is, __init()__ initializes each new instance of the class.

You can create as many attributes in the __init()__ method as you like, but the first parameter will always be a variable called self. When a new class instance is created, the instance is automatically passed to the self parameter in __init()__ so that new attributes can be defined for the object. Note in the code below, the __init()__ function is indented by four spaces and the body of the method by eight spaces. This exact indentation is important to Python, as it indicates that the __init()__ method belongs to the Car class.

In the body of __init()__, there are four statements - one for each attribute - using the self variable:

  1. self.colors = colors creates an attribute called colors and assigns to it the value of the colors parameter.
  2. self.hp = hp creates an attribute called hp and assigns to it the value of the hp parameter.
  3. self.length = length creates an attribute called length and assigns to it the value of the length parameter.
  4. self.width = width creates an attribute called width and assigns to it the value of the width parameter.

As mentioned above, all attributes created in the __init()__ method are specific to the instances and hence referred to as instance attributes. In other words, all car objects have a length, width, etc. but the values for these parameters vary depending on the Car instance.

In order to create attributes that all objects inherit, so called class attributes, a value can be assigned to a variable name outside the __init()__ function. Exemplary, all Cars of our class are models by Tesla.

In [8]:
class Car:
    
    #class atrributes
    brand = "Tesla"
    
    #instance attributes
    def __init__(self, name, colors, hp, length, width):
        self.name = name
        self.colors = colors
        self.hp = hp
        self.length = length
        self.width = width
In [9]:
# instantiating the object
model_x = Car(name = "Model X", 
              colors = ["white", "black", "silver", "blue", "red"],
              hp = 443,
              length = 5.04,
              width = 2.27)

# printing the name and class attribute
print("The {} is a car by {}.".format(model_x.name, model_x.brand))

# printing some instance attributes
print("The {} has a length of {}m and a width of {}m.".format(model_x.name, model_x.length, model_x.width))
The Model X is a car by Tesla.
The Model X has a length of 5.04m and a width of 2.27m.

Our Model X is starting to have a physical appearance thanks to the individual characteristics assigned to it such as its color or width. However, our car still lacks some functionalities to be usable (such as driving).

3. Defining the Functionality

In order to finalize the car, specific methods that define the car's functionality have to be implemented - exemplary, in our case the driving function.

Procedural

In the procedural way, the car's driving function is executed through a simple print statement.

In [10]:
# specifying the model
model_x = "Model X"

# specifying the attributes
model_x_colors = ["white", "black", "silver", "blue", "red"]
model_x_hp = 443
model_x_length = 5.04
model_x_width = 2.27

# specifying the functionality
mph = 100
print("{} is driving at {} mph.".format(model_x, mph))
Model X is driving at 100 mph.

Functional

For the functional paradigm, the function drive is implemented in the same way that the car's attributes are defined. Within the drive function, a simple print statement is used.

In [11]:
# function to specify the model
def specify_model(model_name):
    model = model_name
    
    print("The model under construction is: {}".format(model))
    
    return model

# function to specify the attributes
def car_attr(name, colors, hp, length, width):

    car_attr = [name, colors, hp, length, width]

    print("The {} has the following specifications:".format(name))
    print("Available colours: {}".format(colors))
    print("HP: {}".format(hp))
    print("Length: {}".format(length))
    print("Width: {}".format(width))

    return car_attr

# function for the functionality
def drive(model, mph):
    print("{} is driving at {} mph.".format(model, mph))
In [12]:
# specifying the model
model_x = specify_model("Model X")

# specifying the attributes
model_x_attr = car_attr(name = model_x, 
                        colors = ["white", "black", "silver", "blue", "red"], 
                        hp = 443, 
                        length = 5.04, 
                        width = 2.27)

# using the functionality
drive(model_x, 100)
The model under construction is: Model X
The Model X has the following specifications:
Available colours: ['white', 'black', 'silver', 'blue', 'red']
HP: 443
Length: 5.04
Width: 2.27
Model X is driving at 100 mph.

Object Oriented

Instance methods describe the functions of the objects of a class. They represent the operations (actions) that can be performed on these objects, and only these object. In other words, the defined functions cannot be called from instances other than the ones of that specific class. The execution of a method can lead to a change of the state of the object.

Methods are defined in the same way as normal functions but must be declared within the body of the class. Their first argument always refers to the calling instance, thus methods are said to be functions, attached to objects. Similar to the __innit__ method, the first parameter of the method is by convention always the name self. .drive() has one additional parameter called mph and returns a string containing the car’s state (driving) and the velocity in mph. Of course, you could also implement a method that only uses the self parameter. Naturally, other methods regarding the car example could be: braking, parking, etc.

In [13]:
class Car:
    
    #class atrributes
    brand = "Tesla"
    
    #instance attributes
    def __init__(self, name, colors, hp, length, width):
        self.name = name
        self.colors = colors
        self.hp = hp
        self.length = length
        self.width = width
        self.state = "off"

    # defining the functionalities
    def drive(self, mph):
        self.state = "driving"
        print("{} is {} at {} mph.".format(self.name, self.state, mph))        
In [14]:
# instantiating the object
model_x = Car(name = "Model X", 
              colors = ["white", "black", "silver", "blue", "red"],
              hp = 443,
              length = 5.04,
              width = 2.27)

# printing the name and class attribute
print("The {} is a car by {}.".format(model_x.name, model_x.brand))

# printing mone instance attributes
print("The {} has a length of {}m and a width of {}m.".format(model_x.name, model_x.length, model_x.width))

# using the functionality
model_x.drive(100)
The Model X is a car by Tesla.
The Model X has a length of 5.04m and a width of 2.27m.
Model X is driving at 100 mph.

We have now specified the car, defined some attributes and finally added a functionality in all three programming paradigms. Model_X is now completed. Especially regarding the object oriented programming, you have learnt how to:

  • Define a class
  • Instantiate an object
  • Define attributes
  • Define methods

Although only the driving functionality has been added to our Model X, many other functionalities such as braking, lights on and lights off can be implemented.

In the following, a second car will be built in order to illustrate the benefits of the individual paradigms in different situations.

Building a Different Car

As mentioned above, a big advantage of OOP is its reusability. Classes can be reused for creating similar objects simply trough instantiating a new object of this class. Suppose we want to build Tesla's Model S now. The Model S has the same brand as the Model X, so the class attribute brand stays the same but the instance attributes: name, colors, horsepower (hp), length, width, etc. will have different values.

Now, for building this diffent car all we have to do is reuse the syntax for instantiating an object and pass it the new values for the instance attributes.

In [15]:
# instantiating the Model S
model_s = Car(name = "Model S", 
              colors = ["white", "black", "silver", "blue", "red"], 
              hp = 670,
              length = 4.98,
              width  = 2.19)

See, how easy that was? We can now have a look at our new car's attributes:

In [16]:
# printing the class attributes
print("The {} is a car by {}.".format(model_s.name, model_s.brand))

# printing some instance attributes
print("The {} has a length of {}m and a width of {}m.".format(model_s.name, model_s.length, model_s.width))
The Model S is a car by Tesla.
The Model S has a length of 4.98m and a width of 2.19m.

The new object automatically gets all the class functionalities as well. For our Model_S this means that just like the Model_X it has the method Model_S.drive(mph).

In [17]:
# using the functionality Car.drive()
model_s.drive(100)
Model S is driving at 100 mph.

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 [1]:
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 [2]:
# 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 [3]:
# 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.