# Optimizing Python Classes

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.

```python
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.

```python
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**

```python
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**

```python
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.

<details data-node-type="hn-details-summary"><summary>Noteworthy</summary><div data-type="detailsContent">The choice between <code>staticmethods</code> and instance methods should be based on your program's design and requirements, rather than on performance considerations alone.</div></details>

---

### 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.

```python
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**

```python
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.

<details data-node-type="hn-details-summary"><summary>Noteworthy</summary><div data-type="detailsContent">Slots aren't always a great solution. They can make your classes less flexible and harder to debug. Read more <a target="_blank" rel="noopener noreferrer nofollow" href="https://wiki.python.org/moin/UsingSlots" style="pointer-events: none">here</a>.</div></details>
