Kapittel 13 Funksjoner av høyere orden


Jeg håper du lærte masse om å håndtere feil i forrige kapittel. I dette kapittelet skal vi se nærmere på funksjoner. Det er nemlig slik at funksjoner kan ta en enda større rolle i programmering enn det vi har presentert så langt. Ved å la funksjoner spille hovedrollen kan vi skrive funksjoner av høyere orden129. Dette er funksjoner som enten tar andre funksjoner som argumenter eller som returnerer andre funksjoner. Selv om dette kan høres litt sprøtt ut i begynnelsen er det utrolig nyttig! Senere i kapittelet skal vi gå gjennom mer avanserte konsepter som funksjonell programmering130 og konstruering av egne dekoratører. Når du er ferdig med dette kapittelet vil du ha en dyp forståelse av funksjoner i Python som vil komme deg godt til nytte.

13.1 Introduksjon - Funksjoner kan gjøre mye rart

Så langt har vi skrevet mange funksjoner som tar inn argumenter og returnerer verdier. Her er et eksempel:

def legg_til_to(tall):
    return tall + 2

I Python kan funksjoner bli brukt som argumenter til andre funksjoner. Her er et eksempel:

def legg_til_to(tall):
    return tall + 2


def multiplikator(funksjon, tall, mult):
    return mult * funksjon(tall)


print(multiplikator(legg_til_to, 5, 2))

La oss gå sakte gjennom hva som skjer når vi skriver koden multiplikator(legg_til_to, 5, 2). Vi sender inn funksjonen legg_til_to() som et argument til funksjonen multiplikator(). Innad i funksjonen multiplikator() anvender vi argumentet legg_til_to() på verdien 5. Dette vil returnere tallet 7. Denne returverdien fra funksjonen legg_til_to() vil deretter bli multiplisert med tallet 2 slik at vi får tallet 14. Det er dette som blir returnert av funksjonen koden multiplikator(legg_til_to, 5, 2).

En annen ting vi kan gjøre i Python er å returnere funksjoner fra andre funksjoner:

def hyggelig_hilsen(beskjed):
    return f'Heisan! {beskjed} På gjensyn!'


def streng_hilsen(beskjed):
    return f'Hei du! {beskjed}. Pass på å huske dette!'


def velg_beskjed(hyggelig):
    if hyggelig:
        return hyggelig_hilsen
    else:
        return streng_hilsen


print(velg_beskjed(True)('Husk å ta på en lue!'))

I koden over returnerer velg_beskjed() en annen funksjon. I måten vi bruker velg_beskjed() nederst så vil velg_beskjed(True) returnere funksjonen hyggelig_hilsen(). Derfor kan vi sende 'Husk å ta på en lue!' inn som et argument til dette.

Oppsummert så kan funksjoner både bli brukt som argumenter til andre funksjoner, samt at funksjoner kan returnere andre funksjoner. Eksemplene over er derimot ikke så utrolig nyttige i seg selv. Vi skal i seksjon 13.2 og seksjon 13.3 gi mange nyttige eksempler på funksjoner som tar andre funksjoner som argumenter. I seksjon 13.6 skal vi se at dekoratører, som vi har støtt på tidligere, er egentlig funksjoner som returnerer andre funksjoner!

13.2 Avansert sortering og lambda-funksjoner

Det første eksempelet vi skal se på for funksjoner som tar andre funksjoner som argumenter handler om den innebygde funksjonen sorted(). Husk fra seksjon 4.4 at du kan bruke funksjonen sorted() slik:

kinovisninger = [200_000, 45_000, 265_000, 100_000, 175_000]

sortert_liste = sorted(kinovisninger)
print(f'Sortert liste: {sortert_liste}')
print(f'Original liste: {kinovisninger}')

Når det kommer til en liste med tall er det én åpenbar måte å sortere dem på, nemlig fra minst til størst. La oss se på et mer komplisert eksempel:

from datetime import date


pokemon = [
    {
        'navn': 'Pikachu',
        'styrke': 55,
        'dato_fanget': date(2025, 7, 12)
    },
    {
        'navn': 'Bulbasaur',
        'styrke': 49,
        'dato_fanget': date(2024, 11, 3)
    },
    {
        'navn': 'Charmander',
        'styrke': 52,
        'dato_fanget': date(2025, 1, 18)
    },
    {
        'navn': 'Squirtle',
        'styrke': 48,
        'dato_fanget': date(2024, 5, 27)
    }
]

Som du ser så inneholder listen pokemon forskjellige ordbøker, der hver ordbok representerer en spesifikk Pokémon. Hvordan kan vi sortere listen pokemon? Å prøve å bruke funksjonen sorted() direkte på listen pokemon vil gi en TypeError siden sammenligning ikke er støttet mellom ordbøker. Hvordan kan vi sortere elementene i listen pokemon etter styrke?

Funksjonen sorted() kan ta inn en funksjon som argument i parameteren key. Denne funksjonen må beskrive hvordan sorteringen skal foregå mellom to elementer i listen. Her kan du se hvordan du kan sortere listen pokemon fra lavest til høyest styrkenivå:

def hent_styrke(element):
    return element['styrke']


sortert_styrke_pokemon = sorted(pokemon, key=hent_styrke)
print(f'Sortert basert på styrke: {sortert_styrke_pokemon}')

Som du kan se henter funksjonen hent_styrke() ut styrkenivået til en enkel Pokémon. Vi passerer funksjonen hent_styrke() som argument i funksjonen sorted() til parameteren key. Når du gjør dette bruker Python funksjonen hent_styrke() på hvert element i listen, og deretter sorterer listen basert på dette.

Når du forstår hvordan dette fungerer er det like lett å sortere listen pokemon etter navnet på hver Pokémon eller datoen den ble fanget:

def hent_navn(element):
    return element['navn']


def hent_dato_fanget(element):
    return element['dato_fanget']


