Introduction to Object-Oriented Programming in Python

In this article we will talk about Object-Oriented Programming (OOP). This article is aimed at beginners, but if you are an experienced developer you might also find some useful information in it.

We will start with namespaces and the concept of scope, as it's a good starting point to understand how classes work. Then, we'll move onto classes, objects, and the different types of methods in Python.

After reading this article you will be able to answer the following questions:

  • What are namespaces and the scope?
  • What are classes, objects, methods, and attributes?
  • How are class attributes different from instance attributes?
  • What is a special method?
  • What does the __init__ method do?
  • What are the __repr__ and __str__ methods?

We will also talk briefly about using object-oriented programming instead of functional programming.

Python Namespaces and Scopes

What is a Namespace?

In Python, whenever we create variables, functions, or classes, we give them names. These allow us to refer to the relevant values in later code.

For example:

name = "Lincoln State Bank"
net_income = [7480, 1983, 7799, 4466, 4672]

def print_info():
    return f"{name}'s net income is ${sum(net_income)}."

Here we have created three names: name, net_income, and print_info.

A namespace is the mapping of names to their values.

When you enter a function definition, a new namespace is created and each function will have a different namespace. The names defined inside a function are part of the function's namespace.

Each module you create also has its own namespace. A module is a Python file containing source code in the form of functions, classes, or imperative code.

You can see the names in the namespace of a module by using the built-in function dir():

import math

print(dir(math))

The output of the example would be something like this:

['acos', 'acosh', 'asin', 'asinh', 'atan', 'log' ..., ...]

You can access each name in a module namespace by association using the dot . notation between the module and the name. In this case, math and log:

import math

x = 5
log_of_x = math.log(x)
print(log_of_x)

If you run the code above, your output should look like this:

1.6094379124341003

Note: dir() it's not guaranteed to provide an exhaustive list of names in a given namespace. Read more about it here.

Python has many namespaces. For example, both the requests module and the dict object have a get() method. But because they are in different namespaces, they don't interfere with each other.

The very word "namespace" already tells us that it is a space where Python will store names. These names are what we assign to almost any Python value when we create variables, functions, classes, or anything else.

What is the Scope?

The scope is defined as a given part of the program where a namespace is directly accessible. In other words, the scope points to where in the program you can access and use an element.

Variables that are at the top level of the file are part of the global scope for that file. They are accessible everywhere within the file. In the file below, the scope for the variable name x is global:

import math

x = 5


def get_log_of_x():
    log_of_x = math.log(x)
    print(log_of_x)


print(x)  # 5
get_log_of_x()  # 1.6094379124341003

In case of an assignment, the variable scope starts at the function level. Variables within a function are local, and they are visible only for that given part of the program. The local scope usually refers to the current function's local namespace:

import math


def get_log_of_x():
    x = 5
    log_of_x = math.log(x)
    print(log_of_x)


print(x)  # NameError: name 'x' is not defined
get_log_of_x()  # 1.6094379124341003

Here x was part of the function's local scope, so it's not accessible outside the function.

If we try to use a variable before it's defined, Python will raise a runtime error:

import math


def get_log_of_x():
    log_of_x = math.log(x)
    x = 5

    print(log_of_x)


get_log_of_x()
# UnboundLocalError: local variable 'x' referenced before assignment
# Process finished with exit code 1

Names in separate namespaces have no relationship. In the example below we're using the variable name x in two different functions. Because each function has its own namespace, using the same variable name doesn't cause any scope problems. Within each function the variable x is part of the local scope:

import math


def get_log_of_x():
    x = 5
    log_of_x = math.log(x)
    print(log_of_x)


get_log_of_x()  # 1.6094379124341003


def square_root_x():
    x = 21
    sqrt = math.sqrt(x)
    print(sqrt)


square_root_x() # 4.58257569495584

If you call a function, the interpreter will look for associations in a particular order: Local, Enclosing, Global and Built-in (inner to outer).

In the following diagram you can see a nested function (a function inside another function):

If you'd like to see a full representation of this diagram, click here.

