Notatnik techniczny

Python - wywołaj tylko raz

Mamy dość kosztowną funkcje 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. W kolejnym wpisie umieszczam rozbudowaną wersje poniższego rozwiązania, gdzie śledzimy wartości argumentów funkcji.

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

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 
#

Wadą tego podejścia jest niska elastyczność. Musimy modyfikować funkcje w kilku miejscach, pamiętać aby zapisać wartość dla każdego punktu wyjścia.

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 funkcje 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ą instancje 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 tu kilka możliwości jedną z nich jest skorzystanie z pola qualname 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ę ze rozwiązanie jest niewystarczające. Zamiast publikować kolejne poprawki wrzuciłem kod na GitHub do publicznego repozytorium.

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.