sortert_navn_pokemon = sorted(pokemon, key=hent_navn)
sortert_dato_fanget_pokemon = sorted(pokemon, key=hent_dato_fanget)

print(f'Sortert basert på navn: {sortert_navn_pokemon}')
print(f'Sortert basert på dato fanget: {sortert_dato_fanget_pokemon}')

Som du ser, kan det å bruke funksjoner som argumenter gjøre sortering langt mer fleksibel. Dette er et lurt tips som sparer deg fra å skrive din egen sorteringsalgoritme for å sortere mer kompliserte datatyper.

Før vi går videre vil jeg introdusere et nyttig konsept i Python som heter lambda-funksjoner131. Noen kaller dette også for anonyme funksjoner132. Dette blir nyttig for å forenkle kode i mange situasjoner, spesielt i neste seksjon.

Hvis du ser på eksempelet hadde vi tre forskjellige sorteringsfunksjoner, nemlig hent_styrke(), hent_navn(), og hent_dato_fanget(). Det føles nesten litt bortkastet å gi funksjonene navn siden vi skal bare bruke dem en enkelt gang. I Python kan vi heller bruke nøkkelordet lambda til å skrive lambda-funksjoner slik:

pokemon_styrke = sorted(pokemon, key=lambda element: element['styrke'])
pokemon_navn = sorted(pokemon, key=lambda element: element['navn'])
pokemon_dato = sorted(pokemon, key=lambda element: element['dato_fanget'])

print(f'Sortert basert på styrke: {pokemon_styrke}')
print(f'Sortert basert på navn: {pokemon_navn}')
print(f'Sortert basert på dato fanget: {pokemon_dato}')

Vi bruker lambda for å lage en funksjon på samme linje der den brukes. Den første lambda-funksjonen vi lager sender element til element['styrke']. Dermed er den en fullstendig erstatning for funksjonen hent_styrke() som vi skrev tidligere.

Grunnen til at lambda-funksjoner også blir kalt anonyme funksjoner er nettopp det at funksjonene som blir opprettet ved å bruke nøkkelordet lambda ikke blir navngitt. Dette er nyttig dersom funksjonen bare skal brukes en gang. For lengre funksjoner eller funksjoner som skal bli brukt mange ganger, anbefaler jeg at du heller skriver en tradisjonell funksjon. Når vi jobber med funksjoner av høyere orden er det relativt ofte at funksjoner bare skal bli brukt et sted. I slike situasjoner er lambda-funksjoner svært nyttige.

13.3 Map og filter

Du skal nå lære om de to ikoniske funksjonene map() og filter(). Disse funksjonene blir brukt til å behandle data i lister, ordbøker, eller andre sammensatte datastrukturer. Vi skal holde oss til å bruke map() og filter() på lister for å gjøre det enkelt. I tillegg til å være nyttige er funksjonene map() og filter() god trening på å skrive funksjoner som tar andre funksjoner som argumenter.

13.3.1 Transformasjoner med funksjonen map()

La oss først se på hvordan vi kan bruke funksjonen map() med lister. Du kan bruke map() til å transformere hvert element i en liste på en spesifikk måte. Selve transformasjonen beskrives med en funksjon som map() tar som et argument. Her ser du et enkelt eksempel på hvordan du kan bruke map():

kjente_ordtak = [
    'Jeg er en potet',
    'Dørstokkmila er alltid lengst',
    'Det er dyrt å være fattig',
    'Hverdagsgjest får hverdagshilsen'
]

ordtak_høyt_volum = map(lambda ordtak: ordtak + '!!!', kjente_ordtak)

for ordtak in ordtak_høyt_volum:
    print(ordtak)

Som du kan se over så tar map() inn to argumenter. Det andre argumentet er listen vi ønsker å transformere. Det første argumentet er en funksjon som beskriver hvordan transformasjonen av hvert element i listen skal skje. Jobben til map() er å bruke funksjonen i første argument på hvert element i listen som blir gitt i andre argument.

Til slutt kan du se at vi skriver en for-løkke for å skrive ut hvert enkelt element i ordtak_høyt_volum. Det er kanskje naturlig for deg å tenke at ordtak_høyt_volum er en liste, men dette er ikke helt riktig. Bruker du type()ordtak_høyt_volum så vil du finne ut at dette er et objekt av klassen map. Du kan tenke på dette som veldig likt en generator som du lærte om i seksjon 9.6.

En typisk situasjon er at du har en liste med ordbøker slik som eksempelet med Pokémon:

from datetime import date


pokemon = [
    {
        'navn': 'Pikachu',
        'styrke': 55,
        'dato_fanget': date(2025, 7, 12)
    },
    {
        'navn': 'Bulbasaur',
        'styrke': 49,
        'dato_fanget': date(2024, 11, 3)
    },
    {
        'navn': 'Charmander',
        'styrke': 52,
        'dato_fanget': date(2025, 1, 18)
    },
    {
        'navn': 'Squirtle',
        'styrke': 48,
        'dato_fanget': date(2024, 5, 27)
    }
]

Vi kan bruke map() for å legge til verdien 5 til hver enkelt Pokémon sitt styrkenivå slik:

def legg_til_styrke(pokemon):
    pokemon['styrke'] += 5
    return pokemon


sterkere_pokemon = list(map(legg_til_styrke, pokemon))
print(sterkere_pokemon)

Her har vi skrevet en egen funksjon legg_til_styrke() som vi benytter oss av som argument i map(). Legg merke til at vi bruker list() umiddelbart for å gjøre resultatet om til en liste fremfor et objekt av klassen map. Dette gjør at vi kan skrive ut hele sterkere_pokemon til terminalen uten problemer.