If the interpreter doesn't find the variable name discount in the innermost (local) scope, it will move up to the next (enclosing) scope, to the next-to-last (global) scope, until the outermost (built-in) scope. If it doesn't find it, it will raise an error.

If you wish to learn more about namespaces, please visit this article.

Now, let's move on and discuss Object-Oriented Programming.

Python Classes

When you combine data and functionality together, you create a class. This is a new type of object allowing the production of new instances of its type. The instance can maintain its state with certain characteristics, like attributes. You can change the state of the instance by using methods defined in the class.

Let's break down the new concepts mentioned above in the following sections.

Introduction

The following example shows two banks in the form of dictionaries. Both are trying to solve the same problem: to update the average_income value when the net_income values changes.

bank_1 = {
    'name': 'Lincoln State Bank',
    'net_income': [7480, 1983, 7799, 4466, 4672],
    'average_income': 0
}

bank_2 = {
    'name': 'Rock Canyon Bank',
    'net_income': [3901, 1118, 1979, 6349, 5843],
    'average_income': 0
}

To solve the problem, you could do something like this:

def average(bank):
    bank['average_income'] = sum(bank['net_income']) / len(bank['net_income'])
    return bank['average_income']


print(average(bank_1))
print(average(bank_2))

print(bank_1)
print(bank_2)

Some problems with the code above:

  • The bank must be a dictionary, and it must have specific keys because the function depends on them.
  • Creating many similar dictionaries can be very verbose, since we have to define the keys in every dictionary.
  • There is no dependency between the banks and the average function. In a production environment, they could be in different files. In that case, it would be very hard to figure out that the average function is supposed to be used here.

It would be great to have something inside the dictionary that returns the average_income for that bank. The function would live in the same place as the data it uses, making it easier to use.

For example:

bank_1 = {
    'name': 'Lincoln State Bank',
    'net_income': [7480, 1983, 7799, 4466, 4672],
    'average_income': sum(bank_1['net_income']) / len(bank_1['net_income'])
}

Unfortunately, this code won't work because we're using bank_1 inside the definition of bank_1. Also, even if it did work, it wouldn't update the average_income key automatically when the net_income values change. You must use classes for this.

What is a class?

Below is an example of a class named Bank:

class Bank:
    pass

A class is declared with the keyword class and the name of the class. Even though it doesn't do anything for now, I created a class. The keyword pass is a placeholder for future code.

Whenever you enter a class definition, a new namespace is created. Variable assignments and function definitions are placed in this new namespace. When inside classes, variables become attributes.

In perspective, a class is like a blueprint from which you can create multiple objects.

Python Objects

You can perform two types of operations on a class: instantiation and attribute reference.

Instantiation

Instantiation refers to creating new class instances (known as objects). The process is very similar to invoking functions: a class is an isolated block of code that executes only upon "calling".

Below is an example of an object named lincoln_state_bank:

class Bank:
    pass


lincoln_state_bank = Bank()

In the example above, we created a new instance of the Bank class (what we call an object) and assigned it to the variable lincoln_state_bank, which is the name. In this class you can have any number of attributes, like name and net_income:

class Bank:
    name = 'Lincoln State Bank'
    net_income = [7480, 1983, 7799, 4466, 4672]

    
lincoln_state_bank = Bank()
print(lincoln_state_bank)

If you ran the code, you probably got a strange output like this:

<__main__.Bank object at 0x000001FC48AE4FD0>

To get meaningful output you need to reference the attribute(s) in the print statement. Let's talk about it in the next section.

Attribute reference

Attribute references use the standard dot . notation for name associations discussed in the first section of this article. Referencing Bank.name will return the value of the name attribute, which is 'Lincoln State Bank':

class Bank:
    name = 'Lincoln State Bank'
    net_income = [7480, 1983, 7799, 4466, 4672]
    average_income = None


print(Bank.name)  # Lincoln State Bank

There are two types of attributes: class attributes and instance attributes. What you've seen so far are class attributes. Let's talk about them.

Class Attributes

Class attributes are the variable names in the class's namespace. They are visible throughout all class instances:

class Bank:
    bank_type = 'Central Bank'


lincoln_state_bank = Bank()
rock_canyon_bank = Bank()

