Skip to content

Object Oriented Programming with Python

Pre-requisite

This reading section assumes that you are familiar with the concept of functions. The following video introduces the concept of functions, how they are defined and used in Python. If you already know the concept, you can skip the video.

Functions in Python are easy πŸ“ž

Object Oriented Programming

Let us begin with what we already know about programming (with Python as our example). When we are given a problem, we can think about it in terms of:

β€œWhat procedures (steps) do I need to take to solve this problem?”

For example, suppose we want to write a program that manages a bank account. We may need to:

  1. Create an account.
  2. Store the account balance.
  3. Deposit money into the account.
  4. Withdraw money from the account.
  5. Display the current balance.

We can approach this problem procedurally by defining the data and then writing functions that operate on that data. For example:

balance = 1000

def deposit(balance, amount):
    return balance + amount

def withdraw(balance, amount):
    if amount <= balance:
        return balance - amount
    else:
        print("Insufficient balance")
        return balance

balance = deposit(balance, 500)
balance = withdraw(balance, 200)

print("Balance:", balance)
Here, the program consists of data and procedures/functions that operate on that data.

We can think about it as: Here, the program consists of data and procedures/functions that operate on that data.

We can think about it as:

Data
  ↓
Procedure
  ↓
Updated data
  ↓
Another procedure
  ↓
Updated data

This approach works well, particularly when the program is relatively small. But what happens when the program becomes larger? Imagine that our banking application needs to manage many accounts.

Each account has its own:

  • account number
  • owner
  • balance

And each account can perform operations such as:

  • deposit money
  • withdraw money
  • check its balance

We could continue creating functions that operate on the data.

For example:

balance1 = 1000
balance2 = 2000

balance1 = deposit(balance1, 500)
balance2 = withdraw(balance2, 300)

As the number of accounts grows, however, we have to keep track of which data belongs to which account and make sure that the correct data is passed to each function. For example, imagine that we accidentally do this:

balance1 = 1000
balance2 = 2000

balance1 = deposit(balance2, 500)

The result is now assigned to balance1, even though the operation was performed on balance2. The program has allowed us to mix up the data belonging to two different accounts. With many accounts and many operations, keeping track of which data belongs to which account can become increasingly difficult. We also have to make sure that every function receives and operates on the correct data. So we can ask:

What if we could bind the data of an account together with the operations that are performed on that data?

Instead of having:

Account data              Functions
─────────────             ─────────────
balance1       ───→       deposit()
balance2       ───→       withdraw()
balance3       ───→       check_balance()

We can group each account’s data with the operations that belong to that account:

Account 1
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ balance             β”‚
β”‚ owner               β”‚
β”‚ account_number      β”‚
β”‚                     β”‚
β”‚ deposit()           β”‚
β”‚ withdraw()          β”‚
β”‚ check_balance()     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Account 2
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ balance             β”‚
β”‚ owner               β”‚
β”‚ account_number      β”‚
β”‚                     β”‚
β”‚ deposit()           β”‚
β”‚ withdraw()          β”‚
β”‚ check_balance()     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
So now we are able to keep the data and the operations that belong to that data together. This is one of the ideas behind object-oriented programming. In object-oriented programming, instead of thinking only in terms of:

β€œWhat procedures do I need to perform?”

we can also think in terms of:

β€œWhat objects exist in the problem, what data do they have, and what can they do?”

In our banking example, one of the objects that exists is a bank account. We can represent a bank account as an object.

An object can contain:

  • Data (attributes) describing the object
  • Behaviour provided through methods describing what the object can do.

The important idea is that the data and the behaviour that operates on that data are grouped together in one object. For example, a bank account has data such as its account number and balance, and it can perform operations such as depositing and withdrawing money.

We may have many bank account objects, each representing a different bank account. Each account has its own data values, such as its balance and account number. However, all bank account objects can perform the same types of operations, such as depositing and withdrawing money. We therefore need a way to describe what a bank account object should contain and what it should be able to do.

Defining a Class in Python

In Python, a class definition begins with the keyword class. The keyword is followed by a class name and a colon. The functions defined within a class are known as methods. For the bank account example, the class is structured as follows:

class BankAccount:
    # define a class to simulate a bank account
    def __init__(self):
        # initialize the bank account with zero balance
        self.balance = 0

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

    def withdraw(self, amount):
        if amount <= self.balance:
            self.balance = self.balance - amount
        else:
            print("Insufficient balance")