Noe annet som er nyttig å legge merke til er at hver Pokémon i listen pokemon blir nå permanent endret når vi konstruerer sterkere_pokemon. Dette er fordi funksjonen legg_til_styrke() blir brukt på hvert element i listen pokemon, og den modifiserer feltet med nøkkel 'styrke'. Så ved slutten av koden over har listene pokemon og sterkere_pokemon nå de samme elementene. Dette er ofte det du ønsker, men noen ganger ønsker vi å ta vare på historikken. Uansett hva du er interessert i så er det viktig at du har et bevisst forhold til dette.

13.3.2 Utvelgelser med funksjonen filter()

Funksjonen filter() ligner på map(), men er også litt forskjellig. Likheten er at både filter() og map() tar inn en funksjon og en liste, der funksjonen blir brukt på hvert element i listen. Forskjellen er at filter() filtrerer vekk elementer i listen i stedet for å transformere dem.

Funksjonen du passerer som argument til filter() må returnere en sannhetsverdi. Det er denne sannhetsverdien som avgjør om et element blir filtrert ut eller ikke. Her kan du se et eksempel:

artister = [
    {
        'navn': 'Shaboozey',
        'sjangre': ['Country', 'Hip Hop']
    },
    {
        'navn': 'Zara Larsson',
        'sjangre': ['Pop']
    },
    {
        'navn': 'Scale The Summit',
        'sjangre': ['Prog Rock', 'Instrumental']
    },
    {
        'navn': 'Panamah',
        'sjangre': ['Elektronisk', 'Pop']
    }
]


def er_popartist(artist):
    if 'Pop' in artist['sjangre']:
        return True
    return False


popartister = filter(er_popartist, artister)

for artist in popartister:
    print(f'Artisten {artist['navn']} spiller popmusikk!')

Som du ser hvis du går gjennom listen artister så er det artistene Zara Larsson og Panamah som spiller popmusikk. Det er nettopp dette funksjonen er_popartist() sjekker. Når vi bruker er_popartist() som et argument til filter() så blir hver artist evaluert. Bare de artistene der er_popartist() returnerer verdien True vil bli beholdt.

På samme måte som med map() så returnerer filter() et eget objekt av klassen filter. Dette kan du også konvertere til en liste om du ønsker:

def er_popartist(artist):
    if 'Pop' in artist['sjangre']:
        return True
    return False


popartister = list(filter(er_popartist, artister))
print(popartister)

Nå som du har lært om map() og filter() er det naturlig å sammenligne dem med listebeskrivelser som du lærte om i seksjon 9.5. Vi kan gjøre mye av det samme på begge måtene. Noen ganger er listeforståelser enklere, andre ganger er map() og filter() det. Eksempelet med filter() over synes jeg blir enklere med en listebeskrivelse:

popartister = [artist for artist in artister if 'Pop' in artist['sjangre']]
print(popartister)

Her trenger vi ikke opprette en separert funksjon, og det er lett å lese med en listeforståelse. Når transformasjonene med map() eller filtreringen med filter() blir komplisert, så blir listeforståelser vanskeligere å lese. Da er det mye bedre å skille logikken for å transformere eller filtrere lister ut i en egen funksjon og deretter bruke map() eller filter() for å utføre oppgaven.

En annen fordel med å lære seg map() og filter() er at disse funksjonene finnes i de fleste programmeringsspråk som driver med funksjonell programmering. Så det er høy overføringsverdi ved å lære hvordan du bruker map() og filter(). Listeforståelser er derimot veldig spesifikke for Python, så dette er mindre overførbart til andre programmeringsspråk.

13.4 Rekursive funksjoner

Vi skal nå se på funksjoner som returnerer seg selv som returverdi. Mer spesifikt er dette snakk om funksjoner som returnerer seg selv evaluert med spesifikke argumenter. Dette kalles rekursive funksjoner133. La oss gå gjennom et eksempel fra matematikkens verden for å illustrere hvordan rekursive funksjoner kan være nyttige.

I matematikk er kombinatorikk134 en undergren som interesserer seg for hvor mange konfigurasjoner objekter kan bli plassert i. Et enkelt eksempel er som følger: Sett at vi er fire personer som spiller et rollespill. I spillet må en person være en alv, en person må være en dverg, en person må være en varulv, og en person må være et troll. Hvor mange forskjellige måter kan de fire spillerne fordele disse fire rollene?

Hvis vi begynner med å fordele hvem som skal ha rollen som alv så er det jo 4 muligheter siden ingen av spillerne har fått en rolle enda. Når vi går videre til å fordele rollen som dverg, er det 3 personer som ikke har fått en rolle enda. For rollen som varulv er det 2 personer igjen. Når trollet skal fordeles, er det bare 1 person igjen. Siden alle kombinasjonene er mulige vil antall forskjellige konfigurasjoner være \(4 \cdot 3 \cdot 2 \cdot 1 = 24\).

Vi skriver det vi har regnet ut som \(4!\). Dermed er \(4! = 24\). På samme måte har vi at \(3! = 3 \cdot 2 \cdot 1 = 6\) og at \(7! = 7 \cdot 6 \dots \cdot 2 \cdot 1 = 5040\). Som du ser vokser tallene her raskt. Generelt har vi at hvis \(n\) er et heltall, så er

\[n! = n \cdot (n - 1) \cdot \dots \cdot 2 \cdot 1.\] Vi kaller \(n!\) for n-fakultet135. Dette er altså antallet måter vi kan fordele \(n\) objekter i \(n\) bokser på. I eksempelet over hadde vi 4 personer som skulle få fordelt fire roller i et rollespill.

La oss nå prøve å lage en funksjon som regner ut n-fakultet, der \(n\) er et tall som vi spesifiserer i en parameter. En måte å gjøre dette på er å skrive en for-løkke:

def fakultet_iterativ(n):
    """Regner ut n-fakultet iterativt og returnerer dette."""
    n_fakultet = 1
    for tall in range(2, n + 1):
        n_fakultet *= tall
    return n_fakultet

