Tym razem pochylimy się nad zagadnieniem, jakim są dekoratory funkcji. Pokażemy, jak one działają na bardzo prostym przykładzie.
Oto funkcja sum, która zwraca wynik dodawania dwóch liczb, ładnie opisany:
def sum(a, b):
return f"{a} + {b} = {a+b}"
print(sum(2,2))
# 2 + 2 = 4
Ta funkcja jest myląca, ponieważ zajmuje się zarówno formatowaniem jak i wykonywaniem dodawania. Funkcja powinna mieć zazwyczaj jedno przeznaczenie i „samoopisującą” się nazwę aby łatwo można było wydedukować jej przeznaczenie z samej nazwy i argumentów, jakie przyjmuje.
Załóżmy, że chcemy, aby nasza funkcja sum tylko dodawała dwa argumenty do siebie, wyglądała tak:
def sum(a, b):
return a+b
print(sum(2,2))
# 4
Z drugiej strony, chcemy nadal mieć te dane tak formatowane, jak w pierwszym przykładzie. Po prostu nie chcemy, aby logika tego znajdowała się w naszym sum.
Możemy zrobić nową funkcję:
def sum(a, b):
return a+b
def sum_and_log(a,b):
our_sum = sum(a, b)
return f"{a} + {b} = {our_sum}"
print(sum_and_log(2,2))
# 2 + 2 = 4
Tutaj wywołujemy sum na naszych argumentach, następnie wrzucamy to odpowiednio w f-string. To tylko taki przykład, ale postarajmy się teraz osiągnąć to samo, tylko za pomocą dekoratora, którego jeszcze nie znamy.
Oto kod, za chwilę wyjaśnimy go sobie:
def log(fn):
def wrapper(a, b):
return f"{a} + {b} = {fn(a,b)}"
return wrapper
@log
def sum(a, b):
return a+b
print(sum(2,2))
#2 + 2 = 4
Funkcja log jest funkcją dekoratora. Przyjmuje inną funkcję, nazwaliśmy ją sobie fn. Wewnątrz posiada kolejną funkcję, funkcję wrapper z argumentami a i b. Zwraca f-string odpowiednio opisany z wywołaniem tej funkcji fn z jej argumentami. Zwraca wrapper.
Znakiem @log opatrzyliśmy sum, zaznaczając, że to ją ma dekorator log złapać i odpowiednio obrobić.
Jeżeli nie rozumiemy – trudno, zaraz podam kolejny przykład, może on nieco rozjaśni:
def log(fn):
def wrapper(a, b):
return f"{a} + {b} = {fn(a,b)}"
return wrapper
@log
def add(a, b):
return a+b
@log
def sub(a, b):
return a-b
print(add(2,2))
#2 + 2 = 4
print(sub(2,2))
#2 + 2 = 0
Mamy dwie funkcje, add (dodaje dwie liczby do siebie) i sub (odejmuje 2 liczby od siebie). Obie udekorowane przez log. Obie zwracają dobry wynik, choć na razie sub zwraca znak „+” zamiast „-„.
Log przyjmuje funkcję, tworzy wrapper dla dwuargumentowej funkcji i zwraca f-string „a + b = wywołanie tej funkcji”.
Dlatego mamy 2 + 2 = 4 dla funkcji add i 2 + 2 = 0 dla funkcji sub.
Ok, zróbmy sobie inny dekorator, taki, który sprawi, że dostaniemy wynik danej funkcji dwa razy:
def twice(fn):
def wrapper(a, b):
return fn(a,b), fn(a,b)
return wrapper
@twice
def add(a, b):
return a+b
@twice
def sub(a, b):
return a-b
print(add(2,2))
#(4, 4)
print(sub(2,2))
#(0, 0)
Dekorator twice przyjmuje funkcję, wrapper dla funkcji z argumentami a i b, zwraca wywołanie tej funkcji z jej argumentami 2 razy, zwraca wrapper.
Teraz każda tak udekorowana funkcja, czy to add, czy to sub, zwraca wynik jako tuplę, w której są dwa wyniki.
To teraz takie coś – zwróćmy wynik funkcji oraz wynik funkcji, ale z argumentami podniesionymi do kwadratu.
def twice(fn):
def wrapper(a, b):
return fn(a,b), fn(a**2,b**2)
return wrapper
@twice
def add(a, b):
return a+b
@twice
def sub(a, b):
return a-b
print(add(2,2))
#(4, 8)
print(sub(2,2))
#(0, 0)
W przypadku add zwracamy tuplę, pierwszy element to add(2,2) czyli 2 + 2, drugi element to add(4,4) czyli 8. Robimy to tylko po to, aby pokazać mechanizm działania dekoratora.
Możemy sobie dodać jeszcze trzeci element, czyli, wynik funkcji + 1:
def twice(fn):
def wrapper(a, b):
return fn(a,b), fn(a**2,b**2), fn(a,b) + 1
return wrapper
@twice
def add(a, b):
return a+b
@twice
def sub(a, b):
return a-b
print(add(2,2))
#(4, 8, 5)
print(sub(2,2))
#(0, 0, 1)
Jak widać za trzecim razem dostaliśmy 2 + 2 + 1 oraz 2 – 2 + 1. Takie nasze zabawy z dekoratorami. Teraz, jak znamy ogólny mechanizm działania tych funkcji, napiszemy sobie coś dużo mniej abstrakcyjnego.
Dodawanie typów int i str
Mamy takie sobie funkcje dodające, odejmujące i mnożące:
def add(a,b):
return a + b
def sub(a,b):
return a - b
def mul(a, b):
return a * b
print(add(2,2))
print(sub(2,2))
print(mul(2,2))
# 4
# 0
# 4
Teraz chcemy, aby można było do nich podać zarówno typ int jak i str:
def add(a,b):
return a + b
def sub(a,b):
return a - b
def mul(a, b):
return a * b
print(add("2","2"))
print(mul("2",2))
print(sub("2","2"))
# 22
# 22
# TypeError: unsupported operand type(s) for -: 'str' and 'str'
Tutaj w przypadku add dostaliśmy „sklejony” napis „2” oraz „2”, w przypadku mul, gdy jeden z argumentów jest znakiem a drugi liczbą dostaliśmy znak „2” powtórzony 2 razy, w przypadku sub (odejmowanie) już wyskoczył błąd.
A chodzi nam o to, aby nasze liczby zawsze były dodane, odejmowane i mnożone, nawet jeśli podamy je jako tekst.
Piszemy dekorator enforce_int:
def enforce_int(fn):
def wrapper(a, b):
return fn(int(a), int(b))
return wrapper
@enforce_int
def add(a,b):
return a + b
@enforce_int
def sub(a,b):
return a - b
@enforce_int
def mul(a, b):
return a * b
print(add("2","2"))
print(mul("2",2))
print(sub("2","2"))
# 4
# 4
# 0
Tutaj enforce_int to funkcja dekorująca, przyjmująca funkcję jako argument. Wrapper dla funkcji z argumentami a oraz b, który wywołuje fn z tymi argumentami, ale konwertowanymi do typu int.
Dzięki temu tak udekorowane funkcje add, sub i mul mogą brać argumenty w formie tekstowej a i tak zostaną one odpowiednio zamienione na typ int.
Być może zauważyliśmy, że nasz dekorator działa tylko na funkcjach posiadających odpowiednią ilość argumentów, czyli tutaj 2, a i b. A co zrobić, gdy dodamy funkcję z inną ilością argumentów?
def enforce_int(fn):
def wrapper(a, b):
return fn(int(a), int(b))
return wrapper
@enforce_int
def add(a,b):
return a + b
@enforce_int
def sub(a,b):
return a - b
@enforce_int
def mul(a, b):
return a * b
def add_and_mul(a,b,c):
return a + b * c
print(add_and_mul(2,2,2))
#6
Tutaj mamy funkcję z trzema argumentami. Też chcielibyśmy wymusić na nich bycie typu int, ale sygnatura funkcji add_and_mul z argumentami a,b i c gryzie się z naszym wrapperem, który przyjmuje tylko a i b i nijak go nie zmusisz do pracowania z add_and_mul.
Co zrobić? Użyć znanego nam *args:
def enforce_int(fn):
def wrapper(*args):
args = [int(arg) for arg in args]
return fn(*args)
return wrapper
@enforce_int
def add(a,b):
return a + b
@enforce_int
def sub(a,b):
return a - b
@enforce_int
def mul(a, b):
return a * b
@enforce_int
def add_and_mul(a,b,c):
return a + b * c
print(add_and_mul("2",2,"2"))
#6
Tworzymy wrapper z dowolną ilością argumentów *args, które zostają spakowane do listy. Następnie do args przypisujemy listę elementów z args zamienionych na typ int. Zwracamy wywołanie funkcji razem z rozpakowaną listą args.
Czyli dzieje się to, że „2”,2,”2″ zostaje zamienione do listy [„2”, 2, „2”].
Następnie ta lista zostaje zamieniona na listę [2,2,2].
Następnie wołamy add_and_mul z wypakowanymi argumentami (2,2,2), które ta funkcja bez problemu obsłuży.
Jeżeli wydaje się to trudne to musimy ćwiczyć dalej.
Zwróć tylko unikalne wartości
Napiszmy sobie funkcję, która bierze listę imion i zwraca te imiona z pierwszą literą zapisaną wielką literą:
def first_to_upper(names):
names_upper = []
for name in names:
names_upper.append(name[0].upper()+name[1:])
return names_upper
names = ["bob", "jim", "jane"]
print(first_to_upper(names))
#['Bob', 'Jim', 'Jane']
Bierzemy listę names, tworzymy listę names_upper, przechodzimy po names w pętli i do naszej listy dodajemy imię z pierwszym znakiem zapisanym wielką literą + resztę.
Co się jednak stanie, gdy napiszemy tak?
def first_to_upper(names):
names_upper = []
for name in names:
names_upper.append(name[0].upper()+name[1:])
return names_upper
names = ["bob", "jim", "jane", "Bob"]
print(first_to_upper(names))
#['Bob', 'Jim', 'Jane', 'Bob']
Otrzymamy dwóch Bobów. Wywołanie upper na już wielkiej literze nie zmieni niczego. Zresztą, dane mogą się w naszej liście powtarzać:
def first_to_upper(names):
names_upper = []
for name in names:
names_upper.append(name[0].upper()+name[1:])
return names_upper
names = ["bob", "bob", "jim", "jane", "Bob"]
print(first_to_upper(names))
#['Bob', 'Bob', 'Jim', 'Jane', 'Bob']
Napiszmy zatem dekorator, który sprawi, wymusi, że funkcja zwróci tylko unikalne wartości. Świetnie służy do tego typ danych set (zbiór) na który można listę zamienić i na powrót do listy przekonwertować, już bez duplikatów:
def enforce_unique(fn):
def wrapper(lst):
return list(set(fn(lst)))
return wrapper
@enforce_unique
def first_to_upper(names):
names_upper = []
for name in names:
names_upper.append(name[0].upper()+name[1:])
return names_upper
names = ["bob", "bob", "jim", "jane", "Bob"]
print(first_to_upper(names))
#['Jim', 'Jane', 'Bob']
Dekorator enforce_unique bierze funkcję fn jako argument. Tworzy wrapper dla funkcji przyjmującej 1 argument, tu nazwany lst. Zwraca wywołanie tej funkcji z jej argumentem zamienione na zbiór a następnie na listę, zatem dostajemy listę bez duplikatów.
Lstrip dla każdego argumentu
Co się stanie, gdy zrobimy coś takiego?
def enforce_unique(fn):
def wrapper(lst):
return list(set(fn(lst)))
return wrapper
@enforce_unique
def first_to_upper(names):
names_upper = []
for name in names:
names_upper.append(name[0].upper()+name[1:])
return names_upper
names = [" bob", "bob", "jim", "jane", "Bob"]
print(first_to_upper(names))
#[' bob', 'Bob', 'Jim', 'Jane']
Jak się okazuje, ” bob” i „bob” to różne napisy. I w tym pierwszym „podnieśliśmy” do wielkiej litery… spację.
Spacje z lewej strony usuwa funkcja lstrip:
def enforce_unique(fn):
def wrapper(lst):
return list(set(fn(lst)))
return wrapper
@enforce_unique
def first_to_upper(names):
names_upper = []
for name in names:
names_upper.append(name[0].upper()+name[1:])
return names_upper
names = [" bob".lstrip(), "bob", "jim", "jane", "Bob"]
print(first_to_upper(names))
#['Bob', 'Jim', 'Jane']
A zatem takie wywołanie lstrip na każdym argumencie (czyli usunięcie niepotrzebnych spacji z lewej strony) ułatwiłoby nam mocno życie. Spróbujmy to zrobić przy pomocy dekoratora:
def lstrip_args(fn):
def wrapper(lst):
lst = [name.lstrip() for name in lst]
return fn(lst)
return wrapper
def enforce_unique(fn):
def wrapper(lst):
return list(set(fn(lst)))
return wrapper
@lstrip_args
@enforce_unique
def first_to_upper(names):
names_upper = []
for name in names:
names_upper.append(name[0].upper()+name[1:])
return names_upper
names = [" bob", "bob", "jim", "jane", "Bob"]
print(first_to_upper(names))
#['Bob', 'Jim', 'Jane']
Funkcja dekorująca lstrip_args bierze funkcję fn jako parametr. Mamy wrapper dla funkcji z jednym argumentem lst. Każdy element lst zostaje poddany funkcji lstrip(), która usuwa niepotrzebne spacje z lewej strony. Następnie zwracamy wywołanie funkcji z lst, której każdy element już jest pozbawiony niepotrzebnych spacji.
Możemy to skrócić:
def lstrip_args(fn):
def wrapper(lst):
return fn([name.lstrip() for name in lst])
return wrapper
def enforce_unique(fn):
def wrapper(lst):
return list(set(fn(lst)))
return wrapper
@lstrip_args
@enforce_unique
def first_to_upper(names):
names_upper = []
for name in names:
names_upper.append(name[0].upper()+name[1:])
return names_upper
names = [" bob", "bob", "jim", "jane", "Bob"]
print(first_to_upper(names))
#['Jim', 'Jane', 'Bob']
Tak czy inaczej stworzyliśmy kolejny dekorator. Powinniśmy zauważyć, jak działa mechanizm tworzenia dekoratorów. Funkcja dekorująca, przyjmująca funkcję, funkcja wrapper z argumentami takimi jak nasza funkcja dekorowana. Wewnątrz wrappera zwracamy w jakiś sposób zmodyfikowane wywołanie funkcji dekorowanej z jej argumentami. Zwracamy też wrapper.
Tutaj mamy do czynienia z dwoma dekoratorami dla jednej funkcji. Jeden bawi się argumentami, sprawa, że każdy z nich zostanie poddany funkcji lstrip, zanim funkcja zostanie wywołana.
Drugi dekorator argumentów w żaden sposób nie zmienia, ale po wywołaniu funkcji przepuszcza jej wynik przez konwersję do zbioru a następnie z powrotem do listy, odsiewając duplikaty.
Kolejność dekoratorów ma znaczenie, aczkolwiek to, w jaki sposób działają jest jakoś „pod spodem” batchowane.
Dobrnęliśmy do naprawdę trudnych zagadnień, myślę, że warto zakończyć ten wpis. Nie jestem przesądny, ale wyjątkowo pechowy to wpis. Ciężko mi było przyjść z jakimiś ciekawymi przykładami dekoratorów jak również wytłumaczyć jak one działają w prosty sposób. Nie jest do łatwe zadanie.
Jeżeli jednak zrozumiemy sposób działania dekoratorów, mechanizm, w jaki je piszemy, w jaki mogą one manipulować argumentami przekazywanymi do funkcji jak i wartością zwracaną przez funkcję, to „jesteśmy w domu”. Będziemy wiedzieli w jaki sposób możemy z nich korzystać i gdy przyjdzie czas, że trzeba będzie zrobić z tej wiedzy użytek, a takie sytuacje się zdarzają, poradzimy sobie.
W nauce i tłumaczeniu dekoratory to zagadnienie wyjątkowo niewdzięczne. Dopiero w momencie, w którym przychodzi nam ich używać czerpiemy z tej wiedzy jakiś zysk.