Introduction to object-oriented programming

Python is a so-called “multi-paradigm” language, meaning it allows for multiple ways to code and design programs. One of these ways is object-oriented programming (OOP). OOP is a powerful paradigm but involves fairly complex concepts (polymorphism, inheritance, etc.). Fortunately for us, Python does not enforce coding in OOP. However, the internal workings of Python are heavily influenced by OOP, and most widely used packages rely on objects to varying degrees. In this tutorial, we will study the basics of OOP to be autonomous when its use is necessary.

Object-oriented programming

You may have heard that Python is an “object-oriented programming” language. OOP is a programming paradigm that structures programs around an abstraction, the object, which contains attributes (characteristics of the object) and methods (functions specific to the object) that act on itself. To illustrate this somewhat abstract definition, we can take the example (source) of a “lemon” object that contains the attributes “flavor” and “color”, and a method “squeeze” that allows extracting its juice.

“Everything is an object”

In Python, everything is an object (in the OOP sense). Let’s see what this means by retrieving the type of various objects we’ve seen in previous tutorials.

print(type(1))
print(type("hello"))
print(type([]))
print(type(()))
print(type({}))

def f(x):
    print(x)

print(type(f))
<class 'int'>
<class 'str'>
<class 'list'>
<class 'tuple'>
<class 'dict'>
<class 'function'>

These elements are all of different types, but they have one thing in common: the term class. Just as the def statement defines a function, the class statement defines a class of Python objects. Thus, each object usable in Python has a class that defines the object, its attributes, and its methods.

Defining your own objects

Let’s see how we can use the class statement to define our “lemon” object.

class Lemon:

    def __init__(self, color, juice_qty):
        self.flavor = "sour"
        self.color = color
        self.juice = juice_qty

    def get_juice_qty(self):
        print("There is " + str(self.juice) + " mL of juice left in the lemon.")

    def squeeze_juice(self, amount):
        if amount > self.juice:
            print("There is not enough juice in the lemon for the requested amount.")
        else:
            self.juice = max(0, self.juice - amount)  # avoids any negative value of `juice`

Let’s analyze the syntax for constructing a class of objects:

  • The class statement defines the class of objects. Different objects can be created according to the model defined by this class. By convention, the class name should start with an uppercase letter.

  • The class specifies several functions. In this particular context, these functions are called “methods”: they are specific to the defined class of objects.

  • A first very specific method, named __init__, is called the constructor. It allows defining the attributes attached to this class of objects. It is possible to pass parameters to the function (such as color and juice_qty) to define attributes specific to an instance of the object (more details on this concept in the next section).

  • The constructor has a mandatory parameter: self. It is a reference to the instances that will be created from this class. Note the syntax that defines an attribute: self.attribute = value.

  • The other methods are defined by the user. They also take self as a parameter, allowing them to perform operations on/from the attributes. As they are functions, they can also accept other parameters. Thus, the squeeze_juice function takes an amount parameter that defines how much juice is extracted from the lemon when squeezed.

The class and its instances

The class can be seen as the recipe that allows creating an object: it defines the attributes and methods that all objects defined from this class will have. Defining a class as above simply makes this recipe available in the Python environment. To create an object according to this class, it must be instantiated.

lemon1 = Lemon(color="yellow", juice_qty=45)
lemon2 = Lemon(color="green", juice_qty=32)

print(type(lemon1))
print(type(lemon2))
<class '__main__.Lemon'>
<class '__main__.Lemon'>

Here, we have created two instances of the Lemon class. These two instances are independent: Python sees them as two distinct objects. However, they were created from the same class and therefore have the same type.

This distinction between the class and its instances helps to better understand the meaning of the self parameter. It is a reference to the instances that will be created according to the class, allowing the specification of their attributes and methods. When a given instance is created, it essentially becomes the self.

Attributes

An attribute is a variable associated with an object. An attribute can contain any Python object.

Accessing attributes

Once the object is instantiated, it is possible to access its attributes. The syntax is simple: instance.attribute.

print(lemon1.color)
print(lemon2.color)
print(lemon1.juice)
print(lemon2.juice)
yellow
green
45
32

We can see that the two instances are independent: although they are of the same type, their attributes differ.

Modifying an attribute

Modifying an attribute of an instance is very simple, the syntax is: instance.attribute = new_value.

lemon2.color = "red"
print(lemon2.color)
red

It is also possible to add an attribute according to the same logic: instance.new_attribute = value. However, this is not good programming practice, as the class precisely serves to define the attributes that objects of a given class can have. Therefore, it is generally preferable to define attributes within the class rather than outside.

Class attributes and instance attributes