Her går vi gjennom alle tallene mindre enn eller lik \(n\) og multipliserer dem sammen. Dette gir oss det riktige svaret. Vi starter range() fra tallet \(2\), siden det ikke er noe poeng i å multiplisere n_fakultet med tallet \(1\).

Funksjonen fakultet_iterativ() over løser problemet på en iterativ måte. Det vil si at vi løper gjennom en samling med elementer for å løse problemet, i vårt tilfelle range(2, n + 1). Vi kan også løse dette problemet rekursivt som følger:

def fakultet_rekursiv(n):
    """Regner ut n-fakultet rekursivt og returnerer dette."""
    if n <= 1:
        return 1
    else:
        return n * fakultet_rekursiv(n - 1)

Legg merke til at fakultet_rekursiv(n) returnerer fakultet_rekursiv(n - 1) når n er større enn 1. Så fakultet_rekursiv() er en rekursiv funksjon. Her trenger vi å gå gjennom koden over sakte for å forstå hvordan dette faktisk løser problemet vårt.

Dersom du kaller fakultet_rekursiv(1) får du automatisk returnert 1. Dette er riktig siden \(1! = 1\). Hva skjer når du kaller fakultet_rekursiv(3)? Leser vi koden over sakte vil fakultet_rekursiv(3) returnere 3 * fakultet_rekursiv(2). Her kaller vi fakultet_rekursiv(2) som returnerer 2 * fakultet_rekursiv(1). Vi har allerede kommentert at fakultet_rekursiv(1) returnerer 1. Setter vi dette sammen ser vi at det som blir returnert av fakultet_rekursiv(3) er:

fakultet_rekursiv(3) = 3 * 2 * 1 = 3!.

Som du kan se, løser fakultet_rekursiv() det samme problemet som fakultet_iterativ(), men på en rekursiv måte. Det er ikke slik at rekursive funksjoner alltid er bedre enn iterative funksjoner eller vice versa. I noen situasjoner er det lettere å løse et problem iterativt, mens andre ganger er det lettere å løse et problem rekursivt. I eksempelet over er begge løsningene noenlunde like gode, og det faller i stor grad på smak og behag. Dersom du kan løse problemer både iterativt og rekursivt kan du velge det beste verktøyet til jobben.

13.5 Funksjonell programmering som paradigme

Vi har nå sett at funksjoner i Python kan tas inn som argumenter, returneres som verdier, og kombineres på mange spennende måter. Dette leder oss naturlig til temaet funksjonell programmering.

Funksjonell programmering er ikke bare et sett med teknikker, men en måte å tenke på når man skriver kode. Der objektorientert programmering setter klasser og objekter i sentrum, setter funksjonell programmering funksjoner og transformasjoner av data i sentrum. Målet er å beskrive hva som skal gjøres, heller enn hvordan det skal gjøres.

Selv om funksjonell programmering kan virke litt teoretisk i starten, består det egentlig av noen få, intuitive prinsipper. De viktigste av disse er:

  • Rene funksjoner136

  • Uforanderlighet137

  • Funksjonskomposisjon138

La oss se nærmere på hver av disse prinsippene for å forstå funksjonell programmering bedre. Før vi går gjennom dette er det viktig du forstår at funksjonell programmering er et alternativ du kan bruke for å strukturere koden din. Det betyr ikke at funksjonell programmering alltid er den beste måten å strukturere et program på. Likevel er det ofte et nyttig alternativ, så her er det bare å følge godt med.

13.5.1 Rene funksjoner

En ren funksjon er en funksjon som alltid gir samme resultat når den får samme argumenter, og som ikke har noen bivirkninger. Det betyr blant annet at den ikke endrer på noen variabler utenfor seg selv. Rene funksjoner gjør det samme dersom du kjører dem flere ganger på rad. Her er et enkelt eksempel på en ren funksjon:

def kvadrat(tall):
    """Regner ut kvadrattallet og returnerer dette."""
    return tall ** 2

Kaller du kvadrat(4) får du alltid 16, uansett når eller hvor i programmet ditt du kaller kvadrat(). Funksjonen kvadrat() endrer heller ikke på noen data utenfor seg selv. For å forstå forskjellen kan vi se på en funksjon som endrer på en variabel utenfor seg selv:

indiske_retter = ['Kokos- og linsekorma', 'Palak Paneer']


def legg_til_rett(indisk_rett):
    """Legger til en indisk rett til en spesifikk liste"""
    if indisk_rett not in indiske_retter:
        indiske_retter.append(indisk_rett)
        return 'Retten er lagt til!'
    else:
        return 'Retten eksisterer allerede!'

Funksjonen legg_til_rett() er ikke en ren funksjon. Dette er fordi legg_til_rett() endrer på variabelen indiske_retter som ligger utenfor funksjonen. I tillegg vil legg_til_rett() returnere forskjellige verdier avhengig av når den brukes:

print(legg_til_rett('Chana masala')) # Skriver ut 'Retten er lagt til!'
print(legg_til_rett('Chana masala')) # Skriver ut 'Retten eksisterer allerede!'

Rene funksjoner er enklere å forstå, teste og gjenbruke. Når du vet at en funksjon ikke påvirker noe annet, kan du stole mer på den.

13.5.2 Uforanderlighet

Et annet kjennetegn på funksjonell programmering er at man forsøker å ikke endre eksisterende data, men heller lage nye kopier når noe skal endres. I stedet for å manipulere lister, ordbøker eller andre objekter direkte, lager man ofte nye versjoner basert på de gamle.

tall = [1, 2, 3, 4]

# Ikke-funksjonell måte
tall.append(5)

# Funksjonell måte
nye_tall = tall + [6]

I den første versjonen endrer vi listen tall direkte. I den andre lager vi en ny liste nye_tall uten å røre den opprinnelige.