# add a method in this class that returns the current balance in the account

This is where a class comes in. A class is a definition or blueprint that describes the attributes and behaviour that objects of that class will have. For example, a BankAccount class can define that a bank account has a balance and provides operations such as deposit() and withdraw().

Hence, objects are instances of a class. Each BankAccount object has its own balance, but all of them follow the structure and behaviour defined by the BankAccount class.

Creating Objects in Python

The definition of the class by itself does not create any instances (objects) of the class. It only describes what objects of that class will contain and what they can do. To create an object of the class, the user invokes (calls) the class name as if it were a function.

account1 = BankAccount()  # create an object (make an instance) of BankAccount
account2 = BankAccount()  # create another object (make another instance)

The methods defined within the class description are invoked (called) using dot notation. In other words, once we have an object, we can use its methods by writing the object name, followed by a dot (.) and the method name. For example:

account1.deposit(100)

Here, deposit() is a method defined by the BankAccount class, and we are asking the particular object named account1 to perform that operation. Notice that account1 and account2 are two different objects. They are both instances of the BankAccount class, so they have the same types of behaviour, such as deposit() and withdraw(). However, each object has its own data, such as its own balance.

Encapsulation

What do you think happens if we deposit 100 into account1? Does the balance of account2 also increase?

No. Each object has its own data, and the operations that work on that data are provided through the object. This concept of keeping an object’s data together with the operations that work on that data, while controlling how the data is accessed or changed, is called encapsulation in object-oriented programming.

Encapsulation helps reduce complexity by hiding the internal details of how an operation is performed. The code using the object only needs to know what operations the object provides and what arguments they require; it does not need to know how those operations are implemented internally. For example, code using a BankAccount object can call deposit(100) without needing to know how the deposit is implemented internally. It only needs to know that the deposit() operation is available and what argument it requires.

The self Keyword

If account1.deposit(100) and account2.deposit(100) use the same deposit() method, how does Python know which account’s balance should be changed? This is where self comes in.

Consider our example again:

class BankAccount:
    ...
    def deposit(self, amount):
        self.balance = self.balance + amount
    ...
Here, self refers to the particular object on which the method is being called.

For example:

account1.deposit(100)
account2.deposit(50)

In the first call, self refers to account1; in the second call, self refers to account2. In other words, the object to the left of the dot is implicitly passed to the method as the self argument. In deposit(self, amount), self is the first parameter and represents the object on which the method is called. Thus, the same deposit() method can operate on the data belonging to different objects.

So, going back to our original question: if we deposit 100 into account1, does the balance of account2 also increase? No. The method operates on the object referenced by self, so only the balance of account1 is changed. When we write account1.deposit(100), account1 is implicitly passed as self, so the method operates on account1’s data. Similarly, account2.deposit(50) passes account2 as self and therefore operates on account2’s data.

account1.deposit(100)
       ↓
self = account1

account2.deposit(50)
       ↓
self = account2

Constructors

A special feature of the class definition is the method named __init__(). This method is called a constructor. It is used to initialize a newly created object of the class. We do not directly invoke (call) the constructor. Instead, the constructor is called automatically as part of the process of creating a new object.

So when is the constructor__init__() called in the BankAccount example? Yes, exactly when we write the following lines of code:

account1 = BankAccount()  # create an object (make an instance) of BankAccount
account2 = BankAccount()  # create another object (make another instance)

When account1 is created, Python automatically calls __init__() to initialize that object. The same happens when account2 is created. In our example, the constructor initializes the balance of each newly created account to zero.

We can also define the constructor so that the user can provide an initial value when creating the object:

class BankAccount:
    def __init__(self, balance):
        self.balance = balance

Now, when creating a BankAccount object, we can provide the initial balance:

account1 = BankAccount(1000)
account2 = BankAccount(2000)

Here, when account1 is created, Python automatically calls __init__() and passes 1000 as the balance argument. The constructor then initializes account1 with a balance of 1000. Similarly, when account2 is created, Python calls the constructor again and passes 2000 as the balance argument. Thus, account2 starts with a balance of 2000.

In this way, the constructor allows each object to be initialized with its own initial values when it is created.

Inheritance