The two instances we created illustrate different types of attributes:

  • Class attributes. These are attributes that have the same value for every instance created according to this class. Here, it is the flavor attribute: all lemons are sour, so there is no reason to allow modifying this parameter during instantiation. Strictly speaking, we could even define this attribute outside the constructor.

  • Instance attributes. These are attributes whose values can vary between different instances created according to the same class. Here, these are the color and juice attributes: there are lemons of different colors and larger or smaller lemons that will therefore have different amounts of juice. It is up to the user to define these attributes during instantiation.

Methods

A method is a function associated with an object. It can use its attributes, modify them, and involve other methods of the object.

Calling a method

The syntax for calling a method of an instantiated object is as follows: instance.method(parameters).

lemon1.get_juice_qty()
There is 45 mL of juice left in the lemon.

Two remarks can be made about this syntax. The first is that a method is a function attached to an instance of an object. Unlike functions defined via the def statement, methods do not have an independent existence outside the object’s instance. In our case, calling the get_juice_qty() function independently of the object returns an error.

get_juice_qty()
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[19], line 1
----> 1 get_juice_qty()

NameError: name 'get_juice_qty' is not defined

The second remark is that we no longer specify the self parameter when manipulating an instance. The instance itself has become the self (or rather a self). The link between the method and its instance is already made since the method cannot be used without first calling the instance.

Acting on attributes

The main interest of methods is that they can access attributes and perform operations based on them, as well as modify them. Let’s take our example again to illustrate this possibility.

lemon1 = Lemon(color="yellow", juice_qty=45)

lemon1.get_juice_qty()
lemon1.squeeze_juice(12)
lemon1.get_juice_qty()
There is 45 mL of juice left in the lemon.
There is 33 mL of juice left in the lemon.

The get_juice_qty method simply displays the value of an attribute in a formatted way. The squeeze_juice method, on the other hand, permanently modifies the value of the juice attribute, as shown by the second call to get_juice_qty.

When to use OOP?

The previous example is interesting because it illustrates both an advantage and a disadvantage of OOP.

The fact that objects have attributes allows maintaining the state of a resource—in our example, the amount of juice contained in a given Lemon class object. To take more realistic examples, this property is interesting and used in several cases:

  • Training a machine learning model. It is common to train a model once and then want to continue training it longer or with different data. Saving the state in an instance of the Model class allows for this. This is why most machine learning packages in Python are based on OOP.

  • The continuous operation of a web application. Such an application must keep things in memory to provide a smooth user experience: the fact that the user has logged in, their history, etc. Again, most web frameworks (Django, Flask, etc.) rely on OOP.

At the same time, using objects that keep a state in memory can limit the reproducibility of analyses. To illustrate this, let’s return to the example in the tutorial: execute the following cell several times in a row.


{python}
lemon1.get_juice_qty()
lemon1.squeeze_juice(12)
lemon1.get_juice_qty()

The three executions give different results, even though the executed code is strictly the same. This illustrates the reproducibility issue: when using OOP, you must be mindful of the state of objects kept in memory, or you risk not getting the same results when replicating an analysis.

Exercises

Comprehension questions

  • 1/ “In Python, everything is an object”: what does this phrase mean?

  • 2/ What is the purpose of the class statement?

  • 3/ What is the purpose of the __init__ constructor?

  • 4/ What is the purpose of self?

  • 5/ What is the difference between a class and an instance?

  • 6/ What is an attribute?

  • 7/ What is the difference between a method and a function?

  • 8/ How can you tell the difference between an attribute and a method when calling them?

  • 9/ Can an attribute be modified by a method? Can an attribute be modified outside a method?

  • 10/ When is OOP generally used?

Show solution
  • 1/ It means that all Python objects (numbers, strings, lists, etc.) are objects in the OOP sense: they have attributes and methods defined by a class.

  • 2/ The class statement is used to define a class of objects.

  • 3/ The __init__ constructor is a special method that allows the user to define an object’s attributes.

  • 4/ self serves as a reference to the instance within the class. It indicates who will carry the attributes and methods once the object is instantiated.

  • 5/ The class is the “recipe” that defines all the characteristics of the object. But the object is not truly created until the class is instantiated, i.e., when an instance is created from the class.

  • 6/ An attribute is a variable associated with an object.

  • 7/ A method is a specific function: it is associated with an object and does not exist independently of it.

  • 8/ The presence of parentheses distinguishes between calling an attribute and calling a method. Attribute call: instance.attribute Method call: instance.method() with any parameters.

  • 9/ Yes, this is one of the main uses of methods. But an attribute can also be manually modified.

  • 10/ When manipulating objects whose state of a resource should be maintained within a program.

From mass to juice

Assume that the juice contained in a lemon is a proportional function of its mass, defined as follows: \(juice = \frac {mass} {4}\) where mass is in grams and juice in mL.