print(lincoln_state_bank.bank_type)  # Central Bank
print(rock_canyon_bank.bank_type)  # Central Bank

So, the data for each object is saved in attributes like name, net_income, and average_income:

class Bank:
    name = 'Lincoln State Bank'
    net_income = [7480, 1983, 7799, 4466, 4672]
    average_income = None


lincoln_state_bank = Bank()
rock_canyon_bank = Bank()

print(lincoln_state_bank.name)  # Lincoln State Bank
print(rock_canyon_bank.name)  # Lincoln State Bank

However, this is not ideal. We want two objects with different attribute values. It is therefore possible to create instance attributes.

Instance attributes

Instance attributes are names stored in objects like lincoln_state_bank and rock_canyon_bank. In this case, the attribute is name:

class Bank:
    pass

lincoln_state_bank = Bank()
rock_canyon_bank = Bank()

lincoln_state_bank.name = "Lincoln State Bank"
rock_canyon_bank.name = "Rock Canyon Bank"

Instance attributes are unique for each class instance. They are defined inside methods, most often inside the special method named __init__:

class Bank:
    def __init__(self):
        pass

The value of self is given to each method call by Python, so you don't have to provide a value for it. So, when you create the object you must not provide any argument for self:

class Bank:
    def __init__(self):
        pass

lincoln_state_bank = Bank()

The self.name and self.net_income define two instance attributes that will be bound to a unique object. Then, it assigns the value provided when we create the object:

class Bank:
    def __init__(self, name, net_income):
        self.name = name
        self.net_income = net_income


lincoln_state_bank = Bank(name='Lincoln State Bank', net_income=[7480, 1983, 7799, 4466, 4672])
rock_canyon_bank = Bank(name='Rock Canyon Bank', net_income=[3901, 1118, 1979, 6349, 5843])

print(rock_canyon_bank.name)  # Rock Canyon Bank
print(lincoln_state_bank.name)  # Lincoln State Bank

Consider self.name and self.net_income to be the object's own attributes (self). Each object may have its own set of attributes with different values.

Python invokes the special method __init__ for you every time you create a new instance of a class. For example, when I created the lincoln_state_bank instance, the special method __init__ was called by the Python interpreter.

In the example above we created two objects. They are created from the same class, very much like a blueprint. Both are instances of the Bank class, but they are different objects:

# same type
print(isinstance(lincoln_state_bank, Bank))  # True
print(isinstance(rock_canyon_bank, Bank))  # True

# different objects
print(id(lincoln_state_bank))  # 21515952681152
print(id(rock_canyon_bank))  # 2162583515524

The syntax for the isinstance() function is isinstance(object, type). If the first argument is of the type specified by the second argument, it returns True; else, it returns False.

The id() function returns the identity of an object as an integer. If the IDs differ, that means the objects are different.

Here is an example that shows two objects created from a class with both class and instance attributes:

class Bank:
    bank_type = 'Central bank'  # class attribute
    
    def __init__(self, name, net_income):
        self.name = name  # instance attribute
        self.net_income = net_income  # instance attribute


lincoln_state_bank = Bank(name='Lincoln State Bank', net_income=[7480, 1983, 7799, 4466, 4672])  # object 1
rock_canyon_bank = Bank(name='Rock Canyon Bank', net_income=[3901, 1118, 1979, 6349, 5843])  # object 2

Both objects use the same bank_type attribute, but each one has its own name and net_income.

The example above re-creates the bank dictionaries, using objects. Now, the average function will look like this:

def average(bank):
        return sum(bank.net_income) / len(bank.net_income)

average(lincoln_state_bank)

However, we can move this function inside the class and use it as a method. Then, instead of passing a Bank object to the function's parameter, you should use self:

class Bank:
    def __init__(self, name, net_income):
        self.name = name
        self.net_income = net_income

    def average(self):
        return sum(self.net_income) / len(self.net_income)


lincoln_state_bank = Bank(name='Lincoln State Bank', net_income=[7480, 1983, 7799, 4466, 4672])
rock_canyon_bank = Bank(name='Rock Canyon Bank', net_income=[3901, 1118, 1979, 6349, 5843])

