Til tross for at vi har en haug med unike programmeringsspråk der ute, er det lite innovasjon rundt feilhåndtering. Så og si alle språk implementerer feilhåndtering på en av to måter: Enten håndteres det som unntak som blir kastet oppover, eller så håndteres det som feil som blir returnert av funksjoner.
Å kaste feil via unntak er det Java, C#, JavaScript og Python gjør. Du har litt ekstra syntaks for å kaste unntak, samt for å fange dem igjen litt høyere opp:
try:
print('Skriv inn et heltall: ', end='')
n = int(input())
print('Det dobbelte av tallet ditt er', 2*n)
except ValueError:
print('Hmm, det var ikke et heltall.')
except EOFError:
print('\nJasså, ikke interessert i gi meg et heltall nei')
På den andre siden har du Go, Rust, Elm og flere andre funksjonelle språk som returnerer feil i stedet. De har ikke noen spesiell syntaks for feilhåndtering (eventuelt veldig lite), og det betyr at du må aktivt forholde deg til alle potensielle feil som returneres fra funksjonskall:
fmt.Print("Skriv inn et heltall: ")
var stringN string
_, err := fmt.Scanln(&stringN)
if err != nil {
if errors.Is(io.EOF, err) {
fmt.Println("\nJasså, ikke interessert i gi meg et heltall nei")
return
}
fmt.Println("Her skjedde det noe rart med standard in")
return
}
n, err := strconv.Atoi(stringN)
if err != nil {
fmt.Println("Hmm, det var ikke et heltall.")
return
}
fmt.Println("Det dobbelte av tallet ditt er", 2*n)
Etter å ha jobbet med begge har jeg innsett at ingen av dem nødvendigvis er bedre enn den andre. Hva som er optimalt er delvis en smakssak, og delvis avhengig av hva slags system du jobber med: Å måtte håndtere alle potensielle feil er smertefullt ved prototyping av nye systemer, der en krasj i ny og ne ikke er så farlig. Samtidig ville det vært veldig betryggende å kunne vite at programvaren for kritisk infrastruktur eller, si, pacemakere, håndterer alle feil som potensielt kan dukke opp og ikke bare krasjer.
¶Ofte tilpasset språkets identitet
Tross lite innovasjon, er de som designer programmeringsspråk ofte gode på å velge feilhåndtering som matcher språket sitt. Dette dukker opp når vi går inn på hjemmesiden til Rust:
Salgspitchen er at du kan bygge stabil og effektiv programvare, og for at noe skal være stabilt gir det mening at du må håndtere alle feil som dukker opp (kanskje på bekostning av rask utviklingshastighet).
Det samme kan man si om Python:
Python selger seg inn som et språk der du skal kunne jobbe raskt og integrere med andre systemer effektivt. Å kunne “ignorere” unntak og fokusere på en “happy path”, i alle fall i starten, gjør at du kan jobbe med å få noe oppe og kjøre raskt (muligens på bekostning av stabilitet).
Men det er et språk som jeg syntes har en feilhåndtering som ikke matcher identiteten sin helt perfekt: Clojure.
¶try/catch er ikke interaktivt
Clojure bruker, som de fleste andre JVM-språk, unntak for å håndtere feil. Men unntak passer i mine øyne veldig dårlig måten man utvikler Clojureapplikasjoner. La meg forklare:
Om man bruker superkreftene til Clojure, kjører du opp programmet ditt og snakker med det via et REPL og/eller via editoren du bruker. For eksempel, hvis du utvikler en nettjeneste og jobber med et nytt endepunkt, kan du redefinere/oppdatere funksjonen for endepunktet, eller funksjoner endepunktet kaller, uten å starte hele programmet på nytt.
I tillegg kan du evaluere vilkårlige Clojure-uttrykk i REPLet. Har du noe tilstand du ikke tror er helt korrekt, kan du inspisere tilstanden mens programmet kjører. Om du tror en funksjon ikke fungerer helt riktig, kan du teste den ut og kjøre den med noen argumenter du tror skaper trøbbel for funksjonen. Alt dette er veldig bra, og det virker som du kjører med en debugger hele tiden. Vel, nesten i alle fall.
En ting Clojure ikke får til alene er det å legge inn breakpoints, eller kjøre step-by-step-utførelse av et uttrykk. Det betyr at om noe går galt i REPLet, så får du en stack-trace tilbake uten noen mulighet til å fikse opp i problemet der det inntraff.
¶Hva om debuggeren alltid er tilgjengelig?
Så hva er alternativet? Før jeg begynte med Clojure, programmerte jeg litt i Common Lisp. Common Lisp føler jeg har omfavnet det å være et godt interaktivt verktøy fra dag en: I tillegg til å kaste en feilmelding oppover, har du mulighet til å sende feilhåndteringsmåten nedover.
Dette er veldig annerledes enn de to måtene jeg har nevnt tidligere, så la oss
se litt på hvordan dette fungerer i praksis. Si vi har en funksjon som flytter
filer, og ønsker å eksponere vanlige feilhåndteringsalternativer om filen
allerede eksisterer. I Common Lisp må vi først definere en feiltype, og den
ønsker jeg at har feltene fra-navn
og til-navn
:
(define-condition til-fil-finnes (error)
((fra-navn :initarg :fra-navn :reader fra-navn)
(til-navn :initarg :til-navn :reader til-navn)))
En “condition” i Common Lisp er noe litt mer generelt enn en feil, litt som
Throwable
er noe litt mer generelt enn en Exception
i Java. I denne
bloggposten fokuserer vi kun på feilhåndtering, men det er greit å nevne i
forbifarten at du kan gjøre mer med “condition”-typene enn å kaste dem sånn som
man er vant med.
Etter det kan vi skrive selve logikken for å flytte filen:
(defun flytt-fil-aux (fra-navn til-navn)
(if (probe-file til-navn)
(error 'til-fil-finnes
:fra-navn fra-navn
:til-navn til-navn)
;; else
(rename-file fra-navn til-navn)))
Hvis det allerede finnes en fil med navnet til-navn
(probe-file
sjekker om
filen finnes), lager og kaster vi en instans av feiltypen vi definerte over.
Kastet fungerer så og si likt som i Python, Java og C#, og vi kan fange det opp
med Common Lisps catch
-ekvivalent om vi hadde ønsket det.
Men nå begynner det snasne: Vi kan definere såkalte “restarts”. Jeg syntes navnet er veldig rart og har ikke helt skjønt hvorfor de heter det, men i praksis er de forskjellige feilhåndteringsalternativ som vi eksponerer til brukerene av funksjonen vi definerer. I vårt tilfelle vil funksjonen se slik ut:
(defun flytt-fil (fra-navn til-navn)
(restart-case (flytt-fil-aux fra-navn til-navn)
;; feilhåndteringsalternativer:
(hopp-over-fil () nil)
(overskriv ()
(delete-file til-navn)
(flytt-fil fra-navn til-navn))
(bytt-til-navn (annet-til-navn)
(flytt-fil fra-navn annet-til-navn))
(flytt-til-fil (nytt-til-navn)
(flytt-fil til-navn nytt-til-navn)
(flytt-fil fra-navn til-navn))))
Kallet til restart-case
tar inn et uttrykk som evalueres, og en liste med
potensielle måter å fikse feilen på. Om en feil blir kastet fra uttrykket, vil
disse alternativene bli listet opp. Akkurat her har jeg bestemt meg for å
eksponere det å hoppe over filen, skrive over den, eller bytte navnet på en av
filene.
Hvordan disse alternativene eksponeres kommer an på om du er i et REPL eller om du kjører det som et program. Om vi hopper inn et Common Lisp-REPL med denne funksjonen og prøver å skrive over en eksisterende fil skjer dette:
Som du ser får vi en feilmelding, men i stedet for at vi ender opp tilbake der vi startet, er vi nå automatisk i en debugsesjon. Her kan vi gjøre det vi er vant med i en debugger: Vi kan se på lokale variabler, hoppe opp og ned stakken, kjøre Common Lisp-uttrykk, og så videre. I tillegg får vi opp en liste med alle måter feilen kan håndteres, samt hurtigtaster for å utføre dem.
Dette gjør iterasjonstiden utrolig rask i noen situasjoner hvor den er treg i andre språk. Si for eksempel at vi trenger å tolke logglinjer fra et program som nesten følger et format, men som har noen tullete linjer en sjelden gang. Common Lisp-måten å utvikle denne koden er å skrive implementasjonen sånn her:
(defun parse-log-line (raw-log-line)
(faktisk kode her))
(defun tmp-parse-log-line (raw-log-line)
(restart-case (parse-log-line raw-log-line)
(reparse-log-line () (tmp-parse-log-line raw-log-line))))
(loop for raw-log-line in (read-log-lines)
for entry = (tmp-parse-log-line raw-log-line)
when entry collect it)
Restart-alternativet reparse-log-line
virker jo bare dumt ved første øyekast,
men Common Lisp-måten å jobbe på er å kjøre programmet helt til parse-log-line
krasjer, redefinere funksjonen, for så å prøve å tolke linja på nytt. Dette kan
vi gjøre helt til vi har lest gjennom hele fila med logglinjer.
I de fleste andre språk er den mest praktiske måten å utvikle dette å kjøre programmet til det krasjer, inspisere logglinjen som fikk den til å krasje, for så å starte med å lese logglinjene helt fra begynnelsen. Ikke akkurat et stort problem om du har 100-200 MB med data, men om du har titals gigabyte er det frustrerende om en tullete logglinje krasjer programmet nesten helt på slutten.
¶La oss møtes på midten
Det er jo fiffige saker at disse alternativene dukker opp når du utvikler, men
du ønsker jo ikke at programmet ender opp i en debugsesjon i produksjon. Her er
vanligvis debuggeren skrudd av, og da kan du benytte deg av en catch
-liknende
greie for å fange opp feilen som blir kastet oppover.
Men du har også muligheten til å sende en funksjon ned som sier hva vi skal
gjøre. Om vi går tilbake til flytt-fil
-funksjonen, så kan det hende vi ønsker
å endre navn på til-fila (hvis den eksisterer) ved å legge til .bak
på slutten
av filnavnet. Det kan du gjøre med handler-bind
og inspisere feilen vi får
tilbake:
(defun flytt-gammel-til-backup (feil)
(let ((backup-navn (concatenate 'string (til-navn feil) ".bak")))
(invoke-restart 'flytt-til-fil backup-navn)))
(handler-bind ((til-fil-finnes #'flytt-gammel-til-backup))
(flytt-fil "a.txt" "b.txt"))
;; om b.txt eksisterer, bytter den navn til b.txt.bak før vi gjør om
;; a.txt til b.txt
Her sender vi altså funksjonen flytt-gammel-til-backup
nedover, og om vi ser
en feil av typen til-fil-finnes
blir funksjonen kalt under restart-case
-en i
flytt-fil
-funksjonen. Hvis det skjer vil den påkalle restart-alternativet
flytt-til-fil
, sånn at den originale til-filen slutter med .bak
.
¶Men er det en god idé?
I teorien syntes jeg dette er en veldig kjekk måte å håndtere feil på: Det er
overraskende ofte du vet om de fleste vanlige måtene du kan håndtere feilen, men
ikke helt de som bruker den ønsker. Og noen ganger må du jo håndtere feil nede i
systemet: Det er ikke uten grunn vi sender inn et
RetryPolicy
-objekt
til klienter som må vite hva som skjer om de får en timeout/feilkode tilbake fra
serveren. Å ha en konsistent og strukturert måte å sende inn sånne på uten å ha
en builder-klasse for hver av dem hadde vært veldig kjekt.
Det litt større problemet er vel heller at denne løsningen krever en god del disiplin. Common Lisp har ikke et typesystem, og om vi ser på typene og kallene vi har i denne ene funksjonen ser det slik ut:
Det er ingen piler mellom feiltypen vi har laget og restart-kallene vi potensielt kan benytte oss av. Følgelig er det heller ingen måte vi kan vite helt sikkert, at vi kan, for eksempel, flytte til-fila når vi får en melding om at den finnes.
I praksis betyr det at mange restart-funksjoner sjekker om restart-kallet de
ønsker å gjøre eksisterer før de faktisk kaller det. “God praksis” tilsier at
vår flytt-gammel-til-backup
-funksjon heller bør se slik ut:
(defun flytt-gammel-til-backup (feil)
(let ((backup-navn (concatenate 'string (til-navn feil) ".bak"))
(restart (find-restart 'flyt-til-fil)))
(when restart
(invoke-restart restart backup-navn))))
Dette er litt lite robust for min smak. I stedet for å fange feilen og være
sikker på at den håndteres slik vi ønsker, må vi nesten bare håpe på at
utviklerne av APIet vi bruker ikke har gjort noe kreativt. Jeg ønsker en litt
sterkere binding mellom feiltypen og restart-kallene, sånn at jeg er sikker på
at restart-kallet jeg ønsker å gjøre alltid er et alternativ. Det gjør også at
jeg ikke ved et uhell prøver å finne restart-kallet flyt-til-fil
i stedet for
flytt-til-fil
, som jeg “tilfeldigvis” gjorde i funksjonen over.
Til tross for dette er denne feilhåndteringsmåten noe jeg ønsker å se i flere språk. Det er en veldig praktisk måte å utvikle på, og gjør at iterasjonstiden går ned betraktelig når du blir nødt til å debugge en feil.
¶Det går greit med Clojure
Som sagt får ikke Clojure dette til alene, men det finnes mange biblioteker og editorer med debug-støtte som gjør at dette ikke er et like stort problem i praksis. Det er ikke helt det samme som Common Lisp-opplevelsen, men løser veldig mye av smertene med “krasj/rekompiler/vent”-metoden å debugge feil på.
Det gir forsåvidt mening at Clojure bruker try/catch: En av tingene Clojure har fokusert på er god interoperabilitet med den virtuelle maskinen Clojure kjører på. Både JVMen og JavaScript benytter seg av try/catch, så å gå mot dette gjør det vanskelig å snakke med eksisterende biblioteker i foreldrespråkene.
Og om du virkelig har lyst til å omfavne Common Lisp-måten, kan du benytte deg av biblioteket farolero. Den implementerer til og med interaktiv debugging, og selv om opplevelsen ikke er helt identisk, er den mer enn bra nok for min del.