Dette høres kanskje tungvint ut, men det gjør programmene tryggere. Når data aldri endres uventet, slipper du mange rare feil som kan oppstå fordi en verdi plutselig endret seg et annet sted i programmet. Å lage nye kopier av data krever mer minne, så funksjonell programmering kan være krevende ved store datamengder.

13.5.3 Funksjoner som byggesteiner

I funksjonell programmering kombineres små, rene funksjoner for å bygge opp større funksjoner. Dette kalles funksjonskomposisjon. Under funksjonskomposisjon er returverdien fra én funksjon inngangen til den neste. Her kan du se et kort eksempel på dette ved å bruke funksjonene map() og filter():

tall = [1, 2, 3, 4, 5]

trippel = map(lambda x: x * 3, tall)
partall = filter(lambda x: x % 2 == 0, trippel)

print(list(partall))

Her gjør vi to ting:

  • Vi tripler hvert tall ved å bruke map().

  • Vi filtrerer bort alle oddetall ved å bruke filter().

I stedet for å skrive alt i én stor løkke, bruker vi små funksjoner som kan settes sammen som legoklosser. Dette gjør koden lett å lese og enkel å endre. Du kan i koden over bytte ut én kloss uten å måtte endre alt annet.

Selv om Python støtter funksjonell programmering, er det ikke et rent funksjonelt språk som for eksempel Haskell. Python lar deg mikse funksjonell og objektorientert stil, og det er ofte dette som gir mest mening i praksis. Du må selv ta en avgjørelse om du tenker at å strukturere programmet ditt funksjonelt, objektorientert, eller noe midt imellom er best.

13.6 Dekoratører

De resterende seksjonene i boken etter denne seksjonen er enten prosjekter eller oppgaver. Som den siste ordinære seksjonen i boken skal jeg forklare hvordan dekoratører fungerer i Python, og hvordan du kan skrive dine egne dekoratører. Dette er det vanskeligste temaet i boken, så det går helt fint om ikke alt sitter på første gjennomlesning. Likevel er dekoratører veldig elegante og nyttige å forstå. Det gøyeste? Under panseret er dekoratører helt avhengige av at funksjoner returnerer andre funksjoner!

Vi har allerede snublet over dekoratører i tidligere kapitler uten å helt forklare hvordan de fungerer. I kapittel 11 brukte vi for eksempel dekoratører som @classmethod, @staticmethod, og @property når vi jobbet med klasser. Kanskje du husker at vi skrev kode som dette:

class Person:
    """En klasse som representerer en person."""

    def __init__(self, navn, alder):
        self.navn = navn
        self._alder = None        
        self.alder = alder        

    @property
    def alder(self):
        return self._alder

Når vi skriver @property her gjør Python noe spesielt med alder(). Men hva skjer egentlig her? Det viser seg at dekoratører er egentlig funksjoner av høyere orden! Alfakrøll-syntaksen vi bruker i @property er rett og slett en forkortelse for å sende en funksjon inn som argument til en annen funksjon! La oss dykke ned i hvordan dette fungerer.

13.6.1 Hvordan skrive egne dekoratører

La oss starte helt fra begynnelsen. Sett at vi har en enkel funksjon:

def si_hei():
    print('Heisan du!')

La oss si at vi ønsker å dekorere funksjonen si_hei() slik at den også skriver ut at funksjonen startet og at funksjonen er ferdig. For å gjøre dette lager vi en ny funksjon min_dekorator() slik:

def min_dekorator(funksjon):
    def wrapper():
        print('Starter funksjonen...')
        funksjon()
        print('Funksjonen er ferdig!')
    return wrapper

Her har vi en funksjon, nemlig wrapper(), deklarert inni funksjonen min_dekorator(). Før vi blir overveldet, la oss bruke min_dekorator() på funksjonen si_hei() som vi definerte ovenfor:

dekorerte_funksjon = min_dekorator(si_hei)
dekorerte_funksjon()

Når du kjører funksjonen over vil du få tre linjer skrevet ut. Først 'Starter funksjonen...', deretter 'Heisan du!', og til slutt 'Funksjonen er ferdig!'. Du kan se at funksjonen si_hei() har blitt dekorert med ekstra funksjonalitet: to setninger som skrives ut til terminalen, én før og én etter at funksjonen kjører. Hva er det som skjer her?

Når vi skriver dekorerte_funksjon = min_dekorator(si_hei) så sender vi funksjonen si_hei() inn i funksjonen min_dekorator(). Det som returneres er funksjonen som vi har kalt wrapper(). Funksjonen wrapper() kan kalle si_hei() selv om si_hei() ikke er definert inni wrapper().

Det vi har skrevet over er hvordan vi kan lage en dekoratør fra bunn av i Python. Vi har sett at vi kan dekorere en funksjon med en dekoratør ved å skrive følgende kode:

dekorerte_funksjon = min_dekorator(si_hei)

Dette er litt tungvint, så Python har innført en alfakrøll-syntaks slik at du bare kan skrive dette her:

def min_dekorator(funksjon):
    def wrapper():
        print('Starter funksjonen...')
        funksjon()
        print('Funksjonen er ferdig!')
    return wrapper


@min_dekorator
def si_hei():
    print('Heisan du!')

Nå blir funksjonen si_hei() dekorert med dekoratøren min_dekorator() umiddelbart når du skriver den. Dette er den vanligste måten å bruke dekoratører på i Python.

Noen sier at alfakrøll-syntaksen er syntaktisk sukker139 siden den er en forkortet og mer lesbar skrivemåte for å utføre en operasjon som allerede er mulig. Det forenkler koden din uten å legge til ny funksjonalitet. I dette tilfellet gjør syntaktisk sukker at du slipper å skrive dekorerte_funksjon = min_dekorator(si_hei) manuelt.

Eksempeldekoratøren min_dekorator() er jo litt kjedelig, så la oss lage et mer spennende eksempel.

