## Dekoratorji

Tale tema bo za mnoge naporna miselna vaja. Vendar koristna.

Najprej ponovimo nekaj, kar smo se naučili prejšnji teden.

### Funkcija, ki vrača funkcijo

Tole je funkcija, ki predstavlja polinom 2x2+ 4x+ 3.

    def poly243(x):
        return 2 * x ** 2 + 4 * x + 3

Trivialno. Pokličimo jo, samo za poskus.

    poly243(4)

    51

Zdaj pa bi rad naredil funkcijo, ki sestavlja takšne polinome. Torej: funkcija `create_poly(a, b, c)` bo kot argumente dobila koeficiente polinoma ter sestavila (in vrnila) polinom.

    def create_poly(a, b, c):
        def poly(x):
            return a * x ** 2 + b * x + c

        return poly

S to funkcijo lahko sestavimo gornjo funkcijo.

    poly243 = create_poly(2, 4, 3)

Če razmislimo, kaj se zgodi - očitno se zgodi praktično isto kot prej: ko pokličemo `create_poly(2, 4, 3)` se zgodi definicija funkcije `poly` (s koeficienti 2, 4, 3), ki prejme le en argument `x`. Funkcija `create_poly` vrne to funkcijo in to funkcijo priredimo imenu `poly243`.

    poly243(4)

    51

## Pakiranje in razpakiranje argumentov

Ali: funkcije s poljubnim številom argumentov. In klicanje z neznanim številom argumentov.

Nadaljujmo kar s polinomom. Obstajajo tudi polinomi, ki niso druge stopnje. Obstajajo tudi takšni tretje, ali celo četrte. Naša funkcija `create_poly` pa dela le polinome druge stopnje. Posplošimo jo.

Najprej razmislimo, kako izračunati polinom poljubne stopnje, če imamo seznam (ali terko) koeficientov, recimo `coefs = [c3, c2, c1, c0]` in vrednost `x`. Napisali bomo funkcijo, ki kot argument dobi koeficiente in vrednost x - kar očitno ni isto kot funkcija, ki smo jo pisali zgoraj; v zgornjo so koeficienti že vdelani.

En način je:

    def poly_x(x, coefs):
        y = 0
        for i in range(len(coefs)):
            y += coefs[i] * x ** (len(coefs) - i - 1)
        return y

Upam, da sem vam tega že izbil iz glave, saj lahko uporabimo `enumerate`.

    def poly_x(x, coefs):
        y = 0
        for i, coef in enumerate(coefs):
            y += coef * x ** (len(coefs) - i - 1)
        return y

Tudi to je sicer dokaj neprivlačno; osnovni problem je, da so koeficienti našteti od višjih potenc proti nižjim, vendar je tako prav: tudi polinome vedno pišemo v takem vrstnem redu.

