Abstraction & Flexibility

Examples of Abstraction in practice

🚗 Driving a car

Here is a depiction of abstraction in our life:

Different car engines work in different ways. An electric engine is significantly different than a combustion engine, and an all-wheel-drive car is internally different than a two-wheel-drive car.

But when you drive, the car designers have hidden the unimportant details. You only need to focus on what to do, not the type of engine or how the engine works. And there is a very similar interface -- no matter what type of engine the car has, you just need to know how to use the steering wheel and the pedals.

If there weren't abstraction, we'd have to think about this when we drove:

But because of abstraction, we only need to think about this:

Square roots

When we use the math module, we don't need to know how the computer calculates the square root of a number. We just need to know to use the sqrt() function.

import math

x = 25
y = math.sqrt(x)
print(y)

There are several different algorithms and ways to compute the square root of a number. Some are faster for certain types of computer processors. Some algorithms are slower, but return a more accurate answer. In the vast majority of cases though, those differences are irrelevant. It doesn't really matter what method is used to compute the square root, because the differences are so small that they do not meaningfully affect what the program does.

We could imagine a world where to compute a square root we would have to choose between different algorithms, like math.sqrt_linear_estimate_algorithm, math.sqrt_heron_algorithm, and math.sqrt_babylonian_algorithm.

But this would be confusing to read! It would be distracting to think about the choice of algorithm when the differences have no real impact on what the program does.

When we write code with abstraction in mind, we hide the unimportant details.

What matters most for a high level language like Python is to have code that is easy to read and understand. And so, Python gives us just one choice, math.sqrt. Behind the scenes, Python will use one of the square root algorithms. Because we don't need to know about that detail when we write our program, we can say that detail is hidden.

In this case, the author of Python's math library thought about abstraction and realized that people computing the square root of a number don't need to know the details. So they hid the details, like the choice of which algorithm to use, by only providing one simple sqrt function.

🏷️ Example: getting the price

This is a way to use abstraction in your code: find a piece of complicated code, create a new Python module, and move the complicated code there.

For example, you are working on a store program where items being sold have prices. The way to get the price of an item was complicated and had dozens of lines of code. The solution is to create a file called get_price_helpers.py and move all of that complicated code into a get_price function in that file.

The existing code that needed to get the price of an item is now simple and easy to read. For example, there is a function that calculates the total price when there are many of one type of item. calculate_total_price is now very simple because it does need to see any of the complexity of the get_price function.

import get_price_helpers

def calculate_total_price(item, quantity):
    price = get_price_helpers.get_price(item)
    total_price = price * quantity
    return total_price

In this case, the detail being hidden is the detail of how the price is being retrieved from the item. get_price might be using a database to get the price of the item, or it might be using a dictionary, or even reading from the internet. From the point of view of the calculate_total_price function, it doesn't matter.

The details of the get_price function are hidden from us, and we don't need to know how it works. This is abstraction.

💾 Abstraction in practice

Now with all these new concepts in mind, let's try to improve some code ourselves! When a program has good abstraction, it is more flexible.

This means that when, in the future bug fixes or new features are needed, the code is easier to change.

Imagine that you are creating a game, and you need a feature where the player can save their progress.

There are several places in the code that need to save the game - when the player clicks save, after completing a level, every 5 minutes just in case, and so on.

You know you want saving to be a separate method, since you definitely don't want to have duplicated code in all of those places.

Imagine that calling the method looks like this:

import json
class Game:
    ...
    def on_complete_level(self):
        ...
        self.save_to_json(self.data, 'saved_game.json')
        ...
    def on_every_five_minutes(self):
        ...
        self.save_to_json(self.data, 'saved_game.json')
        ...
    def on_user_click_save(self):
        ...
        self.save_to_json(self.data, 'saved_game.json')
        ...
    def save_to_json(data, filename):
        with open(filename, 'w') as save_file:
            json.dump(data, save_file)

This strategy will work in the short term.

But in the future, it will probably need to change. You might need a feature to store more than one game file. Or you need to store the game in a database. Or you need a feature to save to an online server. Each time a change like this is made, you would need to update each of the several calls - which takes time.

The problem is that the details are not hidden. To improve the code, the high level code should not have details about how the save is occurring!

Let's create a new class with a single job, a single responsibility: Saving a game.

import json
class GameSaver:
    def __init__(self, filename):
        self.filename = filename
    
    def save(self, data):
        with open(self.filename, 'w') as save_file:
            json.dump(data, save_file)
        ...
# Every game that wants to save some data should use an object of the GameSaver class
class Game:
    ...
    def __init__(self):
        self.game_saver = GameSaver('saved_game.json')
        
    def on_complete_level(self):
        ...
        self.game_saver.save(self.data)
        ...
    def on_every_five_minutes(self):
        ...
        self.game_saver.save(self.data)
        ...
    def on_user_click_save(self):
        ...
        self.game_saver.save(self.data)
        ...
    ...

The save method is now well abstracted, because the details are hidden.

And the program is more flexible because it is easier to adapt to future changes. If any changes need to be made, they only need to be made once in the creation of the game_saver object instead of several times. (In this small example, it's not a big deal. But in a large professional software project, having to search for each place that calls a method and making the correct updates can end up being days of work, especially once testing and code review are taken into account).

Also, the program is clearer, because we just see the action save and we are not distracted by seeing the filename and format.

💡 For a program that uses abstraction well, the higher layers only describe what the program does. It is the lower layers that have the details on how the program works.