Table of contents
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
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.