Kapittel 12 Håndtering av feil


Det skjer ofte at vi støter på feil når vi programmerer. Noen ganger introduserer vi dem til og med selv. Andre ganger er det brukeren av programmene våre som gjør noe uventet. Uansett grunn må vi kunne håndtere et bredt spekter med feil for å lage robuste programmer. I dette kapittelet skal du få en oversikt over hvilke typer feil som eksisterer. Du skal lære hvordan forskjellige nøkkelord i Python som raise, try, og except kan hjelpe deg med å håndtere vanlige feil. Ved hjelp av indikering av typer og enhetstester skal du hindre at feil oppstår før du gir fra deg koden din. Programmene dine vil bli betydelig tryggere etter at du har gjennomgått dette kapittelet!

12.1 Introduksjon - En programmerers hverdag er full av feil

La oss avklare én ting først: Det er ikke en god strategi å satse på å aldri gjøre feil. Perfeksjon er et prisverdig mål, men ganske urealistisk. Sannheten er at hverdagen til en programmerer er full av feil, både enkle og vanskelige. Det er mer produktivt å lære hvordan du oppdager feil, fikser feil og til og med overser feil når det er hensiktsmessig.

Jeg har valgt å dele feil inn i tre typer som kalles syntaksfeil, kjørefeil, og logiske feil. La meg i denne introduksjonen gi deg et lite overblikk over de tre typene som lurer rundt hvert hjørne når du programmerer.

12.1.1 Syntaksfeil

En syntaksfeil124 er en feil som er forårsaket av en linje med kode som aldri er gyldig. Her har du et eksempel:

while True
    print('Dette går ikke!')

Kjører du koden over vil du få en SyntaxError. Grunnen til dette er at du mangler kolonsymbolet : etter While True for å signalisere at en kodeblokk kommer. Biten med kode over er aldri gyldig Python kode, uavhengig av hvilke variabler som er definert eller hvor i programmet du legger den.

Et annet eksempel er når du glemmer en parentes:

print('Dette er heller ikke lov'

Som du sikkert ser, mangler vi den siste parentesen ) for å bruke funksjonen print(). Kjører jeg koden over får jeg feilmeldingen:

