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
#

Podsumowanie

Napisaliśmy niewielki dekorator, dzięki któremu możemy wywołać kosztowne funkcje tylko raz. W następnym wpisie rozszerzę jego działanie tak, by zapamiętana wartość zależała od przekazanych argumentów.