5 lutego 2019

Python - wywołaj tylko raz

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