Dette kan du lære av sterkt typet funksjonell programmering

- Prinsippene er nyttige for alle utviklere, mener Stein Kåre Skytteren.

Stein Kåre Skytteren forklarer deg hvorfor prinsippene i sterkt typet funksjonell programmering er nyttig for alle. 📸: Privat
Stein Kåre Skytteren forklarer deg hvorfor prinsippene i sterkt typet funksjonell programmering er nyttig for alle. 📸: Privat Vis mer

Det finnes veldig mange forskjellige programmeringsspråk. De har forskjellige fordeler og ulemper, men hensikten er å gi en utvikler måter å løse problemer med en datamaskin, på en logisk måte ved hjelp av abstraksjoner.

De fleste utdanningsinstitusjonene i Norge har fokus på imperativ og objektorientert programmering. De velger gjerne å undervise i objektorientering.

Det er mange som har brukt mye tid på det, meg selv inkludert. Men er objektorientert programmering den beste måten å løse et problem på?

«Men er objektorientert programmering den beste måten å løse et problem på?»

Svaret er at det kommer an på. Funksjonell programmering løser problemer på en annen måte enn objektorientert programmering.

Det betyr ikke at man ikke kan benytte funksjonelle prinsipper i et objektorientert språk som Java. Det å lære seg prinsippene er derfor nyttig for enhver utvikler.

Hva er funksjonell programmering?

Funksjonell programmering handler i sin enkleste form om at funksjoner kan sendes rundt på lik linje som data i imperative programmeringsspråk. Det betyr at de kan være argumenter eller resultat av funksjoner. Dette kalles høyere ordens-funksjoner.

Et annet viktig aspekt er at funksjoner skal være rene (“pure functions”), og returnere det samme resultatet hver gang, gitt de samme argumentene.

I funksjonell programmering skal ikke en funksjon endre systemets tilstand - uansett hvor mange ganger den kalles. Den skal heller ikke gjøre noe i tillegg til å gi et resultat. Det er også viktig for programflyten. Å kaste feil (“exceptions”) gjør at programflyten plutselig oppfører seg unormalt, og gjør det vanskelig å håndtere unntakssituasjoner på en god måte i typesystemet.

I tillegg til å kaste exceptions, er å lese fra eller til skrive fil samt kalle andre systemer bannlyst. Grunnen er at det vil endre tilstanden i systemet og gi andre effekter enn det som er resultatet av funksjonen som blir kalt. En funksjon skal, som sagt, være ren det vil si at den gir et resultat, og ikke gjøre noen andre sideeffekter.

Sett fra det imperative programmerings-perspektivet er dette uvant, og kan høres ut som en så sterk begrensning at det ikke kan gi nyttige programmer, men ved hjelp av noen fine abstraksjoner så viser det seg å være svært nyttig.

Hvorfor funksjonell programmering?

Sannheten er at funksjonell programmering kan gi bedre feilhåndtering, tryggere lesing av filer, raskere logging, og mer effektive kall av andre systemer og ressursfordeling på maskinvaren, enn man klarer i imperativ programmering.

I tillegg på en mye tryggere måte.

Dette kan virke helt absurd. Det er jo på mange måter en selvmotsigelse av hva man kan og ikke kan i funksjonell programmering. Hvordan kan funksjonell programmering være bedre på å lese fra og skrive til filer når det er bannlyst?

For gi en skikkelig forklaring går turen innom typer.

Hvorfor typer?

Ikke all funksjonell programmering har fokus på typer, men det er fokuset her.

Typer er en merking av “noe”, sånn at det får mer mening enn en serie med 1 og 0. Det betyr at det på den ene siden blir mer begrenset, og på den andre siden at det får flere garantier.

«Det betyr at det på den ene siden blir mer begrenset, og på den andre siden at det får flere garantier.»

Et typesystem er en mekanisme for å definere lovlige tilstander i programmet, som gir bedre kvalitet og forståelse. Det betyr at kompilatoren sjekker at utvikleren holder seg til de reglene som typesystem bestemmer.

I et godt typesystem gir disse reglene logisk uttrykkskraft, som kan brukes til å sikre at systemet er korrekt. Videre kan det også brukes til å utlede typer og forenkle kode. Det vil si at kompilatoren vet hvilke datatyper som det er tilgjengelig i en gitt sammenheng.

Eksempelvis har mange et forhold til typene i Java – `String`, `int`, `long`, `Integer`, `List`, og så videre. Java sitt typesystem har gitt mange utviklere en dårlig brukeropplevelse. Alle typene må uttrykkes overalt. For at brukervennligheten skal være bedre, må kompilatoren kunne utlede hvilken type det skal være, ikke bare sjekket at utvikleren har gjort rett. Nå skal det sies at dette har blitt noe bedre i Java. men det er et stykke igjen for å komme på høyde med andre språk.