So far, we have seen how a class allows us to define the data and operations that belong together, and how encapsulation helps us manage that data and behaviour.

Now, imagine that the bank decides to also offer a savings account. A savings account will have some additional functionality, such as adding interest to the account. One possibility is to create a new SavingsAccount class and write all the code needed for it from scratch as follows:

class SavingsAccount:
    def __init__(self, balance):
        self.balance = balance

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

    def withdraw(self, amount):
        self.balance = self.balance - amount

    def add_interest(self):
        self.balance = self.balance * 1.05

However, we already have a BankAccount class that contains the code for creating an account, depositing money, and withdrawing money. Writing the same code again would result in duplicating code.

The other possibility is to reuse the code that we have already written for BankAccount and then add the functionality needed for a savings account. This is where inheritance becomes useful.

Inheritance allows us to create a new class based on an existing class and reuse its attributes and methods. The new class can then add new functionality or modify existing functionality. Often this can result in significant savings in development time. This is called software reuse.

class SavingsAccount(BankAccount):
    def add_interest(self):
        self.balance = self.balance * 1.05

The name inside the parentheses, BankAccount, tells Python that SavingsAccount inherits from BankAccount. In other words, we are saying:

Create a class called SavingsAccount based on the existing BankAccount class

Because SavingsAccount is based on BankAccount, we call BankAccount the parent class (also called the base class or superclass). We call SavingsAccount the child class (also called the derived class or subclass). The child class can reuse what it inherits from the parent class and can also have additional functionality of its own.

Can you identify what SavingsAccount inherits from BankAccount and what functionality it adds of its own?

Note that the method add_interest() belongs specifically to SavingsAccount. We did not put it in BankAccount because adding interest is functionality that we want to associate with a savings account.

The important idea is: We do not have to start from scratch. We can build a new class on top of an existing class and extend it when necessary.

Method Overriding

Inheritance allows the child class to reuse the methods of the parent class. But what if the child class needs to implement one of those inherited methods differently? For example, suppose the bank has a rule that a savings account should not allow a customer to withdraw more money than is available in the account. We could define a different version of the withdraw() method inside SavingsAccount. This is called method overriding.

Consider the following code:

class BankAccount:
    def __init__(self, balance):
        self.balance = balance

    def withdraw(self, amount):
        self.balance = self.balance - amount

class SavingsAccount(BankAccount):

    def withdraw(self, amount):
        if amount <= self.balance:
            self.balance = self.balance - amount
        else:
            print("Insufficient balance")

Notice that withdraw() already exists in the parent class, BankAccount. We have defined a method with the same name in the child class, SavingsAccount. When withdraw() is called on a SavingsAccount object, Python uses the version defined in SavingsAccount rather than the version inherited from BankAccount. So:

account = SavingsAccount(1000)
account.withdraw(500)

The above code uses the withdraw() method from SavingsAccount.

Method overriding occurs when a child class provides its own implementation of a method that it inherited from its parent class.

From OOP Concepts to IoT Devices

Now that we have seen how inheritance allows us to build a new class on top of an existing class, let us see how these OOP concepts connect to IoT devices such as the micro:bit.

A micro:bit has different capabilities, such as buttons, an LED display, sensors, and radio communication. Software is provided to make it easier for us to interact with these hardware components. Instead of writing all the low-level code needed to communicate with the hardware ourselves, we can use objects, methods, and APIs that have already been provided for us.

What is an API?

An API (Application Programming Interface) provides a set of operations that a program can use to interact with a software component, device, or system. For example, an API may provide operations for displaying something on a device, reading a sensor, detecting a button press, or sending data. You may later encounter code that looks conceptually like this:

device.turn_on()
device.read_sensor()
device.display_value()

Here, device could be an object provided by a software library, and turn_on(), read_sensor(), and display_value() could be methods provided for interacting with the device.

You do not need to know at this point how these operations are implemented internally. You just need to understand what the object provides and how to use it. This is one of the reasons we are learning the basic concepts of object-oriented programming. When you start programming the micro:bit, you will encounter these ideas in real IoT code rather than only in examples such as BankAccount. The BankAccount example helps us understand the OOP concepts. Later, the micro:bit will show us how these concepts are used in an actual IoT system. The goal is to develop a basic understanding of objects, classes, methods, encapsulation, inheritance, and APIs so that you can read and use OOP-based code when programming IoT devices.