13.6.2 Et praktisk eksempel: måle kjøretid

Et klassisk eksempel på en nyttig dekoratør er å måle hvor lang tid en funksjon bruker på å kjøre. Dette gjør man ofte i virkelige prosjekter for å finne flaskehalser i koden.

I Python kan vi bruke standardbiblioteket time til å finne det nåværende tidspunktet. Dette er supert for å sjekke hvor lang tid en del av koden tar: Vi kan måle tiden både før og etter koden kjører, og deretter sjekke forskjellen mellom tidspunktene for å vite hvor lang tid som har gått. Her ser du hvordan dette fungerer:

import time


starttid = time.time()

# Bit med kode som skal sjekkes
liste_med_tall = []
for tall in range(1, 1000001):
    liste_med_tall.append(tall)

sluttid = time.time()

print(f'Koden tok {round(sluttid - starttid, 3)} sekunder.')

Her har vi valgt å sjekke hvor lang tid det tar å legge til en million tall til en liste. Dette burde gå relativt raskt på en moderne datamaskin. Når jeg kjører koden over får jeg beskjed om at koden har kjørt på under en tiendedels sekund. Hvor lang tid koden vil bruke på å kjøre avhenger av hvilken datamaskin du har og hvilke andre prosesser som kjører samtidig.

Uansett er det veldig nyttig å skrive kode over for å forstå hvilke av funksjonene dine som bruker mest tid på å utføres. Skulle vi skrevet koden over for hver enkelt funksjon du lager, blir det enormt mange linjer med kode. En mye bedre løsning er å lage en dekoratør som fikser jobben:

import time


def mål_tid(funksjon):
    def wrapper(*args, **kwargs):
        starttid = time.time()
        resultat = funksjon(*args, **kwargs)
        sluttid = time.time()
        total_tid = round(sluttid - starttid, 5)
        print(f'Funksjonen {funksjon.__name__} tok {total_tid} sekunder.')
        return resultat
    return wrapper

Vi bruker stjerne *args og dobbeltstjerne **kwargs slik at wrapper() kan ta imot et vilkårlig antall posisjon- og nøkkelargumenter, uavhengig av hvilken funksjon vi dekorerer. I tillegg bruker vi funksjon.__name__ for å hente navnet til funksjonen. Dette er nyttig når vi skal bruke dekoratøren mål_tid() på flere funksjoner slik at det ikke blir kaos med hvilken tid som hører til hvilken funksjon.

Vi kan nå bruke dekoratøren vår mål_tid() på flere funksjoner:

@mål_tid
def legg_til_liste(antall):
    liste_med_tall = []
    for tall in range(1, antall + 1):
        liste_med_tall.append(tall)
    return liste_med_tall


@mål_tid
def legg_til_ordbok(antall):
    ordbok_med_tall = {}
    for tall in range(1, antall + 1):
        ordbok_med_tall[str(tall)] = tall
    return ordbok_med_tall


legg_til_liste(1000000)
legg_til_ordbok(1000000)

Som du vil se dersom du kjører koden over så er det typisk mer tidkrevende å legge til nøkler til en ordbok enn å legge til tall i en liste. Dette er grei kunnskap å vite.

Dekoratører lar deg legge til funksjonalitet uten å endre den originale funksjonen. Det er dette som gjør dem så kraftige! Du kan logge informasjon, måle tiden en funksjon tar for å kjøre, eller gjøre andre ting på tvers av mange funksjoner på en konsistent og elegant måte.

Dekoratører kan virke magiske første gang du ser dem, men i bunn og grunn er de bare funksjoner av høyere orden som tar en annen funksjon som argument og returnerer en ny funksjon. Når du blir komfortabel med å bruke dekoratører, vil du virkelig forstå styrken i høyere ordens funksjoner.

13.7 Prosjekt - Primtalltester

Nå skal vi gå gjennom et prosjekt i fellesskap for å arbeide med konseptene vi har lært i kapittelet. Du må kode med mens du leser, slik at du får mest mulig ut av prosjektet.

13.7.1 Oppgaven

I dette prosjektet skal vi løse følgende oppgave: Skriv et program som kan sjekke om et tall er et primtall. Et primtall er et heltall større enn 1 som bare er delelig med 1 og seg selv. De første primtallene er, som du sikkert husker, 2, 3, 5, 7, 11, 13, og 17.

I tillegg til å bare kunne sjekke om et enkelt tall er et primtall skal programmet kunne:

  • Sjekke om ett tall er et primtall.

  • Finne alle primtall i en gitt liste.

  • Måle hvor lang tid det tar å teste om mange tall er primtall.

13.7.2 To løsninger

Vi kan lage både en iterativ og en rekursiv løsning på dette problemet. Vi begynner med å lage en funksjon som sjekker om et tall er et primtall på en iterativ måte:

def er_primtall_iterativ(tall):
    """Bestemmer om et tall er et primtall eller ikke (iterativ versjon)."""
    if tall < 2:
        return False
    for divisor in range(2, tall):
        if tall % divisor == 0:
            return False
    return True

Som du kan se går vi enkelt og greit gjennom alle tallene mellom 2 og tall og sjekker om vi får noen rest når vi deler. Dette gjør vi enkelt og greit med modulus-operatoren %. Hvis tallet ikke har noen divisorer utenom 1 og seg selv, returnerer vi True, siden det da er et primtall. Prøver du å kjøre koden er_primtall_iterativ(7) vil du få ut True siden 7 er et primtall. Derimot vil du få ut False når du kjører er_primtall_iterativ(10) siden 10 ikke er et primtall.

I stedet for å bruke en løkke, kan vi la funksjonen kalle seg selv for å teste mulige divisorer:

def er_primtall_rekursiv(tall, divisor=2):
    """Bestemmer om et tall er et primtall eller ikke (rekursiv versjon)."""
    if tall < 2:
        return False
    if divisor >= tall:
        return True
    if tall % divisor == 0:
        return False
    return er_primtall_rekursiv(tall, divisor + 1)

