Object oriented programming
Object-oriented programming (OOP) is a paradigm present in almost all modern programming languages. It is a way of thinking and structuring code that introduces the following concepts:
- ποΈ Class: These are the blueprints that allow you to create objects with specific characteristics. Like a cookie mold. They are defined using the
class
keyword. - π§ Object: Instances of a class with unique properties.
- π§ Method: Functions that belong to a class, defined with
def
. - 𧬠Inheritance: Enables a class to inherit and extend the behavior of another class.
- π¦ Duck Typing: Allows different classes to be treated the same way, as long as they implement the necessary methods.
Let us explore these concepts and see how you can apply them in your Python programs.
Introduction and context
Here is a class: the Person
class.
class Person:
def __init__(self, first_name, last_name, age):
self.first_name = first_name
self.last_name = last_name
self.age = age
def is_adult(self):
return self.age >= 18
Here is an object: the p1
object of the Person
class.
p1 = Person("Ana", "Ruiz", 20)
This is a method: the is_adult
method.
p1.is_adult()
And these are the attributes: first_name
, last_name
, and age
.
print(p1.first_name) # Ana
print(p1.last_name) # Ruiz
print(p1.age) # 20
The concept of a class is generic. There can be multiple objects of the same class. We say that p1
and p2
are two objects of the Person
class.
- βοΈ They are similar in that they are defined by the same blueprint. Like cookies made with the same mold.
- π But they are different, as each has its own unique characteristics. A cookie made with the same mold but with different ingredients.
p1 = Person("Ana", "Ruiz", 20)
p2 = Person("Bob", "Lopez", 20)
Both have the same attributes but with different values.
print(p1.first_name) # Ana
print(p2.first_name) # Bob
Although object-oriented programming can become as complex as desired, these are the four fundamental concepts on which we will build the rest.
To better understand object-oriented programming and its advantages, let us write the above example without using it. Let us assume for a moment that we have no classes or objects.
The objective is the same: to store a personβs data and operate on it. For example, with a list, we can store the first name, last name, and age.
person = ["Juan", "Prieto", 23]
Now we can define the function is_adult
that receives a person
as an argument and tells us if they are an adult.
def is_adult(person):
return person[2] >= 18
At this point, we have something similar to the Person
class seen above, but we start to see some disadvantages:
- Accessing fields is not very readable. Using
person[0]
is less clear thanperson.first_name
. - There is no control over the length of the list. Someone could create a
person
list with more fields. Nothing prevents this. - The
is_adult
function is not related to the person. It is a separate function. - The variable
person
is accessible by anyone. Anyone could modify it without any restriction.
We have therefore already detected some of the problems that object-oriented programming can solve.
These are the most important concepts associated with object-oriented programming. Once you understand them, you will not want to go back:
- 𧬠Inheritance: Allows a class to inherit from another class, inheriting all its methods and attributes. A
Person
class can inherit from aHuman
class. - 𧩠Cohesion: Each class should contain related elements. You want
Person
to have attributes related to a person. It would not have the color of their house; that would be an attribute of another class,House
. - π Abstraction: Hides complex details from the outside. A TV remote is complex inside, but to a user, it shows only the volume and channel. It abstracts the complexity from the inside.
- π Polymorphism: In Python, this is related to duck typing. If two different classes have the same methods, they can be treated as the same.
- π Coupling: Measured as the degree of dependency between classes. Object-oriented programming helps reduce it.
- π¦ Encapsulation: Object-oriented programming allows you to hide internal details. The engine of your car is hidden. Hiding it minimizes the risk of someone tampering with it unknowingly.
Next, we will see in a practical way how to work in Python with object-oriented programming.
Create your classes and objects
Python allows us to create a class in two lines of code using class
. The following code creates a Person
class. With pass
, we indicate that it is an empty class. Later, we will see how to add attributes and methods to our class.
# Create a class
class Person:
pass
It is important to note that according to Python conventions, classes are named in CamelCase.
Correct:
SomeClass
βBankAccount
β
Incorrect:
some_class
βbankaccount
β
Now that we have our class, we can create an object of it. To do this, we use the name of the class followed by ()
. This invokes the constructor of the class. In this case, the constructor does not receive any parameters.
# Create an object
person = Person()
It is important to understand the difference between class and object:
- The class refers to something generic, in our case, the idea of a person.
- The object refers to something concrete, in our case, a specific person.
print(Person)
# Output: <class '__main__.Person'>
print(person)
# Output: <__main__.Person object at 0x103030a99d0>.
Using type
, we can know the class to which an object belongs.
print(type(person))
# Output: <class '__main__.Person'>
At this point, we have our class, but it is empty. Let us start adding things to it.
Create your constructor
When you use ()
with the class, such as Person()
, you are using the constructor. The constructor allows you to create an object of a given class. Thus, an object of class Person
is constructed.
class Person:
pass
p = Person()
But in this class, we have not defined the constructor. Let us define it.
class Person:
def __init__(self, first_name, last_name, age):
self.first_name = first_name
self.last_name = last_name
self.age = age
Now that the constructor accepts three arguments, we can construct our object as follows.
p = Person("Ana", "Ruiz", 20)
We can also construct the object like this.
p = Person(
first_name="Ana",
last_name="Ruiz",
age=20)
The constructor, like any function, can have default arguments. These will be used if no such argument is provided.
class Person:
def __init__(self, first_name='', last_name='', age=0):
self.first_name = first_name
self.last_name = last_name
self.age = age
In this case, since we do not provide age
, the default value 0
is used.
p = Person("Ana", "Ruiz")
print(p.first_name) # Ana
print(p.age) # 0
The __init__
constructor is also useful to verify that the arguments with which we construct the object are correct. In this case, we verify that the age
is not negative.
class Person:
def __init__(self, first_name, last_name, age):
if age < 0:
raise Exception("Age cannot be negative.")
self.first_name = first_name
self.last_name = last_name
self.age = age
p = Person("Ana", "Ruiz", -10)
# Exception: Age cannot be negative.
Although this is not the recommended way to do it, Python allows you to create an object as follows. It is interesting to understand how it works and how it is done internally, but do not do it that way. Use the way we have seen above.
# It works, but do not use it.
person = Person.__new__(Person)
Person.__init__(person, "Ana", "Ruiz", 20)
You could say that Python internally converts everything inside ()
and passes it to the function. But do not do it that way.
Create your attributes
Every class defines attributes. In object-oriented programming and Python, there are two types of attributes:
- π Class Attributes: These are attributes common to all objects of the class. All objects see the same value.
- π Instance Attributes: These are specific attributes of each object. Each object has a different value.
π The counter
attribute is a class attribute. All objects of class Person
will have the same value.
π The attributes first_name
, last_name
, and age
are instance attributes. Each object of class Person
will have a different value.
class Person:
# Class attribute
counter = 0
# Instance attributes
def __init__(self, first_name, last_name, age):
self.first_name = first_name
self.last_name = last_name
self.age = age
Person.counter += 1
If we create two different people:
p1 = Person("Juan", "Prieto", 23)
p2 = Person("Ana", "Fuentes", 25)
Both share the value of counter
, a class attribute π.
print(p1.counter) # 2
print(p2.counter) # 2
But each one has its own first_name
value, an instance attribute π.
print(p1.first_name) # Juan
print(p2.first_name) # Ana
Another difference is that since counter
is a class attribute, you can access it from the class.
print(Person.counter) # 2
As a final note and to be strictly correct, in this example, we should also decrease the counter
if the object is destroyed.
class Person:
counter = 0
def __init__(self, first_name, last_name, age):
self.first_name = first_name
self.last_name = last_name
self.age = age
Person.counter += 1
def __del__(self):
Person.counter -= 1
If someone now destroys an object with del
, the counter will be updated accordingly.
p1 = Person("Ana", "Ruiz", 0)
print(Person.counter) # 1
del p1
print(Person.counter) # 0
Create your methods
A class has a set of associated functions that allow you to work with it. These functions are known as methods, and there are the following types:
- β¨ Magic: Also known as dunder methods. They are methods defined by Python with special behaviors.
- π§ Instance: These are the βnormalβ and most common methods defined above. They are intended to access or modify some of the attributes of the object or instance. Therefore, they have access to it through
self
. - ποΈ Class: They can only access and modify the class attributes, not being able to modify specific objects since they have no knowledge of them. They access the class through
cls
. - π Static: They cannot access either object or class attributes. Therefore, they can only access parameters passed by input.
Below are examples of all these methods to help you choose the next time you need to define one.
β¨ Magic methods are those that begin and end with __
, such as __init__
. We will see them in more detail in the section on dunder or magic methods. For example, the constructor seen earlier is a magic method.
class Person:
def __init__(self, name):
self.name = name
π§ Instance methods are the most used and common ones. They access object information and allow us to change or query information about them. The self
refers to the object on which they are called. Every instance method has the self
.
class Person:
def __init__(self, age):
self.age = age
# Instance method
def is_adult(self):
return self.age >= 18
p = Person(20)
print(p.is_adult()) # True
ποΈ Class methods can only access the attributes of the class. They cannot access or modify the specific attributes of each object.
Next, we see a class Person
that has a class attribute counter
. This attribute is incremented each time a person is created. The class method total_people
returns the number of people that have been created, but does not have access to name
.
Notice that the class method uses cls
. That is, it has access to the class.
class Person:
counter = 0
def __init__(self, name):
self.name = name
Person.counter += 1
# Class method
@classmethod
def total_people(cls):
return cls.counter
p1 = Person("Ana")
p2 = Person("John")
print(Person.total_people()) # 2
π Static methods have access to neither class nor instance attributes. They are functions grouped under a class. They are usually useful when we want to group a set of related functions under the same namespace. As you can see, we do not create objects, since these methods are accessed directly from the class.
These static methods do not have access to either cls
or self
as seen above.
class Calculator:
@staticmethod
def add(a, b):
return a + b
@staticmethod
def subtract(a, b):
return a - b
print(Calculator.add(10, 5)) # 15
print(Calculator.subtract(10, 5)) # 5
Setter decorator
Object-oriented programming helps us to protect the attributes of our class from unwanted modifications. This follows the principle of encapsulation. Just as the engine of a car is hidden from the outside to protect it, Python allows us to protect the attributes of our classes.
Imagine that you want to protect the age
attribute so that a negative value cannot be used. One way to do this is to check the age
in the constructor.
class Person:
def __init__(self, first_name, last_name, age):
if age < 0:
raise Exception("Age cannot be negative.")
self.first_name = first_name
self.last_name = last_name
self.age = age
This protects against the following:
p = Person("Ana", "Ruiz", -1)
# Exception: Age cannot be negative.
However, we are not protected from the following. We build the object with a valid age, but then modify it.
p = Person("Ana", "Ruiz", 20)
p.age = -1
print(p.age) # -1
To solve this, we can use the following decorators, which go hand in hand:
age.setter
: Allows adding logic to be executed when the attribute is modified, in this caseage
.property
: Allows access to an attribute. We will see it in detail later.
class Person:
def __init__(self, first_name, last_name, age):
self.first_name = first_name
self.last_name = last_name
self.__age = age
@property
def age(self):
return self.__age
@age.setter
def age(self, age):
if age < 0:
raise ValueError("Age cannot be negative.")
self.__age = age
Some notes:
- By using the
__
in__age
, we tell Python that we want to hide this attribute from the outside. It is like saying we do not want anyone to modify it. It is internal to the class. - With
@age.setter
, we tell Python that anyone who wants to modifyage
must do it through this method. And this method has some logic. We forbid the age to be negative.
As you can see, we are now protected from a negative age.
p = Person("Ana", "Ruiz", 20)
p.age = -1
# ValueError: Age cannot be negative.
This is an example of the famous encapsulation of object-oriented programming. It encapsulates the attributes by isolating them from the outside world and can only be modified by certain functions, which are responsible for verifying that everything is correct.
An important note is that Python allows you to do everything. If you really try and want to skip the verification, you can set a negative age using _Person__age
. But this is a bit more advanced and a field you should not access.
p = Person("Ana", "Ruiz", 20)
p._Person__age = -1
print(p.age) # -1
Property decorator
Previously, we defined the attribute age
for our Person
. However, age changes. In 5 years, you are not the same age.
To be consistent, we would have to continuously modify the age, and this is impractical. It is better to store the date of birth.
class Person:
def __init__(self, first_name, last_name, birth_date):
self.first_name = first_name
self.last_name = last_name
self.birth_date = birth_date
With the date of birth, we can calculate the age. It is as simple as looking at the time that has passed between birth_date
and the current time.
We can then define an age
method that returns the age, calculated from the date of birth. Much better.
from datetime import datetime
class Person:
# ...
def age(self):
today = datetime.now()
age = today.year - self.birth_date.year
if (today.month, today.day) < (self.birth_date.month, self.birth_date.day):
age -= 1
return age
We can now access the age
through the method we have created. We no longer need to update the age as time passes. It is calculated automatically.
p = Person("Juan", "Prieto", datetime(1993, 11, 10))
print(p.age())
The example is perfectly valid, but now we have age()
(a method) instead of what we had before age
(an attribute). It can then be confusing because now first_name
and age
are accessed in different ways. Conceptually, both are attributes.
print(p.first_name)
print(p.age())
Precisely for this, we have the @property
decorator. It is very Pythonic and allows using age
instead of age()
.
class Person:
# ...
@property
def age(self):
today = datetime.now()
age = today.year - self.birth_date.year
if (today.month, today.day) < (self.birth_date.month, self.birth_date.day):
age -= 1
return age
Now age
can be accessed as if it were an attribute, without the ()
seen above.
print(p.first_name)
print(p.age)
But age
is a bit of a special attribute. Its value is not stored as such. It is calculated each time using the date of birth.
Having seen this, we can now understand the different types of attributes with respect to the way they are evaluated. These can be:
- ποΈ Eager: The
first_name
is stored βas isβ in memory. No computation is needed to return it. It is the fastest. - π Lazy: The
age
is not stored. It is calculated when you want to access it, using the date of birth. This requires calculations. It can be slower depending on the complexity of the calculations. It is usually associated with@property
.
A few notes to decide the type of evaluation, eager or lazy:
- In cases like
first_name
, it is clear that we want eager. - With
age
, it is clear that we want lazy, using the date of birth. - In other cases, it depends. Using lazy with an attribute that requires a lot of calculations and that there are thousands of processes accessing it hundreds of times per second might be a bad idea, as it will be continuously calculating the attribute.
Class inheritance
Inheritance allows us to create a class that inherits methods and attributes from another. This allows us to:
- 𧩠Extend: We can inherit from a class and add new methods or attributes.
- π οΈ Modify: We can modify existing methods and attributes of the parent class.
- β»οΈ Reuse: We can reuse the behavior of the original class.
Let us return to our Person
class.
class Person:
def __init__(self, first_name, last_name, age):
self.first_name = first_name
self.last_name = last_name
self.age = age
def is_adult(self):
return self.age >= 18
We can create a Student
class that inherits from Person
.
class Student(Person):
def __init__(self, first_name, last_name, age, grade):
super().__init__(first_name, last_name, age)
self.grade = grade
def is_senior(self):
return self.grade == 12
This new class Student
:
- 𧩠Extends
Person
with a new methodis_senior
. - π οΈ Modifies
Person
by adding agrade
attribute. - β»οΈ Reuses
Person
by inheriting its existingis_adult
method.
As you can see in the __init__
method, we use super
. This allows us to access methods of the parent class.
To create an object of class Student
, it is done as we have seen previously. Nothing changes.
s = Student("Ana", "Ruiz", 10, 12)
print(s.is_senior()) # True
And you can check that it inherits the is_adult
method from the parent class. This is the magic. In Student
, you have not defined it, but it takes it from Person
.
print(s.is_adult()) # False
On the other hand, inheritance can be multiple. In this case, a ClassC
inherits all methods and attributes of ClassA
and ClassB
.
class ClassA:
def methodA(self):
return "methodA"
class ClassB:
def methodB(self):
return "methodB"
class ClassC(ClassA, ClassB):
def methodC(self):
return "methodC"
Let us see how all methods are inherited.
obj = ClassC()
print(obj.methodA()) # methodA
print(obj.methodB()) # methodB
print(obj.methodC()) # methodC
Finally, let us look at a practical example. We are going to extend the behavior of the Python list
class. We are going to add a mean
method that returns the average of all its values.
class AverageList(list):
def mean(self):
return sum(self) / len(self)
We create the list and use the method. You can check that the rest of the list
methods like append
and len
have been inherited by our AverageList
.
numbers = AverageList([10, 20, 30, 40])
print("Mean =", numbers.mean())
# Mean = 25.0
Polymorphism and duck typing
Polymorphism is an object-oriented programming concept closely related to inheritance. It allows objects that inherit from the same class to be treated in the same way.
Imagine a class Person
from which two classes Teacher
and Student
inherit. Polymorphism allows treating Teacher
and Student
in the same way, since they inherit from the same class.
For example, both classes have a name
, so a function would not care if an object is of one class or the other.
In Python, this is a little different. It is also much more flexible. Python has what is known as duck typing. There is an associated phrase that says: βIf it walks like a duck and it quacks like a duck, then it must be a duck.β
This is a way of saying that Python does not care about the class or type. If it has the necessary methods, then it works. In this example, you can see two different classes. Python tries to call talk
with each animal. Since they all have the talk
method, it works. But they donβt inherit from the same class.
class Dog:
def talk(self):
print("Woof!")
class Duck:
def talk(self):
print("Quack!")
for animal in Dog(), Duck():
animal.talk()
# Woof! # Quack!
It may seem logical, but in many other programming languages, this would error since these are different types that do not share a common interface.
The following also works. Again, Python does not care what object
you pass it. As long as it has the talk
function, it is enough.
def talk(object):
object.talk()
dog = Dog()
duck = Duck()
talk(dog) # Woof!
talk(duck) # Quack!
Dunder methods
Magic methods or dunder methods in Python are those that begin and end with __
, such as __init__
. The name dunder comes from double underscore.
These are methods defined by Python that can be used internally or associated with operators. For example:
- If you use
()
under the hood it calls__init__
. - If you use
+
,__add__
is called. - If you use
==
,__eq__
is called.
Depending on what you want to do and your class, you may want to define these dunder methods. This is very powerful, as it allows you to define the behavior of your class.
Next, we will see the most known ones. We will use as an example a Point
class with two attributes x
and y
. This class represents a point in space in two dimensions.
Method __init__
. As we have seen previously, it is the constructor and allows us to create the object. It is called when using ()
.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1, 2)
print(p.x) # 1
print(p.y) # 2
Method __del__
. It can be seen as the opposite of __init__
. One constructs, one destroys. So you can implement the logic of your destructor.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __del__(self):
print("Object destroyed")
This method will be called when you destroy your object with del
. You decide what to put there. It is common to see it in places where it is important to free resources after you are done.
p = Point(1, 2)
del p
# Object destroyed
__str__
method. If we print
our object, the information displayed is not very useful.
p = Point(1, 2)
print(p)
# <__main__.Point object at 0x102b5a100>
If we define __str__
, when using print
, it will display whatever we want. It is useful to define this method in your classes, as it helps to see the content of the object.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f"x={self.x} y={self.y}"
p = Point(1, 2)
print(p) # x=1 y=2
Methods __add__
and __sub__
. Imagine you want to add +
or subtract -
two objects. These methods define the addition and subtraction logic.
- β We define the sum of two points as the sum of the coordinates
x
on one side andy
on the other. - β We define the subtraction of two points as the subtraction of the coordinates
x
on one side andy
on the other.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Point(self.x + other.x, self.y + other.y)
def __sub__(self, other):
return Point(self.x - other.x, self.y - other.y)
def __str__(self):
return f"x={self.x} y={self.y}"
Now that we have defined both operators, we can use them. If you do not define them, you will get TypeError
.
p1 = Point(5, 10) + Point(3, 1)
p2 = Point(-1, 0) - Point(-5, 0)
print(p1) # x=8 y=11
print(p2) # x=4 y=0
Methods __eq__
and __ne__
. These two methods allow us to define when two objects are equal or different. We consider two objects equal if their coordinates are equal.
class Point:
# ...
def __eq__(self, other):
return self.x == other.x and self.y == other.y
def __ne__(self, other):
return not self == other
equal = Point(1, 1) == Point(1, 1)
print(equal)
# True
You can check that they are equal. There are also other methods such as __lt__
or __gt__
that are used to define when one object is less <
or greater >
than another.
print(Point(1, 1) == Point(1, 1))
# True
Methods __iter__
and __next__
. These methods allow us to make a class iterable, that is, we can use it with a for
. One defines the iterator object, and the other defines the next number to iterate. We define a class that iterates even numbers up to a limit
value.
class Evens:
def __init__(self, limit):
self.i = 0
self.limit = limit
def __iter__(self):
return self
def __next__(self):
if self.i >= self.limit * 2:
raise StopIteration
even = self.i
self.i += 2
return even
We can generate the first 5 even numbers as follows.
for i in Evens(5):
print(i)
# 0, 2, 4, 6, 8
Methods __enter__
and __exit__
. They are related to the context manager and we will see them in more detail in the chapter on exceptions. For now, it is enough to know that they allow us to execute an action when entering a block with
and another one when exiting. They make a kind of sandwich.
class ContextManager:
def __enter__(self):
print("Enter")
return None
def __exit__(self, exc_type, exc_value, traceback):
print("Exit")
with ContextManager() as f:
print("Inside")
# Enter
# Inside
# Exit
Having seen the most important ones, let us now look at a practical application using the dunder __add__
method seen earlier.
Imagine we have a Currency
class that can store values in USD and EUR. A priori, it is not possible to add USD with EUR, since we cannot mix apples and oranges.
However, we can define __add__
so that a conversion to USD is performed. Once both values have been converted, they can be summed.
class Currency:
rates = {'USD': 1.0, 'EUR': 1.10}
def __init__(self, currency, amount):
self.currency = currency
self.amount = amount
def convert(self, new_currency):
if self.currency != new_currency:
return self.amount * Currency.rates[self.currency] / Currency.rates[new_currency]
return self.amount
def __add__(self, other):
if isinstance(other, Currency):
amount_usd = self.convert('USD') + other.convert('USD')
return Currency('USD', amount_usd)
raise TypeError("Error")
def __str__(self):
return f'{self.currency} {self.amount:.2f}'
Now we can add different currencies, as an automatic conversion is performed.
expense1 = Currency('USD', 5.25)
expense2 = Currency('EUR', 7.99)
print(expense1 + expense2)
# USD 14.04
There are other dunder methods that we invite you to review, but we have covered the main ones that will suffice for most of your programs.
Using dataclasses
If you want to define a class to store information without additional methods, you can use dataclass
. In a few lines, you can define it.
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
And you can use it to create an object. As you can see, it also allows you to print the point with its attributes.
p = Point(10, 20)
print(p)
# Point(x=10, y=20)
They do not really add anything new. You can write code with the same behavior as shown. But it contains a lot of boilerplate code.
class Point:
def __init__(self, x: int, y: int):
self.x = x
self.y = y
def __repr__(self):
return f"Point(x={self.x}, y={self.y})"
def __eq__(self, other):
if isinstance(other, Point):
return self.x == other.x and self.y == other.y
return False
In short, dataclass
allows you to define a simple class while saving code.
Monkey patching
Monkey patching is a technique in Python that allows us to modify the behavior of a class at runtime. In other words, it allows us to change one method for another. Imagine that we have a greet
method.
class Person:
def greet(self):
print("Hello!")
Thanks to monkey patching, we can change the greet
method to new_greet
at runtime.
def new_greet(self):
print("Hello world!")
Person.greet = new_greet
p = Person()
p.greet()
# Hello world!
This is quite interesting for testing. Also, if you want to modify the functionality of third-party code quickly.
However, use it with care, as it can be confusing. For example, it is not recommended to modify Python packages, since everyone expects a certain behavior. You will confuse the readers of your code.