print(lincoln_state_bank.average())  # 5280.0
print(rock_canyon_bank.average())  # 3838.0

Note that the average method also uses the special parameter self. Now, when you update the instance attribute net_income, you should get a new average:

lincoln_state_bank.net_income.append(591000)

print(lincoln_state_bank.average())  # 102900.0
print(rock_canyon_bank.average())  # 3838.0

Even though we modified the instance attribute net_income for the first object, the second object remained unchanged. Let's continue and talk about Python Methods.

Python Methods

Think of methods as standard functions that live within a class. In Python, we have three types of methods: Instance Method, Static Method and Class Method.

We've only talked about instance methods (__init__ and average). Instance methods are the most common, and the default type of methods. You can learn more about the other types of Python methods here.

Python Special Methods

Special methods are surrounded by double underscores, and that's why they are often called dunder methods. You may also meet people that may call them magic methods. Yet, in the official Python documentation there is no reference to such terminology.

We've already seen the __init__ special method, but there are many more that behave in different ways. Let us show you the most popular ones.

Python has two special methods for outputting information:

  • __str__ is usually used to display data in a pretty format for users.
  • __repr__ is used in for purposes like debugging, logging or output for developers.

These special methods allow you to print the object without accessing any of is attributes.

Normally, printing the object without defining __str__ or __repr__, you will get the memory address of the object: <__main__.Bank object at 0x0000024211E5DFA1>:

class Bank:
    def __init__(self, name, net_income):
        self.name = name
        self.net_income = net_income

    def average(self):
        return sum(self.net_income) / len(self.net_income)


rock_canyon_bank = Bank(name='Rock Canyon Bank', net_income=[3901, 1118, 1979, 6349, 5843])

print(rock_canyon_bank)  # <__main__.Bank object at 0x0000024211E5DFA1>

In this example you can see how __str__ and __repr__ could help both users and developers:

class Bank:
    def __init__(self, name, net_income):
        self.name = name
        self.net_income = net_income

    def __str__(self):
        return f"{self.name}. Net income: {self.net_income}"

    def average(self):
        return sum(self.net_income) / len(self.net_income)


lincoln_state_bank = Bank(name='Lincoln State Bank', net_income=[7480, 1983, 7799, 4466, 4672])
rock_canyon_bank = Bank(name='Rock Canyon Bank', net_income=[3901, 1118, 1979, 6349, 5843])

print(lincoln_state_bank)  # Lincoln State Bank. Net income: [7480, 1983, 7799, 4466, 4672]
print(rock_canyon_bank)  # Rock Canyon Bank. Net income: [3901, 1118, 1979, 6349, 5843]

The __repr__ special method works in a similar way:

def __repr__(self):
    return f"Bank({self.name!r}, {self.net_income!r})"

Remember that if you define both __str__ and __repr__, they will both be used but in different situations:

  • __str__ will be used when you print() an object or call the str() function with the object as an argument.
  • __repr__ will be used when you call the repr() function with the object as an argument. Most debuggers do this to show information about an object.

Here's an example:

print(repr(lincoln_state_bank))  # Bank("Lincoln State Bank", 15)

Even though it is possible to use both of them, most of the time you will end up using one. Remember — use __str__ for a pretty format, and __repr__ for development purposes.

Object-Oriented Programming vs. Functional Programming

A key benefit of OOP is that the actions (or functions) that use the data are stored alongside the data itself. This colocation can simplify things. Also, we often talk about objects performing actions. This way of thinking can make coding more intuitive sense in some scenarios.

bank_income = [9048, 1581, 9811, 5150, 5191]


class Bank:
    def __init__(self, name, net_income):
        self.name = name
        self.net_income = net_income

    def average(self):
        return sum(self.net_income) / len(self.net_income)


lincoln_state_bank = Bank(name='Lincoln State Bank', net_income=past_years_income)

print(lincoln_state_bank.average())  # 6156.2

Now check out an attempt to recreate the same code with a function:

bank_income = [9048, 1581, 9811, 5150, 5191]


def average_year_income(incomes):
    return sum(incomes) / len(incomes)


