Link Search Menu Expand Document

Object oriented programming

Testing Image

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 than person.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 a Human 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 case age.
  • 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 modify age 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 method is_senior.
  • πŸ› οΈ Modifies Person by adding a grade attribute.
  • ♻️ Reuses Person by inheriting its existing is_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 and y on the other.
  • βž– We define the subtraction of two points as the subtraction of the coordinates x on one side and y 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.