DEV Community

Cover image for When to use OOP in Python if you are not developing large applications
Pedro S
Pedro S

Posted on

When to use OOP in Python if you are not developing large applications

Having started studying programming from a data science perspective, I had a lot of trouble understanding not exactly what object-oriented programming (OOP) was, but why should I use it. After all, the code I wrote seemed to do just well without custom classes and objects.

Inspired by an introduction on OOP in Python I watched in a recent online conference (in Brazilian Portuguese)congrats on the talk, Maria Clara! –, I decided to write an introduction to OOP that will not focus on syntax or Python perks (I will assume you already know fairly well how to declare classes in Python), but will actually focus on when OOP should be used. I will present: (1) to what kind of code and issues OOP was created, (2) whether it has a place in non-complex applications and (3) what features Python has that can be used besides ordinary class declarations for most of the projects.

Why OOP in the first place?

OOP was developed as a strategy to organize code in large and complex applications.

Let's say we are making an application in a bank that will read the balance of an account from a database and decrease it under request of a user, if there are enough funds. It could follow this very simplified layout:

from db_connection import db_connection

def get_balance(db_connection, account_no):
    ...
    return balance

def withdraw(balance, amount):
    if amount <= balance:
        return balance - amount
    else:
        raise Exception('Insufficient funds')

def update_balance(db_connection, account_no, new_balance):
    ...

if __name__ == '__main__':
    account_no = input('Type account number: ')
    to_withdraw = float(input('Type amount to be withdrawn: '))
    old_balance = get_balance(db_connection, account_no)
    new_balance = withdraw(old_balance, to_withdraw)
    update_balance(db_connection, account_no, new_balance)
Enter fullscreen mode Exit fullscreen mode

What if we want to allow a certain category of bank accounts to withdraw more money than available in the balance under a certain interest rate, as a loan? Our code quickly becomes way more complex:

from db_connection import db_connection

ACCOUNT_CATEGORIES_ALLOWED_TO_LOAN = set(...)

def get_balance(db_connection, account_no):
    ...
    return balance

def get_account_category(db_connection, account_no):
    ...
    return account_category

def withdraw(balance, amount, is_allowed_to_loan):
    if not is_allowed_to_loan and amount > balance:
        raise Exception('Insufficient funds')
    else:
        return balance - amount

def update_balance(db_connection, account_no, new_balance):
    ...

def register_loan(db_connection, account_no, amount_loaned):
    ...

if __name__ == '__main__':
    # Get account info
    account_no = input('Type account number: ')
    to_withdraw = float(input('Type amount to be withdrawn: '))
    acct_category = get_account_category(db_connection, account_no)
    # Withdrawal operation
    old_balance = get_balance(db_connection, account_no)
    is_allowed_to_loan = acct_category in ACCOUNT_CATEGORIES_ALLOWED_TO_LOAN
    new_balance = withdraw(old_balance, to_withdraw, is_allowed_to_loan)
    # Updates
    update_balance(db_connection, account_no, new_balance)
    if new_balance < 0:
        register_loan(db_connection, account_no, new_balance)
Enter fullscreen mode Exit fullscreen mode

Now imagine that there could be many different bank account categories, each of them with different consequences for different operations and available funds. The code base would quickly become spaghetti code: a bunch of different functions and flow control statements that is difficult to understand and to maintain.

That is where OOP steps in. It associates a specific set of data with specific functions to act on them, in a way that the relation between the information your code is dealing with and what it is able to do with it is very clear. Let's check how this is done.

Abstraction

Code in OOP is organized through abstractions of real-life objects. In this sense, the bank account in our application example would be a class, a "thing" with its own characteristics (the data, called "attributes" in OOP) and actions (the "methods"), just like it is in the real world:

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

    def withdraw(self, amount):
        if amount <= self.balance:
            self.balance -= amount
        else:
            raise Exception('Insufficient funds')
Enter fullscreen mode Exit fullscreen mode

Just from taking a quick look at the snippet above, you can know that (1) every account has a certain balance (2) every account can receive a "withdraw" action. This is different from what we had in our previous spaghetti application: if we were to change anything regarding bank accounts in our system, we would have to search through all the code to find where the changes should be made – and we would have to hope that we were making the change in all the necessary places. Classes help everything be more concentrated.

Our application could now be simplified to this:

from db_connection import db_connection
from classes.accounts import Account