print(f"Lincoln State Bank: {average_year_income(bank_income)}")
# Lincoln State Bank: 6156.2

Definitely shorter code! And definitely, if this is the extent of what you are coding, you should go for the functional approach.

But here's two scenarios where OOP might give you some benefits:

Extending the classes

Let's say that you want your banks to be able to do more. For example, offer a loan.

We need to keep track of how much money each bank has loaned, and we need to make sure it doesn't go over the total bank income.

Using OOP, it's relatively straightforward:

class Bank:
    def __init__(self, name, net_income):
        self.name = name
        self.net_income = net_income
        self.loan_value = 0

    def average(self):
        return sum(self.net_income) / len(self.net_income)
    
    def offer_loan(self, amount):
        if self.loan_value + amount > sum(self.net_income):
            raise ValueError(f"{self.name} can't offer a loan for {amount}. Not enough money.")
        self.loan_value += amount
        return amount

Using a functional approach this is also doable, but it starts to get more complex:

bank = {
    "name": "Lincoln State Bank"
    "income": [9048, 1581, 9811, 5150, 5191],
    "loan_amount": 0
}

def average_year_income(incomes):
    return sum(incomes) / len(incomes)

def offer_loan(bank, amount):
    if bank["loan_amount"] + amount > sum(bank["income"]):
        raise ValueError(f"{bank["name"]} can't offer a loan for {amount}. Not enough money.")
    bank["loan_amount"] += amount
    return amount

As we add more functionality, the benefits of the functional approach diminish a bit:

  • In different parts of our application, we need to import the offer_loan function if we want to use it (and other functions we make).
  • We may need to keep track of multiple bank dictionaries, and if we make a mistake some may be missing some properties that are required.

In contrast, with OOP:

  • All parts of the application that used a Bank object can use the offer_loan method on those objects. No need to import more functions and keep them around the namespace.
  • It's not possible to have objects that are missing some critical properties, because those are defined in the __init__ method.

In addition to that, using the OOP approach we can say that banks can offer loans, which makes sense intuitively:

bank.offer_loan(500)

I think the functional approach here is a little bit more difficult to read:

offer_loan(bank, 500)

This is somewhat down to personal preference also, however.

This example is not a slam dunk for OOP, but it's definitely something to think about when you're considering which approach to use.

Changing the incoming data

Let's say that we're getting the bank's income data from an API.

One day the API changes so that instead of returning a list of the bank's income, it returns a dictionary that has the income divided into years. Like this one:

past_years_income = {
    '2018': [7480, 1983, 7799, 4466, 4672],
    '2019': [8491, 8571, 8119, 8191, 5678],
    '2020': [9048, 1581, 9811, 5150, 5191]
}

If you were using the functional approach, it's possible that every function that uses this data would have to change.

Not the end of the world, but using OOP you could save yourself a bit of work. In the __init__ method you can modify the incoming API data so that it matches the old data format:

from itertools.chain import from_iterable

class Bank:
    def __init__(self, name, net_yearly):
        self.name = name
        self.net_yearly = net_yearly
        # Below: turns all dictionary values into a single list
        self.net_income = list(from_iterable(net_yearly.values()))
        self.loan_value = 0

Now none of the methods have to change, and you can still use self.net_yearly if you want to in some other methods.

This is a small example, but there are other features of OOP that can also be useful. Examples of those are @classmethod factories, properties, or using abstract classes.

All of these, and OOP in general, should be used when it simplifies your code and makes it easier to work with. A lot of the time, functions are simpler, faster, or easier to test. In those cases, you should stick with the functional approach.

As you start using both functional and OOP approaches, you will develop the skill to make the right decision about which one to use in different situations.

Conclusion

Today we took a different approach in explaining Object-Oriented Programming comparing to some of our other posts and courses. I hope you've enjoyed the read, and found it useful!

If you want to learn more about Python, consider enrolling in our Complete Python Course which takes you from beginner all the way to advanced (including OOP, web development, async development, and much more!). We have a 30-day money-back guarantee, so you really have nothing to lose by giving it a try. We'd love to have you!

Cover photo by Arif Riyanto on Unsplash