SyntaxError: ‘(’ was never closed

Koden over er aldri en gyldig linje med Python kode. Dette stemmer selv om du har omdefinert print til å være noe annet:

print = 5
print('Dette er heller ikke lov'

Vi har tidligere nevnt at å omdefinere de innebygde funksjonene er en særdeles dårlig plan. Men selv om du gjør dette med print = 5 vil fortsatt koden over gi en syntaksfeil. Dette er fordi det aldri er gyldig å ha en enslig parentes slik vi har over.

Det er litt interessant å vite at syntaksfeil blir sjekket av Python før noe av koden i programmet blir kjørt. Dette kan du se med å kjøre følgende kode:

print('Hei')
print('Dette er heller ikke lov'

Når du kjører koden over blir ingenting skrevet ut til terminalen. Python leter først gjennom hele programmet etter syntaksfeil. Det er bare hvis det ikke finnes noen syntaksfeil at Python starter oppgaven ved å utføre koden linje for linje. Siden det er en syntaksfeil i koden over vil derfor ikke 'Hei' bli skrevet ut til terminalen.

Selv om syntaksfeil er vanlige når du skriver kode er de også relativt enkle å fikse. Les feilmeldingen nøye, finn linjen det gjelder, og les gjennom linjen til du ser feilen. Så enkelt er det!

De to gjenværende feiltypene, kjørefeil og logiske feil, er mer utfordrende å håndtere. Resten av kapittelet skal derfor fokusere på kjørefeil og logiske feil.

12.1.2 Kjørefeil

En kjørefeil125 er en feil som ikke kommer av at koden er ugyldig generelt sett, men at den likevel fører til en feil når koden blir kjørt. Vi har møtt på mange slike feil tidligere. Her har du et fint eksempel:

betaling_per_uke = int(input('Hva er din ukelønn (i kroner)? '))
antall_timer = int(input('Hvor mange timer arbeidet du? '))

betaling_per_time = betaling_per_uke / antall_timer
print(f'Din timelønn er: {betaling_per_time}')

Det er ingen syntaksfeil i koden over, som du kan teste ved å kjøre den selv. Du kan sette betaling_per_uke til 8000 og antall_timer til 40 når du skriver inn informasjonen i terminalen. Som du ser regner programmet helt riktig ut at din timelønn da er 200 kroner.

Programmet over kan likevel skape to vanlige kjørefeil:

  • Du kan skrive inn åtte tusen når du blir bedt om din ukelønn. Siden det ikke er mulig å gjøre om 'åtte tusen' til et heltall med funksjonen int() vil du få en ValueError.

  • Du kan skrive inn 0 når du blir bedt om hvor mange timer du arbeidet. Da vil linjen betaling_per_time = betaling_per_uke / antall_timer sørge for at du får en ZeroDivisionError.

Som du ser oppstår kjørefeil først når vi kjører koden. Det er avhengig av hvilken informasjon vi får fra en bruker, hva variablene våre representerer, osv. Dette skiller kjørefeil fra syntaksfeil, siden syntaksfeil er Python kode som aldri er gyldig.

Så langt i boken har du møtt mange forskjellige kjørefeil. Eksempler du har møtt på tidligere er ValueError, TypeError, NameError, AttributeError, ZeroDivisionError, IndexError, KeyError, og FileNotFoundError. Mye av kapittelet vil gå ut på å håndtere kjørefeil når de først oppstår.

12.1.3 Logiske feil

En siste type feil som ofte skaper problemer er logiske feil126. Logiske feil handler rett og slett om at koden vi har skrevet ikke representerer virkeligheten. Her er et enkelt eksempel:

betaling_per_uke = 8000
antall_timer = 40

betaling_per_time = betaling_per_uke * antall_timer - 16_000
print(f'Din timelønn er: {betaling_per_time}')

Koden over vil verken skape en syntaksfeil eller en kjørefeil. Likevel blir det som blir skrevet ut bare tullball. Timelønnen blir jo over 300 000 kroner! Feilen her er at vi bruker feil formel for å konvertere ukelønn til timelønn.

Logiske feil er også vanlige når vi skriver kode. Siden Python ikke har noen forståelse av timelønn og ukelønn som konsepter vil ikke vi få en beskjed om at utskriften over er helt på tryne. Likevel kan vi skrive tester i Python som sjekker om utregningene våre er på ballen. Vi skal lære om enhetstester i seksjon 12.6 som hjelper oss med å identifisere logiske feil.

I tillegg til tester bør du som regel sjekke koden din ved å kjøre den flere ganger med ulike verdier. Gjør det til en vane å kjøre koden din etter du har skrevet en ny bit med logikk. På denne måten kan du luke ut en del logiske feil raskt selv uten å skrive tester.

12.2 Frembring kjørefeil selv med raise

La oss se litt nærmere på koden vi brukte i forrige seksjon:

betaling_per_uke = int(input('Hva er din ukelønn (i kroner)? ')) 
antall_timer = int(input('Hvor mange timer arbeidet du? '))

betaling_per_time = betaling_per_uke / antall_timer
print(f'Din timelønn er: {betaling_per_time}')

Vi har sett at programmet stopper når vi møter en kjørefeil. Vi støttet for eksempel på en ZeroDivisionError når vi skrev inn 0 som svar på antall timer arbeidet.

Det finnes også situasjoner der du ønsker at programmet skal stoppe, selv om det ikke gjør det automatisk: Hva skjer hvis du skriver inn verdiene 8000 for timelønn og -2 for arbeidstimer? Da vil du få skrevet ut til terminalen:

Din timelønn er: -4000.0

Dette gir ikke så mye mening. I denne situasjonen hadde det egentlig vært bedre om programmet hadde krasjet med en gang du skrev inn -2 som svar på antall timer arbeidet. Dette kan vi oppnå med nøkkelordet raise slik:

betaling_per_uke = int(input('Hva er din ukelønn (i kroner)? ')) 
antall_timer = int(input('Hvor mange timer arbeidet du? '))

if antall_timer <= 0:
    raise ValueError('Antall timer må være et positivt heltall!')

betaling_per_time = betaling_per_uke / antall_timer
print(f'Din timelønn er: {betaling_per_time}')

Utsagnet

raise ValueError('Antall timer må være et positivt heltall!')

frembringer nå kjørefeilen ValueError dersom betingelsen antall_timer <= 0 er opprettholdt. Strengen 'Antall timer må være et positivt heltall!' som vi passerer inn i ValueError() vil bli skrevet til terminalen når vi frembringer denne feilen. Prøver du å kjøre koden over og skriver inn at du har arbeidet -2 timer vil du støte på en ValueError og programmet avsluttes. Dette er bedre enn å skrive ut tøys om negative timelønninger.

I profesjonell programmering ønsker man ofte at feil blir oppdaget tidlig, fremfor at feil fører til rare eller farlige resultater senere. Dette kalles fail fast-prinsippet på litt dårlig norsk. Nøkkelordet raise er et nyttig verktøy som hjelper oss med dette.

Merk at jeg har brukt operatoren <= i if-setningen ovenfor. Dette gjør at vi ikke kan dele på null lenger. Skriver du inn 0 som svar på antall timer arbeidet vil du frembringe en ValueError som sier at vi bare godtar positive heltall. Dette er mer klart for en sluttbruker enn en generisk feilmelding om at man ikke kan dele på 0.

I koden over har jeg spesifikt valgt å bruke ValueError() siden dette best representerer situasjonen. Heltall er ikke feil datatype, så det er derfor ikke snakk om en TypeError. Problemet er at verdier mindre eller lik null ikke er gyldige for utregningen som skal bli utført. Derfor passer ValueError bra her.

I noen situasjoner passer ingen av de innebygde kjørefeilene til situasjonen. Da er det mulig å lage egne klasser som representerer kjørefeil. Dette skal vi ikke gå inn på i denne boken, men det er likevel greit for deg å vite at dette er mulig.

La oss gjøre enda et eksempel med nøkkelordet raise før vi går videre. Sett at du skal skrive en funksjon legg_sammen_utgifter() som legger sammen to utgifter. En første versjon av legg_sammen_utgifter() er jo så enkel du kan få det:

def legg_sammen_utgifter(utgift_1, utgift_2):
    return utgift_1 + utgift_2


print(legg_sammen_utgifter(49.90, 139.50))

Selv om funksjonen legg_sammen_utgifter() er enkel kan det fort komme problemer:

def legg_sammen_utgifter(utgift_1, utgift_2):
    return utgift_1 + utgift_2


print(legg_sammen_utgifter('49.90', '139.50'))

Kjører du koden over slår du sammen strenger og får skrevet ut 49.90139.50. Ikke særlig nyttig! Her kan vi sjekke om det var snakk om riktig datatype først. Dersom argumentene ikke er int eller float, kan vi frambringe en TypeError slik:

def legg_sammen_utgifter(utgift_1, utgift_2):
    if (not isinstance(utgift_1, (int, float)) or 
        not isinstance(utgift_2, (int, float))):
        raise TypeError('Begge utgiftene må være tall, ikke tekst.')
    return utgift_1 + utgift_2


print(legg_sammen_utgifter('49.90', '139.50'))

Som du ser i funksjonen isinstance() så kan du spesifisere en tuppel med klasser som du ønsker å teste opp mot. Koden over vil nå frembringe en TypeError siden '49.90' og '139.50' er strenger. På denne måten oppdager vi feil med én gang, heller enn å la programmet fortsette med ugyldige data.

12.3 Håndter kjørefeil med try og except

Som vi har sett så langt i kapittelet kan kjørefeil skje både av seg selv, eller ved at vi frembringer dem med nøkkelordet raise. Når en kjørefeil skjer, kan vi prøve å håndtere situasjonen fremfor å stoppe hele programmet. Dette kan vi gjøre med nøkkelordene try og except.

Her er et enkelt eksempel som illustrerer hvordan try og except kan brukes:

try:
    alder = int(input('Hvor gammel er du? '))
except ValueError:
    print('Dette er ikke et gyldig tall!')

Som du ser ligner bruken av try og except litt på en if-else-setning. Først prøver vi å utføre koden som er inne i kodeblokken som try definerer. Hvis dette går smertefritt så ignoreres koden i kodeblokken som except definerer. Hvis derimot koden i kodeblokken som try definerer produserer en ValueError vil koden i kodeblokken som except definerer bli utført. Det betyr at dersom du skriver inn tallet 21 blir tallet bare hentet inn på vanlig måte. Skriver du heller inn enogtyve vil beskjeden

'Dette er ikke et gyldig tall!'

bli skrevet ut til terminalen.

Når det blir produsert en ValueError i koden over hopper vi med en gang ned til kodeblokken som except definerer. Dette kan du sjekke ved å legge inn en liten utskrift til terminalen i kodeblokken som try definerer:

try:
    alder = int(input('Hvor gammel er du? '))
    print(f'Din alder er: {alder}')
except ValueError:
    print('Dette er ikke et gyldig tall!')

Som du ser blir bare 'Dette er ikke et gyldig tall!' skrevet til terminalen dersom du skriver inn enogtyve som svar. Med en gang du støter på en kjørefeil vil du hoppe vekk fra kodeblokken som try definerer og se om denne kjørefeilen blir fanget opp av nøkkelordet except.

Hva tror du skjer når du kjører koden under?

try:
    print(alder)
    alder = int(input('Hvor gammel er du? '))
    print(f'Din alder er: {alder}')
except ValueError:
    print('Dette er ikke et gyldig tall!')

Her støter du på en NameError i første linje print(alder) i kodeblokken som try definerer. Dette er fordi alder enda ikke er definert. Derfor blir resten av koden i kodeblokken som try definerer utelatt. Siden det ikke er et except-utsagn som fanger opp en NameError så blir ikke feilen håndtert. Dermed krasjer programmet!

Eksempelet over viser at vi må være forsiktige med å fange opp alle feil som kan skje. Dersom du bare skriver except: så fanger du opp alle kjørefeil slik:

try:
    betaling_per_uke = int(input('Hva er din ukelønn (i kroner)? ')) 
    antall_timer = int(input('Hvor mange timer arbeidet du? '))

    betaling_per_time = betaling_per_uke / antall_timer
    print(f'Din timelønn er: {betaling_per_time}')
except:
    print('Dette gikk ikke så bra!')

Koden her fanger opp alle mulige feil fra kodeblokken som try definerer og skriver da ut 'Dette gikk ikke så bra!'. Selv om dette kan virke lurt er det bedre å håndtere de spesifikke feilene når vi vet hva de er slik:

try:
    betaling_per_uke = int(input('Hva er din ukelønn (i kroner)? ')) 
    antall_timer = int(input('Hvor mange timer arbeidet du? '))

    betaling_per_time = betaling_per_uke / antall_timer
    print(f'Din timelønn er: {betaling_per_time}')
except ValueError:
    print('Du kan bare skrive inn tall!')
except ZeroDivisionError:
    print('Du kan ikke sette antall timer arbeidet til null!')

På denne måten kan vi utføre spesifikke handlinger for hver enkelt situasjon. Vi kan kombinere bruken av try og except med en while-løkke for å gjøre flere forsøk på å hente inn informasjonen:

while True:
    try:
        betaling_per_uke = int(input('Hva er din ukelønn (i kroner)? ')) 
        antall_timer = int(input('Hvor mange timer arbeidet du? '))

        betaling_per_time = betaling_per_uke / antall_timer
        print(f'Din timelønn er: {betaling_per_time}')
        break
    except ValueError:
        print('Du kan bare skrive inn tall!')
    except ZeroDivisionError:
        print('Du kan ikke sette antall timer arbeidet til null!')

Les gjennom koden over og sørg for at du forstår hvorfor vi har lagt inn nøkkelordet break akkurat der det er.

I tillegg til try og except kan du legge på nøkkelordet else etter din siste except kodeblokk. Kodeblokken som else definerer kjører bare når du ikke har støtt på noen kjørefeil i kodeblokken som try definerer. Dette er nyttig for å gjøre kodeblokken som try utfører litt kortere:

while True:
    try:
        betaling_per_uke = int(input('Hva er din ukelønn (i kroner)? ')) 
        antall_timer = int(input('Hvor mange timer arbeidet du? '))
        betaling_per_time = betaling_per_uke / antall_timer
    except ValueError:
        print('Du kan bare skrive inn tall!')
    except ZeroDivisionError:
        print('Du kan ikke sette antall timer arbeidet til null!')
    else:
        print(f'Din timelønn er: {betaling_per_time}')
        break    

Helt til slutt kan du også legge til en kodeblokk som kjører uavhengig av hva som har blitt kjørt tidligere med nøkkelordet finally. Her er et eksempel på dette:

while True:
    try:
        betaling_per_uke = int(input('Hva er din ukelønn (i kroner)? ')) 
        antall_timer = int(input('Hvor mange timer arbeidet du? '))
        betaling_per_time = betaling_per_uke / antall_timer
    except ValueError:
        print('Du kan bare skrive inn tall!')
    except ZeroDivisionError:
        print('Du kan ikke sette antall timer arbeidet til null!')
    else:
        print(f'Din timelønn er: {betaling_per_time}')
        break
    finally:
        print('Vi prøver igjen!')

Kodeblokken som finally definerer vil bare bli utført når kodeblokken som else definerer ikke har kjørt på grunn av nøkkelordet break. Dette vil skje når det har oppstått en kjørefeil som har blitt håndtert med except. Koden over gir ikke opp før brukeren har skrevet inn riktig informasjon. Selv om den er litt mer omfattende enn å gjøre det uten feilhåndtering er du trygg på at ingenting går galt.

Alternativet til å bruke finally er å legge på utsagnet print('Vi prøver igjen!') i begge kodeblokkene som except definerer slik:

while True:
    try:
        betaling_per_uke = int(input('Hva er din ukelønn (i kroner)? ')) 
        antall_timer = int(input('Hvor mange timer arbeidet du? '))
        betaling_per_time = betaling_per_uke / antall_timer
    except ValueError:
        print('Du kan bare skrive inn tall!')
        print('Vi prøver igjen!')
    except ZeroDivisionError:
        print('Du kan ikke sette antall timer arbeidet til null!')
        print('Vi prøver igjen!')
    else:
        print(f'Din timelønn er: {betaling_per_time}')
        break

Dette går også helt fint, men finally sparer deg for å repetere en linje to ganger.

Når det kommer til de fire nøkkelordene try, except, else, og finally vil jeg oppsummere dem slik:

  • Det er bare try og except som er nødvendig når du skal håndtere kjørefeil. Nøkkelordene else og finally er valgfrie å bruke, og mange ganger gir det ikke mening å bruke dem.

  • Spesielt finally måtte jeg streve for å finne et enkelt eksempel der det er veldig nyttig å bruke. I ekte applikasjoner brukes finally ofte til å rydde opp i ressurser, slik som å lukke en fil eller koble fra en database. Du må gjerne bruke finally slik jeg har gjort over til enkle eksempler også, men om du holder deg til try, except, og else så er det også helt fint. Likevel er det nyttig for deg å vite at finally eksisterer og hva det brukes til.

Til slutt er det viktig at du forstår at du kan bruke try og except til å håndtere kjørefeil som du selv har frembrakt med nøkkelordet raise. Her er et fint eksempel på dette:

def sjekk_temperatur(temp):
    if temp < -273.15:
        raise ValueError('Det må være større enn det absolutte nullpunktet!')
    print(f'Temperaturen er {temp} °C')


try:
    temperatur = float(input('Skriv inn temperaturen i °C: '))
    sjekk_temperatur(temperatur)
except ValueError as e:
    print(f'Ugyldig temperatur: {e}')

Som du ser brukes funksjonen sjekk_temperatur() som en hjelpefunksjon som sjekker om en temperatur er gyldig. Hvis temperaturen ikke er fysisk mulig frembringer sjekk_temperatur() en ValueError ved å bruke nøkkelordet raise. Denne kjørefeilen blir håndtert ved hjelp av nøkkelordene try og except.

En annen ting som kommer frem i eksempelet er bruken av nøkkelordet as. Du har tidligere brukt nøkkelordet as i forbindelse med importering i kapittel 8. Det er også mulig å bruke nøkkelordet as sammen med nøkkelordet except for å kunne referere til kjørefeilen. I eksempelet vårt blir kjørefeilen ValueError, som oppstår når du skriver inn et for lavt tall, bundet til variabelen e. Det er en vanlig konvensjon å bruke variabelnavnet e for å referere til selve feilobjektet. I kodeblokken til except skriver vi ut e slik at brukeren får informasjon om hva som gikk galt. I dette tilfellet er meldingen

'Temperaturen kan ikke være lavere enn det absolutte nullpunktet!'.

12.4 Strukturen til kjørefeil

Vi har snakket om hvordan frembringe kjørefeil i seksjon 12.2 og hvordan håndtere kjørefeil i seksjon 12.3. Nå er det på tide å sette oss litt mer inn i hva kjørefeil faktisk er i Python.

I kapittel 11 avslørte jeg at alt i Python er klasser og objekter, og dette gjelder også for kjørefeil. Mer spesifikt er alle kjørefeil i Python objekter som hører til en klasse som arver fra klassen BaseException. Kjørefeil hører sammen i et hierarki der mer spesifikke kjørefeil arver fra mer generelle kjørefeil. På toppen av hierarkiet sitter BaseException som den mest generelle kjørefeilen.

For å gjøre dette litt klarere kan vi illustrere dette med et eksempel. Vi har støtt på både IndexError og KeyError tidligere. For å minne deg på hvordan disse kjørefeilene oppstår:

# Dette utløser en IndexError
karakterer = ['Omar Little', 'Stringer Bell', 'Kima Greggs']
print(karakterer[3])

# Dette utløser en KeyError
karakterer = {
    'omar': {
        'navn': 'Omar Little',
        'slagord': 'It\'s all in the game, yo'
    },
    'kima': {
        'navn': 'Kima Greggs',
        'slagord': 'If I hear the music, I\'m gonna dance'
    }
}
print(karakterer['stringer'])

Både IndexError og KeyError arver fra en felles superklasse som heter LookupError. Hvorfor er dette nyttig å vite? Når du vet dette, kan du skrive kode som håndterer både IndexError og KeyError samtidig slik:

def hent_karakter(kilde, nøkkel):
    try:
        return kilde[nøkkel]
    except LookupError as e:
        print(f'Oppslag feilet: {e.__class__.__name__}{e}')
        return None


# Eksempel 1: IndexError
karakterer = ['Omar Little', 'Stringer Bell', 'Kima Greggs']
hent_karakter(karakterer, 3)

# Eksempel 2: KeyError
karakterer = {
    'omar': {
        'navn': 'Omar Little',
        'slagord': 'It\'s all in the game, yo'
    },
    'kima': {
        'navn': 'Kima Greggs',
        'slagord': 'If I hear the music, I\'m gonna dance'
    }
}
hent_karakter(karakterer, 'stringer')

La oss gå gjennom hva som skjer i koden sammen. Funksjonen hent_karakter() kan nå brukes både på lister og ordbøker. Koden return kilde[nøkkel] prøver å utføre et oppslag. Hvis dette feiler med en liste får vi en IndexError. Hvis dette feiler med en ordbok får vi en KeyError. Siden både IndexError og KeyError arver fra LookupError kan vi håndtere begge kjørefeilene med ett enkelt except-utsagn.

Husk at når vi skriver except LookupError as e så bruker vi nøkkelordet as til å kunne henvise til LookupError som e. Dette gjør det mulig å hente informasjonen e.__class__.__name__ inne i kodeblokken som except definerer. Vi skriver ut navnet på kjørefeilen som faktisk inntreffer slik at vi vet hva som har gått galt.

Merk deg at det er en avveining med hvor generelt du bør håndtere kjørefeil. Ved å håndtere de helt spesifikke kjørefeilene IndexError og KeyError individuelt får du mer kontroll, men det blir også flere individuelle except-utsagn som må til. Ved å håndtere kjørefeilen ett nivå opp i hierarkiet med LookupError trenger vi bare ett except-utsagn, men vi må da behandle begge kjørefeilene samtidig. Uansett hva du velger er det greit å vite om begge mulighetene, spesielt når du leser andres kode der begge situasjoner kan oppstå.

12.5 Indikering av typer

Vi skal i denne seksjonen og den neste se på hjelpemidler for å unngå feil i koden vår. Denne seksjonen handler om indikering av typer127 som hjelper oss med å forstå hvilken datatype som er forventet.

For å motivere indikering av typer, la oss se på hva som kan gå galt hvis vi ikke forstår hvilke datatyper som forventes. Her er en funksjon som er rimelig enkel å forstå:

def doble_mengde(mengde):
    return mengde * 2

En enklere funksjon enn dette får vi nesten ikke. Likevel kan en bruker misforstå og sende inn strengen '5' for å doble dette:

print(doble_mengde('5'))

Dette vil ikke gå så bra. Kjører du koden over får du ikke en kjørefeil, men får skrevet ut verdien '55' til terminalen. Det er fordi å multiplisere en streng med 2 vil duplisere strengen.

Hvordan kan vi unngå at dette skjer? En måte er å bruke funksjonen isinstance() til å sjekke at vi har riktig type:

def doble_mengde(mengde):
    if isinstance(mengde, (int, float)):
        return mengde * 2
    else:
        return None

Dette blåser lengden til en veldig enkel funksjon opp en del. En annen mulighet er å bruke indikering av typer slik:

def doble_mengde(mengde: int | float) -> int | float:
    return mengde * 2

Her beskriver vi at argumentet bør være enten en int eller en float med å skrive mengde: int | float. Notasjonen -> int | float indikerer at returverdien til funksjonen også enten er en int eller en float. Med dette kan den som bruker funksjonen se hva som forventes for både argumenter og returverdier til en funksjon uten å forstå innholdet til funksjonen.

Egentlig har indikering av typer to hensikter. Den ene er bedre dokumentasjon, slik at det blir klarere hvilke typer som forventes. Den andre er at man kan bruke eksterne pakker som mypy til å sjekke at koden opprettholder hvordan typene er indikert. Verktøyet mypy vil reagere på kode som dette:

def doble_mengde(mengde: int | float) -> int | float:
    return mengde * 2


print(doble_mengde('5'))

Jeg kommer ikke til å demonstrere verktøyet mypy eller andre tredjepartsverktøy som sjekker indikering av typer. Det er likevel greit for deg å vite at slike verktøy eksisterer. Dokumentasjon er en god nok grunn til å bruke indikering av typer for å gjøre koden sin klarere.

La meg bruke resten av seksjonen til å forklare de grunnleggende måtene du kan bruke indikering av typer som dokumentasjon.

Vi kan indikere typen til variabelutsagn ved å bruke følgende notasjon:

# Uten indikering av typer
soppnavn = 'Kantarell'
antall_observasjoner = 6315
spiselig = True

# Med indikering av typer
soppnavn: str = 'Kantarell'
antall_observasjoner: int = 6315
spiselig: bool = True

Som du ser bruker vi symbolet : etter variabelnavnet for å spesifisere typen til variabelen. I eksempelet ovenfor kan du direkte observere hva typen er ved å lese verdien til variabelen, så det er ikke veldig nyttig. La oss for eksempelets skyld anta at det eksisterte en pakke observasjoner som hadde funksjonen hent_antall_observasjoner() som vi kunne bruke:

from observasjoner import hent_antall_observasjoner


antall_observasjoner: int = hent_antall_observasjoner(soppnavn = 'Kantarell')

Her hjelper indikeringen av typer : int oss med å forstå hva funksjonen hent_antall_observasjoner() returnerer. Nå slipper neste person som leser koden over å navigere seg frem til innholdet i funksjonen hent_antall_observasjoner() for å finne ut hva den returnerer.

I tillegg til å indikere typen til variabelutsagn så brukes indikering av typer ofte med funksjoner:

def hent_antall_observasjoner(soppnavn: str) -> int:
    if soppnavn == 'Kantarell':
        return 6315
    elif soppnavn == 'Steinsopp':
        return 2765
    else:
        return 0

Her har vi skrevet en tøyseversjon av hent_antall_observasjoner(), men som likevel illustrerer poenget. Vi beskriver at parameteren soppnavn skal være en streng. I tillegg bruker vi notasjonen -> int til å forklare at returverdien til funksjonen hent_antall_observasjoner() er et heltall. Dette er ofte veldig nyttig å få raskt greie på når du skal bruke andres funksjoner.

Dersom hent_antall_observasjoner() i noen sammenhenger også returnerer None bør vi indikere dette slik:

def hent_antall_observasjoner(soppnavn: str) -> int | None:
    if soppnavn == 'Kantarell':
        return 6315
    elif soppnavn == 'Steinsopp':
        return 2765
    else:
        return None

Standardbiblioteket typing har mer avansert funksjonalitet for å hjelpe deg med indikering av typer. Likevel gir vi oss her slik at du ikke blir helt overveldet.

Hovedpoenget er at indikering av typer er nyttig for å unngå å gjøre feilaktige antakelser om hvilke typer vi jobber med. Som jeg har nevnt i tidligere kapitler er det ofte feile antakelser om typer som skaper problemer. Indikering av typer gjør det enklere å gjøre ting riktig når du jobber med mange forskjellige typer samtidig.

12.6 Enhetstester

Enhetstester128 hjelper oss med å teste at koden vår gjør det vi forventer. For å motivere enhetstester, la oss si at du skriver en funksjon som regner ut gjennomsnittet til en tallrekke:

def gjennomsnitt(tall):
    return len(tall) / sum(tall)

Ved første øyekast ser funksjonen gjennomsnitt() riktig ut, men du velger likevel å teste koden med noen eksempler slik:

def gjennomsnitt(tall):
    return len(tall) / sum(tall)


print(gjennomsnitt(tall=[10, 20, 30])) # Skriver ut 0.05
print(gjennomsnitt(tall=[5])) # Skriver ut 0.2

Kjører du koden over ser du at dette ikke ser helt riktig ut. Du går tilbake til funksjonen gjennomsnitt() og merker til slutt at du har byttet om bruken av sum() og len(). Funksjonen sum() burde bli brukt i telleren, og funksjonen len() burde bli brukt i nevneren. Du endrer det og ser at nå fungerer koden som den skal:

def gjennomsnitt(tall):
    return sum(tall) / len(tall)


print(gjennomsnitt(tall=[10, 20, 30])) # Skriver ut 20.0
print(gjennomsnitt(tall=[5])) # Skriver ut 5

Du fjerner så begge funksjonskallene til print() og sitter igjen med dette:

def gjennomsnitt(tall):
    return sum(tall) / len(tall)

Tipp topp! Du tar deg nå en velfortjent 3 ukers ferie.

En uke etter at du har dratt på ferie ser din kollega Kari over koden du har skrevet. Hun er ikke helt overbevist om at funksjonen gjennomsnitt() er riktig, så hun lager sine egne manuelle tester slik:

def gjennomsnitt(tall):
    return sum(tall) / len(tall)


if gjennomsnitt(tall=[3, 5]) != 4.0:
    print('Her er det noe galt!')
    
if gjennomsnitt(tall=[2]) != 2.0:
    print('Her er det noe galt!')

Etter å ha overbevist seg selv om at alt likevel er som det skal tar Kari ferie og glemmer å fjerne if-setningene over. Når du kommer tilbake fra ferie ser du koden over og må bruke tid på å forstå hva if-setningene gjør. Du lurer på hvorfor Kari har manuelt testet koden din når du selv har gjort dette. Fant hun kanskje noen nye feil med koden? Kari er fortsatt på ferie, så her må du vente til Kari er tilbake før du kan være sikker.

Situasjonen jeg har beskrevet ovenfor er ikke særlig uvanlig hvis du bare bruker manuelle tester. Selv om manuelle tester er bra for enkle sjekker at koden gjør som den skal er det ikke et bra alternativ for å holde orden på feil i en større kodebase. I stedet kan du skrive enhetstester. Enhetstester er logisk sett veldig likt det Kari skrev ovenfor, men det er strukturert litt lurere.

I Python er det to pakker folk flest bruker for å skrive enhetstester. Det ene er pakken unittest som er inkludert i standardbiblioteket, mens det andre er den eksterne pakken pytest. Fordelen med unittest er at det kommer med når du installerer Python. Fordelen med pytest er at de fleste synes dette er litt enklere å bruke. Jeg skal nå vise deg hvordan du kan skrive enhetstester i pytest. Oppgave 5 i slutten av kapittelet ber deg om å skrive enhetstestene i unittest slik at du får erfaring med begge pakkene.

Skriv pip install pytest i terminalen for å laste ned den eksterne pakken pytest. Etter at du har installert pytest kan vi skrive vår første enhetstest.

For å holde ting ryddig er det vanlig å ha enhetstestene i en separat fil. Legg funksjonen gjennomsnitt() i en fil som heter funksjoner.py. I samme mappe lager du en ny fil som heter test_funksjoner.py. Det er en viktig konvensjon at filer som inneholder enhetstester starter navnet sitt med test_. I filen test_funksjoner.py kan du skrive følgende:

from funksjoner import gjennomsnitt


def test_gjennomsnitt_to_tall():
    assert gjennomsnitt([3, 5]) == 4.0


def test_gjennomsnitt_ett_tall():
    assert gjennomsnitt([2]) == 2.0

Her skjer det to viktige ting:

  • Vi importerer funksjonen vi skal teste fra filen der den ligger. I vår situasjon er dette funksjonen gjennomsnitt() fra filen funksjoner.py.

  • Vi lager funksjoner som starter med navnet test_. Denne konvensjonen er viktig for at pytest skal forstå hvilke funksjoner som er enhetstester.

  • Vi bruker nøkkelordet assert til å sjekke likheten mellom to verdier. Du kan lese koden assert gjennomsnitt([3, 5]) == 4.0 som ”Jeg forventer at gjennomsnitt([3, 5]) gir 4.0, ellers er det krise.”

For å kjøre enhetstestene skal du ikke selv bruke funksjonene test_gjennomsnitt_to_tall() og test_gjennomsnitt_ett_tall(). Det eneste du trenger å gjøre er å skrive kommandoen pytest i terminalen. Da vil du få ut følgende informasjon:

Som du kan se av utskriften over så ble begge testene godkjent. Her noterer pytest at det er to enhetstester som ble utført, og begge lykkes. La oss være litt rampete og endre gjennomsnitt() i filen funksjoner.py tilbake til den feilaktige versjonen:

def gjennomsnitt(tall):
    return len(tall) / sum(tall)

Hva skjer nå når vi kjører enhetstestene? Skriver du kommandoen pytest i terminalen får du ut dette:

Les beskjeden nøye. Her får du streng beskjed om at ikke alt er som det skal. Begge enhetstestene test_gjennomsnitt_to_tall() og test_gjennomsnitt_ett_tall() feiler, og du får informasjon om hva som går galt.

Poenget med enhetstester er at du kan endre koden din med trygghet. Hvis du senere legger til nye funksjoner eller optimaliserer eksisterende kode, kan du kjøre testene og med én gang se om du har ødelagt noe. Du slipper altså å gjenta feriescenarioet med Kari som lager manuelle sjekker som blir liggende i koden. I stedet har du et fast, ryddig testoppsett som alltid kan kjøres, uansett hvem som sitter ved tastaturet.

Det er veldig mye mer jeg kunne sagt om både enhetstesting og pakken pytest hvis vi hadde hatt mer tid. Enhetstesting er på mange måter en kunst, og det å skrive gode enhetstester er en verdifull ferdighet som sparer både deg og andre utviklere rundt deg for mye smerte. Du vil som regel få rikelig med erfaring med enhetstester når du jobber profesjonelt med å skrive kode i Python.

12.7 Prosjekt - Terningkast til en kveld med brettspill

Mange brettspill og rollespill starter med et enkelt spørsmål: Hva fikk du på terningen? Enten du spiller Dungeons & Dragons, yatzy eller bare vil avgjøre hvem som skal ta oppvasken, er terningen en klassisk måte å bringe inn litt spenning og tilfeldighet.

12.7.1 Oppgaven

Skriv et program som tar inn to biter med informasjon:

  • Antall terninger som skal trilles.

  • Hvor mange sider terningene har.

Simuler deretter terningkastene og skriv verdiene ut til brukeren. Bruk tid på å sikre programmet ved håndtering av kjørefeil og indikering av typer.

12.7.2 Den grunnleggende logikken

La oss først skrive den grunnleggende logikken til programmet. I rollespill er det vanlig å bruke følgende forkortelser:

  • 3d6 representerer 3 terninger som har 6 sider.

  • 1d20 representerer 1 terning som har 20 sider.

Som du ser indikeres både antall terninger og hvor mange sider de har enkelt med dette formatet. Ved å bruke dette trenger vi bare å stille brukeren ett enkelt spørsmål:

print('Formatet er <terninger>d<sider> (f.eks. 3d6)')
terninghånd = input('Beskriv antall terninger og sider: ')

Vi kan deretter bruke strengmetoden .split() til å splitte strengen slik:

print('Formatet er <terninger>d<sider> (f.eks. 3d6)')
terninghånd = input('Beskriv antall terninger og sider: ')

terninger = int(terninghånd.split('d')[0])
sider = int(terninghånd.split('d')[1])

Merk at vi bruker funksjonen int() siden vi ønsker å jobbe med heltall, mens strengmetoden .split() returnerer strenger. Vi kan nå bruke funksjonen randint() fra standardbiblioteket random til å generere terningkast:

from random import randint


print('Formatet er <terninger>d<sider> (f.eks. 3d6)')
terninghånd = input('Beskriv antall terninger og sider: ')

terninger = int(terninghånd.split('d')[0])
sider = int(terninghånd.split('d')[1])

terningkast = [randint(1, sider) for _ in range(terninger)]

print('Dine terningkast er: ')
for kast in terningkast:
    print(kast)

Her bruker vi en listeforståelse til å simulere terningkastene. Til slutt skriver vi ut resultatet til terminalen på en leselig måte.

Den helt grunnleggende logikken til programmet fungerer nå fint. Du kan prøve det ut selv for å se hvordan det fungerer!

12.7.3 Håndtering av en vanlig feil

Slik programmet er skrevet nå kan flere feil inntreffe. Kanskje det mest sannsynlige er at brukeren glemmer å skrive tegnet 'd'. Så brukeren skriver inn 36 fremfor 3d6 med en feil. Hva vil skje da? Brukeren vil få en beskjed om at det har skjedd en IndexError og programmet blir avsluttet. Ikke særlig nyttig.

Først av alt, hvorfor skjer en IndexError? Dette kan vi undersøke i Python-REPL. Husk at du kan åpne en Python-REPL med å skrive kommandoen python i et terminalvindu. Her kan du teste ut enkle kommandoer. Når du skriver '36'.split('d') i Python-REPL så vil du få listen med ett element ['36']. Derfor vil utsagnet int(terninghånd.split('d')[1]) gi en IndexError.

Dette er ikke veldig brukervennlig. Vi kan heller sjekke om d er med i terninghånd og frembringe en ValueError hvis dette ikke stemmer:

print('Formatet er <terninger>d<sider> (f.eks. 3d6)')
terninghånd = input('Beskriv antall terninger og sider: ')

if 'd' not in terninghånd:
    raise ValueError('Formatet er <terninger>d<sider>. Prøv igjen!')

Vi kan nå håndtere denne feilen med nøkkelordene try og except slik:

from random import randint


while True:
    try:
        print('Formatet er <terninger>d<sider> (f.eks. 3d6)')
        terninghånd = input('Beskriv antall terninger og sider: ')

        if 'd' not in terninghånd:
            raise ValueError('Husk at formatet er <terninger>d<sider>.')
    except ValueError as e:
        print(e)
    else:
        break
      
terninger = int(terninghånd.split('d')[0])
sider = int(terninghånd.split('d')[1])
terningkast = [randint(1, sider) for _ in range(terninger)]

print('Dine terningkast er: ')
for kast in terningkast:
    print(kast)

Merk at her har også brukt en while-løkke for å repetere spørsmålet dersom svaret ikke inneholder bokstaven 'd'. Det er flere andre feil som kunne oppstått enn det å glemme å skrive inn bokstaven 'd'. En bruker kan for eksempel skrive inn et negativt tall slik som -3d6. Dette gir ikke så mye mening. Du må selv avgjøre om feil er vanlige og alvorlige nok til å håndteres.

Jeg omskriver nå programmet til tre funksjoner slik at vi kan lettere jobbe videre med koden:

from random import randint


def innhent_terninghånd():
    """Henter inn informasjon om antall terninger og sider."""

    while True:
        try:
            print('Formatet er <terninger>d<sider> (f.eks. 3d6)')
            terninghånd = input('Beskriv antall terninger og sider: ')

            if 'd' not in terninghånd:
                raise ValueError('Husk at formatet er <terninger>d<sider>.')
        except ValueError as e:
            print(e)
        else:
            break

    terninger = int(terninghånd.split('d')[0])
    sider = int(terninghånd.split('d')[1])
    return terninger, sider


def triller_terninger(terninger, sider):
    """Simulerer trilling av terningkast."""
    return [randint(1, sider) for _ in range(terninger)]


def utskrift(terningkast):
    """Skriver ut informasjon om terningkastene til terminalen."""
    print('Dine terningkast er: ')
    for kast in terningkast:
        print(kast)


if __name__ == '__main__':
    terninger, sider = innhent_terninghånd()
    terningkast = triller_terninger(terninger, sider)
    utskrift(terningkast)

12.7.4 Indikering av typer

Indikering av typer hjelper oss med å holde orden på hvilke datatyper som sendes frem og tilbake i programmene våre. Hvis du ser på programmet vårt over så er det to forskjellige måter vi kan bruke indikering av typer:

  • Vi kan legge på indikering av typer på variabelutsagn.

  • Vi kan legge på indikering av typer på funksjoner.

Det er indikering av typer på funksjoner som er mest nyttig i dette prosjektet siden variabelutsagnene er relativt enkle. Derfor legger vi bare på indikering av typer for funksjoner under:

from random import randint


def innhent_terninghånd() -> tuple[int]:
    """Henter inn informasjon om antall terninger og sider."""

    while True:
        try:
            print('Formatet er <terninger>d<sider> (f.eks. 3d6)')
            terninghånd = input('Beskriv antall terninger og sider: ')

            if 'd' not in terninghånd:
                raise ValueError('Husk at formatet er <terninger>d<sider>.')
        except ValueError as e:
            print(e)
        else:
            break

    terninger = int(terninghånd.split('d')[0])
    sider = int(terninghånd.split('d')[1])
    return terninger, sider


def triller_terninger(terninger: int, sider: int) -> list[int]:
    """Simulerer trilling av terningkast."""
    return [randint(1, sider) for _ in range(terninger)]


def utskrift(terningkast: list[int]) -> None:
    """Skriver ut informasjon om terningkastene til terminalen."""
    print('Dine terningkast er: ')
    for kast in terningkast:
        print(kast)


if __name__ == '__main__':
    terninger, sider = innhent_terninghånd()
    terningkast = triller_terninger(terninger, sider)
    utskrift(terningkast)

Som du sikkert forstår så indikerer list[int] at dette skal være en liste med heltall. På samme måte indikerer tuple[int] at dette skal være en tuppel med heltall. Jo mer kompliserte programmene dine blir, jo mer hjelper indikering av typer med å holde hodet over vannet når det kommer til datatyper.

12.8 Oppgaver

Oppgave 1

Koden under inneholder tre forskjellige typer feil:

  • én syntaksfeil,

  • én kjørefeil, og

  • én logisk feil.

Finn alle tre og forklar hva som er galt.

def beregn_gjennomsnitt(liste)
    total = sum(liste)
    antall = len(liste)

    gjennomsnitt = total / (antall + 1)
    return gjennomsnitt


data = [4, 8, '12', 16]
beregn_gjennomsnitt(data)

Oppgave 2

Trommeslageren Truls vil bygge en enkel robotarm som slår på en skarptromme med en trommestikke. Truls er en ekspert på både tromming og robotikk, men trenger litt hjelp av deg med Python. Han vil lage en funksjon som venter et gitt tidsintervall før den slår et slag. Det Truls sliter med er å sikre at ingen gir urimelige verdier som kan ødelegge robotarmen.

Du skal hjelpe Truls med følgende: Lag en funksjon spill_trommeslag() som har én enkelt parameter ms_mellom_slag. Parameteren ms_mellom_slag representerer antall millisekunder programmet skal vente før det slår et slag. Funksjonen spill_trommeslag() skal:

  • Frembringe en TypeError dersom ms_mellom_slag ikke er et heltall.

  • Frembringe en ValueError dersom ms_mellom_slag er mindre enn 50 ms eller større enn 5000 ms. Motoren til robotarmen vil bli brent opp dersom armen slår raskere enn et slag per 50 ms. På den andre siden er det ikke realistisk å vente lengre enn 5000 ms mellom hvert slag.

Er verdien ms_mellom_slag derimot gyldig, skal spill_trommeslag() skrive ut en melding til terminalen som bekrefter at alt er som det skal være.

Oppgave 3

Du har fått i oppdrag å skrive et lite program som ber brukeren om å registrere en firesifret PIN-kode. Det virket som en enkel oppgave, så du skrev følgende hjelpefunksjon:

def registrer_pinkode():
    """En hjelpefunksjon som registrerer PIN-kode til brukeren."""
    pinkode = input('Skriv inn en firesifret pinkode: ')
    return pinkode

Denne koden fungerer fint så lenge brukeren faktisk skriver inn en firesifret PIN-kode. Men etter at systemet blir tatt i bruk, får du dårlige nyheter:

  • Noen brukere skriver inn PIN-koder som er for lange, som for eksempel 123456.

  • Andre registrerer en tom PIN-kode, altså ingenting i det hele tatt.

Dette er ikke ideelt, spesielt når vi snakker om noe så viktig som PIN-koder. Vi ønsker kun å godta PIN-koder som er akkurat 4 tegn lange og består kun av tall.

Lag en forbedret versjon av registrer_pinkode() som:

  • Bruker en while-løkke til å la brukeren prøve igjen ved feil.

  • Bruker raise til å kaste en passende ValueError når PIN-koden er ugyldig.

  • Bruker try og except til å håndtere feilen på en ryddig måte og gi brukeren en hjelpsom tilbakemelding.

Oppgave 4

Et bakeri selger én spesiell kake hver dag i uken, med rabatt. Tabellen under viser hvilken kake som gjelder hvilken dag:

Dag Kake
Mandag Eplekake
Tirsdag Ostekake
Onsdag Gulrotkake
Torsdag Pavlova
Fredag Brownies
Lørdag Sitronterte
Søndag Sjokoladekake

Koden under fungerer, men mangler indikering av typer både på variabler og funksjoner. Din oppgave er å legge til riktig indikering av typer for alle variabler og funksjoner.

from datetime import datetime


def beregn_rabatt(pris, prosent):
    return pris * (1 - prosent / 100)


def hent_dagens_kake():
    dag = datetime.now().weekday()  # 0 = mandag, 6 = søndag
    if dag == 0:
        return 'Eplekake'
    elif dag == 1:
        return 'Ostekake'
    elif dag == 2:
        return 'Gulrotkake'
    elif dag == 3:
        return 'Pavlova'
    elif dag == 4:
        return 'Brownies'
    elif dag == 5:
        return 'Sitronterte'
    else:
        return 'Sjokoladekake'


kake = hent_dagens_kake()
pris_per_stk = 85
rabatt_prosent = 20
rabattert_pris = beregn_rabatt(pris_per_stk, rabatt_prosent)

print(f'Dagens kake: {kake} – Kun {rabattert_pris} kr/stk.')

Oppgave 5 (Utfordrende)

I seksjon 12.6 skrev vi tester for funksjonen gjennomsnitt() ved å bruke den eksterne pakken pytest. Nå skal du skrive de samme testene, men denne gangen ved å bruke pakken unittest i standardbiblioteket. Her må du greie deg selv uten noen håndholding fra min side. Start med å se på dokumentasjonen for unittest her:

https://docs.python.org/3/library/unittest.html#basic-example

Lykke til!