def get_account(db_connection, account_no) -> Account:
    ...
    return Account(balance)

def update_database(db_connection, account_obj: Account):
    ...

if __name__ == '__main__':
    account_no = input('Type account number: ')
    to_withdraw = float(input('Type amount to be withdrawn: '))
    account = get_account(db_connection, account_no)
    account.withdraw(to_withdraw)
    update_database(db_connection, account)
Enter fullscreen mode Exit fullscreen mode

Notice that all the actions related to changing the data related to the account (ie, the account's state) is not present in this script anymore. The act of withdrawing money can now be all in a separate script where the definition of the Account class is - a package called "classes", with a script called "accounts.py", for example. Any change related to what happens when money is withdrawn from an account should be made in that separate script; any change related to how a user withdraws money (what information is requested, for example), should be made in our main script.

If you paid attention to the type annotations, you may have noticed that the database-related functions now deal with Account objects directly. This makes it easier if, in addition to withdrawing money, we also want the user to be able to call other methods from the Account class - that would just require the addition of some more lines, with no need to instantiate new objects.

Encapsulation

Our Account class can have its balance easily edited during runtime. If we do account.balance = 0.0005, the balance would change, even though that would be a strange amount for an ordinary account in dollars.

That is why it is recommended that the attributes of a class be encapsulated, ie, hidden from the outside world (the rest of the code). In Python, this can be done with the help of the @property decorator (or, alternatively, with the convention of naming attributes with leading underscores1):

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

    def withdraw(self, amount):
        if amount <= self.balance:
            self.balance -= amount
        else:
            raise Exception('Insufficient funds')

    @property
    def balance(self):
        # Nothing special about getting the balance;
        # we will just return it.
        return self._balance

    @balance.setter
    def balance(self, new_value):
        # When changing the balance, however,
        # we want to enforce certain rules
        if (new_value * 100) % 1 > 0:
            raise Exception('Balance can only have up to two decimal houses')
        else:
            self._balance = new_value
Enter fullscreen mode Exit fullscreen mode

Now, any time that the balance attribute of the Account class is set up with no respect to the rules defined in the Account class itself, an exception is raised:

>>> acct = Account(55.663)
...
Exception: Balance can only have up to two decimal houses
Enter fullscreen mode Exit fullscreen mode

Encapsulation allows the implementation of the attributes to be reserved to the code of the class itself. Instead of checking in our main script if the new balance is a valid value, this action is reserved to the class declaration. Again, this results in a more organized code.

Inheritance

Inheritance is a nice feature of OOP that allows classes to be related to each other. When one class inherits another, all the attributes and the methods of the class it is inheriting from are automatically attached to it, with no code repetition being necessary. In our bank application, this allows different Account types to be easily implemented, as a different HighIncomeAccount type, for example:

class HighIncomeAccount(Account):
    pass
Enter fullscreen mode Exit fullscreen mode

Just the lines of code above are enough to create a different data structure that has the same attributes and methods of the main Account class (and is recognized as an instance of it, although "indirectly", in practice), at the same time it can be recognized as an object of a different type:

>>> simple_account = Account(55)
>>> high_income_account = HighIncomeAccount(99955)
>>> all(hasattr(acct, 'withdraw')
...     for acct in (simple_account, high_income_account))
True
>>> isinstance(high_income_account, Account)
True
>>> type(simple_account) is type(high_income_account)
False
Enter fullscreen mode Exit fullscreen mode

In our application, we would have to change our get_account function to create either an Account or a HighIncomeAccount object depending on the case. However, besides that change, the rest of the code would be able to continue calling account.withdraw in the same way as before. This is how OOP programs are seen to work: as "messages" (such as the withdraw order) being transmitted from one part of the code to another.

Polymorphism

Inheritance can be better used in our application by taking advantage of polymorphism: the same method can produce different results depending on which object it is called. We can, for example, change how withdraw works for a HighIncomeAccount:

class HighIncomeAccount(Account):
    def withdraw(self, amount):
        diff = self.balance - amount
        if diff >= 0:
            super().withdraw(amount)
        else:
            self.amount_loaned = diff
            self.balance = diff
Enter fullscreen mode Exit fullscreen mode

That way, the exception regarding insufficient funds is raised only on Account objects, but not on HighIncomeAccount objects:

>>> simple_account = Account(55)
>>> high_income_account = HighIncomeAccount(99955)
>>> simple_account.withdraw(99999999)
...
Exception: Insufficient funds
>>> high_income_account.withdraw(99999999)
>>> high_income_account.balance
-99900044
Enter fullscreen mode Exit fullscreen mode

And, once again, our main script representing the user interaction can remain unchanged (besides the database interactions, which should be updated to consider the new amount_loaned attribute). All the logic regarding the bank accounts is concentrated in the classes definition. The code base as a whole is, therefore, much easier to read and maintain.

Using OOP in simpler applications

All the above makes a lot of sense if you are dealing with code for complex systems. It is a different reality, however, if you write code for exploratory data analysis, for example, which is much more objective: given a certain dataset, tasks are executed one after another in order to provide certain insights (results). In this case, classes may not be necessary, as your code may not have to deal with different data structures. If everything is a DataFrame and all your functions can act on any DataFrame, there is not much reason to waste your time creating classes and declaring different methods. Much of the features of OOP, such as inheritance and polymorphism, in fact, would just not be useful at all.

As a rule of thumb, creating custom classes is useful when you have the need to associate specific data and actions. That was the case in the application example above: we needed a way to associate a "balance" with a certain "withdraw" action. As a different example, it can also be useful if we are building a scraper that collects information from different sources or in different ways, as the scraper of a hospitals database that also looks for the distance between one hospital and another and checks a different database for the number of beds in the hospital:

class Hospital():
    def __init__(self, address):
        self.address = address
        self.beds_no = self.get_number_of_beds()

    def get_number_of_beds(self):
        ...
        return beds_no

    def get_distance(self, to: 'Hospital'):
        ...
        return distance
Enter fullscreen mode Exit fullscreen mode

Using such a class in your program can make information be transmitted much more easily between different parts of your code. It is clearer and shorter to call Hospital.get_distance(to=another_hospital) when necessary than to retrieve an address, call a separate function like get_distance(from=one_address, to=another_address) and deal with scattered information.

Another good application of data and actions being put together is when you need a different custom data type. In Python, data types such as list and dict can be seen as classes with special methods - as any other class, you can inherit from them and change their behavior. Let's say you need a list that only accepts instances of a dict, and you need to be sure of that, for any obscure reason. Then you can be creative and do:

class ListOfDicts(list):
    def __init__(self):
        # We will not accept iterables as an argument to the constructor,
        # or else ListOfDicts({'a': 'dict'}) will result in ['a'].
        super()

    def append(self, item):
        self._execute_if_is_dict(super().append, item)

    def insert(self, idx, obj):
        self._execute_if_is_dict(super().insert, idx, obj)

    def __setitem__(self, item):
        self._execute_if_is_dict(super().__setitem__, item)

    def _execute_if_is_dict(self, action, *args):
        if not isinstance(args[-1], dict):
            raise Exception('Only dicts are accepted as items')
        else:
            action(*args)
Enter fullscreen mode Exit fullscreen mode

This approach should only be used if you are really sure of which methods you need to override. It shows, however, how Python can be flexible. If you ever catch yourself asking what if a certain data type could behave in a specific way, do some research: it is probable that someone has already written a custom class that does exactly what you need.

Beyond classes: useful data structures

You may be tempted to create a class to encapsulate a simple set of information, for example:

>>> class Person:
...     def __init__(self, name, age, address):
...         self.name = name
...         self.age = age
...         self.address = address
>>> holmes = Person('Sherlock Holmes', 60, '221B Baker Street')
Enter fullscreen mode Exit fullscreen mode

Do not do it this way for simple data structures like this one. You can aggregate data like that in a simple dict, and that will not raise questions regarding the possibility of any special method being attached to your Person class – which is, actually, very simple:

>>> holmes = {
...     'name': 'Sherlock Holmes',
...     'age': 60,
...     'address': '221B Baker Street'
... }
Enter fullscreen mode Exit fullscreen mode

This will equally allow you to retrieve information from the "holmes" object in a very direct way. It is true, however, that you may need a template, ie, a way of ensuring that every possible Person have three different attributes associated with it: a name, an age, and an address. That is the use case of a NamedTuple.

Named tuples

A named tuple is, like a tuple, an immutable ordered collection. However, its items can be retrieved based on a named index, just like in a dict. In the end, they are like an immutable dict that must be created from a specific template:

>>> from collections import namedtuple
>>> Person = namedtuple('Person', ['name', 'age', 'address'])
>>> holmes = Person('Sherlock Holmes', 60, '221B Baker Street')
Enter fullscreen mode Exit fullscreen mode

Instantiating an object from a named tuple is very similar to instantiating an object from a custom class. Accessing the attributes is also done with dot notation and, besides all that, printing the object will exhibit a user-friendly representation:

>>> holmes.age
60
>>> print(holmes)
Person(name='Sherlock Holmes', age=60, address='221B Baker Street')
Enter fullscreen mode Exit fullscreen mode

Data classes

Named tuples may present issues in some applications:

  • A named tuple can be compared as equal to another that carries the same fields. The holmes object we created could be considered equal to a named tuple Character(name='Sherlock Holmes', age=60, address='221B Baker Street'), for example;
  • In the same way, a named tuple is also considered equal to a tuple carrying the same fields: holmes == ('Sherlock Holmes', 60, '221B Baker Street') returns True.
  • Named tuples are iterable. Part of your code may iterate on a Person named tuple and expect it to return a name, an age and then an address; if you add a different field to the named tuple definition (a country attribute, for example), you may break this other part unwillingly.
  • You may want to change the values of an attribute in the named tuple. However, as tuples are immutable, that is not possible.
  • You may want more complexity. Maybe you want to query Wikipedia before creating your holmes object, and then save the resulting link to the named tuple itself. This is not possible, as you cannot change the methods underlying the named tuple (unless you create a new custom class yourself).
  • Composing the attributes in a named tuple based on other named tuples (as if doing class inheritance) is complicated and may result in obscure code.

These issues require a complex data structure - which is solved with the use of classes. However, much of the work related to the creation of a class to hold different attributes was made easier in Python 3.7 with the addition of the @dataclass decorator (see the documentation and the discussion reported in PEP 557). Its basic use eliminates some of the boilerplate necessary when creating a class, at the same time it adds a lot of advanced functionality for when you need something more complex than both a dict and a named tuple:

from dataclasses import dataclass, field

@dataclass
class Person:
    name: str
    age: int
    address: str
    wikipedia_page: str = field(init=False, repr=False)

    def __post_init__(self):
        self.wikipedia_page = get_wikipedia_page(self.name)

def get_wikipedia_page(query):
    ...
    return page_address
Enter fullscreen mode Exit fullscreen mode

The code above is equivalent to this one:

class Person:
    def __init__(self, name: str, age: int, address: str):
        self.name = name
        self.age = age
        self.address = address
        self.wikipedia_page: str = get_wikipedia_page(self.name)

    def __repr__(self):
        attrs_dict = vars(self)
        attrs_dict.pop('wikipedia_page')
        attrs_as_str = ', '.join(f'{k}={v.__repr__()}' for k, v in attrs_dict.items())
        return f'{type(self).__name__}({attrs_as_str})'

def get_wikipedia_page(query):
    ...
    return page_address
Enter fullscreen mode Exit fullscreen mode

What the @dataclass decorator does is to look for the class variables that contain a type annotation and make both a __init__ constructor and a __repr__ method with them. There is also extra functionality: the field function, for example, is telling the decorator that this field should be taken care of by the __post_init__ function and that it should not show up in the __repr__ result. The dataclass class also contains extra functionality that allows for a finer control of how the object will be instantiated (see the options of the class constructor and the field function), compared to others (see eq and order parameters of the constructor) and transformed into different data types (asdict and astuple methods) or in just a different object with different fields (replace method). This is a good amount of fine tuning in a much simpler code structure, as seen from the reduction of lines above.

Conclusion

OOP does not have much space in simple, procedural programs. When necessary, however, they can add a lot of functionality to your data structures at the same time they can make code that is easier to scale and maintain. For everyday scripting (as in much of data science tasks), a dict, a named tuple or the simplification provided by the @dataclass decorator are all good alternatives to the creation of a custom class if there is no necessity of putting together specific data and functions.


Let me know your thoughts and comments. This is my first article on programming and any criticism is very appreciated 😄

What do you think about OOP when not building complex applications? Do you think functional programming or other programming styles can scale just as well?

Cover image by Ross Sneddon on Unsplash.


  1. Check the use of single and double leading underscores here: https://dbader.org/blog/meaning-of-underscores-in-python. This is a convention adopted in PEP8, the style guide of the Python language: https://www.python.org/dev/peps/pep-0008/

Discussion (0)