Tester kan ikke sikre fraværet av feil, men bare verifisere at koden er rett for de verdiene som det testes for. Et typesystem kan eliminere en rekke feil ved at kompilatoren gjør sjekker på typene. Det gir en mye raskere feedback enn å kjøre en serie med tester.

Det gjelder spesielt på integrasjon mellom deler av systemet. Å teste integrasjoner på tvers av system er komplisert, men ved å lene seg på typesystemet så er det lite behov for denne typen tester. Målet er å modellere domenet slik at flest mulig feil kan bli fanget opp av kompilatoren.

Hvordan bruke typet funksjonell programmering?

Det å benytte typer for å uttrykke mening preger typet funksjonell programmering.

Et eksempel er `Optional<A>` i Java. Her er `A` satt i en kontekst av “kanskje”. I ren objektorientert programmering, uten støtte for høyere ordens funksjoner, er kontekster som `Optional<A>` lite utviklervennlige.

Ved å bruke funksjonell programmering får man en mye bedre brukeropplevelse.

Funksjoner og typer

For å forenkle bruken av disse kontekstene er det spesielt et par metoder som er nyttige – `map` og `flatMap`. De tar begge funksjoner som argumenter og gir nye verdier innenfor konteksten `Optional`.

Så ved å kalle `map` med en funksjon `f`, som tar en verdi av den generiske typen `A` og gir en verdi av den generiske typen `B`, blir resultatet `B` innenfor konteksten `Optional`. Altså `Optional<B>`.

I Java kan man skrive det som:

<B> Optional<B> map(Function <A, B> f)

Eksemplene videre er i Scala – et typet funksjonelt programmeringsspråk. Språket uttrykker funksjoner på en god måte som A => B, og samtidig har metoder tilsvarende struktur med resultat til slutt.

I Scala er det:

def map[B](f: A => B): Option[B]

Tilsvarende har vi `flatMap`:

def flatMap[B](fo: A => Option[B]): Option[B]

Forskjellene fra `map` er at funksjonen `fo` returnerer en verdi i konteksten `Option`. Det gjør at det er enkelt å fortsette innenfor samme kontekst. For å komme seg ut av `Option[A]` finnes metoden `getOrElse(orElse: A): A`.

«Ved å frigjøre funksjoner fra objekter kan man få større gjenbruk av logikk.»

Flere kontekster

`List[T]` en annen kontekst i Scala tilsvarende `Option[T]`, som beskriver en liste av verdier `T`.

Her kjenner vi igjen metodene `map` og `flatMap`:

def map[B](f: A => B): List[B]
def flatMap[B](fo: A => List[B]): List[B]

Her er det et mønster som gjentar seg.

Det er lett å se for seg at man kunne brukt dette på `Stream[T]`, `Try[T]` og andre kontekster (computational context) om man vil. Legge merke til at man kan gjenbruke funksjonen `f` på forskjellige kontekster.

Ved å frigjøre funksjoner fra objekter kan man få større gjenbruk av logikk. Når funksjonene er rene så blir de også mye enklere å teste.

Unntak (exception)?

Det er noen ganger funksjoner ikke kan gi et rett svar for alle argumenter. En objektorientert utvikler kan få lyst til å gjøre et unntak fra reglene - en lite presis oversettelse av `throw Exception`.

I Java har man “checked exceptions” som gjør at det står i metode-signaturen hvilken “exceptions” som kan kastes. Det fungerer dårlig i funksjonelle programmer, og metoden kan ikke gjøres om til en funksjon, siden en funksjon ikke kan kaste “checked exceptions”.

Noen tenker at man kan trylle bort problemet med å stryke seg over skjegget og gjøre "checked exceptions" om til "unchecked runtime exceptions". Det er som å kaste søppel i havet. Det kan se ut som om problemet blir borte. Virkeligheten er at det blir en miljøbombe som kan treffe hvem som helst, når som helst - gjerne i produksjonsmiljøet.

Farlig avfall skal og må håndteres ordentlig. I typet funksjonell programmering er feilhåndtering eksplisitt og tydelig for alle. At det er muligheter for feil modelleres med typer. Det som gjør at `Either[L, R]` er godt egnet til å modellere feilsituasjoner, er favoriseringen av høyresiden – Right.

Den favoriseringen muliggjøre samme mønster som tidligere:

def map[B](f: R => B): Either[L, B] = this match { 
   case Left(feil) => Left(feil) 
   case Right(verdi) => Right(f(verdi)) 
}
 