Les over koden nøye. Her ser du at vi i bunn og grunn gjør det samme, men på en rekursiv måte. Før vi avslører hvilken måte som er best i denne situasjonen kan vi bruke de to funksjonene vi har laget til å finne alle primtall i en liste.

13.7.3 Finne primtall i en liste

Sett at vi har en liste med tall:

tallrekke = [4, 8, 15, 16, 23, 42]

Vi kan nå bruke de to funksjonene er_primtall_iterativ() og er_primtall_rekursiv() til å finne primtallene i listen tallrekke ved å bruke funksjonen filter() slik:

tallrekke = [4, 8, 15, 16, 23, 42]
primtall_iterativ = list(filter(er_primtall_iterativ, tallrekke))
primtall_rekursiv = list(filter(er_primtall_rekursiv, tallrekke))

Som du vil se så vil primtall_iterativ og primtall_rekursiv bare inneholde tallet 23 siden dette var det eneste primtallet. Vi kan også bruke funksjonen range() til å finne alle primtallene mellom 1 og 100 slik:

små_primtall_iterativ = list(filter(er_primtall_iterativ, range(1, 100)))
små_primtall_rekursiv = list(filter(er_primtall_rekursiv, range(1, 100)))

13.7.4 Måle ytelsen med en dekoratør

Det er nå på tide å måle ytelsen til den iterative og rekursive fremgangsmåten. I seksjon 13.6 skrev vi nettopp en dekoratør for dette som så slik ut:

import time


def mål_tid(funksjon):
    def wrapper(*args, **kwargs):
        starttid = time.time()
        resultat = funksjon(*args, **kwargs)
        sluttid = time.time()
        total_tid = round(sluttid - starttid, 5)
        print(f'Funksjonen {funksjon.__name__} tok {total_tid} sekunder.')
        return resultat
    return wrapper

Det mest naturlige å gjøre er å legge dekoratøren på hver av de to funksjonene slik:

@mål_tid
def er_primtall_iterativ(tall):
    """Bestemmer om et tall er et primtall eller ikke (iterativ versjon)."""
    if tall < 2:
        return False
    for divisor in range(2, tall):
        if tall % divisor == 0:
            return False
    return True


@mål_tid
def er_primtall_rekursiv(tall, divisor=2):
    """Bestemmer om et tall er et primtall eller ikke (rekursiv versjon)."""
    if tall < 2:
        return False
    if divisor >= tall:
        return True
    if tall % divisor == 0:
        return False
    return er_primtall_rekursiv(tall, divisor + 1)


tallrekke = [4, 8, 15, 16, 23, 42]
primtall_iterativ = list(filter(er_primtall_iterativ, tallrekke))
primtall_rekursiv = list(filter(er_primtall_rekursiv, tallrekke))

små_primtall_iterativ = list(filter(er_primtall_iterativ, range(1, 100)))
små_primtall_rekursiv = list(filter(er_primtall_rekursiv, range(1, 100)))

Dette fungerer rent teknisk sett, men du vil nå skrive ut flere hundre linjer med kode til terminalen. Hver linje vil gi informasjon om hvor lang tid det tok å kjøre enten funksjonen er_primtall_iterativ() eller er_primtall_rekursiv() en gang. Men vi kjører funksjonene mange ganger, så dette er ikke så oversiktlig. Det er bedre å lage en egen funksjon som håndterer å teste mange primtall, og deretter bruke dekoratøren @mål_tid på denne nye funksjonen:

@mål_tid
def test_primtall_iterativ(tallrekke):
    """Finner primtallene i en tallrekke (iterativ versjon)."""
    return list(filter(er_primtall_iterativ, tallrekke))


@mål_tid
def test_primtall_rekursiv(tallrekke):
    """Finner primtallene i en tallrekke (rekursiv versjon)."""
    return list(filter(er_primtall_rekursiv, tallrekke))


test_primtall_iterativ(range(1, 100))
test_primtall_rekursiv(range(1, 100))

Nå vil du se at vi kan teste hvor lang tid hver fremgangsmåte tar. Det er nyttig å sjekke litt større tallmengder enn bare hundre tall, så la oss prøve å øke dette til de første hundre tusen tallene. Dersom du søker gjennom de første hundre tusen tallene ved å bruke range(1, 100_000) vil du mest sannsynlig støte på en RecursionError med beskjeden:

maximum recursion depth exceeded.

Hva betyr dette? Python har en innebygd sikkerhetsmekanisme som stopper et program dersom en funksjon kaller seg selv for mange ganger uten å avslutte. Dette gjøres for å hindre at et program går i en uendelig løkke og til slutt krasjer hele systemet. Som standard tillater Python rundt 1000 rekursive kall før den stopper og viser denne feilmeldingen.

I vårt tilfelle betyr det at er_primtall_rekursiv() kaller seg selv for hver potensiell divisor tallet har. For små tall går dette helt fint, men når du begynner å teste store tall (som opp mot en million), vil rekursjonen bli så dyp at Python sier stopp.

Vi kan utvide rekursjonsgrensen ved å bruke standardbiblioteket sys slik:

import sys


sys.setrecursionlimit(100_000)

Merk at dette kun bør gjøres i eksperimenter. Det å øke rekursjonsgrensen kan føre til at programmet bruker mye minne. Gjør vi likevel dette kan jeg måle at den iterative fremgangsmåten tar ca. 24 sekunder for å gå gjennom de første hundre tusen tallene, mens den rekursive tar ca. 79 sekunder. I denne situasjonen ser vi at den iterative er raskest, så vi går videre med den.

13.7.5 En siste forbedring

