5 trinn for å lage din aller første Type Class i Scala

I dette blogginnlegget lærer du hvordan du implementerer din første type klasse, som er grunnleggende språkfunksjon på ikonet for funksjonelle programmeringsspråk - Haskell.

Foto av Stanley Dai på Unsplash

Type Class er et mønster som stammer fra Haskell, og det er dets standard måte å implementere polymorfisme. Denne typen polymorfisme kalles ad-hoc polymorfisme. Navnet kommer fra det faktum at i motsetning til kjent polymorfisme av subtype kan vi utvide biblioteksfunksjonaliteten selv uten å ha tilgang til kildekoden til biblioteket og klassen hvilken funksjonalitet vi ønsker å utvide til.

I dette innlegget vil du se at bruk av typeklasser kan være like praktisk som å bruke vanlig OOP-polymorfisme. Innhold nedenfor vil lede deg gjennom alle faser av implementering av Type Class-mønster for å hjelpe deg med å få bedre forståelse for internals i funksjonelle programmeringsbiblioteker.

Oppretter din første Type Class

Teknisk er Type Class bare en parameterisert egenskap med antall abstrakte metoder som kan implementeres i klasser som utvider den egenskapen. Så langt alt ser veldig ut som i en kjent undertypemodell.
Den eneste forskjellen er at ved hjelp av undertyping må vi implementere kontrakt i klasser som er en del av domenemodellen, i Type Classes blir implementering av trekk plassert i en helt annen klasse som er koblet til “domain class” etter type parameter.

Som et eksempel i denne artikkelen vil jeg bruke Eq Type Class fra Cats-biblioteket.

trekk Eq [A] {
  def areEquals (a: A, b: A): Boolean
}

Type klasse Eq [A] er en kontrakt for å ha muligheten til å sjekke om to objekter av type A er like basert på noen kriterier implementert i areEquals-metoden.

Å lage forekomst av vår type klasse er så enkelt som å innlede klassen som utvider nevnte egenskap med bare en forskjell at vår forekomst av typen klasse vil være tilgjengelig som implisitt objekt.

def moduloEq (divisor: Int): Eq [Int] = ny ekvivalent [Int] {
 overstyre def erEquals (a: Int, b: Int) = a% divisor == b% divisor
}
implisitt val modulo5Eq: Eq [Int] = moduloEq (5)

Over koden kan komprimeres litt i følgende form.

def moduloEq: Eq [Int] = (a: Int, b: Int) => a% 5 == b% 5

Men vent, hvordan kan du tilordne funksjon (Int, Int) => Boolsk til referanse med type Eq [Int] ?! Denne tingen er mulig takket være Java 8-funksjonen kalt Single Abstract Method type interface. Vi kan gjøre noe slikt når vi bare har en abstrakt metode i egenskapen vår.

Type Class-oppløsning

I dette avsnittet skal jeg vise deg hvordan du bruker forekomster av typeklasse og hvordan du magisk kan slå sammen klasse Eq [A] med tilsvarende objekt av type A når det vil være nødvendig.

Her har vi implementert funksjonalitet for å sammenligne to Int-verdier ved å sjekke om modulodelingsverdiene deres er like. Med all den jobben som er gjort er vi i stand til å bruke vår Type Class for å utføre en viss forretningslogikk, f.eks. vi vil koble to verdier som er modulo like.

def pairEquals [A] (a: A, b: A) (implisitt ekv .: Ekv [A]): ​​Alternativ [(A, A)] = {
 if (eq.areEquals (a, b)) Noen ((a, b)) annet Ingen
}

Vi har parameterisert funksjonsparEquals for å jobbe med alle typer som gir forekomst av klasse Eq [A] tilgjengelig i det implisitte omfanget.

