Mamy kosztowną funkcję i chcemy ją wykonać tylko raz, jeśli funkcja zostanie ponownie wywołana z innymi parametrami, to zostanie zwrócona wartość z pierwszego wywołania.
def expensive_function(arg):
print("expensive_function call arg = {}".format(arg))
return arg
print("Result is: {} \n".format(expensive_function(1)))
print("Result is: {} \n".format(expensive_function(2)))
print("Result is: {} \n".format(expensive_function(3)))
# expensive_function call arg = 1
# Result is: 1
#
# expensive_function call arg = 2
# Result is: 2
# expensive_function call arg = 3
# Result is: 3
#
Rozwiązanie prymitywne
Wadą tego podejścia jest niska elastyczność. Musimy modyfikować funkcje w kilku miejscach pamiętać, aby zapisać wartość dla każdego punktu wyjścia.
def expensive_function(arg):
if hasattr(expensive_function, "cached_result"):
return expensive_function.cached_result
print("expensive_function call arg = {}".format(arg))
expensive_function.cached_result = arg
return arg
print("Result is: {}".format(expensive_function(1)))
print("Result is: {}".format(expensive_function(2)))
print("Result is: {}".format(expensive_function(3)))
# expensive_function call arg = 1
# Result is: 1
# Result is: 1
# Result is: 1
#
Rozwiązanie z dekoratorem
def call_once(target):
attribute_name = "_cached_result"
def wrapper(*args, **kwargs):
if not hasattr(wrapper, attribute_name):
setattr(wrapper, attribute_name, target(*args, **kwargs))
return getattr(wrapper, attribute_name)
return wrapper
from call_once import *
@call_once
def expensive_function(arg):
print("expensive_function call arg = {}".format(arg))
return arg
print("Result is: {} ".format(expensive_function(1)))
print("Result is: {} ".format(expensive_function(2)))
print("Result is: {} ".format(expensive_function(3)))
# expensive_function call arg = 1
# Result is: 1
# Result is: 1
# Result is: 1
#
O wiele czytelniej. Możemy łatwo wydzielić kod dekoratora i korzystać z niego w innych projektach.
Obiekt funkcji jest tworzony raz i również raz tworzona jest funkcja zewnętrzna zdefiniowana w dekoratorze. Sprawdzamy, czy nasza funkcja ma zdefiniowany atrybut i jeśli nie to wywołujemy funkcję bazową i zapamiętujemy wynik. Problem pojawia się, gdy chcemy zastosować to rozwiązanie do metod.
class Foo(object):
@call_once
def expensive_method(self, arg):
print("expensive_method call arg = {}".format(arg))
return arg
c1 = Foo()
c2 = Foo()
print("c1.expensive_method: {} ".format(c1.expensive_method(5)))
print("c1.expensive_method: {} ".format(c1.expensive_method(6)))
print("c2.expensive_method: {} ".format(c2.expensive_method(7)))
print("c2.expensive_method: {} ".format(c2.expensive_method(8)))
# expensive_method call arg = 5
# c1.expensive_method: 5
# c1.expensive_method: 5
# c2.expensive_method: 5
# c2.expensive_method: 5
#
Ponieważ dla wszystkich instancji mamy tylko jedną instancję obiektu funkcji, wywołanie dla różnych instancji zapisują zapamiętany wynik w tym samym obiekcie reprezentującym funkcje. Możemy napisać osobny dekorator dla metod.
def call_once_method(target):
attribute_name = "_cached_result"
def decorated(self, *args, **kwargs):
if not hasattr(self, attribute_name):
setattr(self, attribute_name, target(self, *args, **kwargs))
return getattr(self, attribute_name)
return decorated
W tym przypadku zamiast zapisywać zapamiętany wynik w obiekcie reprezentującym funkcje robimy to w instancji klasy.
Niestety teraz mamy dwa dekoratory i musimy ręcznie wybrać właściwy.
Ujednolicenie dla funkcji i metody
Korzystanie z dwóch osobnych dekoratorów osobno dla funkcji i metod jest niewygodne. Kluczowe tu jest rozróżnienie z wnętrza dekoratora czy jest od zaczepiony do funkcji, czy metody. Mamy kilka możliwości jedną z nich jest skorzystanie z pola qualname (opens new window) dostępnego począwszy od wersji Pythona 3.3. Dodatkowo uzależniamy nazwę naszego atrybutu od hash obiektu, do jakiego jest doczepiony dekorator.
def call_once(target):
attribute_name = "_{}_cached_result".format(id(target))
is_method = target.__qualname__.find(".") > 0
def wrapper(*args, **kwargs):
cache_obj = args[0] if is_method else wrapper
if not hasattr(cache_obj, attribute_name):
setattr(cache_obj, attribute_name, target(*args, **kwargs))
return getattr(cache_obj, attribute_name)
return wrapper
Rozwiązanie ostateczne
Nasz dekorator zachowuje się prawidłowo, poza przypadkiem, kiedy chcemy dynamicznie sprawdzić nazwę, opis lub nazwę modułu. Dostajemy wówczas niewłaściwy wynik.....
from call_once import *
@call_once
def expensive_function(arg):
"""description"""
print("expensive_function call arg = {}".format(arg))
return arg
print(expensive_function.__name__)
print(expensive_function.__doc__)
# wrapper
# None
#
Aby, temu zaradzić musimy wprowadzić drobne modyfikacje.
def call_once(target):
attribute_name = "_{}_cached_result".format(id(target))
is_method = target.__qualname__.find(".") > 0
from functools import wraps
@wraps(target)
def wrapper(*args, **kwargs):
cache_obj = args[0] if is_method else wrapper
if not hasattr(cache_obj, attribute_name):
setattr(cache_obj, attribute_name, target(*args, **kwargs))
return getattr(cache_obj, attribute_name)
return wrapper
from call_once import *
@call_once
def expensive_function(arg):
"""description"""
print("expensive_function call arg = {}".format(arg))
return arg
print(expensive_function.__name__)
print(expensive_function.__doc__)
# expensive_function
# description
#
Poprawione rozwiązanie (edit 20.02.,2019)
Po opublikowaniu tego artykułu przekonałem się, że rozwiązanie jest niewystarczające. Poprawiony kod jest w repozytorium (opens new window).
Poniższa metoda sprawdza, czy dekorator jest podczepiony do funkcji, czy metody, niestety dzieje się to za każdym wywołaniem nie przy konstrukcji dekoratora.
def _get_object_instance(target, *args):
if len(args) == 0:
return None
method = getattr(args[0], target.__name__, None)
if not callable(method):
return None
return args[0] if id(method.__wrapped__) == id(target) else None
Sprawdzamy:
- Czy funkcja została wywołana z minimum jednym argumentem?
- Czy pierwszy argument zawiera atrybut o takiej samej nazwie jak nasza funkcja?
- Czy ten atrybut jest wydobywalny?
- Czy jego id jest takie samo jak id funkcji "pod" dekoratorem?
Projekt będzie stopniowo rozwijany. Założenia są następujące:
- Uniwersalny dekorator do funkcji i metod.
- Rozróżnia lub ignoruje argumenty funkcji.
- Możliwość trwałego cachowania na dysku.
Podsumowanie
Napisaliśmy niewielki dekorator, dzięki któremu możemy wywołać kosztowne funkcje tylko raz. Kod będzie stopniowo rozwijany, aby spełnić wszystkie założenia, a postępy publikowane na GitHub (opens new window).