En annen forbedring vi kan gjøre er rent matematisk. Hvis vi tenker oss om så trenger vi jo ikke egentlig å sjekke alle mulige divisorer opp til tallet vi er interessert i. Det holder faktisk å sjekke alle tallene opp til kvadratroten av tallet. Hvorfor det?

La oss si at vi tester om et tall \(n\) er et primtall. Hvis \(n\) ikke er et primtall, så betyr det at vi kan skrive det som et produkt av to heltall \(a\) og \(b\) større enn 1 slik:

\[n = a \cdot b.\]

Hvis både \(a\) og \(b\) er større enn kvadratroten av \(n\), så vil produktet deres \(a \cdot b\) være større enn \(n\), noe som ikke kan stemme. Dermed må minst én av faktorene være mindre enn eller lik kvadratroten av \(n\). Det betyr at hvis vi har søkt gjennom alle tallene mindre eller lik kvadratroten til tallet \(n\) og ikke funnet noen divisorer, så er tallet \(n\) et primtall.

Med dette i bakhodet kan vi forbedre funksjonen er_primtall_iterativ() slik:

import math


def er_primtall_iterativ(tall):
    """Bestemmer om et tall er et primtall eller ikke (iterativ versjon)."""
    if tall < 2:
        return False
    for divisor in range(2, int(math.sqrt(tall)) + 1):
        if tall % divisor == 0:
            return False
    return True

Dette er mye mer effektivt, fordi vi trenger å sjekke langt færre tall. Hvis vi igjen prøver å finne primtall blant de første hundre tusen tallene, så går dette for meg på under 0.2 sekunder. Husk at dette tidligere tok ca. 24 sekunder. Her snakker vi om en dramatisk forbedring. Dette viser at ikke alle kodeforbedringer handler om programmeringsparadigmer. Noen ganger handler det rett og slett om å forstå situasjonen, i dette tilfellet matematikk, bedre.

13.8 Oppgaver

Oppgave 1

Hva gjør følgende kode? Beskriv hvert steg!

universitetsfag = [
    {
        'navn': 'HMS-kurs for 1. årsstudenter',
        'fagkode': 'HMS0002',
        'nivå': 'Grunnleggende emner',
        'studiepoeng': 0,
    },
    {
        'navn': 'Diskret matematikk',
        'fagkode': 'TMA4140',
        'nivå': 'Grunnleggende emner',
        'studiepoeng': 7.5,
    },
    {
        'navn': 'Big Data-arkitektur',
        'fagkode': 'TDT4305',
        'nivå': 'Høyere grads nivå',
        'studiepoeng': 7.5,
    }
]

grunnleggende = list(
    filter(
        lambda fag: fag['nivå'] == 'Grunnleggende emner', universitetsfag
    )
)
grunnleggende_sortert = sorted(grunnleggende, key=lambda fag: fag['navn'])

for fag in grunnleggende_sortert:
    print(
        f'Faget {fag['navn']} er grunnleggende med fagkode {fag['fagkode']}.')

Oppgave 2

Du har fått i oppgave av vennegjengen å sortere spillelisten til en roadtrip. Sangene de har kommet med ser slik ut:

sanger = [
    {
        'tittel': 'Blue Suede Shoes', 
        'artist': 'Elvis Presley', 
        'lengde(s)': '119'
    },
    {
        'tittel': 'Set Fire to the Rain', 
        'artist': 'Adele', 
        'lengde(s)': '242'
    },
    {
        'tittel': 'The Count of Tuscany', 
        'artist': 'Dream Theater', 
        'lengde(s)': '1157'
    },
    {
        'tittel': 'Night and Day', 
        'artist': 'Ella Fitzgerald', 
        'lengde(s)': '184'
    },
    {
        'tittel': 'Kashmir', 
        'artist': 'Led Zeppelin', 
        'lengde(s)': '517'
    }
]

Bruk funksjonen sorted() med nøkkelordet key til å sortere listen sanger etter:

  • Tittelen til sangen.

  • Artisten til sangen.

  • Sanglengden.

Hint: Merk at datatypen til sanglengdene ikke er heltall, men strenger. Har dette noe å si?

Oppgave 3

Vi fortsetter å se på listen sanger fra Oppgave 2.

  1. Bruk filter() til å hente ut alle sanger som varer i mer enn fire minutter.

  2. Bruk map() til å gjøre om tittelen til store bokstaver på alle sangene du fikk fra deloppgave 1.

  3. Skriv en listebeskrivelse som oppnår det samme som resultatet fra deloppgave 2. Hva foretrekker du?

Oppgave 4

Fibonacci-tallene er en tallrekke der hvert tall er summen av de to foregående. Rekken starter slik:

\[0, \, 1, \, 1, \, 2, \, 3, \, 5, \, 8, \, 13, \, 21, \, 34, \dots \] Formelt kan vi definere den slik:

\[F(n) = \begin{cases} 0 \qquad & \textrm{hvis } n = 0, \\ 1 \qquad & \textrm{hvis } n = 1, \\ F(n - 1) + F(n - 2) \qquad & \textrm{ellers}. \\ \end{cases}\]

Bruk rekursjon til å skrive en funksjon fibonacci() som returnerer det n-te Fibonacci-tallet. Funksjonen fibonacci() bør returnere følgende verdier:

>>> fibonacci(0)
0
>>> fibonacci(1)
1
>>> fibonacci(5)
5
>>> fibonacci(8)
21

Oppgave 5 (Utfordrende)

Skriv en dekoratør @sjekk_positive som sjekker at alle numeriske argumenter til en funksjon er positive. Hvis et argument er mindre enn eller lik null, skal dekoratøren skrive ut en feilmelding og ikke kjøre funksjonen. Vi forventer altså at koden

@sjekk_positive
def areal_rektangel(bredde, høyde):
    return bredde * høyde

skal oppføre seg slik at vi får:

>>> areal_rektangel(3, 4)
12
>>> areal_rektangel(-2, 5)
Ugyldig argument: -2