Når kompilatoren ikke finner noen forekomst som samsvarer med deklarasjonen ovenfor, vil den ende opp med samlingsfeilvarsel om mangel på riktig forekomst i implisitt omfang.
  1. Compiler vil utlede typen angitte parametere ved å bruke argumenter på vår funksjon og tilordne den til alias A.
  2. Forrige argument ekv: Ekv [A] med implisitt nøkkelord vil utløse forslag om å lete etter objekt av type ekv [A] i implisitt omfang.

Takket være implikasjoner og typede parametere, er kompilatoren i stand til å binde klasse sammen med den tilhørende typeklasseinstansen.

Alle forekomster og funksjoner er definert, la oss sjekke om koden vår gir gyldige resultater

pairEquals (2,7)
res0: Alternativ [(Int, Int)] = Noen ((2,7))
pairEquals (2,3)
res0: Alternativ [(Int, Int)] = Ingen

Som du ser fikk vi forventede resultater, slik at vår type klasse klarer bra. Men denne ser litt rotete ut, med en god del kjeleplate. Takket være magien med Scalas syntaks kan vi få en kjeleplate til å forsvinne.

Innholdsgrenser

Det første jeg vil forbedre i koden vår, er å kvitte seg med den andre argumentelisten (den med implisitte nøkkelord). Vi passerer ikke direkte den ene når vi påkaller funksjon, så la implisitt være implisitt igjen. I Scala kan implisitte argumenter med typeparametere erstattes av språkkonstruksjon kalt Context Bound.

Context Bound er deklarasjon i listen med typeparametere hvilken syntaks A: Eq sier at hver type som brukes som argument for pairEquals-funksjonen må ha implisitt verdi av typen Eq [A] i det implisitte omfanget.

def pairEquals [A: Eq] (a: A, b: A): Alternativ [(A, A)] = {
 hvis (implisitt [Ekv [A]]. er like (a, b)) Noen ((a, b)) annet Ingen
}

Som du har lagt merke til endte vi opp med ingen henvisning til implisitt verdi. For å overvinne dette problemet bruker vi funksjon implisitt [F [_]] som trekker funnet implisitt verdi ved å spesifisere hvilken type vi viser til.

Dette er hva scala-språket tilbyr oss for å gjøre det hele mer kortfattet. Det ser likevel ikke bra ut for meg. Context Bound er et veldig kult syntaktisk sukker, men dette implisitt ser ut til å forurense koden vår. Jeg vil lage et fint triks for hvordan du kan løse dette problemet og redusere implementeringsverdigheten.

Det vi kan gjøre er å tilby parameterisert applikasjonsfunksjon i ledsagerobjekt av vår type klasse.

objektet lik {
 def Apply [A] (implisitt ekv: Ekv [A]): ​​Ekv [A] = ekv
}

Denne virkelig enkle tingen gjør at vi kan bli kvitt implisitt og trekke vår forekomst fra limbo som skal brukes i domenelogikk uten kjeleplate.

def pairEquals [A: Eq] (a: A, b: A): Alternativ [(A, A)] = {
 if (Eq [A] .arequals (a, b)) Noen ((a, b)) annet Ingen
}

Implisitte konverteringer - alias. Syntaksmodul

Det neste jeg vil komme på arbeidsbenken min er Eq [A] .areEquals (a, b). Denne syntaks ser veldig ordinær ut fordi vi eksplisitt refererer til type klasseinstans som burde være implisitt, ikke sant? Den andre tingen er at her fungerer vår type klasseinstans som Service (i DDD-betydning) i stedet for ekte A-klasseutvidelse. Heldigvis at man også kan fikses ved hjelp av en annen nyttig bruk av implisitte søkeord.

Det vi skal gjøre her er å tilby såkalt syntaks eller (ops som i noen FP-biblioteker) modul ved å bruke implisitte konverteringer som lar oss utvide API for noen klasse uten å endre kildekoden.

implisitt klasse EqSyntax [A: Eq] (a: A) {
 def === (b: A): Boolean = Eq [A] .areEquals (a, b)
}

