Optimizing Python Classes

Optimizing Python Classes

From Slow to Swift

Classes provide a way to encapsulate data and functionality, making it easy to reuse code and maintain a clean codebase. However, if your classes become bloated or inefficient, it can lead to slower performance and decreased productivity.

Let's dive in and explore techniques and best practices for optimizing Python classes to improve performance and reduce resource usage!

cached_property

The cached_property is a decorator that caches the value of a property after it is calculated for the first time. This can improve performance by avoiding expensive and unnecessary computations such as network calls or database hits when the property is accessed multiple times.

import requests
from functools import cached_property

class Weather:

    def __init__(self, location):
        self._location = location

    @property
    def location(self):
        return self._location

    @cached_property
    def geo_location(self) -> tuple:
        """returns lat and long of location"""
        res = requests.get(f"https://someurl.com/?location={self.location}")
        res.raise_for_status()
        data = res.json()
        return data["latitude"], data["longitude"]

In the above example, the geo_location property is computed using an HTTP request to an external API, based on the instance's location. Once the geo_location property is set for a specific location, the result will not change for the lifetime of the Weather instance. This makes it safe to cache the result of the property using cached_property.

Since cached_property caches the value of the property based on the instance of the class, any subsequent calls to geo_location for the same Weather instance will return the cached value, avoiding the need to make another HTTP request. This can significantly improve performance by reducing the number of HTTP requests made to the external API.

When not to use cached_property

To use cached_property safely and effectively, the property must be immutable. This means that once the value of the property is set, it cannot be changed.

class Weather:
    def __init__(self, temperature):
        self._temperature = temperature

    @property
    def temperature(self):
        return self._temperature

    @temperature.setter
    def temperature(self, value):
        self._temperature = value

    @cached_property
    def is_hot(self):
        return self.temperature > 25

The possibility of the temperature property changing over time makes is_hot mutable, thus caching the property's value would not be safe. For example, if the temperature changes after the property has been computed and cached, then the cached value would be incorrect.


static method

Let's first illustrate when and how to use static methods in Python.

Bad

class Weather:
    def __init__(self, temperature, humidity):
        self.temperature = temperature
        self.humidity = humidity

    def is_weather_good(self, temperature, humidity):
        if temperature > 20 and humidity < 80:
            return True
        else:
            return False

A general rule of thumb for deciding when to make a method static or instance bound is whether or not it uses the self argument passed to it. It is obvious, in this case, that is_weather_good method doesn't need to access any instance state. We could make this method faster by making it a static method.

Good

class Weather:
    def __init__(self, temperature, humidity):
        self.temperature = temperature
        self.humidity = humidity

    @staticmethod
    def is_weather_good(temperature, humidity):
        if temperature > 20 and humidity < 80:
            return True
        else:
            return False

the duel: static vs instance methods

When an instance method is called, the interpreter first looks up the method on the instance's class, then passes the instance (self) as the first argument to the method. This means that every time you call an instance method, Python needs to perform a method lookup and pass an extra argument to the method.

In contrast, when you call a staticmethod, the method is looked up directly on the class, without the need to pass any extra arguments. This makes staticmethods faster and more efficient than instance methods.

Noteworthy
The choice between staticmethods and instance methods should be based on your program's design and requirements, rather than on performance considerations alone.

slots

When you define a class, Python creates a dictionary to store its attributes. The keys of the dictionary are the attribute names, and the values are the attribute values.

class Weather:
    def __init__(self, temperature, humidity):
        self.temperature = temperature
        self.humidity = humidity

weather = Weather(temperature=20, humidity=50)
print(weather_instance.__dict__)
>> {'temperature': 20, 'humidity': 50}

As you can see, python has dynamically created a __dict__ attribute under the hood to store all the attribute names and values for our Weather instance. When you access weather.temperature, Python first looks for the temperature attribute in the dictionary associated with the weather instance. If the attribute is not found in the instance dictionary, Python then looks for the attribute in the dictionary associated with the Weather class.

This dynamic attribute lookup mechanism allows Python to be very flexible, as you can add or remove attributes from a class at runtime. This can, however, be slower, especially for classes with a large number of attributes or instances.

Enter slots

class Weather:
    __slots__ = ('temperature', 'humidity')

    def __init__(self, temperature, humidity):
        self.temperature = temperature
        self.humidity = humidity

Slots are a way to specify the attributes that a class can have ahead of time. By doing this, Python allocates a fixed amount of memory for the class attributes, instead of using a dictionary to store them dynamically. This means that Python doesn't need to perform a dictionary lookup to access the attributes, which can be faster.

Noteworthy
Slots aren't always a great solution. They can make your classes less flexible and harder to debug. Read more here.