def flatMap[B](fo: R => Either[L, B]): Either[L, B] = this match {
   case Left(feil) => Left(feil) 
   case Right(verdi) => fo(verdi) 
}

`Either` kan med andre ord ses på som en kontekst tilsvarende de tidligere nevnte `Option`, `Try` og `List`.

For å komme fram til en verdi `C`, og ikke `Either[L, R]`, så er metoden `fold` veldig hendig:

def fold[C](leftF: L => C, rightF: R => C): C

Hvordan `fold` er implementert og brukes er en god øvelse for den som ønsker å blir bedre kjent med funksjonell programmering.

Hva med IO?

For å gå tilbake til å skrive og lese til og fra fil eller kommunikasjon over nettverk - altså Input/Output (I/O) - så er dette ikke annet enn en ny kontekst. Det vil si `IO[A]`. Her er `A` i konteksten `IO`.

For å lese fra fil trengs det et API kall der resultatet er en `String` i konteksten av IO:

def leseFilSomString(navn: String): IO[String]

Når `leseFilSomString` kalles blir man ikke da bannlyst? Nei, og grunnen er at metoden ikke gjøre noe som helst I/O. Den beskriver bare en operasjon om hvordan man skal gjøre I/O.

For å gjøre det enklere å jobbe med IO har man de samme mønstrene som tidligere:

def map[B](f: A => B): IO[B]
def flatMap[B](fo: A => IO[B]): IO[B]

Ved å benytte `map` og `flatMap` kan man sette sammen en beskrivelse av flere IO steg uten at noe faktisk blir gjort. Det blir omtrent som å skrive en matoppskrift uten å lage noe mat og heller ikke noe søl.

Når det er behov for IO, som det veldig ofte er, bruker man ikke `main`-metoden som i Java. I stedet benyttes en type `run` metode (eksempel hentet fra IOApp i cats-effect biblioteket):

def run(argumenter: List[String]): IO[ExitCode] = {
   // Fyll inn kode som returnerer IO med ExitCode her 
}

Nå er det opp til kjøresystemet å kjøre det som utvikleren ga som resultat. Optimalisering og håndtering av ressurser blir også overlatt til kjøresystemet. Ved å gjøre det på denne måten har systemet bedre forutsetninger for å optimalisere enn det man har som utvikler.

Det gjelder spesielt rundt det med å håndtere mange operasjoner i parallell. En Java-utvikler kan gjerne få til I/O for en serie med operasjoner like bra, men i mange av dagens systemer så er det mange serier som skjer samtidig. Dette er det lettere for et kjøresystem å optimalisere. I Scala er Cats-Effect og ZIO eksempler på denne typen system.

Oppsummert

På tvers av forskjellige kontekster, som blant annet `Option`, `List`, `Either`, og `IO`, tilbyr funksjonell programmering muligheten for å gjenbruke funksjoner.

Det er gjort noen forenklinger i denne gjennomgangen slik at flest mulig skal forstå, men de generelle prinsippene er riktige. Typet funksjonell programmering byr på mye mer enn dette, men det er en smakebit.

Det er flere løsnings-mønstre man bør forstå før man får god flyt i funksjonell kode, og ved å la typene lede utviklingen så blir det bedre kode.

«A language that doesn't affect the way you think about programming, is not worth knowing.» Alan Perlis

Hvordan lære mer?

Det er mulig å få til noe funksjonelt i Java. Etterhvert så blir det ønskelig å få til bedre abstraksjoner enn det som er mulig i Java, og da er det på tide å prøve noe nytt:

  • Scala er et mer uttrykksfullt programmeringsspråk, som lar en utvikler gjøre bedre funksjonelle abstraksjoner. Syntaksen er ikke helt ulik Java, men er bedre egnet for typet funksjonell programmering.
  • For den som ønsker å gå “all in”, ikke er redd ny syntaks eller et helt nytt tankesett, så er Haskell verdt å prøve.
  • På web finnes blant annet Scala.js, Elm og Purescript. For de som er interessert i webutvikling er de verdt en titt.
  • For den som er interessert i konseptene, er functor, monoid, semigroup, monad og kleisli, noen stikkord. Det kan ta lang tid å forstå disse konseptene, men det åpner opp en helt ny og bedre verden i programmering. Det gjelder å ikke la seg skremme, men å se på typene med tilhørende funksjoner er et godt tips. De som ønsker å diskutere disse konseptene bør ta seg en tur på Scala-pilsen i regi av ScalaBin.
  • Det er også verdt å nevne algebraiske datatyper (ADT), som benyttes for å beskrive kategorier av typer. Det gir et fundament for å kunne se typer på et mer abstrakt nivå. ADT er virkelig verdt å sette seg inn i.