Denne koden forteller kompilatoren om å konvertere klasse A med forekomst av type klasse Eq [A] til klasse EqSyntax som har en funksjon ===. Alt dette gjør inntrykk av at vi har lagt funksjon === til klasse A uten kildekodemodifisering.

Vi har ikke bare skjult type klasseforekomstreferanse, men gir også mer klassisk syntaks som gjør inntrykk av metode === som blir implementert i klasse A, selv vi ikke vet noe om denne klassen. To fugler drept med en stein.

Nå har vi lov til å bruke metode === på type A når vi har EqSyntax-klasse i omfang. Nå vil implementeringen av pairEquals endres litt, og vil være som følger.

def pairEquals [A: Eq] (a: A, b: A): Alternativ [(A, A)] = {
 hvis (a === b) Noen ((a, b)) annet Ingen
}

Som jeg lovet, har vi endt opp med implementering der den eneste synlige forskjellen sammenlignet med OOP-implementering er merknad om kontekstbundet etter en parameter-type. Alle tekniske aspekter av typeklasse er atskilt fra vår domenelogikk. Det betyr at du kan oppnå mye mer kule ting (som jeg vil nevne i den separate artikkelen hva som vil bli publisert snart) uten å skade koden din.

Implisitt omfang

Som du ser er type klasser i Scala strengt avhengig av å bruke implisitte funksjoner, så det er viktig å forstå hvordan man jobber med implisitt omfang.

Implisitt omfang er et omfang der kompilatoren vil søke etter implisitte forekomster. Det er mange valg, så det var behov for å definere en rekkefølge der det blir sett etter tilfeller. Ordren er som følger:

1. Lokale og arvelige forekomster
2. Importerte forekomster
3. Definisjoner fra ledsagerobjektet av typen klasse eller parametrene

Det er så viktig fordi når kompilatoren finner flere forekomster eller ikke tilfeller i det hele tatt, vil det føre til en feil. For meg er den mest praktiske måten å få forekomster av typeklasser å plassere dem i ledsagerobjektet til selve type klassen. Takket være det trenger vi ikke å bry oss med å importere eller implementere forekomster på stedet som lar oss glemme stedsproblemer. Alt er magisk levert av kompilatoren.

La oss diskutere punkt 3 ved å bruke eksempel på velkjent funksjon fra Scalas standardbibliotek, sortert hvilken funksjonalitet som er basert på implisitt leverte komparatorer.

sortert [B>: A] (implisitt ord: matematikk.Retting [B]): Liste [A]

Type klasseinstans blir søkt i:
 * Bestiller ledsagerobjekt
 * Liste ledsager objekt
 * B-ledsagerobjekt (som også kan være et ledsagerobjekt på grunn av definisjon av lavere grenser)

Simulacrum

Alt dette hjelper mye når du bruker typeklasse, men dette er repeterbart arbeid som må gjøres i hvert prosjekt. Disse ledetrådene er et åpenbart tegn på at prosessen kan hentes ut til biblioteket. Det er et utmerket makrobasert bibliotek kalt Simulacrum, som håndterer alt som trengs for å generere syntaksmodul (kalt ops i Simulacrum) osv. For hånd.

Den eneste endringen vi bør innføre er @typeclass-merknaden, som er merket for makroer for å utvide syntaksmodulen.

import simulacrum._
@typeklassetrekk Eg [A] {
 @op (“===”) def erEkvaliteter (a: A, b: A): Boolsk
}

De andre delene av implementeringen vår krever ingen endringer. Det er alt. Nå vet du hvordan du implementerer type klassen mønster i Scala av dine egne, og jeg håper du fikk bevissthet om hvordan biblioteker som Simulacrum fungerer.

Takk for at du leste, jeg vil virkelig sette pris på alle tilbakemeldinger fra deg, og jeg ser frem til å møte deg i fremtiden med en annen publisert artikkel.