Uporabimo malo osnovnošolske matematike: c3x3+c2x2+c1x1+c0x0 je isto kot (((c3x+c2)x+c1)x+c0. (Ta reč ima celo ime: Hornerjevo pravilo. Če boste imeli kdaj kakšen predmet v slogu numeričnih metod, vas bodo učili celo, da je ta oblika boljša, ker je numerično stabilnejša, to je, vodi v manjše zaokrožitvene napake.)

    def poly_x(x, coefs):
        y = 0
        for coef in coefs:
            y = x * y + coef
        return y

    x = 4
    poly_x(x, [2, 4, 3])

    51

Luštno bi bilo, če bi lahko podali številke kar kot argumente, na primer `poly_x(x, 2, 4, 3)`. Funkcija, ki prejme poljubno število argumentov, kot vemo od prejšnjega tedna, deklarira argument, katerega ime se začne z `*` in ta argument bo terka z vsemi dodatnimi argumenti - torej našimi koeficienti.

    def poly_x(x, *coefs):
        y = 0
        for coef in coefs:
            y = x * y + coef
        return y

    x = 4
    poly_x(x, 2, 4, 3)

    51

Spremenilo se je samo to, da smo dodali zvezdico.

Ta funkcija prejme `x` in koeficiente. Radi bi takšno, ki prejme le `x`, koeficiente pa že ima. Torej, želeli bi `create_poly`, ki bo ustvarila `poly`.

    def create_poly(*coefs):
        def poly(x):
            y = 0
            for coef in coefs:
                y = x * y + coef
            return y

        return poly

    poly324 = create_poly(2, 4, 3)
    poly324(4)

    51

## Funkcija, ki ovije funkcijo

Recimo, da bi hoteli iz nekega neumnega razloga želeli, da `sqrt`, `sin` in `cos` vračajo zaokrožajo rezultate na tri decimalke. Lahko napišemo nove funkcije, ki povozijo stare.

    import math

    def rounded_sqrt(x):
        y = math.sqrt(x)
        return round(y, 3)

    def rounded_sin(x):
        y = math.sin(x)
        return round(y, 3)

    def rounded_cos(x):
        y = math.cos(x)
        return round(y, 3)

    rounded_sqrt(5)

    2.236

Lahko pa napišemo funkcijo, ki sestavlja takšne funkcije. Prej smo napisali funkcijo, ki definira in vrne funkcijo, ki računa polinome. Zdaj pa bomo napisali funkcijo, ki definira funkcijo, ki pokliče neko funkcijo (`math.sqrt`, `math.sin`, `math.cos`...) in vrne njen rezultat zaokrožen na tri decimalke. Tako kot je bila `poly` prej funkcija znotraj `create_poly`, bo zdaj funkcija, kot so zgornje, znotraj funkcije ... no, imenujmo jo `rounder`.

    def rounder(f):
        def rounded_f(x):  # funkcija, ekvivalentna gornjim rounded_sqrt, rounded_sin...
            y = f(x)
            return round(y, 3)

        return rounded_f

    rounded_sqrt = rounder(math.sqrt)
    rounded_sin = rounder(math.sin)
    rounded_cos = rounder(math.cos)

Seveda lahko na podoben način ovijamo tudi svoje funkcije.

    from math import pi

    def circumf(r):
        return 2 * pi * r

    rounded_circumf = rounder(circumf)

`rounder` je imenitna in gotovo izjemno uporabna funkcija (no, to pač ne), še imenitnejša pa bo, če ne bo pričakovala, da ovita funkcija (`rounded_f`) vedno prejme samo en argument. Takole jo popravimo.

    def rounder(f):
        def rounded_f(*args):  # funkcija, ekvivalentna gornjim rounded_sqrt, rounded_sin...
            y = f(*args)
            return round(y, 3)

        return rounded_f

Podobno kot smo se učili zgoraj, tudi tu pridemo do poljubnega števila argumentov tako, da uporabimo zvezdico; za takšne argumente navadno uporabimo ime `args`. Zgoraj pa nismo ponovili (pač pa smo prejšnji teden vseeno videli), kako pokličemo funkcijo, če imamo argumente v terki: spet tako, da damo pred terko zvezdico, torej `y = f(*args)`.

## Vohun

Zdaj pa naredimo nekaj, kar bi bilo morda lahko celo uporabno: funkcijo, ki, podobno kot `rounder`, ovije funkcijo, vendar pusti rezultat pri miru. Pač pa ob vsakem klicu funkcije izpiše argumente in rezultat.

    def spied(f):
        def spied_f(*args):
            y = f(*args)
            print("Function", f.__name__, "called with", args, ", returned", y)
            return y

        return spied_f

    def add(a, b):
        return a + b

    add = spied(add)

    v = []
    for x in range(5):
        v.append(add(42, x))

    Function add called with (42, 0) , returned 42
    Function add called with (42, 1) , returned 43
    Function add called with (42, 2) , returned 44
    Function add called with (42, 3) , returned 45
    Function add called with (42, 4) , returned 46

Spet, na podoben način bi lahko ovili tudi Pythonove funkcije.

    sqrt = spied(math.sqrt)

    v = []
    for x in range(5):
        v.append(sqrt(x))

    Function sqrt called with (0,) , returned 0.0
    Function sqrt called with (1,) , returned 1.0
    Function sqrt called with (2,) , returned 1.4142135623730951
    Function sqrt called with (3,) , returned 1.7320508075688772
    Function sqrt called with (4,) , returned 2.0

## Dekoratorji

Takšnih funkcij bomo, za vajo, napisali še nekaj. Zdaj pa se naučimo drugačnega načina za njihovo rabo. Omogočil nam bo, da se izognemo temu.

    def add(a, b):
        return a + b

    add = spied(add)

    def sub(a, b):
        return a - b

    sub = spied(sub)

    def circumf(r):
        return 2 * pi * r

    circumf = rounder(circumf)

Vzorec je povsod isti: imamo funkcijo, ki jo hočemo oviti v neko drugo funkcijo, tako da uporabimo funkcijo za ovijanje. `rounder` je funkcije, ki ovije `circumf` v neko lokalno funkcijo (ki kliče originalno). Ker je to še kar pogosta zadeva, obstaja krajši, preglednejši način: dekoratorji. Namesto zgornjega lahko pišemo:

    @spied
    def add(a, b):
        return a + b

    @spied
    def sub(a, b):
        return a - b

    @rounder
    def circumf(r):
        return 2 * pi * r

Z

    @decorator
    def f(...):
        ...

definiramo neko funkcijo `f`, jo podamo funkciji `decorator` in "pravi" `f` bo tisto, kar vrne `decorator`. Kot v gornjih primerih.

## Dekorator, ki šteje klice funkcije

Napišimo dekorator, ki bo štel, kolikokrat je funkcija poklicana.

Najprej moramo izvedeti tole: Pythonove funkcije so objekti in imajo lahko tudi atribute.

    def f(x):
        return 2 * x

    f.foo = 42

    print(f.foo)

    42

Funkcija bi lahko štela, kolikokrat smo jo poklicali.

    def f(x):
        f.called += 1
        return 2 * x

    f.called = 0

    for x in range(5):
        f(x)

    print(f.called)

    5

Vendar zdaj poznamo zabavnejši način: znamo napisati dekorator. Ta bo ovil funkcijo v funkcijo, ki šteje, kolikokrat je poklicana. Sicer pa bo le poklical in vrnil ovito funkcijo, ne da bi se vtikal v argumente ali rezultat.

    def count_calls(f):
        def wrapper(*args):
            y = f(*args)
            wrapper.called += 1
            return y

        wrapper.called = 0
        return wrapper

    @count_calls
    def add(x, y):
        return x + y

    for x in range(5):
        add(x, x ** 2)

    print(add.called)

    5

Še bolj imenitne stvari lahko počnemo: lahko naredimo cel log klicev! Seznam parov (argumenti, rezultat) za vse klice funkcije!

    def logged(f):
        def wrapper(*args):
            y = f(*args)
            wrapper.log.append((args, y))
            return f(*args)

        wrapper.log = []
        return wrapper

    @logged
    def add(x, y):
        return x + y

    for x in range(5):
        add(x, x ** 2)

    print(add.log)

    [((0, 0), 0), ((1, 1), 2), ((2, 4), 6), ((3, 9), 12), ((4, 16), 20)]

## Funkcije s spominom

Čas je, da naredimo kaj uporabnega.

Tule imamo funkcijo, ki računa Fibonaccijeva števila. Napisana je rekurzivno; z njo lahko izračunamo prvih nekaj Fibonaccijevih števil, potem pa postane prepočasna. Ker znamo logirati klice, lahko ugotovimo tudi, zakaj.

    @logged
    def fibo(n):
        if n < 2:
            return 1
        return fibo(n - 2) + fibo(n - 1)

    fibo(5)

    print(fibo.log)

    [((1,), 1), ((0,), 1), ((1,), 1), ((2,), 2), ((0,), 1), ((1,), 1), ((3,), 3), ((1,), 1), ((0,), 1), ((1,), 1), ((2,), 2), ((0,), 1), ((1,), 1), ((0,), 1), ((1,), 1), ((2,), 2), ((0,), 1), ((1,), 1), ((1,), 1), ((0,), 1), ((1,), 1), ((2,), 2), ((0,), 1), ((1,), 1), ((3,), 3), ((1,), 1), ((0,), 1), ((1,), 1), ((2,), 2), ((0,), 1), ((1,), 1), ((4,), 5), ((0,), 1), ((1,), 1), ((2,), 2), ((0,), 1), ((1,), 1), ((1,), 1), ((0,), 1), ((1,), 1), ((2,), 2), ((0,), 1), ((1,), 1), ((3,), 3), ((1,), 1), ((0,), 1), ((1,), 1), ((2,), 2), ((0,), 1), ((1,), 1), ((5,), 8), ((1,), 1), ((0,), 1), ((1,), 1), ((2,), 2), ((0,), 1), ((1,), 1), ((3,), 3), ((1,), 1), ((0,), 1), ((1,), 1), ((2,), 2), ((0,), 1), ((1,), 1), ((0,), 1), ((1,), 1), ((2,), 2), ((0,), 1), ((1,), 1), ((1,), 1), ((0,), 1), ((1,), 1), ((2,), 2), ((0,), 1), ((1,), 1), ((3,), 3), ((1,), 1), ((0,), 1), ((1,), 1), ((2,), 2), ((0,), 1), ((1,), 1), ((4,), 5), ((0,), 1), ((1,), 1), ((2,), 2), ((0,), 1), ((1,), 1), ((1,), 1), ((0,), 1), ((1,), 1), ((2,), 2), ((0,), 1), ((1,), 1), ((3,), 3), ((1,), 1), ((0,), 1), ((1,), 1), ((2,), 2), ((0,), 1), ((1,), 1)]

    len(fibo.log)

    101

Da izračuna peto Fibonaccijevo število, funkcija 101-krat pokliče samo sebe. Za šesto se pokliče že 277-krat.

    fibo.log = []
    fibo(6)
    len(fibo.log)

    277

Problem je v tem, da za izračun petega potrebuje tretje in četrto. Za izračuna četrtega potrebuje drugo in tretje -- torej bo dvakrat računalo tretje Fibonaccijevo število. Za izračun tretjega potrebuje drugo in prvo (vsakega po dvakrat - poleg tega pa bo računala še enkrat, namreč takrat, ko bo računala tretjega) ... Skratka, teh klicev je več in več.

Fibonaccijeva števila je seveda možno računati učinkoviteje, namreč naprej in ne nazaj. A to ni bistvo. Bistvo je, da imamo počasno funkcijo, ki bi se jo dalo pospešiti tako, da stvari, ki jih je že enkrat izračunala, ne bi računala ponovno. Recimo tako, da bi imela slovar, katerega ključi bi bili pretekli argumenti, vrednosti pa rezultat pri teh argumentih. Ob vsakem klicu bi preverila, če je bila s temi argumenti že klicana. Če, potem le vrne že izračunani rezultat. Če ne, računa in shrani.

    cache = {}

    @logged
    def fibo(n):
        # Če rezultat za te argumente še ni v slovarju, ga izračuamo in dodamo v slovar
        if n not in cache:
            if n < 2:
                cache[n] = 1
            else:
                cache[n] = fibo(n - 2) + fibo(n - 1)

        return cache[n]

    fibo(5)

    print(fibo.log)

    [((1,), 1), ((0,), 1), ((1,), 1), ((2,), 2), ((3,), 3), ((2,), 2), ((3,), 3), ((4,), 5), ((5,), 8)]

Tole je seveda samo za demo. Uporablja globalne spremenljivke in to se ne dela.

Vendar ni problema. To bomo itak posplošili: naredili bomo dekorator, ki ovije funkcijo v funkcijo, ki shranjuje rezultate ovite funkcije. In ovito funkcijo kliče le, če in kadar je to potrebno.

    def cached(f):
        cache = {}
        def wrapped(x):
            if not x in cache:
                cache[x] = f(x)
            return cache[x]

        return wrapped

    @logged
    @cached
    def fibo(n):
        if n < 2:
            return 1
        return fibo(n - 2) + fibo(n - 1)

    fibo(5)
    print(fibo.log)

    [((1,), 1), ((0,), 1), ((1,), 1), ((2,), 2), ((3,), 3), ((2,), 2), ((3,), 3), ((4,), 5), ((5,), 8)]

To je potrebno le še posplošiti tako, da deluje s funkcijami s poljubnim številom argumentov. A ne bomo. Vemo, samo `wrapped(x)` zamenjamo z `wrapped(*x)` in `f(x)` z `f(*x)`. A vseeno ne bomo. Zato ker so takšen dekorator že naredili namesto nas. Imenuje se `lru_cache` in je v modulu `functools`. Dekorator sprejme tudi argument: povedati mu je potrebno, koliko zadnjih klicev naj si zapomni.

    from functools import lru_cache

    @logged
    @lru_cache(10)
    def fibo(n):
        if n < 2:
            return 1
        return fibo(n - 2) + fibo(n - 1)

    fibo(5)
    print(fibo.log)

    [((1,), 1), ((0,), 1), ((1,), 1), ((2,), 2), ((3,), 3), ((2,), 2), ((3,), 3), ((4,), 5), ((5,), 8)]

Kako napisati dekorator, ki sprejema tudi argumente? Poleg funkcije?

No, `lru_cache` v resnici ni dekorator, temveč funkcija, ki vrne dekorator. Če je dekorator funkcija, ki vrača funkcijo, je `lru_cache` parametriziran dekorator, torej funkcija, ki vrača funkcijo, ki vrača funkcijo. Ni tako komplicirano, vendar smo danes že dovolj zvijali možgane.

## Dekoratorji, ki prčkajo po argumentih

Napisali smo par dekoratorjev, ki se ukvarjajo z rezultati funkcije, še nobenega pa v zvezi z argumenti. Storimo še to.

Recimo, da bi nek modrijan prišel na modro idejo, da bi bilo fino, če bi funkcije, ki sicer sprejemajo samo `float`-e, sprejele tudi nize - seveda takšne nize, ki se lahko pretvorijo v števila.

    from math import sqrt

    def to_float(f):
        def wrapped_f(*args):
            new_args = []
            for arg in args:
                new_args.append(float(arg))
            return f(*new_args)
        return wrapped_f

    sqrt = to_float(sqrt)

    sqrt("25")

    5.0

Tule smo "popravili" že vdelano funkcijo `sqrt`. Vsak dekorator je možno uporabiti tudi na ta način. Običajno pa jih uporabljamo za dekoriranje lastnih funkcij, torej tudi tu poskusimo še to.

    @to_float
    def circumf(r):
        return 2 * pi * r

    circumf("1")

    6.283185307179586

## "Overloadanje" funkcij

V nekaterih jezikih je mogoče napisati več različic funkcije z istim imenom, vendar različnimi tipi (ali številom) argumentov. Ob klicu funkcije se prevajalnik na osnovi argumentov odloči, katero funkcijo bo poklical.

V Pythonu to ne gre, saj v definiciji funkcije ne deklariramo tipov argumentov. No, lahko, vendar jih Python ignorira in jih, kot prisegajo avtorji, tudi vedno bo.

Vseeno pa obstaja dekorator, s katerim lahko dosežemo nekaj takšnega: `singledispatch`.

    from functools import singledispatch

    help(singledispatch)

    Help on function singledispatch in module functools:

    singledispatch(func)
        Single-dispatch generic function decorator.

        Transforms a function into a generic function, which can have different
        behaviours depending upon the type of its first argument. The decorated
        function acts as the default implementation, and additional
        implementations can be registered using the register() attribute of the
        generic function.

Uporabljamo jo tako.

    from functools import singledispatch

    @singledispatch
    def add(x, y):
        return x, y

    @add.register(str)
    def _(x, y):
        return x + " " + y

    @add.register(list)
    def _(x, y):
        r = []
        for e, f in zip(x, y):
            r.append(e + f)
        return r

Osnovna različica preprosto sešteva.

    add(5, 6)

    (5, 6)

Če kot argument podamo niz, bo mednju vtaknila presledek.

    add("Ana", "Berta")

    'Ana Berta'

Če podamo seznama, pa ju bo seštela po elementih.

    add([1, 5, 4], [2, -3, 8])

    [3, 2, 12]

Kako uporabimo `singledispatch`, vidimo v gornjem primeru. Osnovno različico dekoriramo s `singledispatch`, nadaljnje pa z `<ime-osnovne-funkcije>.register`. Ime nadaljnjih ne sme biti enako osnovni, temveč mora biti kaj neumnega, po možnosti `_`.

Osebno mislim, da je to, da nadaljnje funkcije ne morejo imeti normalnih imen, grdo. In mi smo tu zato, da naredimo boljše.

## Domač dekorator za overload

Tole, kar sledi, je way beyond, ne, to je way way way beyond prvi letnik. Take stvari delajo na enem težjih predmetov magistrskega študija. Kdor prebere in razume, naj bo kar ponosen nase.

    def overloadable(base_func):
        overloads = {}

        def func(*args):
            tpe = type(args[0])
            return overloads.get(tpe, base_func)(*args)

        def overload(tpe):
            def overloaded(over_f):
                overloads[tpe] = over_f
                return func
            return overloaded

        func.overload = overload
        return func

Najprej se prepričajmo, da deluje.

    @overloadable
    def add(x, y):
        return x + y

    @add.overload(str)
    def add(x, y):
        return x + " " + y

    @add.overload(list)
    def add(x, y):
        r = []
        for e, f in zip(x, y):
            r.append(e + f)
        return r

    add(5, 6)

    11

    add("Ana", "Berta")

    'Ana Berta'

    add([5, 4, 2], [1, -2, 3])

    [6, 2, 5]

Zdaj pa še, zakaj deluje.

    def overloadable(base_func):
        overloads = {}

        def func(*args):
            tpe = type(args[0])
            return overloads.get(tpe, base_func)(*args)

        def overload(tpe):
            def overloaded(over_f):
                overloads[tpe] = over_f
                return func
            return overloaded

        func.overload = overload
        return func

Slovar `overloads` bo vseboval vse različice funkcije (razen osnovne). Ključi slovarja bodo tipi, pripadajoče vrednosti pa funkcije, ki jih je potrebno poklicati za posamezen tip.

Naš dekorator `overloadable` bo zamenjal podano funkcijo (osnovno, tisto, ki jo bomo kasneje "overloadali") s funkcijo `func`. Funkcija `func` pogleda tip prvega argumenta (da, gledamo le prvi argument, a tudi `singledispatch` počne isto!). V slovarju poišče pripadajočo funkcijo, vendar ne uporablja običajnega indeksiranja (`overloads[tpe]`) temveč `get`, ki mu poda privzeto vrednost. Privzeta vrednost pa je kar `base_func`. To funkcijo potem pokliče s podanimi argumenti.

Poleg tega pa v dekoratorju definiramo funkcijo `overload`, ki jo pripnemo funkciji, ki jo bomo vrnili. Ta skrbi za to, da bomo lahko kasneje izvedli

    @add.overload(str)
    def add(x, y):
        return x + " " + y

Funkcijo `overload` bomo pripeli kot atribut k funkciji `func`, ki jo vračamo. Ta, ki uporablja naš dekorator, bo funkcijo `overload` torej videl pod imenom `add.overload`. Ta funkcija, `add.overload` kot argument prejme tip, v gornjem primeru `str`. Kaj pa vrne? Dekorator! Imamo namreč `@add.overload(str)`, torej pričakujemo, da bo rezultat klica `add.overload(str)` dekorator. Funkcija `overloaded` (v gornji kodi) je torej dekorator, ki prejme novo različico funkcije (argument `over_f`). V slovar `overloads` pod ključ `tpe` zabeleži tole funkcijo. Vrne pa dekorirano osnovno funkcijo, `func`!

To, slednje, je tisto, po čemer se Pythonov `singledispatch` razlikuje od našega (poleg tega, da Pythonov omogoča tudi uporabo za registriranje že napisanih funkcij, ne le dekoriranja, in da je nekoliko hitrejši). Pythonov register namreč vrne `func`, ki pa se nanaša na novo funkcijo, ne staro.