Lazy evaluation has a wide range of use areas. Especially, if your code includes some expensive execution codes like some sort of requests at instantiation, SQL queries, or any long-running jobs, and if you don’t want them to run at object initialization, the lazy implementation will be the solution.
What is lazy evaluation?
In terms of how expressions are evaluated in Python, for example when we run the code below:
if kwargs and isinstance(items, list): ...
the compiler won’t evaluate the
isinstance method if
kwargs doesn’t exist or provided.
In general lazy evaluation means “hold the value until when it’s actually needed and evaluate then”. Because users probably won’t need this big chunk swallowing piece of code most of the time.
Django ultimately benefits from this. Django ORM itself includes well-designed lazy implementation in its query operations (you can see they did tons of lazy wrappings in Django 3). Here is an example Django query of a database for a township.
def get_township(zipcode): townships = Township.objects.all() if zipcode: township = townships.filter(zipcode=zipcode)
You may think this code queries the database twice but it isn’t. It actually doesn’t query the database in the first line, it just evaluates lazily. After the if condition evaluates true, then the queryset object queries the database and brings the township information that we want. It makes everything easier in case of writing query codes. We can customize our query as much as we want and run it once in the end.
When to use lazy implementation?
There’s not just one reason for lazy implementation, there’s more than one reason apart from that we saw in the example. There may be some other reasons to implement this, but mostly the main reason is for performance optimization, deferring long-running requests or queries until it’s needed.
If you remember migrate command from Django that creates tables for us, you can also remember that your project doesn’t need it to fully wake up. You will just type runserver and enter, and the project won’t raise any error until it queries the uncreated tables.
In Django, there’re a lot of discussions about how to take a setting. Taking settings directly from django.conf.settings is the best way because it delays the retrieval of the setting until it’s really needed. Other ways such as using get_user_model will run at import time. This is why taking settings from django.conf.settings is always recommended.
As an example, let’s say we have a main.py and using Django’s SimpleLazyObject we created a lazy object like this:
class MyClass: def long_running_method_1(self): # send request # process output # send requests again def long_running_method_2(self): # make queries # process output # make updates on tables def proxy_method(): my_class = MyClass() return my_class lazy_object = SimpleLazyObject(proxy_method)
Now you can use your lazy object wherever you want just by importing it inside your project anywhere, it won’t be run until you want it to run:
from main import lazy_object lazy_object.long_running_method_1() lazy_object.long_running_method_2()
How to implement lazy objects in Python?
In the previous example, we saw the use of Django’s SimpleLazyObject, but we don’t know how it actually makes objects lazy. Shall we give it a try? You can read its source code here, and I will just simplify the whole code to main parts by pruning some. The working principle will be the same. I think these two things will work:
- store the object that we want to make lazy
- redefine the __getattr__ magic method
The simplified code will be like below:
empty = object() def new_method_proxy(func): def inner(self, *args): if self._wrapped is empty: self._setup() return func(self._wrapped, *args) return inner class IamLazier: def __init__(self, f): self.__dict__["_setupfunc"] = f self._wrapped = empty def _setup(self): self._wrapped = self._setupfunc() __getattr__ = new_method_proxy(getattr)
and if we try this, we can see that the
func(self._wrapped, *args) part eventually will run like
__getattr__(f, *args) .
from collections import namedtuple X = namedtuple('X', ['a', 'b']) z = IamLazier(lambda: X(a=1, b=2)) print(z.a) # out: 1 print(type(z._wrapped)) # out: X(a=1, b=2)
Any downsides of lazy evaluation?
Even though it allows you to write functional code which becomes very understandable in smaller pieces, it goes complex in time, you really need to test these parts before going live. For example, you shouldn’t get time lazily. Also, not every time but you should be aware when you use mutable states and lazy evaluation together. If you encounter a problem then it’s better to find a way to simply overwrite lazy evaluation at some point. Lastly, this bookkeeping can be overhead after some time. Think about when most of the lazy objects are being evaluated at the same time, chaos might occur these times.