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 youprint()
an object or call thestr()
function with the object as an argument.__repr__
will be used when you call therepr()
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 theoffer_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