Modify the Lemon class, reproduced in the following cell, so that:

  • During instantiation, the user no longer defines the quantity of juice, but the mass of the lemon.

  • The juice attribute is calculated according to the above formula.

  • Add a method that displays “The mass of the lemon is x grams.”

Instantiate a new lemon and verify that everything works as expected.

class Lemon:

    def __init__(self, color, juice_qty):
        self.flavor = "sour"
        self.color = color
        self.juice = juice_qty

    def get_juice_qty(self):
        print("There is " + str(self.juice) + " mL of juice left in the lemon.")

    def squeeze_juice(self, amount):
        if amount > self.juice:
            print("There is not enough juice in the lemon for the requested amount.")
        else:
            self.juice = max(0, self.juice - amount)  # avoids any negative value of `juice`
# Test your answer in this cell
Show solution
class Lemon:

    def __init__(self, color, mass):
        self.flavor = "sour"
        self.color = color
        self.mass = mass
        self.juice = mass / 4

    def get_mass(self):
        print("The mass of the lemon is " + str(self.mass) + " grams.")

    def get_juice_qty(self):
        print("There is " + str(self.juice) + " mL of juice left in the lemon.")

    def squeeze_juice(self, amount):
        if amount > self.juice:
            print("There is not enough juice in the lemon for the requested amount.")
        else:
            self.juice = max(0, self.juice - amount)  # avoids any negative value of `juice`

lemon = Lemon("yellow", 500)

lemon.get_mass()
lemon.get_juice_qty()
The mass of the lemon is 500 grams.
There is 125.0 mL of juice left in the lemon.

Bank accounts

Exercise freely inspired by: https://github.com/Pierian-Data/Complete-Python-3-Bootcamp

We have seen that OOP is particularly interesting when we want to manipulate objects that keep the state of a resource. This is, for example, the case of a bank account, which keeps a balance and allows or disallows certain operations based on this balance.

Implement a BankAccount class with:

  • Two attributes: holder (account holder’s name) and balance (account balance in euros).

  • A show_balance method that displays: “The balance of the account of holder_name is x euros.”

  • A deposit method that accepts an amount parameter. When a deposit is made, the account balance is incremented by the deposit amount.

  • A withdraw method that accepts an amount parameter. When a withdrawal is made:

    • If the amount is less than the balance: the balance is decremented by the amount, and “Withdrawal accepted.” is displayed.

    • If the amount is greater than the balance: “Withdrawal denied: insufficient funds.” is displayed and the balance remains unchanged.

  • A transfer method that accepts an amount parameter and a recipient parameter that takes another instance of the BankAccount class (i.e., another client). For example, client1.transfer(recipient=client2, amount=1000):

    • If the amount is less than client1’s balance: client1’s balance is decremented by the amount, client2’s balance is incremented by the amount.

    • If the amount is greater than client1’s balance: “Transfer denied: insufficient funds.” is displayed and the balances of both clients remain unchanged.

Create two clients and test that the various functionalities to be implemented work as expected.

# Test your answer in this cell
Show solution
class BankAccount:
    def __init__(self, holder, balance):
        self.holder = holder
        self.balance = balance

    def show_balance(self):
        print("The balance of the account of " + self.holder + " is " + str(self.balance) + " euros.")

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        if self.balance >= amount:
            self.balance -= amount
            print("Withdrawal accepted.")
        else:
            print("Withdrawal denied: insufficient funds.")

    def transfer(self, recipient, amount):
        if self.balance >= amount:
            recipient.balance += amount
            self.balance -= amount
        else:
            print("Transfer denied: insufficient funds.")

client1 = BankAccount("Bernard", 2000)
client2 = BankAccount("Bianca", 5000)

client1.show_balance()
client2.show_balance()

print()  # newline

client1.deposit(1000)
client1.show_balance() # +1000

print()

client2.withdraw(6000)
client2.show_balance() # no change

print()

client2.withdraw(1000)
client2.show_balance() # -1000

print()

client2.transfer(client1, 5000)
client2.show_balance() # no change

print()

client2.transfer(client1, 2000)
client2.show_balance() # - 2000
client1.show_balance() # + 2000
The balance of the account of Bernard is 2000 euros.
The balance of the account of Bianca is 5000 euros.

The balance of the account of Bernard is 3000 euros.

Withdrawal denied: insufficient funds.
The balance of the account of Bianca is 5000 euros.

Withdrawal accepted.
The balance of the account of Bianca is 4000 euros.

Transfer denied: insufficient funds.
The balance of the account of Bianca is 4000 euros.

The balance of the account of Bianca is 2000 euros.
The balance of the account of Bernard is 5000 euros.